Compare commits

...

11 Commits

Author SHA1 Message Date
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
rouggy 806b39970b feat: status bar added 2026-05-30 01:35:50 +02:00
rouggy 8f1ad126ac External services (QRZ/Clublog/LoTW) + QSL Manager
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:52:10 +02:00
75 changed files with 14917 additions and 1342 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"9e3eadf5-7e35-4848-8cf9-515589d63e73","pid":5360,"acquiredAt":1780094656528}
+3017 -53
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
+1305 -393
View File
File diff suppressed because it is too large Load Diff
+515
View File
@@ -0,0 +1,515 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save, Download, 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,
} 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)); }
}
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">{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>
<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,225 @@
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.
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'state', 'cont', 'country', 'grid']);
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('');
const [results, setResults] = 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]);
useEffect(() => {
if (q.length < 2) { setResults([]); return; }
const t = window.setTimeout(async () => {
setBusy(true);
try {
// References are always scoped to the contacted DXCC entity.
const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50);
setResults((r ?? []) as any);
} catch { setResults([]); }
finally { setBusy(false); }
}, 200);
return () => window.clearTimeout(t);
}, [awardCode, q, dxcc]);
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(''); setResults([]); }}
>
<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>
)}
{!busy && q.length < 2 && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
Type 2+ chars to search
</div>
)}
{!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>
);
}
+111 -10
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]> = {
@@ -65,6 +67,28 @@ const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']], '6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
}; };
// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, label }: { cls: string; label: string }) {
return (
<span className="inline-flex items-center gap-1">
<span className={cn('size-2 rounded-full', cls)} />
{label}
</span>
);
}
// Human-readable label for a spot status — used in the pill hover tooltip
// so the operator can see WHY a spot is coloured the way it is.
function statusLabel(s: string): string {
switch (s) {
case 'new': return 'NEW DXCC (entity never worked)';
case 'new-band': return 'NEW BAND (entity not worked on this band)';
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
case 'worked': return 'Worked (this band + mode already in log)';
default: return 'Entity not resolved';
}
}
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } { function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
// pill = full pill background+text+border // pill = full pill background+text+border
// bar = thick left accent inside the pill // bar = thick left accent inside the pill
@@ -114,8 +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 DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once.
// These pile up on the watering-hole frequencies and otherwise spawn
// hundreds of spots that fan out and cover the whole map. ONLY digital is
// capped — CW and SSB are always shown in full. When more than this digital
// spots are in band we keep the most useful (new entities first, worked
// last; ties broken by closeness to the rig freq).
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);
@@ -146,19 +177,55 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
// larger). When more labels stack than fit in the band's natural pixel // larger). When more labels stack than fit in the band's natural pixel
// span, totalH grows so scrolling reveals them. // span, totalH grows so scrolling reveals them.
type Placed = { spot: Spot; freqY: number; labelY: number }; type Placed = { spot: Spot; freqY: number; labelY: number };
const { placed, totalH } = useMemo<{ placed: Placed[]; totalH: number }>(() => { const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => {
// innerH is the band's stretched pixel span; total adds top+bottom // innerH is the band's stretched pixel span; total adds top+bottom
// padding so the edge freq labels aren't clipped at y=0 / y=H. // padding so the edge freq labels aren't clipped at y=0 / y=H.
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD }; if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 };
const seen = new Set<string>(); const seen = new Set<string>();
const filtered: Spot[] = []; const inBand: Spot[] = [];
for (const s of spots) { for (const s of spots) {
if (s.freq_khz < lo || s.freq_khz > hi) continue; if (s.freq_khz < lo || s.freq_khz > hi) continue;
if (seen.has(s.dx_call)) continue; if (seen.has(s.dx_call)) continue;
seen.add(s.dx_call); seen.add(s.dx_call);
filtered.push(s); inBand.push(s);
} }
// Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…)
// are capped — they're what floods the watering-hole frequencies. We key
// off the mode CATEGORY (not a literal "FT8" string) because many FT8
// 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 otherSpots = inBand.filter((s) => !isFlood(s));
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
const rank = (s: Spot) => {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
switch (spotStatus[k]?.status ?? '') {
case 'new': return 0;
case 'new-band': return 1;
case 'new-slot': return 2;
case 'worked': return 4;
default: return 3;
}
};
let keptFt = ftSpots;
let hiddenCount = 0;
if (ftSpots.length > MAX_VISIBLE_SPOTS) {
const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2;
keptFt = [...ftSpots]
.sort((a, b) => {
const r = rank(a) - rank(b);
if (r !== 0) return r;
return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK);
})
.slice(0, MAX_VISIBLE_SPOTS);
hiddenCount = ftSpots.length - keptFt.length;
}
const filtered = [...otherSpots, ...keptFt];
filtered.sort((a, b) => b.freq_khz - a.freq_khz); filtered.sort((a, b) => b.freq_khz - a.freq_khz);
// Desired pill-CENTRE Y for each spot = its true frequency's Y. // Desired pill-CENTRE Y for each spot = its true frequency's Y.
@@ -206,14 +273,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return { return {
placed: out, placed: out,
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD), totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
hidden: hiddenCount,
}; };
}, [spots, range, lo, hi, span, pxPerKHz, containerH]); }, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
// freqToY for elements rendered outside the memo (ticks, rig pointer). // freqToY for elements rendered outside the memo (ticks, rig pointer).
// Must mirror the same offset so the rig triangle sits on the right kHz. // Must mirror the same offset so the rig triangle sits on the right kHz.
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH; const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
// Auto-centre on the rig frequency when the map opens or the band changes
// (once per band, so it doesn't fight the user's manual scrolling). Waits
// for the scroller height to be measured and a valid in-band rig freq.
const centeredForRef = useRef<string>('');
useEffect(() => {
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
const kHz = currentFreqHz / 1000;
if (kHz < lo || kHz > hi) return;
if (centeredForRef.current === band) return;
const el = scrollerRef.current;
if (!el) return;
centeredForRef.current = band;
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
// freqToY is recomputed each render; intentionally excluded from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi]);
useEffect(() => { useEffect(() => {
const el = scrollerRef.current; const el = scrollerRef.current;
if (!el) return; if (!el) return;
@@ -282,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"
@@ -375,7 +467,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
{/* Pills absolutely positioned at their (anti-overlapped) Y */} {/* Pills absolutely positioned at their (anti-overlapped) Y */}
{placed.map((p, i) => { {placed.map((p, i) => {
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz); const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
const st = spotStatus[k]?.status ?? ''; const entry = spotStatus[k];
const st = entry?.status ?? '';
const style = statusStyle(st); const style = statusStyle(st);
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz); const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
return ( return (
@@ -389,7 +482,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
'hover:translate-x-0.5 hover:shadow', 'hover:translate-x-0.5 hover:shadow',
style.pill, style.pill,
)} )}
title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`} title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
> >
{/* Status accent strip on the left */} {/* Status accent strip on the left */}
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden /> <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
@@ -406,8 +499,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
})} })}
</div> </div>
</div> </div>
{/* Colour legend — what each pill colour means. */}
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
<LegendDot cls="bg-rose-400" label="New DXCC" />
<LegendDot cls="bg-amber-400" label="New band" />
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
</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} 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>
+48 -26
View File
@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ChevronUp, 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';
@@ -8,6 +7,8 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
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 { AwardRefSelector } from '@/components/AwardRefSelector';
export interface DetailsState { export interface DetailsState {
state: string; state: string;
@@ -36,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 {
@@ -45,9 +50,22 @@ interface Props {
remoteGrid: string; // entry-strip Grid value — destination remoteGrid: string; // entry-strip Grid value — destination
details: DetailsState; details: DetailsState;
onChange: (patch: Partial<DetailsState>) => void; onChange: (patch: Partial<DetailsState>) => void;
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
wb?: any;
wbBusy?: boolean;
band: string;
mode: string;
imageUrl?: string;
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 = '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'];
@@ -67,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 }: Props) { export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) {
const [open, setOpen] = useState<TabName | null>(null); 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(
@@ -79,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((prev) => (prev === t ? null : 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) {
@@ -94,15 +113,16 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
} }
const tabs: { key: TabName; label: string }[] = [ const tabs: { key: TabName; label: string }[] = [
{ key: 'info', label: 'Info (F2)' }, { key: 'stats', label: `Stats (${fk}1)` },
{ key: 'awards', label: 'Awards (F3)' }, { key: 'info', label: `Info (${fk}2)` },
{ key: 'my', label: 'My (F4)' }, { key: 'awards', label: `Awards (${fk}3)` },
{ key: 'extended', label: 'Extended (F5)' }, { key: 'my', label: `My (${fk}4)` },
{ key: 'extended', label: `Extended (${fk}5)` },
]; ];
return ( return (
<section className="border-b border-border bg-card shrink-0"> <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"> <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
key={t.key} key={t.key}
@@ -117,17 +137,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
{t.label} {t.label}
</button> </button>
))} ))}
{open && (
<button
onClick={() => setOpen(null)}
className="ml-auto text-muted-foreground hover:text-foreground p-1.5"
title="Close"
>
<ChevronUp className="size-3.5" />
</button>
)}
</nav> </nav>
<div className="overflow-y-auto min-h-0">
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} />
</div>
)}
{open === 'info' && ( {open === 'info' && (
<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="State / pref"> <Field label="State / pref">
@@ -182,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">
@@ -251,6 +272,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
</Field> </Field>
</div> </div>
)} )}
</div>
</section> </section>
); );
} }
+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>
);
}
+193 -135
View File
@@ -1,15 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { UploadCloud, Search, Loader2 } from 'lucide-react'; import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} 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 { 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 } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
type UploadRow = { type UploadRow = {
@@ -17,13 +14,17 @@ type UploadRow = {
band: string; mode: string; country: string; status: string; band: string; mode: string; country: string; status: string;
}; };
type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
};
const SERVICES = [ 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' },
]; ];
// Sent-status filter values. Empty string = blank/none.
const SENT_STATUSES = [ const SENT_STATUSES = [
{ v: 'R', label: 'Requested' }, { v: 'R', label: 'Requested' },
{ v: 'N', label: 'No' }, { v: 'N', label: 'No' },
@@ -41,38 +42,63 @@ 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())}`;
} }
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) { // 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.
export function QSLManagerPanel() {
const [service, setService] = useState('lotw'); const [service, setService] = useState('lotw');
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());
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
const [logOpen, setLogOpen] = useState(false); const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
const [confFilter, setConfFilter] = useState('all'); // all | new | dxcc | band | slot
const [showLog, setShowLog] = useState(false);
const [logLines, setLogLines] = useState<string[]>([]); const [logLines, setLogLines] = useState<string[]>([]);
const [uploadDone, setUploadDone] = useState(false); const [busy, setBusy] = useState(false);
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
useEffect(() => { useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line])); const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => { const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} uploaded`]); setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setUploadDone(true); setBusy(false);
}); });
return () => { offLog(); offDone(); }; const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
setViewMode('confirmations');
});
return () => { offLog(); offDone(); offConf(); };
}, []); }, []);
const selectedCount = selected.size; const selectedCount = selected.size;
const allSelected = rows.length > 0 && selected.size === rows.length; const allSelected = rows.length > 0 && selected.size === rows.length;
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
async function selectRequired() { const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) {
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
case 'dxcc': return c.new_dxcc;
case 'band': return c.new_band;
case 'slot': return c.new_slot;
default: return true;
}
}), [confirmations, confFilter]);
const selectRequired = useCallback(async () => {
setSearching(true); setSearching(true);
setError(''); setError('');
try { try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[]; const list = (r ?? []) as UploadRow[];
setRows(list); setRows(list);
setSelected(new Set(list.map((x) => x.id))); // auto-select all found setSelected(new Set(list.map((x) => x.id)));
setViewMode('upload');
setShowLog(false);
} catch (e: any) { } catch (e: any) {
setError(String(e?.message ?? e)); setError(String(e?.message ?? e));
setRows([]); setRows([]);
@@ -80,7 +106,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
} finally { } finally {
setSearching(false); setSearching(false);
} }
} }, [service, sent]);
function toggle(id: number) { function toggle(id: number) {
setSelected((s) => { setSelected((s) => {
@@ -96,138 +122,170 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
async function upload() { async function upload() {
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id); const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
if (ids.length === 0) return; if (ids.length === 0) return;
setLogLines([]); setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
setUploadDone(false); try { await UploadQSOsManual(service, ids); }
setLogOpen(true); catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
try {
await UploadQSOsManual(service, ids);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
} }
function closeLog() { async function download() {
setLogOpen(false); setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
// Refresh the list so uploaded QSOs drop out of the current filter. try { await DownloadConfirmations(service, addNotFound); }
selectRequired(); catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
} }
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); function viewResults() {
setShowLog(false);
if (logAction === 'upload') selectRequired();
}
return ( return (
<> <div className="flex flex-col min-h-0 flex-1">
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}> {/* Search toolbar */}
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0"> <div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
<DialogHeader className="px-4 pt-4"> <div className="flex flex-col gap-1">
<DialogTitle>QSL Manager</DialogTitle> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription> <Select value={service} onValueChange={setService}>
</DialogHeader> <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
{/* Search toolbar */} </Select>
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20"> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
<Select value={service} onValueChange={setService}> <Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)} </Select>
</SelectContent> </div>
</Select> <Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
</div> {searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
<div className="flex flex-col gap-1"> Select required
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label> </Button>
<Select value={sent} onValueChange={setSent}> <div className="flex-1" />
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger> {!showLog && viewMode === 'confirmations' && (
<SelectContent> <div className="flex flex-col gap-1">
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)} <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
</SelectContent> <Select value={confFilter} onValueChange={setConfFilter}>
</Select> <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
</div> <SelectContent>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}> <SelectItem value="all">All</SelectItem>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />} <SelectItem value="new">New (any)</SelectItem>
Select required <SelectItem value="dxcc">New DXCC</SelectItem>
</Button> <SelectItem value="band">New band</SelectItem>
<div className="flex-1" /> <SelectItem value="slot">New slot</SelectItem>
<span className="text-xs text-muted-foreground"> </SelectContent>
{rows.length} found · {selectedCount} selected </Select>
</span>
</div> </div>
)}
{logLines.length > 0 && (
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
</Button>
)}
<span className="text-xs text-muted-foreground">
{viewMode === 'confirmations'
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
: `${rows.length} found · ${selectedCount} selected`}
</span>
</div>
{/* Results grid */} {/* Content: log OR results grid */}
<div className="overflow-auto px-4 py-2 min-h-[200px]"> <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>}
{rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">
Pick a service + sent status, then Select required.
</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={allSelected} onCheckedChange={toggleAll} /></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">Country</th>
<th className="py-1.5 px-2">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}
>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(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 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter className="px-4 py-3 border-t border-border"> {showLog ? (
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button> <div className="font-mono text-[11px] space-y-0.5 py-1">
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
<UploadCloud className="size-3.5" />
Upload {selectedCount} to {serviceLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Upload progress / log window */}
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Uploading to {serviceLabel}</DialogTitle>
<DialogDescription className="sr-only">Upload progress log.</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
{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>
) : logLines.map((l, i) => ( ) : logLines.map((l, i) => (
<div key={i} className={cn(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div> <div key={i} className={cn(
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
: 'text-foreground/90')}>{l}</div>
))} ))}
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working</div>}
</div> </div>
<DialogFooter> ) : viewMode === 'confirmations' ? (
<Button size="sm" onClick={closeLog} disabled={!uploadDone}> shownConfs.length === 0 ? (
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading</>} <div className="text-sm text-muted-foreground py-10 text-center">
</Button> {confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
</DialogFooter> </div>
</DialogContent> ) : (
</Dialog> <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">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">Country</th><th className="py-1.5 px-2">New?</th>
</tr>
</thead>
<tbody>
{shownConfs.map((c, i) => (
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2">
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
: <span className="text-muted-foreground/50"></span>}
</td>
</tr>
))}
</tbody>
</table>
)
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</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={allSelected} onCheckedChange={toggleAll} /></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">Country</th><th className="py-1.5 px-2">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(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 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Action bar */}
<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">
<Button variant="outline" size="sm" onClick={download} disabled={busy}
title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" /> Download confirmations
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
</label>
</div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
</Button>
</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>
);
}
+358 -107
View File
@@ -1,5 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2 } from 'lucide-react'; import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { applyAwardRefs, buildAwardRefs } from '@/lib/awardRefs';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -12,11 +15,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 = [
@@ -28,11 +45,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 {
@@ -95,20 +147,117 @@ 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 [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras)); const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
const [localErr, setLocalErr] = useState(''); const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [looking, setLooking] = useState(false);
// === 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;
// Which awards are reference-list (manual) ones? Ask the backend, which
// also tells us pickable vs computed for the current QSO.
try {
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
const pickableCodes = new Set(all.filter((r: any) => r.pickable).map((r: any) => String(r.code).toUpperCase()));
const pickable = list
.filter((d) => pickableCodes.has(String(d.code).toUpperCase()))
.map((d) => ({ code: String(d.code), field: String(d.field || '').toLowerCase() }));
setAwardRefs(buildAwardRefs(draft, pickable));
} 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
// the draft — handy after correcting the callsign. Only overwrites the
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
async function fetchLookup() {
const call = (draft.callsign ?? '').trim().toUpperCase();
if (!call) { setLocalErr('Callsign required'); return; }
setLooking(true);
setLocalErr('');
try {
const r: any = await LookupCallsign(call);
setDraft((d) => ({
...d,
name: r.name ?? d.name,
qth: r.qth ?? d.qth,
address: r.address ?? (d as any).address,
email: r.email ?? (d as any).email,
country: r.country ?? d.country,
grid: r.grid ?? d.grid,
state: r.state ?? d.state,
cnty: r.cnty ?? d.cnty,
cont: r.cont ?? d.cont,
qsl_via: r.qsl_via ?? d.qsl_via,
dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz,
lat: r.lat || d.lat,
lon: r.lon || d.lon,
}));
} catch (e: any) {
setLocalErr('Lookup: ' + String(e?.message ?? e));
} finally {
setLooking(false);
}
}
function save() { function save() {
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; } if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
setSaving(true); setSaving(true);
@@ -122,16 +271,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),
@@ -147,6 +294,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
tx_pwr: numOrUndef(draft.tx_pwr), tx_pwr: numOrUndef(draft.tx_pwr),
extras: parseExtras(extrasText), extras: parseExtras(extrasText),
}; };
// 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);
} }
@@ -173,15 +325,15 @@ 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="extras"> <TabsTrigger value="extras">
Extras Extras
{extrasCount > 0 && ( {extrasCount > 0 && (
@@ -197,96 +349,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">
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11" <div className="flex flex-col flex-1 min-w-0">
<Label>Callsign</Label>
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} /> value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</F> </div>
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F> <div className="flex flex-col w-20"><Label>S</Label>
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F> <Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
<F label="Band"> <div className="flex flex-col w-20"><Label>R</Label>
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}> <Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
<SelectTrigger><SelectValue /></SelectTrigger> <Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent> title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
</Select> {looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
</F> </Button>
<F label="Mode"> </div>
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
<SelectTrigger><SelectValue /></SelectTrigger> <div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent> {/* ── Left column ── */}
</Select> <div className="flex flex-col gap-2.5">
</F> <div><Label>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
<F label="Submode"><Input value={draft.submode ?? ''} onChange={(e) => set('submode', e.target.value)} /></F> <div className="flex items-center gap-2">
<F label="Band RX"> <Label className="w-20 shrink-0">Band</Label>
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}> <Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
<SelectItem value="_"></SelectItem> </Select>
{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)} </div>
</SelectContent> <div className="flex items-center gap-2">
</Select> <Label className="w-20 shrink-0">RX Band</Label>
</F> <Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
<F label="Freq (MHz)"><Input value={freqMhz} onChange={(e) => setFreqMhz(e.target.value)} /></F> <SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<F label="Freq RX (MHz)"><Input value={freqRxMhz} onChange={(e) => setFreqRxMhz(e.target.value)} /></F> <SelectContent><SelectItem value="_">—</SelectItem>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F> </Select>
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F> </div>
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Mode</Label>
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Country</Label>
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
onChange={onCountryChange} className="flex-1" />
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">ITU</Label>
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
<Label>CQ</Label>
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
<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" />
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Freq</Label>
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
<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">
@@ -325,7 +583,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>
@@ -344,13 +602,6 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="notes" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Comment" span={6}><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></F>
<F label="Notes" span={6}><Textarea rows={6} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="extras" className="mt-0 space-y-2"> <TabsContent value="extras" className="mt-0 space-y-2">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
ADIF fields not promoted to first-class columns. One per line:{' '} ADIF fields not promoted to first-class columns. One per line:{' '}
+79 -26
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';
@@ -74,9 +83,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,13 +146,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: '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 },
@@ -189,14 +206,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)
@@ -214,21 +250,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>) {
@@ -280,6 +315,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>
@@ -292,7 +331,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}
@@ -301,6 +340,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)}
@@ -308,6 +349,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>
@@ -317,7 +370,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;
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react';
import { Satellite, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
export interface RecentSpotQSO {
callsign: string;
freqKHz: number;
mode: string;
band?: string;
}
interface Props {
open: boolean;
onClose: () => void;
// Pre-fill values: callsign from the QSO entry (or last logged), the
// current TX freq in kHz, and the current mode (goes into the comment).
defaultCall: string;
defaultFreqKHz: number;
defaultMode: string;
// Master cluster name, shown so the user knows where the spot goes.
targetName?: string;
recent: RecentSpotQSO[];
onSend: (call: string, freqKHz: number, comment: string) => Promise<void>;
}
// SendSpotModal — Log4OM-style "Send Spot" window. Announces a DX spot on
// the master cluster: callsign + frequency (kHz) + a free message (defaults
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
// contact into the form.
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
const [call, setCall] = useState('');
const [freqKHz, setFreqKHz] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [ok, setOk] = useState(false);
const callRef = useRef<HTMLInputElement>(null);
// (Re)initialise the form each time the dialog opens.
useEffect(() => {
if (!open) return;
setCall((defaultCall || '').toUpperCase());
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
setMessage(defaultMode || '');
setError('');
setOk(false);
// Focus the freq if the call is already known, else the call.
setTimeout(() => callRef.current?.focus(), 50);
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
async function send() {
const c = call.trim().toUpperCase();
const f = parseFloat(freqKHz);
if (!c) { setError('Callsign required'); return; }
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
setBusy(true);
setError('');
try {
await onSend(c, f, message.trim());
setOk(true);
// Brief success flash, then close.
setTimeout(() => { setOk(false); onClose(); }, 700);
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
function pick(q: RecentSpotQSO) {
setCall(q.callsign.toUpperCase());
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
if (q.mode) setMessage(q.mode);
setError('');
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Satellite className="size-4 text-primary" /> Send DX Spot
</DialogTitle>
</DialogHeader>
<div className="px-5 py-3 space-y-3">
<div className="flex gap-3">
<div className="flex flex-col flex-1">
<Label className="mb-1">Callsign</Label>
<Input
ref={callRef}
className="font-mono uppercase font-bold"
value={call}
onChange={(e) => setCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="DX call"
/>
</div>
<div className="flex flex-col w-32">
<Label className="mb-1">Frequency (kHz)</Label>
<Input
className="font-mono"
value={freqKHz}
onChange={(e) => setFreqKHz(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="14205"
/>
</div>
</div>
<div className="flex flex-col">
<Label className="mb-1">Message</Label>
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="e.g. CW · TNX QSO"
/>
</div>
{recent.length > 0 && (
<div>
<Label className="mb-1 block">Latest QSOs</Label>
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
{recent.map((q, i) => (
<button
key={`${q.callsign}-${i}`}
type="button"
onClick={() => pick(q)}
className="flex w-full items-center gap-2 px-2 py-1 text-left text-xs hover:bg-accent/40"
>
<span className="font-mono font-bold w-24 truncate">{q.callsign}</span>
<span className="font-mono text-muted-foreground w-20 text-right">{q.freqKHz > 0 ? trimKHz(q.freqKHz) : '—'}</span>
<span className="text-muted-foreground">{q.mode || ''}</span>
</button>
))}
</div>
</div>
)}
{error && <div className="text-xs text-rose-600">{error}</div>}
</div>
<DialogFooter>
<span className="text-[11px] text-muted-foreground mr-auto self-center">
{ok ? 'Spot sent ✓' : targetName ? `${targetName}` : 'Master cluster'}
</span>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={send} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
{busy ? 'Sending…' : 'Send spot'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// trimKHz formats a kHz value without a trailing ".0" (14205) but keeps
// sub-kHz precision when present (10138.7).
function trimKHz(khz: number): string {
return String(Math.round(khz * 10) / 10).replace(/\.0$/, '');
}
File diff suppressed because it is too large Load Diff
+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">
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react';
import { Input } from './input';
import { cn } from '@/lib/utils';
// Searchable combobox: type to filter, click/Enter to pick. On blur it commits
// only an exact (case-insensitive) match — otherwise it reverts, so the field
// can't hold a typo'd value that isn't in the list.
export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false,
}: {
value: string;
onChange: (v: string) => void;
options: string[];
placeholder?: string;
className?: string;
allowFreeText?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
const filtered = open
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
: [];
function commit(v: string) {
onChange(v);
setQuery(v);
setOpen(false);
}
function onBlur() {
// Defer so a click on an option registers first.
setTimeout(() => {
setOpen(false);
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
if (exact) { onChange(exact); setQuery(exact); }
else if (allowFreeText) { onChange(query.trim()); }
else { setQuery(value); } // revert typo
}, 120);
}
return (
<div ref={ref} className={cn('relative', className)}>
<Input
value={open ? query : value}
placeholder={placeholder}
// Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onBlur={onBlur}
onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
// Tab: just let it move on; onBlur commits/closes. Options are
// tabIndex=-1 so a single Tab leaves the field.
}}
/>
{open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
{filtered.map((o) => (
<button
key={o}
type="button"
tabIndex={-1}
className="block w-full text-left px-2 py-1 hover:bg-accent/40"
onMouseDown={(e) => { e.preventDefault(); commit(o); }}
>
{o}
</button>
))}
</div>
)}
</div>
);
}
+3 -3
View File
@@ -25,10 +25,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean } React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
>(({ className, children, hideClose, ...props }, ref) => ( >(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> {!hideOverlay && <DialogOverlay />}
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
+98
View File
@@ -0,0 +1,98 @@
// 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;
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 '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);
if (v) out.push(`${code.toUpperCase()}@${v}`);
}
return out.join(';');
}
+78
View File
@@ -0,0 +1,78 @@
// Maps ADIF DXCC entity numbers to ISO-3166 alpha-2 codes (flagcdn slugs),
// so we can show the contacted entity's flag in the UI.
//
// Why not emoji flags? Windows does NOT render regional-indicator flag
// emoji (it shows the two letters instead), and this is a Windows app — so
// we use flagcdn.com PNGs keyed by ISO code instead.
//
// Numbers are taken verbatim from internal/dxcc/adif_numbers.go so they're
// guaranteed correct. Coverage is the commonly-worked entities; an unknown
// DXCC number simply yields no flag (graceful — better than a wrong one).
//
// French overseas territories (Corsica, Guadeloupe, Martinique, Réunion,
// New Caledonia, French Polynesia…) are mapped to "fr": they fly the French
// tricolore and several of their ISO codes aren't served by flagcdn.
const DXCC_ISO: Record<number, string> = {
// North America
1: 'ca', 291: 'us', 6: 'us', 110: 'us', 50: 'mx',
202: 'pr', 285: 'vi', 91: 'vg', 69: 'ky', 82: 'jm', 60: 'bs', 64: 'bm',
78: 'ht', 72: 'do', 70: 'cu', 62: 'bb', 90: 'tt', 77: 'gd', 97: 'lc',
98: 'vc', 95: 'dm', 96: 'ms', 249: 'kn', 94: 'ag', 89: 'tc',
79: 'fr', 84: 'fr', 63: 'fr',
// Central America
66: 'bz', 80: 'hn', 74: 'sv', 86: 'ni', 308: 'cr', 88: 'pa', 76: 'gt',
// South America
116: 'co', 120: 'ec', 71: 'ec', 136: 'pe', 104: 'bo', 112: 'cl',
100: 'ar', 144: 'uy', 132: 'py', 108: 'br', 148: 've', 129: 'gy',
140: 'sr', 216: 'co',
// Western Europe
227: 'fr', 214: 'fr', 230: 'de', 209: 'be', 263: 'nl', 254: 'lu',
287: 'ch', 251: 'li', 206: 'at', 248: 'it', 225: 'it', 281: 'es',
272: 'pt', 203: 'ad', 278: 'sm', 295: 'va', 260: 'mc',
// British Isles
223: 'gb-eng', 279: 'gb-sct', 294: 'gb-wls', 265: 'gb-nir', 245: 'ie',
114: 'im', 122: 'je', 106: 'gg',
// Central / Eastern Europe
269: 'pl', 503: 'cz', 504: 'sk', 239: 'hu', 275: 'ro', 212: 'bg',
296: 'rs', 514: 'me', 499: 'si', 497: 'hr', 501: 'ba', 502: 'mk',
522: 'xk', 7: 'al',
// Greece / Mediterranean
236: 'gr', 45: 'gr', 40: 'gr', 180: 'gr', 215: 'cy', 257: 'mt', 390: 'tr',
// Nordic / Baltic
221: 'dk', 222: 'fo', 237: 'gl', 284: 'se', 266: 'no', 224: 'fi',
5: 'ax', 242: 'is', 52: 'ee', 145: 'lv', 146: 'lt',
// Eastern Europe / Caucasus / Russia
27: 'by', 288: 'ua', 179: 'md', 75: 'ge', 14: 'am', 18: 'az',
54: 'ru', 15: 'ru', 126: 'ru',
// Middle East
336: 'il', 342: 'jo', 354: 'lb', 384: 'sy', 378: 'sa', 391: 'ae',
304: 'bh', 348: 'kw', 376: 'qa', 370: 'om', 492: 'ye', 330: 'ir', 333: 'iq',
// North Africa
478: 'eg', 436: 'ly', 400: 'dz', 446: 'ma', 302: 'eh', 474: 'tn',
// Sub-Saharan Africa
462: 'za', 464: 'na', 402: 'bw', 452: 'zw', 482: 'zm', 181: 'mz',
438: 'mg', 165: 'mu', 453: 'fr', 379: 'sc', 430: 'ke', 470: 'tz',
286: 'ug', 53: 'et', 51: 'er', 466: 'sd', 521: 'ss', 450: 'ng',
424: 'gh', 406: 'cm', 456: 'sn', 434: 'lr', 458: 'sl', 416: 'bj',
483: 'tg', 428: 'ci', 442: 'ml', 187: 'ne', 410: 'td', 32: 'cv',
420: 'ga', 444: 'mr', 382: 'dj', 454: 'rw', 107: 'gn', 39: 'km', 169: 'fr',
// Asia
339: 'jp', 137: 'kr', 344: 'kp', 318: 'cn', 324: 'in', 372: 'pk',
315: 'lk', 369: 'np', 305: 'bd', 306: 'bt', 309: 'mm', 299: 'my',
46: 'my', 381: 'sg', 327: 'id', 375: 'ph', 345: 'bn', 312: 'kh',
143: 'la', 387: 'th', 293: 'vn', 130: 'kz', 292: 'uz', 280: 'tm',
262: 'tj', 135: 'kg', 3: 'af', 159: 'mv', 363: 'mn', 321: 'hk',
386: 'tw', 511: 'tl',
// Oceania
150: 'au', 163: 'pg', 185: 'sb', 158: 'vu', 176: 'fj', 190: 'ws',
170: 'nz', 162: 'fr', 175: 'fr', 160: 'to', 282: 'tv', 301: 'ki',
188: 'nu', 168: 'mh', 189: 'nf', 147: 'au',
};
// flagURL returns a flagcdn PNG URL for the given DXCC entity number, or ''
// when we don't have a mapping. Height ~20px by default (retina-friendly).
export function flagURL(dxcc?: number): string {
if (!dxcc) return '';
const iso = DXCC_ISO[dxcc];
return iso ? `https://flagcdn.com/h20/${iso}.png` : '';
}
+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; }
+166 -2
View File
@@ -4,9 +4,13 @@ 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 {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';
@@ -15,10 +19,18 @@ 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 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 +39,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,26 +79,60 @@ 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 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 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 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,22 +145,46 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
export function GetQSO(arg1:number):Promise<qso.QSO>; export function GetQSO(arg1:number):Promise<qso.QSO>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>; export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetStartupStatus():Promise<main.StartupStatus>; 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 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>>;
export function ListCountries():Promise<Array<string>>;
export function ListOperatingTree():Promise<Array<operating.Station>>; export function ListOperatingTree():Promise<Array<operating.Station>>;
export function ListProfiles():Promise<Array<profile.Profile>>; 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>>;
@@ -101,18 +193,44 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>; export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function MoveDatabase(arg1:string):Promise<void>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenDatabase(arg1:string):Promise<void>;
export function OpenExternalURL(arg1:string):Promise<void>; 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 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 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 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>;
@@ -123,12 +241,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>;
@@ -149,30 +275,68 @@ 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 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 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>;
+324 -4
View File
@@ -10,6 +10,18 @@ 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 ClearLookupCache() { export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache'](); return window['go']['main']['App']['ClearLookupCache']();
} }
@@ -18,6 +30,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 +50,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,12 +130,32 @@ 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) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
}
export function DuplicateProfile(arg1, arg2) { 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 FilterFields() {
return window['go']['main']['App']['FilterFields']();
} }
export function FindQSOsForUpload(arg1, arg2) { export function FindQSOsForUpload(arg1, arg2) {
@@ -86,6 +166,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']();
} }
@@ -98,6 +206,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']();
} }
@@ -110,6 +222,22 @@ 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 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 +262,10 @@ export function GetQSO(arg1) {
return window['go']['main']['App']['GetQSO'](arg1); return window['go']['main']['App']['GetQSO'](arg1);
} }
export function GetRotatorHeading() {
return window['go']['main']['App']['GetRotatorHeading']();
}
export function GetRotatorSettings() { export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings'](); return window['go']['main']['App']['GetRotatorSettings']();
} }
@@ -146,14 +278,50 @@ 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 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() {
return window['go']['main']['App']['ListClusterServers'](); return window['go']['main']['App']['ListClusterServers']();
} }
export function ListCountries() {
return window['go']['main']['App']['ListCountries']();
}
export function ListOperatingTree() { export function ListOperatingTree() {
return window['go']['main']['App']['ListOperatingTree'](); return window['go']['main']['App']['ListOperatingTree']();
} }
@@ -166,6 +334,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']();
} }
@@ -182,10 +358,18 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1); return window['go']['main']['App']['LookupCallsign'](arg1);
} }
export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1);
}
export function OpenADIFFile() { export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
export function OpenDatabase(arg1) {
return window['go']['main']['App']['OpenDatabase'](arg1);
}
export function OpenExternalURL(arg1) { export function OpenExternalURL(arg1) {
return window['go']['main']['App']['OpenExternalURL'](arg1); return window['go']['main']['App']['OpenExternalURL'](arg1);
} }
@@ -194,10 +378,38 @@ 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']();
} }
export function PickOpenDatabase() {
return window['go']['main']['App']['PickOpenDatabase']();
}
export function 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() {
return window['go']['main']['App']['QuitApp']();
}
export function RefreshCtyDat() { export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat'](); return window['go']['main']['App']['RefreshCtyDat']();
} }
@@ -206,6 +418,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() {
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);
} }
@@ -226,6 +454,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);
} }
@@ -238,6 +478,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);
} }
@@ -278,10 +522,26 @@ 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);
} }
export function 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);
} }
@@ -290,6 +550,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);
} }
@@ -298,6 +562,14 @@ 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);
} }
@@ -306,6 +578,10 @@ 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']();
} }
@@ -314,6 +590,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']();
} }
@@ -322,14 +602,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);
} }
+692 -51
View File
@@ -19,6 +19,7 @@ export namespace adif {
export class ImportResult { export class ImportResult {
total: number; total: number;
imported: number; imported: number;
updated: number;
skipped: number; skipped: number;
duplicates: number; duplicates: number;
duplicate_samples: string[]; duplicate_samples: string[];
@@ -32,6 +33,7 @@ export namespace adif {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.total = source["total"]; this.total = source["total"];
this.imported = source["imported"]; this.imported = source["imported"];
this.updated = source["updated"];
this.skipped = source["skipped"]; this.skipped = source["skipped"];
this.duplicates = source["duplicates"]; this.duplicates = source["duplicates"];
this.duplicate_samples = source["duplicate_samples"]; this.duplicate_samples = source["duplicate_samples"];
@@ -41,6 +43,279 @@ export namespace adif {
} }
export namespace audio {
export class Device {
id: string;
name: string;
default: boolean;
static createFrom(source: any = {}) {
return new Device(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.default = source["default"];
}
}
}
export namespace award {
export class BandCount {
band: string;
worked: number;
confirmed: number;
static createFrom(source: any = {}) {
return new BandCount(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.band = source["band"];
this.worked = source["worked"];
this.confirmed = source["confirmed"];
}
}
export class Def {
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[];
valid_bands?: string[];
valid_modes?: string[];
emission?: string[];
confirm: string[];
validate?: string[];
grant_codes?: string;
export_credit_granted?: boolean;
total: number;
builtin: boolean;
static createFrom(source: any = {}) {
return new Def(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.name = source["name"];
this.description = source["description"];
this.valid = source["valid"];
this.protected = source["protected"];
this.url = source["url"];
this.download_url = source["download_url"];
this.ref_url = source["ref_url"];
this.valid_from = source["valid_from"];
this.valid_to = source["valid_to"];
this.alias = source["alias"];
this.type = source["type"];
this.field = source["field"];
this.match_by = source["match_by"];
this.exact_match = source["exact_match"];
this.pattern = source["pattern"];
this.leading_str = source["leading_str"];
this.trailing_str = source["trailing_str"];
this.multi = source["multi"];
this.dynamic = source["dynamic"];
this.add_prefixes = source["add_prefixes"];
this.dxcc_filter = source["dxcc_filter"];
this.valid_bands = source["valid_bands"];
this.valid_modes = source["valid_modes"];
this.emission = source["emission"];
this.confirm = source["confirm"];
this.validate = source["validate"];
this.grant_codes = source["grant_codes"];
this.export_credit_granted = source["export_credit_granted"];
this.total = source["total"];
this.builtin = source["builtin"];
}
}
export class Ref {
ref: string;
name?: string;
group?: string;
subgrp?: string;
worked: boolean;
confirmed: boolean;
validated: boolean;
bands: string[];
confirmed_bands: string[];
validated_bands: string[];
static createFrom(source: any = {}) {
return new Ref(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ref = source["ref"];
this.name = source["name"];
this.group = source["group"];
this.subgrp = source["subgrp"];
this.worked = source["worked"];
this.confirmed = source["confirmed"];
this.validated = source["validated"];
this.bands = source["bands"];
this.confirmed_bands = source["confirmed_bands"];
this.validated_bands = source["validated_bands"];
}
}
export class Result {
code: string;
name: string;
field: string;
worked: number;
confirmed: number;
validated: number;
total: number;
bands: BandCount[];
refs: Ref[];
error?: string;
static createFrom(source: any = {}) {
return new Result(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.name = source["name"];
this.field = source["field"];
this.worked = source["worked"];
this.confirmed = source["confirmed"];
this.validated = source["validated"];
this.total = source["total"];
this.bands = this.convertValues(source["bands"], BandCount);
this.refs = this.convertValues(source["refs"], Ref);
this.error = source["error"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace awardref {
export class Ref {
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;
static createFrom(source: any = {}) {
return new Ref(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.name = source["name"];
this.dxcc = source["dxcc"];
this.group = source["group"];
this.subgrp = source["subgrp"];
this.dxcc_list = source["dxcc_list"];
this.pattern = source["pattern"];
this.valid = source["valid"];
this.valid_from = source["valid_from"];
this.valid_to = source["valid_to"];
this.score = source["score"];
this.bonus = source["bonus"];
this.gridsquare = source["gridsquare"];
this.alias = source["alias"];
}
}
export class Preset {
key: string;
name: string;
field: string;
dxcc: number;
refs: Ref[];
static createFrom(source: any = {}) {
return new Preset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.key = source["key"];
this.name = source["name"];
this.field = source["field"];
this.dxcc = source["dxcc"];
this.refs = this.convertValues(source["refs"], Ref);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace cat { export namespace cat {
export class RigState { export class RigState {
@@ -188,6 +463,7 @@ export namespace extsvc {
export class ServiceConfig { export class ServiceConfig {
api_key: string; api_key: string;
email: string; email: string;
username: string;
password: string; password: string;
callsign: string; callsign: string;
force_station_callsign: string; force_station_callsign: string;
@@ -207,6 +483,7 @@ export namespace extsvc {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.api_key = source["api_key"]; this.api_key = source["api_key"];
this.email = source["email"]; this.email = source["email"];
this.username = source["username"];
this.password = source["password"]; this.password = source["password"];
this.callsign = source["callsign"]; this.callsign = source["callsign"];
this.force_station_callsign = source["force_station_callsign"]; this.force_station_callsign = source["force_station_callsign"];
@@ -349,6 +626,110 @@ export namespace lookup {
export namespace main { export namespace main {
export class AudioSettings {
from_radio: string;
to_radio: string;
recording_device: string;
listening_device: string;
qso_record: boolean;
qso_dir: string;
preroll_seconds: number;
ptt_method: string;
ptt_port: string;
format: string;
from_gain: number;
mic_gain: number;
static createFrom(source: any = {}) {
return new AudioSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.from_radio = source["from_radio"];
this.to_radio = source["to_radio"];
this.recording_device = source["recording_device"];
this.listening_device = source["listening_device"];
this.qso_record = source["qso_record"];
this.qso_dir = source["qso_dir"];
this.preroll_seconds = source["preroll_seconds"];
this.ptt_method = source["ptt_method"];
this.ptt_port = source["ptt_port"];
this.format = source["format"];
this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"];
}
}
export class AwardRefMeta {
code: string;
count: number;
updated_at: string;
can_update: boolean;
static createFrom(source: any = {}) {
return new AwardRefMeta(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.count = source["count"];
this.updated_at = source["updated_at"];
this.can_update = source["can_update"];
}
}
export class AwardStatRow {
label: string;
cells: number[];
total: number;
grand_total: number;
static createFrom(source: any = {}) {
return new AwardStatRow(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.cells = source["cells"];
this.total = source["total"];
this.grand_total = source["grand_total"];
}
}
export class AwardStatsResult {
code: string;
bands: string[];
rows: AwardStatRow[];
static createFrom(source: any = {}) {
return new AwardStatsResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.bands = source["bands"];
this.rows = this.convertValues(source["rows"], AwardStatRow);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class BackupSettings { export class BackupSettings {
enabled: boolean; enabled: boolean;
folder: string; folder: string;
@@ -393,6 +774,24 @@ export namespace main {
this.digital_default = source["digital_default"]; this.digital_default = source["digital_default"];
} }
} }
export class ClublogCtyInfo {
enabled: boolean;
loaded: boolean;
date: string;
count: number;
static createFrom(source: any = {}) {
return new ClublogCtyInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.loaded = source["loaded"];
this.date = source["date"];
this.count = source["count"];
}
}
export class CtyDatInfo { export class CtyDatInfo {
path: string; path: string;
entities: number; entities: number;
@@ -411,6 +810,88 @@ export namespace main {
this.file_mod_time = source["file_mod_time"]; this.file_mod_time = source["file_mod_time"];
} }
} }
export class DVKMessage {
slot: number;
label: string;
has_audio: boolean;
duration_sec: number;
static createFrom(source: any = {}) {
return new DVKMessage(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.slot = source["slot"];
this.label = source["label"];
this.has_audio = source["has_audio"];
this.duration_sec = source["duration_sec"];
}
}
export class DVKStatus {
recording: boolean;
playing: boolean;
rec_slot: number;
static createFrom(source: any = {}) {
return new DVKStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.recording = source["recording"];
this.playing = source["playing"];
this.rec_slot = source["rec_slot"];
}
}
export class DatabaseSettings {
path: string;
default_path: string;
is_custom: boolean;
static createFrom(source: any = {}) {
return new DatabaseSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.default_path = source["default_path"];
this.is_custom = source["is_custom"];
}
}
export class EmailSettings {
enabled: boolean;
smtp_host: string;
smtp_port: number;
smtp_user: string;
smtp_password: string;
from: string;
encryption: string;
auth: boolean;
auto_send: boolean;
subject: string;
body: string;
static createFrom(source: any = {}) {
return new EmailSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.smtp_host = source["smtp_host"];
this.smtp_port = source["smtp_port"];
this.smtp_user = source["smtp_user"];
this.smtp_password = source["smtp_password"];
this.from = source["from"];
this.encryption = source["encryption"];
this.auth = source["auth"];
this.auto_send = source["auto_send"];
this.subject = source["subject"];
this.body = source["body"];
}
}
export class ModePreset { export class ModePreset {
name: string; name: string;
default_rst_sent?: string; default_rst_sent?: string;
@@ -430,6 +911,9 @@ export namespace main {
export class ListsSettings { export class ListsSettings {
bands: string[]; bands: string[];
modes: ModePreset[]; modes: ModePreset[];
rst_phone: string[];
rst_cw: string[];
rst_digital: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ListsSettings(source); return new ListsSettings(source);
@@ -439,6 +923,9 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.bands = source["bands"]; this.bands = source["bands"];
this.modes = this.convertValues(source["modes"], ModePreset); this.modes = this.convertValues(source["modes"], ModePreset);
this.rst_phone = source["rst_phone"];
this.rst_cw = source["rst_cw"];
this.rst_digital = source["rst_digital"];
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -496,6 +983,7 @@ export namespace main {
clublog_status: string; clublog_status: string;
hrdlog_status: string; hrdlog_status: string;
qrzcom_status: string; qrzcom_status: string;
qrzcom_confirmed: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLDefaults(source); return new QSLDefaults(source);
@@ -512,6 +1000,43 @@ export namespace main {
this.clublog_status = source["clublog_status"]; this.clublog_status = source["clublog_status"];
this.hrdlog_status = source["hrdlog_status"]; this.hrdlog_status = source["hrdlog_status"];
this.qrzcom_status = source["qrzcom_status"]; this.qrzcom_status = source["qrzcom_status"];
this.qrzcom_confirmed = source["qrzcom_confirmed"];
}
}
export class QSOAwardRef {
code: string;
ref: string;
name?: string;
pickable: boolean;
static createFrom(source: any = {}) {
return new QSOAwardRef(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.ref = source["ref"];
this.name = source["name"];
this.pickable = source["pickable"];
}
}
export class RotatorHeading {
enabled: boolean;
ok: boolean;
azimuth: number;
raw: string;
static createFrom(source: any = {}) {
return new RotatorHeading(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.ok = source["ok"];
this.azimuth = source["azimuth"];
this.raw = source["raw"];
} }
} }
export class RotatorSettings { export class RotatorSettings {
@@ -632,6 +1157,86 @@ export namespace main {
this.my_pota_ref = source["my_pota_ref"]; this.my_pota_ref = source["my_pota_ref"];
} }
} }
export class WKMacro {
label: string;
text: string;
static createFrom(source: any = {}) {
return new WKMacro(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.text = source["text"];
}
}
export class WinkeyerSettings {
enabled: 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;
engine: string;
esc_clears_call: boolean;
send_on_type: boolean;
macros: WKMacro[];
static createFrom(source: any = {}) {
return new WinkeyerSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.port = source["port"];
this.baud = source["baud"];
this.wpm = source["wpm"];
this.weight = source["weight"];
this.lead_in_ms = source["lead_in_ms"];
this.tail_ms = source["tail_ms"];
this.ratio = source["ratio"];
this.farnsworth = source["farnsworth"];
this.sidetone_hz = source["sidetone_hz"];
this.mode = source["mode"];
this.swap = source["swap"];
this.autospace = source["autospace"];
this.use_ptt = source["use_ptt"];
this.serial_echo = source["serial_echo"];
this.engine = source["engine"];
this.esc_clears_call = source["esc_clears_call"];
this.send_on_type = source["send_on_type"];
this.macros = this.convertValues(source["macros"], WKMacro);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
} }
@@ -872,6 +1477,22 @@ export namespace qso {
this.status = source["status"]; this.status = source["status"];
} }
} }
export class Condition {
field: string;
op: string;
value: string;
static createFrom(source: any = {}) {
return new Condition(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.field = source["field"];
this.op = source["op"];
this.value = source["value"];
}
}
export class ListFilter { export class ListFilter {
callsign?: string; callsign?: string;
band?: string; band?: string;
@@ -953,6 +1574,8 @@ export namespace qso {
hrdlog_qso_upload_status?: string; hrdlog_qso_upload_status?: string;
qrzcom_qso_upload_date?: string; qrzcom_qso_upload_date?: string;
qrzcom_qso_upload_status?: string; qrzcom_qso_upload_status?: string;
qrzcom_qso_download_date?: string;
qrzcom_qso_download_status?: string;
contest_id?: string; contest_id?: string;
srx?: number; srx?: number;
stx?: number; stx?: number;
@@ -1058,6 +1681,8 @@ export namespace qso {
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"]; this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"]; this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"];
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"]; this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
this.contest_id = source["contest_id"]; this.contest_id = source["contest_id"];
this.srx = source["srx"]; this.srx = source["srx"];
this.stx = source["stx"]; this.stx = source["stx"];
@@ -1118,6 +1743,44 @@ export namespace qso {
return a; return a;
} }
} }
export class QueryFilter {
quick_callsign?: string;
conditions?: Condition[];
match?: string;
limit?: number;
offset?: number;
static createFrom(source: any = {}) {
return new QueryFilter(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.quick_callsign = source["quick_callsign"];
this.conditions = this.convertValues(source["conditions"], Condition);
this.match = source["match"];
this.limit = source["limit"];
this.offset = source["offset"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class UploadRow { export class UploadRow {
id: number; id: number;
qso_date: string; qso_date: string;
@@ -1142,55 +1805,6 @@ export namespace qso {
this.status = source["status"]; this.status = source["status"];
} }
} }
export class WorkedEntry {
id: number;
// Go type: time
qso_date: any;
band: string;
mode: string;
rst_sent?: string;
rst_rcvd?: string;
qsl_sent?: string;
qsl_rcvd?: string;
lotw_sent?: string;
lotw_rcvd?: string;
static createFrom(source: any = {}) {
return new WorkedEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.qso_date = this.convertValues(source["qso_date"], null);
this.band = source["band"];
this.mode = source["mode"];
this.rst_sent = source["rst_sent"];
this.rst_rcvd = source["rst_rcvd"];
this.qsl_sent = source["qsl_sent"];
this.qsl_rcvd = source["qsl_rcvd"];
this.lotw_sent = source["lotw_sent"];
this.lotw_rcvd = source["lotw_rcvd"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class WorkedBefore { export class WorkedBefore {
callsign: string; callsign: string;
count: number; count: number;
@@ -1201,7 +1815,7 @@ export namespace qso {
bands: string[]; bands: string[];
modes: string[]; modes: string[];
band_modes: BandMode[]; band_modes: BandMode[];
entries: WorkedEntry[]; entries: QSO[];
dxcc?: number; dxcc?: number;
dxcc_name?: string; dxcc_name?: string;
dxcc_count: number; dxcc_count: number;
@@ -1227,7 +1841,7 @@ export namespace qso {
this.bands = source["bands"]; this.bands = source["bands"];
this.modes = source["modes"]; this.modes = source["modes"];
this.band_modes = this.convertValues(source["band_modes"], BandMode); this.band_modes = this.convertValues(source["band_modes"], BandMode);
this.entries = this.convertValues(source["entries"], WorkedEntry); this.entries = this.convertValues(source["entries"], QSO);
this.dxcc = source["dxcc"]; this.dxcc = source["dxcc"];
this.dxcc_name = source["dxcc_name"]; this.dxcc_name = source["dxcc_name"];
this.dxcc_count = source["dxcc_count"]; this.dxcc_count = source["dxcc_count"];
@@ -1295,3 +1909,30 @@ export namespace udp {
} }
export namespace winkeyer {
export class Status {
connected: boolean;
busy: boolean;
wpm: number;
version: number;
port: string;
error?: string;
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.busy = source["busy"];
this.wpm = source["wpm"];
this.version = source["version"];
this.port = source["port"];
this.error = source["error"];
}
}
}
+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"])
}
})
}
}
+53 -13
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()
@@ -69,8 +99,8 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
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)
@@ -169,6 +200,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus) writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate) writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus) writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
// --- Contest --- // --- Contest ---
writeField(bw, "CONTEST_ID", q.ContestID) writeField(bw, "CONTEST_ID", q.ContestID)
@@ -216,8 +249,15 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
writeField(bw, "NOTES", q.Notes) writeField(bw, "NOTES", q.Notes)
// --- Extras (unpromoted ADIF fields preserved verbatim) --- // --- Extras (unpromoted ADIF fields preserved verbatim) ---
// In standard mode we drop application-specific tags (APP_*) so the file
// stays portable to other loggers; in full mode they're kept for a
// lossless OpsLog round-trip.
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_") {
continue
}
writeField(bw, tag, v)
} }
bw.WriteString("<EOR>\n") bw.WriteString("<EOR>\n")
+102 -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,12 +264,13 @@ 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",
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status", "hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status", "qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
// Contest // Contest
"contest_id", "srx", "stx", "srx_string", "stx_string", "contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect", "check", "precedence", "arrl_sect",
@@ -252,6 +339,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"]
@@ -299,6 +395,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"]
@@ -315,6 +414,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"] q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"] q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"] q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
// Contest // Contest
q.ContestID = rec["contest_id"] q.ContestID = rec["contest_id"]
+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) {
+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
}
+760
View File
@@ -0,0 +1,760 @@
// 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 {
lq := []string{"lotw", "qsl"}
return []Def{
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lq, 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: lq, 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: lq, 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: []string{"lotw", "qsl", "eqsl"}, 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: lq, 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: lq, 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: []string{"qsl"}, 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: lq, 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: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lq, 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)
}
// 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)
}
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]})
}
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
var found []string
switch {
case re != nil:
// Award-level regex: capture group 1 (or whole match) for each hit.
found = regexTokens(re, raw)
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.
found = []string{normalizeRef(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)) }
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
}
+172
View File
@@ -0,0 +1,172 @@
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)
}
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)
}
}
}
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
}
+161
View File
@@ -0,0 +1,161 @@
package awardref
import (
"context"
"encoding/csv"
"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},
}
// 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
+173
View File
@@ -0,0 +1,173 @@
// 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 list, keyed by upper-cased callsign.
type DB struct {
exceptions map[string][]Exception
date string // cty.xml generation date (for the UI)
count int
}
// Count returns how many exceptions were loaded.
func (db *DB) Count() int { return db.count }
// 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{}}
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++
}
}
return db, nil
}
// 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,5 @@
-- QRZ.com confirmation (download) tracking. Mirrors the upload columns from
-- 0014: QRZCOM_QSO_DOWNLOAD_STATUS = 'Y' when QRZ reports the QSO as
-- confirmed (matched by the other op), with the date it was pulled.
ALTER TABLE qso ADD COLUMN qrzcom_qso_download_date TEXT;
ALTER TABLE qso ADD COLUMN qrzcom_qso_download_status TEXT;
@@ -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 '';
+112 -318
View File
@@ -1,333 +1,127 @@
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
}
// 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",
+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": 155,
"western kiribati": 301,
"western sahara": 302,
"willis island": 303,
"yemen": 492,
"zambia": 482,
"zimbabwe": 452,
}
+26
View File
@@ -7,6 +7,8 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -147,6 +149,30 @@ func (m *Manager) Lookup(callsign string) (Match, bool) {
return db.Lookup(callsign) return db.Lookup(callsign)
} }
// EntityNames returns the sorted, de-duplicated DXCC entity names from the
// loaded cty.dat — the canonical list for a "Country" picker. Empty until
// cty.dat has loaded.
func (m *Manager) EntityNames() []string {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return nil
}
seen := map[string]bool{}
var out []string
for _, e := range db.Entities() {
n := strings.TrimSpace(e.Name)
if n == "" || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
sort.Strings(out)
return out
}
// Info returns metadata about the currently-loaded cty.dat (or zero value // Info returns metadata about the currently-loaded cty.dat (or zero value
// if nothing loaded). // if nothing loaded).
func (m *Manager) Info() ctySource { func (m *Manager) Info() ctySource {
+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
}
+6 -3
View File
@@ -53,16 +53,19 @@ 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).
type ServiceConfig struct { type ServiceConfig struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Email string `json:"email"` // Club Log account email Email string `json:"email"` // Club Log account email
Password string `json:"password"` // Club Log account password Username string `json:"username"` // LoTW website login (for confirmation download)
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)
+57
View File
@@ -4,6 +4,9 @@ import (
"context" "context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io"
"net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -11,6 +14,60 @@ import (
"time" "time"
) )
// lotwReportURL is LoTW's confirmation-report endpoint. It returns an ADIF
// document of the user's QSOs (optionally only confirmed ones).
const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
// returned — used for incremental "Last download" updates.
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("lotw: website login (username/password) not set")
}
q := url.Values{}
q.Set("login", user)
q.Set("password", cfg.Password)
q.Set("qso_query", "1")
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
if s := strings.TrimSpace(since); s != "" {
q.Set("qso_qslsince", s)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil)
if err != nil {
return "", fmt.Errorf("lotw: build request: %w", err)
}
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("lotw: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return "", fmt.Errorf("lotw: read response: %w", err)
}
text := string(body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
}
// LoTW returns a plain-text error (not ADIF) on bad login.
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
msg := strings.TrimSpace(text)
if len(msg) > 200 {
msg = msg[:200]
}
return "", fmt.Errorf("lotw: unexpected response: %s", msg)
}
return text, nil
}
// LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no // LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no
// plain HTTP API — every QSO must be signed with the station certificate // plain HTTP API — every QSO must be signed with the station certificate
// before LoTW accepts it. We write the QSO to a temporary ADIF file and run // before LoTW accepts it. We write the QSO to a temporary ADIF file and run
+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
+90 -2
View File
@@ -3,6 +3,7 @@ package extsvc
import ( import (
"context" "context"
"fmt" "fmt"
"html"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -70,6 +71,80 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
return parseQRZResponse(string(body)) return parseQRZResponse(string(body))
} }
// QRZFetchResult is the parsed outcome of a QRZ FETCH.
type QRZFetchResult struct {
ADIF string // raw ADIF document
Result string // RESULT field (OK / FAIL / AUTH)
Count string // COUNT field reported by QRZ
}
// FetchQRZ pulls logbook records as ADIF via the QRZ FETCH action. option is
// the QRZ OPTION string (e.g. "ALL"). The ADIF document is returned in the
// response's ADIF field.
func FetchQRZ(ctx context.Context, client *http.Client, apiKey, option string) (QRZFetchResult, error) {
var out QRZFetchResult
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return out, fmt.Errorf("qrz: api key not set")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "FETCH")
if option != "" {
form.Set("OPTION", option)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return out, fmt.Errorf("qrz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return out, fmt.Errorf("qrz: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
if err != nil {
return out, fmt.Errorf("qrz: read response: %w", err)
}
// The response is "RESULT=OK&COUNT=N&ADIF=<adif>". The ADIF blob can
// contain '&' and ';', so we can't url.ParseQuery the whole body (Go
// caps the number of params). Split off the ADIF value manually and
// only query-parse the small status header.
full := string(body)
head, adifPart := full, ""
if i := strings.Index(full, "ADIF="); i >= 0 {
head = full[:i]
adifPart = full[i+len("ADIF="):]
}
vals, _ := url.ParseQuery(strings.TrimRight(head, "&"))
out.Result = strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
out.Count = strings.TrimSpace(vals.Get("COUNT"))
if out.Result == "AUTH" || out.Result == "FAIL" {
reason := strings.TrimSpace(vals.Get("REASON"))
if reason == "" {
reason = "fetch rejected"
}
return out, fmt.Errorf("qrz: %s", reason)
}
// The ADIF value may be url-encoded (%3C) and/or HTML-entity-encoded
// (QRZ returns &lt; &gt; &amp;). Decode both so the ADIF parser sees
// real '<' / '>' tags.
if strings.Contains(adifPart, "%3C") || strings.Contains(adifPart, "%3c") {
if dec, derr := url.QueryUnescape(adifPart); derr == nil {
adifPart = dec
}
}
if strings.Contains(adifPart, "&lt;") || strings.Contains(adifPart, "&gt;") || strings.Contains(adifPart, "&amp;") {
adifPart = html.UnescapeString(adifPart)
}
out.ADIF = adifPart
return out, nil
}
// TestQRZ checks a logbook API key with ACTION=STATUS and returns a short // TestQRZ checks a logbook API key with ACTION=STATUS and returns a short
// human-readable summary (callsign + QSO count) for the settings UI. An // human-readable summary (callsign + QSO count) for the settings UI. An
// invalid key comes back as STATUS=AUTH → returned as an error. // invalid key comes back as STATUS=AUTH → returned as an error.
@@ -100,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 == "" {
@@ -126,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"))
@@ -147,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 {
+11 -4
View File
@@ -50,7 +50,7 @@ type Provider interface {
// don't (or when no provider returned anything). Decoupled via interface so // don't (or when no provider returned anything). Decoupled via interface so
// `lookup` doesn't import the dxcc package directly. // `lookup` doesn't import the dxcc package directly.
type DXCCResolver interface { type DXCCResolver interface {
Resolve(callsign string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool) Resolve(callsign string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool)
} }
// Manager composes a cache with one or more providers. // Manager composes a cache with one or more providers.
@@ -210,7 +210,7 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil { if dxcc == nil {
return false return false
} }
country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign) dxccNum, country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
if !ok { if !ok {
return false return false
} }
@@ -221,8 +221,15 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if ituz != 0 { r.ITUZ = ituz; filled = true } if ituz != 0 { r.ITUZ = ituz; filled = true }
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true } if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true } if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
// Slashed call → drop QRZ's DXCC# (it's the home call's). // cty.dat is authoritative for the *operating* entity: it strips benign
if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 { // suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
// Use its DXCC# when known — this overrides the provider's home-call
// value AND fixes portable calls like F4BPO/P (same entity, must keep
// France's 227). Only when cty.dat can't map a slashed call do we drop
// the provider's number rather than mislabel.
if dxccNum != 0 {
if r.DXCC != dxccNum { r.DXCC = dxccNum; filled = true }
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
r.DXCC = 0 r.DXCC = 0
filled = true filled = true
} }
+144
View File
@@ -0,0 +1,144 @@
// 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 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
}
+408 -44
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.
@@ -78,6 +108,8 @@ type QSO struct {
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"` HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"` QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"` QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
// --- Contest --- // --- Contest ---
ContestID string `json:"contest_id,omitempty"` ContestID string `json:"contest_id,omitempty"`
@@ -167,6 +199,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
clublog_qso_upload_date, clublog_qso_upload_status, clublog_qso_upload_date, clublog_qso_upload_status,
hrdlog_qso_upload_date, hrdlog_qso_upload_status, hrdlog_qso_upload_date, hrdlog_qso_upload_status,
qrzcom_qso_upload_date, qrzcom_qso_upload_status, qrzcom_qso_upload_date, qrzcom_qso_upload_status,
qrzcom_qso_download_date, qrzcom_qso_download_status,
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect, contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path, prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
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,
@@ -219,6 +252,7 @@ func (q *QSO) args() []any {
q.ClublogUploadDate, q.ClublogUploadStatus, q.ClublogUploadDate, q.ClublogUploadStatus,
q.HRDLogUploadDate, q.HRDLogUploadStatus, q.HRDLogUploadDate, q.HRDLogUploadStatus,
q.QRZComUploadDate, q.QRZComUploadStatus, q.QRZComUploadDate, q.QRZComUploadStatus,
q.QRZComDownloadDate, q.QRZComDownloadStatus,
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect, q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath, q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
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,
@@ -549,6 +583,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
@@ -565,7 +841,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"`
@@ -611,19 +887,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.
@@ -636,7 +899,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{},
@@ -651,9 +914,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)
@@ -664,40 +927,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()
@@ -1062,6 +1310,118 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode) return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
} }
// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
out[DedupeKey(call, when, band, mode)] = id
}
return out, rows.Err()
}
// ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
type ConfirmedSets struct {
DXCC map[int]bool // dxcc entity confirmed
Band map[string]bool // "dxcc|band"
Slot map[string]bool // "dxcc|band|mode"
}
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
func SlotKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
}
// confirmedCols whitelists the received-status columns ConfirmedSlots may
// OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{
"lotw_rcvd": true,
"qsl_rcvd": true,
"eqsl_rcvd": true,
"qrzcom_qso_download_status": true,
}
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
// only the given received-status columns as "confirmed". This lets the caller
// scope award-relevant confirmations per service — e.g. LoTW download uses
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
// {qrzcom_qso_download_status}.
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
var conds []string
for _, c := range cols {
if confirmedCols[c] {
conds = append(conds, c+" = 'Y'")
}
}
if len(conds) == 0 {
return sets, nil
}
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,''))
FROM qso
WHERE `+strings.Join(conds, " OR "))
if err != nil {
return sets, err
}
defer rows.Close()
for rows.Next() {
var dxcc int
var band, mode string
if err := rows.Scan(&dxcc, &band, &mode); err != nil {
return sets, err
}
if dxcc == 0 {
continue
}
sets.DXCC[dxcc] = true
sets.Band[BandKey(dxcc, band)] = true
sets.Slot[SlotKey(dxcc, band, mode)] = true
}
return sets, rows.Err()
}
// MarkQRZConfirmed stamps QRZCOM_QSO_DOWNLOAD_STATUS=Y and the date on a QSO
// confirmed via a QRZ.com download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
}
return nil
}
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
}
return nil
}
// scanner is what both *sql.Row and *sql.Rows satisfy for our needs. // scanner is what both *sql.Row and *sql.Rows satisfy for our needs.
type scanner interface { type scanner interface {
Scan(dest ...any) error Scan(dest ...any) error
@@ -1095,6 +1455,7 @@ func scanQSO(s scanner) (QSO, error) {
clublogDate, clublogStatus sql.NullString clublogDate, clublogStatus sql.NullString
hrdlogDate, hrdlogStatus sql.NullString hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
contestID sql.NullString contestID sql.NullString
srx, stx sql.NullInt64 srx, stx sql.NullInt64
srxStr, stxStr sql.NullString srxStr, stxStr sql.NullString
@@ -1127,6 +1488,7 @@ func scanQSO(s scanner) (QSO, error) {
&clublogDate, &clublogStatus, &clublogDate, &clublogStatus,
&hrdlogDate, &hrdlogStatus, &hrdlogDate, &hrdlogStatus,
&qrzcomDate, &qrzcomStatus, &qrzcomDate, &qrzcomStatus,
&qrzcomDlDate, &qrzcomDlStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect, &contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath, &propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA, &stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
@@ -1214,6 +1576,8 @@ func scanQSO(s scanner) (QSO, error) {
q.HRDLogUploadStatus = hrdlogStatus.String q.HRDLogUploadStatus = hrdlogStatus.String
q.QRZComUploadDate = qrzcomDate.String q.QRZComUploadDate = qrzcomDate.String
q.QRZComUploadStatus = qrzcomStatus.String q.QRZComUploadStatus = qrzcomStatus.String
q.QRZComDownloadDate = qrzcomDlDate.String
q.QRZComDownloadStatus = qrzcomDlStatus.String
q.ContestID = contestID.String q.ContestID = contestID.String
if srx.Valid { if srx.Valid {
v := int(srx.Int64) v := int(srx.Int64)
+54
View File
@@ -10,6 +10,7 @@ package pst
import ( import (
"fmt" "fmt"
"net" "net"
"strconv"
"time" "time"
) )
@@ -53,6 +54,59 @@ func (c *Client) Park() error {
return c.send("<PST><PARK>1</PARK></PST>") return c.send("<PST><PARK>1</PARK></PST>")
} }
// Heading queries PstRotator for the current azimuth. PstRotator's protocol:
// send "<PST>AZ?</PST>" to the command port, and it reports the azimuth back
// on UDP port+1. So we bind a listener on port+1 first, send the query, then
// read the reply. Returns the raw reply too, for diagnostics. err is non-nil
// on timeout (no reply) or an unparseable response.
func (c *Client) Heading() (az int, raw string, err error) {
// Listen on port+1 where PstRotator sends its position report.
pc, err := net.ListenPacket("udp4", fmt.Sprintf(":%d", c.Port+1))
if err != nil {
return 0, "", fmt.Errorf("listen :%d for PstRotator reply: %w", c.Port+1, err)
}
defer pc.Close()
if err := c.send("<PST>AZ?</PST>"); err != nil {
return 0, "", fmt.Errorf("query PstRotator: %w", err)
}
_ = pc.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
buf := make([]byte, 512)
n, _, rerr := pc.ReadFrom(buf)
if rerr != nil {
return 0, "", fmt.Errorf("no reply on :%d: %w", c.Port+1, rerr)
}
raw = string(buf[:n])
a, ok := parseAzimuth(raw)
if !ok {
return 0, raw, fmt.Errorf("no azimuth in reply %q", raw)
}
return a, raw, nil
}
// parseAzimuth extracts the first integer found in a PstRotator reply
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
// it to [0,360).
func parseAzimuth(s string) (int, bool) {
i := 0
for i < len(s) && (s[i] < '0' || s[i] > '9') {
i++
}
if i >= len(s) {
return 0, false
}
j := i
for j < len(s) && s[j] >= '0' && s[j] <= '9' {
j++
}
n, err := strconv.Atoi(s[i:j])
if err != nil {
return 0, false
}
return ((n % 360) + 360) % 360, true
}
func (c *Client) send(payload string) error { func (c *Client) send(payload string) error {
addr := fmt.Sprintf("%s:%d", c.Host, c.Port) addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
conn, err := net.DialTimeout("udp", addr, 2*time.Second) conn, err := net.DialTimeout("udp", addr, 2*time.Second)
+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")
}