Compare commits
11 Commits
806b39970b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 176cc0e62b | |||
| b4e104f5a2 | |||
| 922a185208 | |||
| 51d3a734e8 | |||
| 88623f55df | |||
| cf9dbf26f3 | |||
| 95fdc1ccd1 | |||
| a2a29c66d2 | |||
| 1a425a1b0d | |||
| 2b4326b553 | |||
| 2eb77370e4 |
Generated
+25
@@ -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",
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
687705a933fcf09f20bdb5083955a417
|
c98874941451e4e6ffa48f22c1d764e7
|
||||||
+1067
-341
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { spotStatusKey, inferSpotMode } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||||
@@ -32,6 +32,8 @@ interface Props {
|
|||||||
currentFreqHz: number;
|
currentFreqHz: number;
|
||||||
onSpotClick: (s: Spot) => void;
|
onSpotClick: (s: Spot) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
side?: 'left' | 'right';
|
||||||
|
onToggleSide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
const BAND_RANGES: Record<string, [number, number]> = {
|
||||||
@@ -136,15 +138,15 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
|
|||||||
const LABEL_W = 200;
|
const LABEL_W = 200;
|
||||||
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
||||||
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||||
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single
|
// Max DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once.
|
||||||
// watering-hole frequency and otherwise spawn hundreds of spots that fan out
|
// These pile up on the watering-hole frequencies and otherwise spawn
|
||||||
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other
|
// hundreds of spots that fan out and cover the whole map. ONLY digital is
|
||||||
// digital spots are always shown in full. When more than this FT spots are in
|
// capped — CW and SSB are always shown in full. When more than this digital
|
||||||
// band we keep the most useful (new entities first, worked last; ties broken
|
// spots are in band we keep the most useful (new entities first, worked
|
||||||
// by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) {
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
const [zoomIdx, setZoomIdx] = useState(0);
|
const [zoomIdx, setZoomIdx] = useState(0);
|
||||||
@@ -189,14 +191,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
inBand.push(s);
|
inBand.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single
|
// Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…)
|
||||||
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is
|
// are capped — they're what floods the watering-hole frequencies. We key
|
||||||
// always shown in full.
|
// off the mode CATEGORY (not a literal "FT8" string) because many FT8
|
||||||
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz));
|
// spots carry no mode word and the band-plan fallback labels them the
|
||||||
|
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
||||||
|
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
||||||
const ftSpots = inBand.filter(isFlood);
|
const ftSpots = inBand.filter(isFlood);
|
||||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||||
|
|
||||||
// Rank an FT spot by usefulness (new entity → unworked → worked); ties
|
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
||||||
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
||||||
const rank = (s: Spot) => {
|
const rank = (s: Spot) => {
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
@@ -363,6 +367,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
title="Scroll to current rig frequency">
|
title="Scroll to current rig frequency">
|
||||||
<Crosshair className="size-3" />
|
<Crosshair className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
{onToggleSide && (
|
||||||
|
<button type="button" onClick={onToggleSide}
|
||||||
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||||
|
title={side === 'right' ? 'Move band map to the left' : 'Move band map to the right'}>
|
||||||
|
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button type="button" onClick={onClose}
|
<button type="button" onClick={onClose}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||||
@@ -497,7 +508,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
|
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden — top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Construction } from 'lucide-react';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { pathBetween } from '@/lib/maidenhead';
|
import { pathBetween } from '@/lib/maidenhead';
|
||||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||||
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
|
|
||||||
export interface DetailsState {
|
export interface DetailsState {
|
||||||
state: string;
|
state: string;
|
||||||
@@ -37,6 +37,10 @@ export interface DetailsState {
|
|||||||
srx?: number;
|
srx?: number;
|
||||||
stx?: number;
|
stx?: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
// Award references for the contacted station (set via the Awards tab picker).
|
||||||
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||||
|
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||||
|
award_refs?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -53,9 +57,15 @@ interface Props {
|
|||||||
mode: string;
|
mode: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
onOpenImage?: () => void;
|
onOpenImage?: () => void;
|
||||||
|
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||||
|
tab?: TabName;
|
||||||
|
onTab?: (t: TabName) => void;
|
||||||
|
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
||||||
|
// shown as Ctrl+F1…F5 instead of F1…F5.
|
||||||
|
keyerActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||||
|
|
||||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||||
|
|
||||||
@@ -75,9 +85,9 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) {
|
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) {
|
||||||
const [open, setOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
// Bearing/distance from operator's home grid to the remote station.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
const path = useMemo(
|
const path = useMemo(
|
||||||
@@ -87,7 +97,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||||
|
|
||||||
function toggle(t: TabName) { setOpen(t); }
|
function toggle(t: TabName) { onTab ? onTab(t) : setInternalOpen(t); }
|
||||||
|
const fk = keyerActive ? 'Ctrl+F' : 'F';
|
||||||
|
|
||||||
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
||||||
function setSatellite(on: boolean) {
|
function setSatellite(on: boolean) {
|
||||||
@@ -102,15 +113,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { key: TabName; label: string }[] = [
|
const tabs: { key: TabName; label: string }[] = [
|
||||||
{ key: 'stats', label: 'Stats (F1)' },
|
{ key: 'stats', label: `Stats (${fk}1)` },
|
||||||
{ key: 'info', label: 'Info (F2)' },
|
{ key: 'info', label: `Info (${fk}2)` },
|
||||||
{ key: 'awards', label: 'Awards (F3)' },
|
{ key: 'awards', label: `Awards (${fk}3)` },
|
||||||
{ key: 'my', label: 'My (F4)' },
|
{ key: 'my', label: `My (${fk}4)` },
|
||||||
{ key: 'extended', label: 'Extended (F5)' },
|
{ key: 'extended', label: `Extended (${fk}5)` },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border border-border rounded-lg bg-card flex flex-col flex-1 min-h-0 overflow-hidden">
|
<section className="bg-card shadow-sm border border-border rounded-lg flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
@@ -189,18 +200,21 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'awards' && (
|
{open === 'awards' && (
|
||||||
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
|
<div className="px-3 py-2.5">
|
||||||
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
|
<AwardRefSelector
|
||||||
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div>
|
dxcc={details.dxcc}
|
||||||
|
value={details.award_refs ?? ''}
|
||||||
|
onChange={(v) => onChange({ award_refs: v })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'my' && (
|
{open === 'my' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||||
<Field label="Ant. azimuth (°)">
|
<Field label="Azimuth (°)">
|
||||||
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ant. elevation (°)">
|
<Field label="Elevation (°)">
|
||||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ant. path">
|
<Field label="Ant. path">
|
||||||
|
|||||||
@@ -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 F1–F6
|
||||||
|
// 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 & voice keyer</strong> to record F1–F6.
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = '© OpenStreetMap © CARTO';
|
||||||
|
const OSM_ATTR = '© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||||
import { LookupCallsign } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||||
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
|
import { 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';
|
||||||
@@ -13,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 = [
|
||||||
@@ -29,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 {
|
||||||
@@ -96,21 +147,82 @@ 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);
|
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
|
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
|
||||||
// the draft — handy after correcting the callsign. Only overwrites the
|
// the draft — handy after correcting the callsign. Only overwrites the
|
||||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||||
@@ -159,16 +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),
|
||||||
@@ -184,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,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 && (
|
||||||
@@ -234,106 +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">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11 flex-1"
|
<Label>Callsign</Label>
|
||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||||
<Button type="button" variant="outline" className="h-11" onClick={fetchLookup} disabled={looking}
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, QTH, country, grid, zones…">
|
</div>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
Fetch
|
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
||||||
</Button>
|
<div className="flex flex-col w-20"><Label>R</Label>
|
||||||
|
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
||||||
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
|
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
|
||||||
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
|
{/* ── Left column ── */}
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div><Label>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-20 shrink-0">Band</Label>
|
||||||
|
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||||
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
|
<Label className="w-20 shrink-0">RX Band</Label>
|
||||||
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
|
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||||
<F label="Band">
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
<SelectContent><SelectItem value="_">—</SelectItem>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
</Select>
|
||||||
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
</div>
|
||||||
</Select>
|
<div className="flex items-center gap-2">
|
||||||
</F>
|
<Label className="w-20 shrink-0">Mode</Label>
|
||||||
<F label="Mode">
|
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
</Select>
|
||||||
</Select>
|
</div>
|
||||||
</F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="Submode"><Input value={draft.submode ?? ''} onChange={(e) => set('submode', e.target.value)} /></F>
|
<Label className="w-20 shrink-0">Country</Label>
|
||||||
<F label="Band RX">
|
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
||||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
onChange={onCountryChange} className="flex-1" />
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
<div className="flex items-center gap-2">
|
||||||
<SelectItem value="_">—</SelectItem>
|
<Label className="w-20 shrink-0">ITU</Label>
|
||||||
{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
|
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||||
</SelectContent>
|
<Label>CQ</Label>
|
||||||
</Select>
|
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||||
</F>
|
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title="DXCC entity # — set automatically from Country" />
|
||||||
<F label="Freq (MHz)"><Input value={freqMhz} onChange={(e) => setFreqMhz(e.target.value)} /></F>
|
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||||
<F label="Freq RX (MHz)"><Input value={freqRxMhz} onChange={(e) => setFreqRxMhz(e.target.value)} /></F>
|
</div>
|
||||||
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
|
<Label className="w-20 shrink-0">Freq</Label>
|
||||||
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
</div>
|
||||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-20 shrink-0">RX Freq</Label>
|
||||||
|
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||||
|
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right column ── */}
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||||
|
<div>
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
|
||||||
|
</Label>
|
||||||
|
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col flex-1"><Label>Grid</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||||
|
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||||
|
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="contacted" className="mt-0">
|
<TabsContent value="contact" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
{/* Left column */}
|
||||||
<F label="QTH" span={3}><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></F>
|
<div className="flex flex-col gap-2.5">
|
||||||
<F label="Address" span={6}><Input value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></F>
|
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||||
<F label="Email" span={3}><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></F>
|
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||||
<F label="Web" span={3}><Input value={draft.web ?? ''} onChange={(e) => set('web', e.target.value)} /></F>
|
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||||
<F label="DXCC"><Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} /></F>
|
</div>
|
||||||
<F label="Cont"><Input value={draft.cont ?? ''} onChange={(e) => set('cont', e.target.value)} /></F>
|
{/* Right column */}
|
||||||
<F label="CQ zone"><Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} /></F>
|
<div className="flex flex-col gap-2.5">
|
||||||
<F label="ITU zone"><Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} /></F>
|
<div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||||
<F label="State"><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></F>
|
<div className="flex items-end gap-2">
|
||||||
<F label="County"><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></F>
|
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||||
<F label="Grid ext"><Input value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} /></F>
|
</div>
|
||||||
<F label="VUCC grids" span={2}><Input value={draft.vucc_grids ?? ''} onChange={(e) => set('vucc_grids', e.target.value)} /></F>
|
<div><Label>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||||
<F label="IOTA"><Input value={draft.iota ?? ''} onChange={(e) => set('iota', e.target.value)} /></F>
|
<div><Label>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
||||||
<F label="SOTA ref"><Input value={draft.sota_ref ?? ''} onChange={(e) => set('sota_ref', e.target.value)} /></F>
|
</div>
|
||||||
<F label="POTA ref"><Input value={draft.pota_ref ?? ''} onChange={(e) => set('pota_ref', e.target.value)} /></F>
|
</div>
|
||||||
<F label="Age"><Input type="number" value={draft.age ?? ''} onChange={(e) => set('age', intOrUndef(e.target.value) as any)} /></F>
|
</TabsContent>
|
||||||
<F label="Latitude"><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} /></F>
|
|
||||||
<F label="Longitude"><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} /></F>
|
<TabsContent value="awards" className="mt-0">
|
||||||
<F label="Rig (contacted)" span={3}><Input value={draft.rig ?? ''} onChange={(e) => set('rig', e.target.value)} /></F>
|
<div className="grid grid-cols-[1fr_240px] gap-5">
|
||||||
<F label="Antenna (contacted)" span={3}><Input value={draft.ant ?? ''} onChange={(e) => set('ant', e.target.value)} /></F>
|
{/* Left: pick reference-list awards (POTA/SOTA/IOTA/WWFF/…) */}
|
||||||
|
<div>
|
||||||
|
<AwardRefSelector dxcc={draft.dxcc} value={awardRefs} onChange={setAwardRefs} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: computed awards (read-only) derived from this QSO */}
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
<span className="text-xs font-semibold">Computed (automatic)</span>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.
|
||||||
|
</p>
|
||||||
|
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
|
||||||
|
{computedRefs.length === 0 ? (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
|
||||||
|
) : (
|
||||||
|
computedRefs.map((r) => (
|
||||||
|
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
||||||
|
<span className="font-mono font-semibold">{r.code}@{r.ref}</span>
|
||||||
|
{r.name && <span className="text-[10px] text-muted-foreground ml-1.5 truncate">{r.name}</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="qsl" className="mt-0">
|
<TabsContent value="qsl" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
{(() => {
|
||||||
<F label="QSL sent"><QslSelect value={draft.qsl_sent ?? ''} onChange={(v) => set('qsl_sent', v)} /></F>
|
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||||
<F label="QSL rcvd"><QslSelect value={draft.qsl_rcvd ?? ''} onChange={(v) => set('qsl_rcvd', v)} /></F>
|
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||||
<F label="QSL sent date"><Input value={draft.qsl_sent_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_sent_date', e.target.value)} /></F>
|
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||||
<F label="QSL rcvd date"><Input value={draft.qsl_rcvd_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_rcvd_date', e.target.value)} /></F>
|
return (
|
||||||
<F label="QSL via" span={3}><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></F>
|
<div className="flex gap-6">
|
||||||
<F label="QSL message" span={3}><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></F>
|
{/* Left: edit one confirmation channel at a time */}
|
||||||
<F label="QSL message rcvd" span={6}><Input value={draft.qslmsg_rcvd ?? ''} onChange={(e) => set('qslmsg_rcvd', e.target.value)} /></F>
|
<div className="flex-1 max-w-sm space-y-3">
|
||||||
<F label="LoTW sent"><QslSelect value={draft.lotw_sent ?? ''} onChange={(v) => set('lotw_sent', v)} /></F>
|
<div>
|
||||||
<F label="LoTW rcvd"><QslSelect value={draft.lotw_rcvd ?? ''} onChange={(v) => set('lotw_rcvd', v)} /></F>
|
<Label>Manage Confirmation</Label>
|
||||||
<F label="LoTW sent date"><Input value={draft.lotw_sent_date ?? ''} onChange={(e) => set('lotw_sent_date', e.target.value)} /></F>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<F label="LoTW rcvd date"><Input value={draft.lotw_rcvd_date ?? ''} onChange={(e) => set('lotw_rcvd_date', e.target.value)} /></F>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<F label="eQSL sent"><QslSelect value={draft.eqsl_sent ?? ''} onChange={(v) => set('eqsl_sent', v)} /></F>
|
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</SelectContent>
|
||||||
<F label="eQSL rcvd"><QslSelect value={draft.eqsl_rcvd ?? ''} onChange={(v) => set('eqsl_rcvd', v)} /></F>
|
</Select>
|
||||||
<F label="eQSL sent date"><Input value={draft.eqsl_sent_date ?? ''} onChange={(e) => set('eqsl_sent_date', e.target.value)} /></F>
|
</div>
|
||||||
<F label="eQSL rcvd date"><Input value={draft.eqsl_rcvd_date ?? ''} onChange={(e) => set('eqsl_rcvd_date', e.target.value)} /></F>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<F label="Clublog status" span={2}><Input value={draft.clublog_qso_upload_status ?? ''} onChange={(e) => set('clublog_qso_upload_status', e.target.value)} /></F>
|
<div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||||
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F>
|
<div><Label>Received</Label>
|
||||||
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F>
|
{def.rcvd
|
||||||
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F>
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||||
<F label="QRZ.com status" span={2}><Input value={draft.qrzcom_qso_upload_status ?? ''} onChange={(e) => set('qrzcom_qso_upload_status', e.target.value)} /></F>
|
: <Input disabled value="—" />}
|
||||||
<F label="QRZ.com date"><Input value={draft.qrzcom_qso_upload_date ?? ''} onChange={(e) => set('qrzcom_qso_upload_date', e.target.value)} /></F>
|
</div>
|
||||||
</div>
|
<div><Label>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||||
|
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||||
|
{def.via && (
|
||||||
|
<div className="col-span-2"><Label>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Pick a channel, edit it — the table on the right updates live. Everything is written when you click <strong>Save changes</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: live status grid for every channel */}
|
||||||
|
<div className="w-72 shrink-0">
|
||||||
|
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="text-left font-semibold">Type</th>
|
||||||
|
<th className="font-semibold">Sent</th>
|
||||||
|
<th className="font-semibold">Received</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{CONFIRMATIONS.map((c) => (
|
||||||
|
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
|
||||||
|
<td className="font-medium pr-2 py-0.5">{c.label}</td>
|
||||||
|
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||||
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="contest" className="mt-0">
|
<TabsContent value="contest" className="mt-0">
|
||||||
@@ -372,7 +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>
|
||||||
@@ -391,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:{' '}
|
||||||
|
|||||||
@@ -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,14 +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: 'QRZ.com confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
{ group: 'Uploads', label: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||||
|
{ group: 'Uploads', label: 'HRDLog sent date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog S date', field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
{ group: 'Uploads', label: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||||
|
{ group: 'Uploads', label: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||||
|
{ group: 'Uploads', label: 'QRZ.com sent date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com S date', field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
{ group: 'Uploads', label: 'QRZ.com rcvd date', colId: 'qrzcom_qso_download_date', headerName: 'QRZ.com R date', field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
// ── Contest ──
|
// ── Contest ──
|
||||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||||
@@ -190,14 +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)
|
||||||
@@ -215,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>) {
|
||||||
@@ -281,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>
|
||||||
@@ -293,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}
|
||||||
@@ -302,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)}
|
||||||
@@ -309,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>
|
||||||
@@ -318,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;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud,
|
Compass, Wifi, Construction, UploadCloud, Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
@@ -11,14 +11,20 @@ import {
|
|||||||
GetCATSettings, SaveCATSettings,
|
GetCATSettings, SaveCATSettings,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
|
GetEmailSettings, SaveEmailSettings, TestEmail,
|
||||||
|
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
||||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||||
|
GetPOTAToken, SavePOTAToken, SyncPOTAHunterLog,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
@@ -131,6 +137,8 @@ interface Props {
|
|||||||
Section IDs are stable strings — adding new ones means adding a panel below.
|
Section IDs are stable strings — adding new ones means adding a panel below.
|
||||||
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
||||||
type SectionId =
|
type SectionId =
|
||||||
|
| 'general'
|
||||||
|
| 'email'
|
||||||
| 'station'
|
| 'station'
|
||||||
| 'profiles'
|
| 'profiles'
|
||||||
| 'operating'
|
| 'operating'
|
||||||
@@ -146,6 +154,7 @@ type SectionId =
|
|||||||
| 'awards'
|
| 'awards'
|
||||||
| 'cat'
|
| 'cat'
|
||||||
| 'rotator'
|
| 'rotator'
|
||||||
|
| 'winkeyer'
|
||||||
| 'antenna'
|
| 'antenna'
|
||||||
| 'audio';
|
| 'audio';
|
||||||
|
|
||||||
@@ -165,15 +174,16 @@ const TREE: TreeNode[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
|
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
|
||||||
|
{ kind: 'item', label: 'General', id: 'general' },
|
||||||
|
{ kind: 'item', label: 'E-mail (SMTP)', id: 'email' },
|
||||||
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
|
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
|
||||||
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
|
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
|
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
|
||||||
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
||||||
]},
|
]},
|
||||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||||
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
||||||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
{ kind: 'item', label: 'Database', id: 'database' },
|
||||||
{ kind: 'item', label: 'Database location', id: 'database' },
|
|
||||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -181,8 +191,9 @@ const TREE: TreeNode[] = [
|
|||||||
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
||||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
||||||
|
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
||||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||||||
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -199,11 +210,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
'lists-modes': 'Modes & default RST',
|
'lists-modes': 'Modes & default RST',
|
||||||
cluster: 'DX Cluster',
|
cluster: 'DX Cluster',
|
||||||
backup: 'Database backup',
|
backup: 'Database backup',
|
||||||
database: 'Database location',
|
database: 'Database',
|
||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
rotator: 'Rotator',
|
||||||
|
winkeyer: 'CW Keyer (WinKeyer)',
|
||||||
antenna: 'Antenna',
|
antenna: 'Antenna',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
@@ -284,6 +296,21 @@ function SectionHeader({ title, hint }: { title: string; hint?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProfileScopeNote flags that the panel's settings are saved per-profile, so
|
||||||
|
// the user knows which operating identity (F4BPO / TM2Q / …) they're editing.
|
||||||
|
function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: string } }) {
|
||||||
|
return (
|
||||||
|
<div className="-mt-2 mb-4">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 text-primary text-xs px-2.5 py-1">
|
||||||
|
<User className="size-3.5" />
|
||||||
|
Saved for profile <strong className="font-semibold">{profile?.name || '—'}</strong>
|
||||||
|
{profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
|
||||||
|
— switch profiles to edit another identity.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||||
const label = SECTION_LABELS[id] ?? id;
|
const label = SECTION_LABELS[id] ?? id;
|
||||||
const IconCmp = Icon ?? Construction;
|
const IconCmp = Icon ?? Construction;
|
||||||
@@ -340,17 +367,91 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
|
type WKMac = { label: string; text: string };
|
||||||
|
type WKSettings = {
|
||||||
|
enabled: boolean; engine: string; esc_clears_call: boolean;
|
||||||
|
port: string; baud: number; wpm: number; weight: number;
|
||||||
|
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
||||||
|
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
||||||
|
use_ptt: boolean; serial_echo: boolean; macros: WKMac[];
|
||||||
|
};
|
||||||
|
const [wk, setWk] = useState<WKSettings>({
|
||||||
|
enabled: false, engine: 'winkeyer', esc_clears_call: true,
|
||||||
|
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
||||||
|
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
||||||
|
swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [],
|
||||||
|
});
|
||||||
|
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||||
|
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||||
|
|
||||||
|
// ── Audio (DVK + QSO recorder) ──
|
||||||
|
type AudioSettings = {
|
||||||
|
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||||
|
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||||
|
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||||||
|
from_gain: number; mic_gain: number;
|
||||||
|
};
|
||||||
|
type AudioDev = { id: string; name: string; default: boolean };
|
||||||
|
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||||
|
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||||
|
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||||||
|
from_gain: 100, mic_gain: 100,
|
||||||
|
});
|
||||||
|
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||||
|
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||||||
|
const setAudioField = (patch: Partial<AudioSettings>) => setAudioCfg((s) => ({ ...s, ...patch }));
|
||||||
|
const reloadAudioDevices = () => {
|
||||||
|
ListAudioInputDevices().then((d) => setAudioInputs((d ?? []) as AudioDev[])).catch(() => {});
|
||||||
|
ListAudioOutputDevices().then((d) => setAudioOutputs((d ?? []) as AudioDev[])).catch(() => {});
|
||||||
|
};
|
||||||
|
// DVK voice-keyer messages (F1–F6).
|
||||||
|
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||||
|
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||||
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
|
const [dvkErr, setDvkErr] = useState('');
|
||||||
|
|
||||||
|
// General behaviour prefs (machine-local, applied live via localStorage).
|
||||||
|
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||||
|
|
||||||
|
// E-mail / SMTP (send QSO recordings).
|
||||||
|
type EmailCfg = {
|
||||||
|
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
||||||
|
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
||||||
|
};
|
||||||
|
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
||||||
|
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
||||||
|
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||||
|
});
|
||||||
|
const [emailMsg, setEmailMsg] = useState('');
|
||||||
|
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||||
|
// ClubLog Country File (cty.xml) exception status.
|
||||||
|
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
||||||
|
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
||||||
|
const [clubBusy, setClubBusy] = useState(false);
|
||||||
|
const [clubErr, setClubErr] = useState('');
|
||||||
|
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
|
||||||
|
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
|
||||||
|
useEffect(() => {
|
||||||
|
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||||
|
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||||||
|
return () => { off?.(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
type QSLDefaults = {
|
type QSLDefaults = {
|
||||||
qsl_sent: string; qsl_rcvd: string;
|
qsl_sent: string; qsl_rcvd: string;
|
||||||
lotw_sent: string; lotw_rcvd: string;
|
lotw_sent: string; lotw_rcvd: string;
|
||||||
eqsl_sent: string; eqsl_rcvd: string;
|
eqsl_sent: string; eqsl_rcvd: string;
|
||||||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||||||
|
qrzcom_confirmed: string;
|
||||||
};
|
};
|
||||||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||||||
qsl_sent: '', qsl_rcvd: '',
|
qsl_sent: '', qsl_rcvd: '',
|
||||||
lotw_sent: '', lotw_rcvd: '',
|
lotw_sent: '', lotw_rcvd: '',
|
||||||
eqsl_sent: '', eqsl_rcvd: '',
|
eqsl_sent: '', eqsl_rcvd: '',
|
||||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||||||
|
qrzcom_confirmed: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// External services (logbook upload). One block per service; only QRZ is
|
// External services (logbook upload). One block per service; only QRZ is
|
||||||
@@ -381,7 +482,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
// Active tab in the External Services panel — lifted here because
|
// Active tab in the External Services panel — lifted here because
|
||||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz');
|
||||||
|
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||||
|
const [potaToken, setPotaToken] = useState('');
|
||||||
|
const [potaBusy, setPotaBusy] = useState(false);
|
||||||
|
const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []);
|
||||||
|
|
||||||
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||||||
enabled: false, folder: '', rotation: 5, zip: false,
|
enabled: false, folder: '', rotation: 5, zip: false,
|
||||||
@@ -483,6 +589,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const locs: any = await ListTQSLStationLocations();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||||
|
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||||
|
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||||
|
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
|
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||||
|
reloadAudioDevices();
|
||||||
|
reloadDvk();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -598,7 +710,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save(close = true) {
|
||||||
setSaving(true); setErr(''); setMsg('');
|
setSaving(true); setErr(''); setMsg('');
|
||||||
try {
|
try {
|
||||||
// Bands: dedup, lowercase, trim. Order = user's drag order.
|
// Bands: dedup, lowercase, trim. Order = user's drag order.
|
||||||
@@ -636,6 +748,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
|
await SaveWinkeyerSettings(wk as any);
|
||||||
|
await SaveAudioSettings(audioCfg as any);
|
||||||
|
await SaveEmailSettings(emailCfg as any);
|
||||||
await SaveBackupSettings(backupCfg as any);
|
await SaveBackupSettings(backupCfg as any);
|
||||||
await SaveQSLDefaults(qslDefaults as any);
|
await SaveQSLDefaults(qslDefaults as any);
|
||||||
await SaveExternalServices(extSvc as any);
|
await SaveExternalServices(extSvc as any);
|
||||||
@@ -643,7 +758,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
setMsg('Settings saved.');
|
setMsg('Settings saved.');
|
||||||
onSaved();
|
onSaved();
|
||||||
setTimeout(onClose, 500);
|
if (close) setTimeout(onClose, 500);
|
||||||
|
else setTimeout(() => setMsg(''), 2000);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -788,7 +904,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
async function profileActivate() {
|
async function profileActivate() {
|
||||||
if (!currentProfile) return;
|
if (!currentProfile) return;
|
||||||
try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); onSaved(); }
|
try {
|
||||||
|
await ActivateProfile(currentProfile.id as number);
|
||||||
|
await reloadProfiles();
|
||||||
|
// Per-profile settings follow the active identity — reload the panels
|
||||||
|
// that are now scoped to the newly-active profile.
|
||||||
|
const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]);
|
||||||
|
setActiveProfile(ap as Profile);
|
||||||
|
setQslDefaults(qd as any);
|
||||||
|
setExtSvc(es as any);
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function profileRemove() {
|
async function profileRemove() {
|
||||||
@@ -1436,6 +1562,153 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WinkeyerPanel() {
|
||||||
|
const setMacro = (i: number, patch: Partial<WKMac>) => setWk((s) => {
|
||||||
|
const macros = [...s.macros];
|
||||||
|
while (macros.length <= i) macros.push({ label: '', text: '' });
|
||||||
|
macros[i] = { ...macros[i], ...patch };
|
||||||
|
return { ...s, macros };
|
||||||
|
});
|
||||||
|
const num = (v: string, d: number) => { const n = parseInt(v, 10); return isNaN(n) ? d : n; };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="CW Keyer (WinKeyer)"
|
||||||
|
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
|
||||||
|
Enable CW keyer (shows the keyer panel)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3 items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Keyer engine</Label>
|
||||||
|
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||||
|
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||||||
|
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
||||||
|
ESC clears the callsign too (otherwise ESC only stops transmission)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Serial port</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
|
||||||
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud</Label>
|
||||||
|
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Speed (WPM)</Label>
|
||||||
|
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Weight</Label>
|
||||||
|
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Lead-in (ms)</Label>
|
||||||
|
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Tail (ms)</Label>
|
||||||
|
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Ratio (33-66)</Label>
|
||||||
|
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Farnsworth</Label>
|
||||||
|
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Sidetone (Hz)</Label>
|
||||||
|
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Paddle mode</Label>
|
||||||
|
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="iambic_b">Iambic B</SelectItem>
|
||||||
|
<SelectItem value="iambic_a">Iambic A</SelectItem>
|
||||||
|
<SelectItem value="ultimatic">Ultimatic</SelectItem>
|
||||||
|
<SelectItem value="bug">Bug</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Macro editor */}
|
||||||
|
<div className="border-t border-border/60 pt-3">
|
||||||
|
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
||||||
|
<p className="text-[11px] text-muted-foreground mb-2">
|
||||||
|
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
|
const m = wk.macros[i] ?? { label: '', text: '' };
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
|
||||||
|
<Input className="h-8 w-28 shrink-0 text-xs" placeholder="Label" value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
||||||
|
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function statusForServer(id: number): ClusterServerStatus | undefined {
|
function statusForServer(id: number): ClusterServerStatus | undefined {
|
||||||
return clusterStatuses.find((s) => (s.server_id as number) === id);
|
return clusterStatuses.find((s) => (s.server_id as number) === id);
|
||||||
}
|
}
|
||||||
@@ -1623,6 +1896,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
title="Confirmations"
|
title="Confirmations"
|
||||||
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
|
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
|
||||||
/>
|
/>
|
||||||
|
<ProfileScopeNote profile={activeProfileObj} />
|
||||||
<div className="space-y-3 max-w-2xl">
|
<div className="space-y-3 max-w-2xl">
|
||||||
{/* Paper QSL */}
|
{/* Paper QSL */}
|
||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
@@ -1663,13 +1937,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
Upload status fields (Clublog / HRDLog / QRZ.com) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
|
"Sent" = the QSO's upload status to the service; "N" is typical so OpsLog tracks which QSOs still need uploading. Club Log & HRDLog are upload-only (no "rcvd" in ADIF); QRZ.com also has "Rcvd" = confirmed back.
|
||||||
</div>
|
</div>
|
||||||
{/* Clublog */}
|
{/* Clublog */}
|
||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
<Label className="text-sm font-medium pb-1.5">Clublog</Label>
|
<Label className="text-sm font-medium pb-1.5">Clublog</Label>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||||
{renderSelect('clublog_status', FULL_OPTIONS)}
|
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div />
|
||||||
@@ -1678,7 +1952,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
<Label className="text-sm font-medium pb-1.5">HRDLog</Label>
|
<Label className="text-sm font-medium pb-1.5">HRDLog</Label>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||||
{renderSelect('hrdlog_status', FULL_OPTIONS)}
|
{renderSelect('hrdlog_status', FULL_OPTIONS)}
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div />
|
||||||
@@ -1687,10 +1961,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
<Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
|
<Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||||
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||||||
|
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1852,7 +2129,22 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
{ k: 'eqsl', label: 'EQSL' },
|
{ k: 'eqsl', label: 'EQSL' },
|
||||||
{ k: 'hamqth', label: 'HAMQTH' },
|
{ k: 'hamqth', label: 'HAMQTH' },
|
||||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||||
|
{ k: 'pota', label: 'POTA', ready: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function syncPota() {
|
||||||
|
setPotaBusy(true);
|
||||||
|
setPotaResult(null);
|
||||||
|
try {
|
||||||
|
await SavePOTAToken(potaToken);
|
||||||
|
const r: any = await SyncPOTAHunterLog();
|
||||||
|
setPotaResult({ ok: true, msg: `${r.updated} QSO updated · ${r.already_tagged} already tagged · ${r.unmatched} unmatched (of ${r.fetched} hunter-log entries).` });
|
||||||
|
} catch (e: any) {
|
||||||
|
setPotaResult({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setPotaBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
const qrz = extSvc.qrz;
|
const qrz = extSvc.qrz;
|
||||||
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
||||||
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
|
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
|
||||||
@@ -1923,6 +2215,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first)."
|
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first)."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProfileScopeNote profile={activeProfileObj} />
|
||||||
|
|
||||||
{/* Tab strip */}
|
{/* Tab strip */}
|
||||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
@@ -2100,6 +2394,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<ArrowDown className="size-3.5 rotate-90" />
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Label className="text-sm">Force station callsign</Label>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={lotw.force_station_callsign}
|
||||||
|
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="e.g. F4BPO/P — leave blank to use the QSO's own call"
|
||||||
|
className="font-mono uppercase w-64"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls
|
||||||
|
(F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Label className="text-sm">Key password</Label>
|
<Label className="text-sm">Key password</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -2151,6 +2458,37 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'pota' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Update your QSOs with the park reference from your <strong>pota.app hunter log</strong>.
|
||||||
|
Paste your session token: log in at <span className="font-mono">pota.app</span>, open the browser
|
||||||
|
DevTools → <strong>Network</strong> tab, click any <span className="font-mono">api.pota.app</span> request,
|
||||||
|
and copy the full <strong>Authorization</strong> header value. The token expires after a while — re-copy it if the sync fails.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-start">
|
||||||
|
<Label className="text-sm pt-2">Session token</Label>
|
||||||
|
<Textarea
|
||||||
|
value={potaToken}
|
||||||
|
onChange={(e) => setPotaToken(e.target.value)}
|
||||||
|
placeholder="eyJ… (Authorization header from pota.app)"
|
||||||
|
className="font-mono text-[11px] h-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={syncPota} disabled={potaBusy || !potaToken.trim()}>
|
||||||
|
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> Syncing…</> : 'Sync hunter log'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">Matches by callsign + band within ±5 min. Only fills QSOs without a POTA ref.</span>
|
||||||
|
</div>
|
||||||
|
{potaResult && (
|
||||||
|
<div className={cn('text-xs rounded-md px-3 py-2 border',
|
||||||
|
potaResult.ok ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
|
||||||
|
{potaResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-muted-foreground">After a sync, rescan the POTA award to see the new references counted.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
||||||
<Construction className="size-10 opacity-30" />
|
<Construction className="size-10 opacity-30" />
|
||||||
@@ -2184,6 +2522,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
async function createNew() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await CreateDatabase(p);
|
||||||
|
await refreshDb();
|
||||||
|
setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
async function resetDefault() {
|
async function resetDefault() {
|
||||||
try {
|
try {
|
||||||
await ResetDatabaseToDefault();
|
await ResetDatabaseToDefault();
|
||||||
@@ -2194,8 +2541,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Database location"
|
title="Database"
|
||||||
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file."
|
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -2210,15 +2557,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={openExisting}>Open existing database…</Button>
|
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> New database…</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch to it…</Button>
|
<Button variant="outline" size="sm" onClick={openExisting}>Open existing…</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch…</Button>
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
||||||
<strong>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '}
|
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
|
||||||
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
|
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
|
||||||
A database change takes effect on the next launch.
|
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
|
||||||
|
Any database change takes effect on the next launch.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dbMsg && (
|
{dbMsg && (
|
||||||
@@ -2228,12 +2577,320 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Backup settings, merged into this Database section. */}
|
||||||
|
<div className="border-t border-border/60 mt-6 pt-5">
|
||||||
|
{BackupPanel()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioPanel() {
|
||||||
|
const deviceSelect = (
|
||||||
|
field: keyof AudioSettings,
|
||||||
|
devices: AudioDev[],
|
||||||
|
placeholder: string,
|
||||||
|
) => (
|
||||||
|
<Select
|
||||||
|
value={(audioCfg[field] as string) || '_'}
|
||||||
|
onValueChange={(v) => setAudioField({ [field]: v === '_' ? '' : v } as any)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_">— none / system default —</SelectItem>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<SectionHeader
|
||||||
|
title="Audio devices & voice keyer"/>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
|
||||||
|
Refresh devices
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">From Radio (RX in)</Label>
|
||||||
|
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
|
||||||
|
<Label className="text-sm">To Radio (TX out)</Label>
|
||||||
|
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
|
||||||
|
<Label className="text-sm">Recording mic</Label>
|
||||||
|
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
|
||||||
|
<Label className="text-sm">Listening (preview)</Label>
|
||||||
|
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
||||||
|
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
|
||||||
|
Record every QSO to an audio file (From Radio + your mic)
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Recordings folder</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
|
||||||
|
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
|
||||||
|
<Button variant="outline" size="sm" className="h-8 shrink-0"
|
||||||
|
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
|
||||||
|
Browse…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Label className="text-sm">Pre-roll (seconds)</Label>
|
||||||
|
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
|
||||||
|
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
|
||||||
|
className="h-8 w-24 font-mono" />
|
||||||
|
<Label className="text-sm">File format</Label>
|
||||||
|
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
|
||||||
|
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
|
||||||
|
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Label className="text-sm">From Radio level</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
||||||
|
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||||
|
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
||||||
|
</div>
|
||||||
|
<Label className="text-sm">Mic level</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
||||||
|
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||||
|
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
|
||||||
|
Auto-send the recording to the station by e-mail when I log a QSO
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1–F6)</h4>
|
||||||
|
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
|
||||||
|
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
|
||||||
|
<Label className="text-sm">PTT method</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
|
||||||
|
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None (VOX)</SelectItem>
|
||||||
|
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
|
||||||
|
<SelectItem value="rts">Serial RTS</SelectItem>
|
||||||
|
<SelectItem value="dtr">Serial DTR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{audioCfg.ptt_method !== 'none' && (
|
||||||
|
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT().catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||||||
|
Test PTT
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||||
|
<>
|
||||||
|
<Label className="text-sm">PTT COM port</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_">— select —</SelectItem>
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
|
||||||
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{dvkMsgs.map((m) => {
|
||||||
|
const recHere = dvkStat.recording && dvkStat.rec_slot === m.slot;
|
||||||
|
const recBusy = dvkStat.recording && !recHere;
|
||||||
|
return (
|
||||||
|
<div key={m.slot} className="flex items-center gap-2">
|
||||||
|
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
|
||||||
|
<Input
|
||||||
|
className="h-8 flex-1"
|
||||||
|
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
|
||||||
|
value={m.label}
|
||||||
|
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
|
||||||
|
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
|
||||||
|
/>
|
||||||
|
<span className="w-16 text-[11px] text-muted-foreground text-right">
|
||||||
|
{m.has_audio ? `✓ ${m.duration_sec.toFixed(1)}s` : '—'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={recHere ? 'destructive' : 'outline'} size="sm" className="h-8 w-28 shrink-0 select-none touch-none"
|
||||||
|
disabled={recBusy}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
setDvkErr('');
|
||||||
|
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recHere ? '● Recording…' : '● Hold to rec'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline" size="sm" className="h-8 w-20 shrink-0"
|
||||||
|
disabled={!m.has_audio || dvkStat.recording}
|
||||||
|
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
|
||||||
|
>
|
||||||
|
{dvkStat.playing ? '■ Stop' : '▶ Play'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneralPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="General" hint="App behaviour preferences (saved instantly, machine-local)." />
|
||||||
|
<div className="space-y-4 max-w-lg">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={autofocusWB}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); localStorage.setItem('opslog.autofocusWB', v ? '1' : '0'); }}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Auto-focus "Worked before" for stations already worked
|
||||||
|
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||||
|
When you type a callsign you've contacted before, OpsLog jumps to the Worked before tab. Turn off to stay on your current tab.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={clubInfo.enabled}
|
||||||
|
onCheckedChange={async (c) => {
|
||||||
|
const v = !!c; setClubInfo((s) => ({ ...s, enabled: v })); setClubErr('');
|
||||||
|
try {
|
||||||
|
await SetClublogCtyEnabled(v);
|
||||||
|
let info = (await GetClublogCtyInfo()) as ClubInfo;
|
||||||
|
// First enable with no cached data → download it now.
|
||||||
|
if (v && !info.loaded) {
|
||||||
|
setClubBusy(true);
|
||||||
|
try { info = (await DownloadClublogCty()) as ClubInfo; }
|
||||||
|
finally { setClubBusy(false); }
|
||||||
|
}
|
||||||
|
setClubInfo(info);
|
||||||
|
} catch (e: any) { setClubErr(String(e?.message ?? e)); }
|
||||||
|
}}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Use ClubLog Country File for callsign resolution
|
||||||
|
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||||
|
Applies ClubLog's date-ranged full-callsign <strong>exceptions</strong> that cty.dat lacks — e.g. VK2/SP9FIH
|
||||||
|
resolves to Lord Howe Island (not Australia) for the DXpedition dates. Used on entry, import, UDP, and the
|
||||||
|
right-click <em>Update from ClubLog</em>.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 pl-6">
|
||||||
|
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
||||||
|
onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
|
||||||
|
{clubBusy ? 'Downloading…' : (clubInfo.loaded ? 'Update ClubLog data' : 'Download ClubLog data')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{clubInfo.loaded
|
||||||
|
? `${clubInfo.count.toLocaleString()} exceptions${clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : ''}`
|
||||||
|
: 'not downloaded'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmailPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="E-mail"/>
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={emailCfg.enabled} onCheckedChange={(c) => setEmailField({ enabled: !!c })} />
|
||||||
|
Enable e-mail sending
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
|
<Label className="text-sm">SMTP server</Label>
|
||||||
|
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
|
||||||
|
<Label className="text-sm">Port / encryption</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
|
||||||
|
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
|
||||||
|
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="starttls">STARTTLS (587)</SelectItem>
|
||||||
|
<SelectItem value="ssl">SSL/TLS (465)</SelectItem>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={emailCfg.auth} onCheckedChange={(c) => setEmailField({ auth: !!c })} />
|
||||||
|
SMTP requires authorization
|
||||||
|
</label>
|
||||||
|
<Label className="text-sm">Username</Label>
|
||||||
|
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||||||
|
<Label className="text-sm">Password</Label>
|
||||||
|
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||||
|
<Label className="text-sm">From address</Label>
|
||||||
|
<Input className="h-8" placeholder="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" className="h-8"
|
||||||
|
onClick={() => { setEmailMsg('Sending test…'); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg('Test e-mail sent ✓')).catch((e: any) => setEmailMsg('Test failed: ' + String(e?.message ?? e))); }}>
|
||||||
|
Send test e-mail
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map sections to their content + icon (for placeholder).
|
// Map sections to their content + icon (for placeholder).
|
||||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||||
|
general: GeneralPanel,
|
||||||
|
email: EmailPanel,
|
||||||
station: StationPanel,
|
station: StationPanel,
|
||||||
profiles: ProfilesPanel,
|
profiles: ProfilesPanel,
|
||||||
operating: OperatingPanelWrapper,
|
operating: OperatingPanelWrapper,
|
||||||
@@ -2249,8 +2906,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
rotator: RotatorPanel,
|
rotator: RotatorPanel,
|
||||||
|
winkeyer: WinkeyerPanel,
|
||||||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||||||
audio: () => <ComingSoon id="audio" icon={Server} />,
|
audio: AudioPanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2293,7 +2951,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
<Button onClick={save} disabled={saving || loading}>{saving ? 'Saving…' : 'Save all'}</Button>
|
<Button variant="outline" onClick={() => save(false)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save'}</Button>
|
||||||
|
<Button onClick={() => save(true)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save and close'}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(';');
|
||||||
|
}
|
||||||
@@ -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,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 (0–360). Distance in km.
|
// points. Bearing in degrees from true north (0–360). 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; }
|
||||||
|
|||||||
Vendored
+150
-2
@@ -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,30 +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 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 GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||||
|
|
||||||
|
export function GetEmailSettings():Promise<main.EmailSettings>;
|
||||||
|
|
||||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
@@ -77,6 +141,8 @@ export function GetLogFilePath():Promise<string>;
|
|||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
@@ -89,7 +155,23 @@ 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>>;
|
||||||
|
|
||||||
@@ -101,6 +183,10 @@ export function ListProfiles():Promise<Array<profile.Profile>>;
|
|||||||
|
|
||||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListSerialPorts():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
@@ -119,20 +205,34 @@ export function OpenExternalURL(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||||
|
|
||||||
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickOpenDatabase():Promise<string>;
|
export function PickOpenDatabase():Promise<string>;
|
||||||
|
|
||||||
export function PickSaveDatabase():Promise<string>;
|
export function PickSaveDatabase():Promise<string>;
|
||||||
|
|
||||||
|
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function QSOAudioBegin():Promise<boolean>;
|
||||||
|
|
||||||
|
export function QSOAudioCancel():Promise<void>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
|
|
||||||
|
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
|
|
||||||
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
@@ -143,12 +243,20 @@ export function RunBackupNow():Promise<string>;
|
|||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
|
||||||
|
|
||||||
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||||
|
|
||||||
|
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
||||||
|
|
||||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
@@ -159,6 +267,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.A
|
|||||||
|
|
||||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||||
|
|
||||||
|
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
@@ -169,32 +279,70 @@ export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
|
|
||||||
|
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||||
|
|
||||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SyncPOTAHunterLog():Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
|
export function TestEmail(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function TestLoTWUpload():Promise<string>;
|
export function TestLoTWUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function TestPTT():Promise<void>;
|
||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
|
||||||
|
|
||||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
|
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerBackspace():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerConnect():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerDisconnect():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerSend(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerStop():Promise<void>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|||||||
@@ -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,6 +130,10 @@ export function DisconnectClusterServer(arg1) {
|
|||||||
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadClublogCty() {
|
||||||
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1, arg2) {
|
export function DownloadConfirmations(arg1, arg2) {
|
||||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
|
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -78,8 +142,20 @@ 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) {
|
||||||
@@ -90,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']();
|
||||||
}
|
}
|
||||||
@@ -102,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']();
|
||||||
}
|
}
|
||||||
@@ -114,10 +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() {
|
export function GetDatabaseSettings() {
|
||||||
return window['go']['main']['App']['GetDatabaseSettings']();
|
return window['go']['main']['App']['GetDatabaseSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetEmailSettings() {
|
||||||
|
return window['go']['main']['App']['GetEmailSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetExternalServices() {
|
export function GetExternalServices() {
|
||||||
return window['go']['main']['App']['GetExternalServices']();
|
return window['go']['main']['App']['GetExternalServices']();
|
||||||
}
|
}
|
||||||
@@ -134,6 +254,10 @@ export function GetLookupSettings() {
|
|||||||
return window['go']['main']['App']['GetLookupSettings']();
|
return window['go']['main']['App']['GetLookupSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPOTAToken() {
|
||||||
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetQSLDefaults() {
|
export function GetQSLDefaults() {
|
||||||
return window['go']['main']['App']['GetQSLDefaults']();
|
return window['go']['main']['App']['GetQSLDefaults']();
|
||||||
}
|
}
|
||||||
@@ -158,8 +282,40 @@ 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() {
|
||||||
@@ -182,6 +338,14 @@ export function ListQSO(arg1) {
|
|||||||
return window['go']['main']['App']['ListQSO'](arg1);
|
return window['go']['main']['App']['ListQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListQSOFiltered(arg1) {
|
||||||
|
return window['go']['main']['App']['ListQSOFiltered'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListSerialPorts() {
|
||||||
|
return window['go']['main']['App']['ListSerialPorts']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListTQSLStationLocations() {
|
export function ListTQSLStationLocations() {
|
||||||
return window['go']['main']['App']['ListTQSLStationLocations']();
|
return window['go']['main']['App']['ListTQSLStationLocations']();
|
||||||
}
|
}
|
||||||
@@ -218,6 +382,10 @@ export function OperatingDefaultForBand(arg1) {
|
|||||||
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PickAudioFolder() {
|
||||||
|
return window['go']['main']['App']['PickAudioFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickBackupFolder() {
|
export function PickBackupFolder() {
|
||||||
return window['go']['main']['App']['PickBackupFolder']();
|
return window['go']['main']['App']['PickBackupFolder']();
|
||||||
}
|
}
|
||||||
@@ -230,6 +398,18 @@ export function PickSaveDatabase() {
|
|||||||
return window['go']['main']['App']['PickSaveDatabase']();
|
return window['go']['main']['App']['PickSaveDatabase']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PopulateBuiltinReferences(arg1) {
|
||||||
|
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioBegin() {
|
||||||
|
return window['go']['main']['App']['QSOAudioBegin']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOAudioCancel() {
|
||||||
|
return window['go']['main']['App']['QSOAudioCancel']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QuitApp() {
|
export function QuitApp() {
|
||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
@@ -242,10 +422,22 @@ export function ReloadUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReplaceAwardReferences(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResetAwardDefs() {
|
||||||
|
return window['go']['main']['App']['ResetAwardDefs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ResetDatabaseToDefault() {
|
export function ResetDatabaseToDefault() {
|
||||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RestartQSORecorder() {
|
||||||
|
return window['go']['main']['App']['RestartQSORecorder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -266,6 +458,18 @@ export function SaveADIFFile() {
|
|||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAudioSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveAwardDefs(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAwardDefs'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveAwardReference(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SaveAwardReference'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveBackupSettings(arg1) {
|
export function SaveBackupSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveBackupSettings'](arg1);
|
return window['go']['main']['App']['SaveBackupSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -278,6 +482,10 @@ export function SaveClusterServer(arg1) {
|
|||||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveEmailSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveEmailSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveExternalServices(arg1) {
|
export function SaveExternalServices(arg1) {
|
||||||
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
||||||
}
|
}
|
||||||
@@ -298,6 +506,10 @@ export function SaveOperatingStation(arg1) {
|
|||||||
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SavePOTAToken(arg1) {
|
||||||
|
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveProfile(arg1) {
|
export function SaveProfile(arg1) {
|
||||||
return window['go']['main']['App']['SaveProfile'](arg1);
|
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||||
}
|
}
|
||||||
@@ -318,6 +530,14 @@ export function SaveUDPIntegration(arg1) {
|
|||||||
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveWinkeyerSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendClusterCommand(arg1) {
|
export function SendClusterCommand(arg1) {
|
||||||
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||||
}
|
}
|
||||||
@@ -326,6 +546,10 @@ export function SendClusterSpot(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
|
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendQSORecordingEmail(arg1) {
|
||||||
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetCATFrequency(arg1) {
|
export function SetCATFrequency(arg1) {
|
||||||
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||||
}
|
}
|
||||||
@@ -334,6 +558,10 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetClublogCtyEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1) {
|
export function SetClusterAutoConnect(arg1) {
|
||||||
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||||
}
|
}
|
||||||
@@ -342,14 +570,30 @@ export function SetCompactMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCompactMode'](arg1);
|
return window['go']['main']['App']['SetCompactMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetDVKLabel(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetUIPref(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SyncPOTAHunterLog() {
|
||||||
|
return window['go']['main']['App']['SyncPOTAHunterLog']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestClublogUpload() {
|
export function TestClublogUpload() {
|
||||||
return window['go']['main']['App']['TestClublogUpload']();
|
return window['go']['main']['App']['TestClublogUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestEmail(arg1) {
|
||||||
|
return window['go']['main']['App']['TestEmail'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestLoTWUpload() {
|
export function TestLoTWUpload() {
|
||||||
return window['go']['main']['App']['TestLoTWUpload']();
|
return window['go']['main']['App']['TestLoTWUpload']();
|
||||||
}
|
}
|
||||||
@@ -358,6 +602,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestPTT() {
|
||||||
|
return window['go']['main']['App']['TestPTT']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestQRZUpload() {
|
export function TestQRZUpload() {
|
||||||
return window['go']['main']['App']['TestQRZUpload']();
|
return window['go']['main']['App']['TestQRZUpload']();
|
||||||
}
|
}
|
||||||
@@ -366,14 +614,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);
|
||||||
}
|
}
|
||||||
|
|||||||
+664
-51
@@ -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 {
|
||||||
@@ -351,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;
|
||||||
@@ -395,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;
|
||||||
@@ -413,6 +810,40 @@ 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 {
|
export class DatabaseSettings {
|
||||||
path: string;
|
path: string;
|
||||||
default_path: string;
|
default_path: string;
|
||||||
@@ -429,6 +860,38 @@ export namespace main {
|
|||||||
this.is_custom = source["is_custom"];
|
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;
|
||||||
@@ -510,6 +973,24 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class POTASyncResult {
|
||||||
|
fetched: number;
|
||||||
|
updated: number;
|
||||||
|
already_tagged: number;
|
||||||
|
unmatched: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new POTASyncResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.fetched = source["fetched"];
|
||||||
|
this.updated = source["updated"];
|
||||||
|
this.already_tagged = source["already_tagged"];
|
||||||
|
this.unmatched = source["unmatched"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class QSLDefaults {
|
export class QSLDefaults {
|
||||||
qsl_sent: string;
|
qsl_sent: string;
|
||||||
qsl_rcvd: string;
|
qsl_rcvd: string;
|
||||||
@@ -520,6 +1001,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);
|
||||||
@@ -536,6 +1018,25 @@ 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 {
|
export class RotatorHeading {
|
||||||
@@ -674,6 +1175,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,6 +1495,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;
|
||||||
@@ -1164,6 +1761,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;
|
||||||
@@ -1188,55 +1823,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;
|
||||||
@@ -1247,7 +1833,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;
|
||||||
@@ -1273,7 +1859,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"];
|
||||||
@@ -1341,3 +1927,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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-13
@@ -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)
|
||||||
|
|
||||||
@@ -218,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")
|
||||||
|
|||||||
+99
-1
@@ -20,6 +20,7 @@ import (
|
|||||||
type ImportResult struct {
|
type ImportResult struct {
|
||||||
Total int `json:"total"` // records found in the file
|
Total int `json:"total"` // records found in the file
|
||||||
Imported int `json:"imported"` // successfully inserted
|
Imported int `json:"imported"` // successfully inserted
|
||||||
|
Updated int `json:"updated"` // existing QSOs refreshed (update-duplicates mode)
|
||||||
Skipped int `json:"skipped"` // dropped (missing required fields)
|
Skipped int `json:"skipped"` // dropped (missing required fields)
|
||||||
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
||||||
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
||||||
@@ -36,6 +37,19 @@ type Importer struct {
|
|||||||
Repo *qso.Repo
|
Repo *qso.Repo
|
||||||
BatchSize int // 0 → 500
|
BatchSize int // 0 → 500
|
||||||
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
||||||
|
// UpdateDuplicates, when true, takes precedence over SkipDuplicates:
|
||||||
|
// a record matching an existing QSO MERGES its non-empty fields onto
|
||||||
|
// that QSO (refreshes QSL/confirmation statuses on re-sync) instead of
|
||||||
|
// being skipped or re-inserted.
|
||||||
|
UpdateDuplicates bool
|
||||||
|
// Enrich, when set, is called on each parsed QSO before dedup/insert.
|
||||||
|
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||||
|
// source file (common with contest loggers) is corrected on the way in.
|
||||||
|
Enrich func(*qso.QSO)
|
||||||
|
// OnProgress, when set, is called periodically with (processed, total)
|
||||||
|
// record counts so the UI can show a progress bar. total is an estimate
|
||||||
|
// from counting <EOR> tags up front.
|
||||||
|
OnProgress func(processed, total int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportFile reads the file at path and imports it into the repo. The
|
// ImportFile reads the file at path and imports it into the repo. The
|
||||||
@@ -62,6 +76,14 @@ func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult,
|
|||||||
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
||||||
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
||||||
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
||||||
|
// ValueDecoderFor returns the per-field byte decoder appropriate for a raw
|
||||||
|
// ADIF payload: identity when it's valid UTF-8, otherwise a Windows-1252
|
||||||
|
// decoder. Exposed so non-file ingest paths (UDP auto-log from Log4OM /
|
||||||
|
// JTAlert) transcode accented NAME/QTH fields the same way file import does.
|
||||||
|
func ValueDecoderFor(data []byte) func([]byte) string {
|
||||||
|
return pickValueDecoder(data)
|
||||||
|
}
|
||||||
|
|
||||||
func pickValueDecoder(data []byte) func([]byte) string {
|
func pickValueDecoder(data []byte) func([]byte) string {
|
||||||
if utf8.Valid(data) {
|
if utf8.Valid(data) {
|
||||||
return nil // identity
|
return nil // identity
|
||||||
@@ -97,6 +119,15 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
res := ImportResult{}
|
res := ImportResult{}
|
||||||
batch := make([]qso.QSO, 0, im.BatchSize)
|
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||||
|
|
||||||
|
// Up-front record-count estimate (count <EOR> tags, case-insensitive) so
|
||||||
|
// the UI progress bar has a denominator. Cheap single scan.
|
||||||
|
total := countEOR(data)
|
||||||
|
reportProgress := func(force bool) {
|
||||||
|
if im.OnProgress != nil && (force || res.Total%200 == 0) {
|
||||||
|
im.OnProgress(res.Total, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One upfront query for every existing dedup key — cheaper than N
|
// One upfront query for every existing dedup key — cheaper than N
|
||||||
// per-record EXISTS calls. The same map gets new keys appended as we
|
// per-record EXISTS calls. The same map gets new keys appended as we
|
||||||
// import so duplicates inside the file are caught too. Loaded
|
// import so duplicates inside the file are caught too. Loaded
|
||||||
@@ -107,6 +138,16 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
return res, fmt.Errorf("load dedupe keys: %w", err)
|
return res, fmt.Errorf("load dedupe keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update-duplicates mode needs the existing row's ID per key so it can
|
||||||
|
// fetch, merge and write it back. Loaded only when needed (extra query).
|
||||||
|
var keyIDs map[string]int64
|
||||||
|
if im.UpdateDuplicates {
|
||||||
|
keyIDs, err = im.Repo.DedupeKeyIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("load dedupe ids: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flush := func() error {
|
flush := func() error {
|
||||||
if len(batch) == 0 {
|
if len(batch) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -119,6 +160,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
|
|
||||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||||
res.Total++
|
res.Total++
|
||||||
|
reportProgress(false)
|
||||||
q, ok := recordToQSO(rec)
|
q, ok := recordToQSO(rec)
|
||||||
if !ok {
|
if !ok {
|
||||||
res.Skipped++
|
res.Skipped++
|
||||||
@@ -128,6 +170,9 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if im.Enrich != nil {
|
||||||
|
im.Enrich(&q)
|
||||||
|
}
|
||||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||||
if _, dup := seen[key]; dup {
|
if _, dup := seen[key]; dup {
|
||||||
res.Duplicates++
|
res.Duplicates++
|
||||||
@@ -138,6 +183,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
||||||
q.Band, q.Mode))
|
q.Band, q.Mode))
|
||||||
}
|
}
|
||||||
|
if im.UpdateDuplicates {
|
||||||
|
if id, ok := keyIDs[key]; ok {
|
||||||
|
existing, gerr := im.Repo.GetByID(ctx, id)
|
||||||
|
if gerr != nil {
|
||||||
|
if len(res.Errors) < maxErrors {
|
||||||
|
res.Errors = append(res.Errors,
|
||||||
|
fmt.Sprintf("record %d (%s): load existing: %v", res.Total, q.Callsign, gerr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qso.MergeNonZero(&existing, q)
|
||||||
|
if uerr := im.Repo.Update(ctx, existing); uerr != nil {
|
||||||
|
if len(res.Errors) < maxErrors {
|
||||||
|
res.Errors = append(res.Errors,
|
||||||
|
fmt.Sprintf("record %d (%s): update: %v", res.Total, q.Callsign, uerr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res.Updated++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if im.SkipDuplicates {
|
if im.SkipDuplicates {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -159,9 +226,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
if err := flush(); err != nil {
|
if err := flush(); err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
reportProgress(true) // final 100%
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countEOR estimates the record count by counting case-insensitive <EOR>
|
||||||
|
// tags. Used only to give the import progress bar a denominator.
|
||||||
|
func countEOR(data []byte) int {
|
||||||
|
n := 0
|
||||||
|
for i := 0; i+4 <= len(data); i++ {
|
||||||
|
if data[i] != '<' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (data[i+1] == 'e' || data[i+1] == 'E') &&
|
||||||
|
(data[i+2] == 'o' || data[i+2] == 'O') &&
|
||||||
|
(data[i+3] == 'r' || data[i+3] == 'R') &&
|
||||||
|
(i+4 < len(data) && (data[i+4] == '>' || data[i+4] == ':')) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
||||||
// Anything not in this set ends up in Extras.
|
// Anything not in this set ends up in Extras.
|
||||||
var adifPromoted = stringSet(
|
var adifPromoted = stringSet(
|
||||||
@@ -178,7 +264,7 @@ var adifPromoted = stringSet(
|
|||||||
"age", "lat", "lon", "rig", "ant",
|
"age", "lat", "lon", "rig", "ant",
|
||||||
// QSL
|
// QSL
|
||||||
"qsl_sent", "qsl_rcvd",
|
"qsl_sent", "qsl_rcvd",
|
||||||
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd",
|
"qslsdate", "qslrdate", "qsl_via", "qsl_sent_via", "qsl_rcvd_via", "qslmsg", "qslmsg_rcvd",
|
||||||
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
||||||
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
||||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||||
@@ -253,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"]
|
||||||
@@ -300,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"]
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
// Package award computes amateur-radio award progress (worked / confirmed)
|
||||||
|
// directly from the logbook. An award is defined declaratively: a QSO FIELD to
|
||||||
|
// scan plus an optional regular-expression PATTERN that extracts the reference
|
||||||
|
// from that field. With no pattern the whole field value is the reference; with
|
||||||
|
// a pattern, capture group 1 (or the whole match) is the reference and a single
|
||||||
|
// QSO may yield several references (e.g. a Note holding "D74 D73").
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// DXCC : field "dxcc" (no pattern) → entity number
|
||||||
|
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
||||||
|
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
||||||
|
// WPX : field "prefix" (computed from callsign)
|
||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AwardType selects how a QSO is matched to an award's references.
|
||||||
|
//
|
||||||
|
// "DXCC" — match the QSO's DXCC entity number (references keyed by entity)
|
||||||
|
// "QSOFIELDS" — search a QSO field for a reference code/description/pattern
|
||||||
|
// "REFERENCE" — the reference is carried by a dedicated field (POTA_REF, …) or
|
||||||
|
// the per-reference DXCC list (e.g. RAC provinces by state)
|
||||||
|
// "GRID" — match a Maidenhead grid square
|
||||||
|
type AwardType = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeDXCC AwardType = "DXCC"
|
||||||
|
TypeQSOFields AwardType = "QSOFIELDS"
|
||||||
|
TypeReference AwardType = "REFERENCE"
|
||||||
|
TypeGrid AwardType = "GRID"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Def defines one award. Fields mirror Log4OM's Award Management model: an
|
||||||
|
// identity + scope (when the award applies) + a matching rule (how a QSO maps
|
||||||
|
// to a reference) + confirmation rules. Most fields are optional; the zero
|
||||||
|
// value of a legacy Def (only Field/Pattern/DXCCFilter/Confirm/Total set) still
|
||||||
|
// behaves as before.
|
||||||
|
type Def struct {
|
||||||
|
// --- Identity ---
|
||||||
|
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||||
|
Name string `json:"name"` // friendly name
|
||||||
|
Description string `json:"description,omitempty"` // free text
|
||||||
|
Valid bool `json:"valid"` // award enabled
|
||||||
|
Protected bool `json:"protected,omitempty"` // shipped/locked award
|
||||||
|
URL string `json:"url,omitempty"` // award home page
|
||||||
|
DownloadURL string `json:"download_url,omitempty"` // reference-list source
|
||||||
|
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
|
||||||
|
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||||
|
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||||
|
Alias string `json:"alias,omitempty"`
|
||||||
|
|
||||||
|
// --- Type & matching ---
|
||||||
|
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||||
|
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||||
|
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||||
|
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||||
|
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||||
|
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||||
|
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||||
|
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
||||||
|
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||||
|
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
||||||
|
|
||||||
|
// --- Scope ---
|
||||||
|
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||||
|
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
||||||
|
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
|
||||||
|
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
|
||||||
|
|
||||||
|
// --- Confirmation ---
|
||||||
|
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||||
|
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||||
|
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||||
|
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||||
|
|
||||||
|
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||||
|
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||||
|
func Defaults() []Def {
|
||||||
|
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
||||||
|
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
||||||
|
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
||||||
|
// accepts it (WAC).
|
||||||
|
lq := []string{"lotw", "qsl"}
|
||||||
|
lo := []string{"lotw"}
|
||||||
|
return []Def{
|
||||||
|
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate upgrades award definitions saved before the richer model existed.
|
||||||
|
// Such defs have Type=="" and the zero value for the new fields (notably
|
||||||
|
// Valid==false, which would otherwise hide every legacy award). For each legacy
|
||||||
|
// def it enables the award, fills the matching/confirmation fields from the
|
||||||
|
// matching built-in default (preserving the user's field/filters/confirm), and
|
||||||
|
// fixes the DDFM capture pattern. Returns the (possibly) migrated slice and
|
||||||
|
// whether anything changed. Idempotent: a def with Type!="" is left untouched.
|
||||||
|
func Migrate(defs []Def) ([]Def, bool) {
|
||||||
|
defaults := map[string]Def{}
|
||||||
|
for _, d := range Defaults() {
|
||||||
|
defaults[strings.ToUpper(d.Code)] = d
|
||||||
|
}
|
||||||
|
const oldDDFM = `(?i)\bD(\d{1,2}[AB]?)\b`
|
||||||
|
changed := false
|
||||||
|
out := make([]Def, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
if d.Type != "" {
|
||||||
|
out[i] = d // already on the new model
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
d.Valid = true // legacy defs predate the Valid flag → enable them
|
||||||
|
if def, ok := defaults[strings.ToUpper(d.Code)]; ok {
|
||||||
|
d.Type = def.Type
|
||||||
|
d.ExactMatch = def.ExactMatch
|
||||||
|
d.Dynamic = def.Dynamic
|
||||||
|
d.Protected = def.Protected
|
||||||
|
if len(d.Validate) == 0 {
|
||||||
|
d.Validate = def.Validate
|
||||||
|
}
|
||||||
|
// Fix DDFM's capture group ("06" → "D06") so refs match the list.
|
||||||
|
if strings.EqualFold(d.Code, "DDFM") && (d.Pattern == "" || d.Pattern == oldDDFM) {
|
||||||
|
d.Pattern = def.Pattern
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d.Type = TypeQSOFields // sensible default for custom legacy awards
|
||||||
|
}
|
||||||
|
out[i] = d
|
||||||
|
}
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields lists the scannable QSO fields for the award editor.
|
||||||
|
func Fields() []string {
|
||||||
|
return []string{
|
||||||
|
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||||
|
"state", "cont", "country", "grid",
|
||||||
|
"iota", "sota_ref", "pota_ref", "wwff",
|
||||||
|
"name", "qth", "address", "comment", "note",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BandCount holds distinct-reference counts on one band.
|
||||||
|
type BandCount struct {
|
||||||
|
Band string `json:"band"`
|
||||||
|
Worked int `json:"worked"`
|
||||||
|
Confirmed int `json:"confirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref is one reference's status within an award.
|
||||||
|
type Ref struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
SubGrp string `json:"subgrp,omitempty"`
|
||||||
|
Worked bool `json:"worked"`
|
||||||
|
Confirmed bool `json:"confirmed"`
|
||||||
|
Validated bool `json:"validated"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
ConfirmedBands []string `json:"confirmed_bands"`
|
||||||
|
ValidatedBands []string `json:"validated_bands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is an award's computed progress.
|
||||||
|
type Result struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Field string `json:"field"`
|
||||||
|
Worked int `json:"worked"`
|
||||||
|
Confirmed int `json:"confirmed"`
|
||||||
|
Validated int `json:"validated"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Bands []BandCount `json:"bands"`
|
||||||
|
Refs []Ref `json:"refs"`
|
||||||
|
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
||||||
|
type NameResolver func(field, ref string) string
|
||||||
|
|
||||||
|
type refAgg struct {
|
||||||
|
bands map[string]struct{}
|
||||||
|
confirmedBands map[string]struct{}
|
||||||
|
validatedBands map[string]struct{}
|
||||||
|
anyConfirmed bool
|
||||||
|
anyValidated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// refList is the per-award reference data Compute needs (a thin view of
|
||||||
|
// awardref.Ref, kept local so the award package stays storage-agnostic).
|
||||||
|
type refList struct {
|
||||||
|
byCode map[string]RefMeta // uppercased code → metadata
|
||||||
|
codes []string // codes in input order (for stable unworked listing)
|
||||||
|
withPattern []string // codes whose reference declares a regex (usually none)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWPXPrefix(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"F4BPO": "F4",
|
||||||
|
"EA8ABC": "EA8",
|
||||||
|
"9A1AA": "9A1",
|
||||||
|
"OH2BH": "OH2",
|
||||||
|
"K1ABC": "K1",
|
||||||
|
"RAEM": "RA0",
|
||||||
|
"F4BPO/P": "F4",
|
||||||
|
"F4BPO/9": "F9",
|
||||||
|
"VP8/F4BPO": "VP8",
|
||||||
|
"PA0XYZ": "PA0",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := wpxPrefix(in); got != want {
|
||||||
|
t.Errorf("wpxPrefix(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ip(n int) *int { return &n }
|
||||||
|
|
||||||
|
func TestComputeDXCCAndConfirm(t *testing.T) {
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA", LOTWRcvd: "Y"},
|
||||||
|
{Callsign: "K2DEF", Band: "40m", DXCC: ip(291), State: "NY"}, // worked, not confirmed
|
||||||
|
{Callsign: "DL1XYZ", Band: "20m", DXCC: ip(230), QSLRcvd: "Y"}, // DXCC Germany confirmed
|
||||||
|
{Callsign: "F4BPO", Band: "20m", DXCC: ip(227), Notes: "nice qso D74", EQSLRcvd: "Y"}, // France dept 74 in note
|
||||||
|
{Callsign: "F5ABC", Band: "40m", DXCC: ip(227), Notes: "D2A Corsica", QSLRcvd: "Y"}, // France dept 2A, confirmed
|
||||||
|
}
|
||||||
|
res := Compute(Defaults(), qsos, nil, nil)
|
||||||
|
by := map[string]Result{}
|
||||||
|
for _, r := range res {
|
||||||
|
by[r.Code] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
dxcc := by["DXCC"]
|
||||||
|
if dxcc.Worked != 3 { // USA, Germany, France
|
||||||
|
t.Errorf("DXCC worked = %d, want 3", dxcc.Worked)
|
||||||
|
}
|
||||||
|
// DXCC confirms on lotw|qsl → USA(lotw) + Germany(qsl) + France(qsl via F5ABC).
|
||||||
|
if dxcc.Confirmed != 3 {
|
||||||
|
t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed)
|
||||||
|
}
|
||||||
|
// Validated is the stricter LoTW-only tier: a paper QSL confirms but does
|
||||||
|
// NOT validate, so only USA (LoTW) counts.
|
||||||
|
if dxcc.Validated != 1 {
|
||||||
|
t.Errorf("DXCC validated = %d, want 1 (LoTW only, QSL doesn't validate)", dxcc.Validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
was := by["WAS"]
|
||||||
|
if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter)
|
||||||
|
t.Errorf("WAS worked = %d, want 2", was.Worked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DDFM scans the Note field with pattern D(\d{1,2}[AB]?): 74 and 2A.
|
||||||
|
ddfm := by["DDFM"]
|
||||||
|
if ddfm.Worked != 2 {
|
||||||
|
t.Errorf("DDFM worked = %d, want 2 (refs %v)", ddfm.Worked, refCodes(ddfm))
|
||||||
|
}
|
||||||
|
if ddfm.Confirmed != 1 { // 2A confirmed via QSL; 74 only eQSL (not accepted)
|
||||||
|
t.Errorf("DDFM confirmed = %d, want 1", ddfm.Confirmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNatLess(t *testing.T) {
|
||||||
|
in := []string{"10", "2", "1", "20", "3", "D10", "D2A", "D2", "AL", "AK"}
|
||||||
|
want := []string{"1", "2", "3", "10", "20", "AK", "AL", "D2", "D2A", "D10"}
|
||||||
|
sort.Slice(in, func(i, j int) bool { return natLess(in[i], in[j]) })
|
||||||
|
for i := range want {
|
||||||
|
if in[i] != want[i] {
|
||||||
|
t.Fatalf("natLess order = %v, want %v", in, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Package clublog uses the ClubLog Country File (cty.xml) to resolve callsign
|
||||||
|
// EXCEPTIONS — date-ranged full-callsign overrides for DXpeditions and special
|
||||||
|
// operations that prefix-based files (cty.dat) get wrong. e.g. VK2/SP9FIH was
|
||||||
|
// Lord Howe Island (not Australia) between specific 2025 dates.
|
||||||
|
package clublog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exception is one date-ranged full-callsign override.
|
||||||
|
type Exception struct {
|
||||||
|
Call string
|
||||||
|
Entity string
|
||||||
|
ADIF int
|
||||||
|
CQZ int
|
||||||
|
Cont string
|
||||||
|
Lat float64
|
||||||
|
Lon float64
|
||||||
|
Start time.Time // zero = no lower bound
|
||||||
|
End time.Time // zero = no upper bound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Exception) covers(t time.Time) bool {
|
||||||
|
if !e.Start.IsZero() && t.Before(e.Start) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !e.End.IsZero() && t.After(e.End) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB holds the parsed exception + prefix tables. Exceptions are keyed by the
|
||||||
|
// full callsign; prefixes are keyed by the prefix string (both may hold several
|
||||||
|
// date-ranged entries). cty.xml carries entity + CQ zone + continent per
|
||||||
|
// record, but NOT ITU zone.
|
||||||
|
type DB struct {
|
||||||
|
exceptions map[string][]Exception
|
||||||
|
prefixes map[string][]Exception
|
||||||
|
date string // cty.xml generation date (for the UI)
|
||||||
|
count int
|
||||||
|
prefixCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns how many exceptions were loaded.
|
||||||
|
func (db *DB) Count() int { return db.count }
|
||||||
|
|
||||||
|
// PrefixCount returns how many prefix records were loaded.
|
||||||
|
func (db *DB) PrefixCount() int { return db.prefixCount }
|
||||||
|
|
||||||
|
// Date returns the cty.xml generation timestamp.
|
||||||
|
func (db *DB) Date() string { return db.date }
|
||||||
|
|
||||||
|
// xml decode shapes.
|
||||||
|
type xlException struct {
|
||||||
|
Call string `xml:"call"`
|
||||||
|
Entity string `xml:"entity"`
|
||||||
|
ADIF int `xml:"adif"`
|
||||||
|
CQZ int `xml:"cqz"`
|
||||||
|
Cont string `xml:"cont"`
|
||||||
|
Long string `xml:"long"`
|
||||||
|
Lat string `xml:"lat"`
|
||||||
|
Start string `xml:"start"`
|
||||||
|
End string `xml:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadGzip parses a gzipped ClubLog cty.xml stream.
|
||||||
|
func LoadGzip(r io.Reader) (*DB, error) {
|
||||||
|
zr, err := gzip.NewReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gunzip: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
return Load(zr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parses a (plain) ClubLog cty.xml stream, extracting only the
|
||||||
|
// <exceptions> section via a streaming decoder (the file is ~10 MB).
|
||||||
|
func Load(r io.Reader) (*DB, error) {
|
||||||
|
db := &DB{exceptions: map[string][]Exception{}, prefixes: map[string][]Exception{}}
|
||||||
|
dec := xml.NewDecoder(r)
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("xml: %w", err)
|
||||||
|
}
|
||||||
|
se, ok := tok.(xml.StartElement)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch se.Name.Local {
|
||||||
|
case "clublog":
|
||||||
|
for _, a := range se.Attr {
|
||||||
|
if a.Name.Local == "date" {
|
||||||
|
db.date = a.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "exception":
|
||||||
|
var x xlException
|
||||||
|
if err := dec.DecodeElement(&x, &se); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(x.Call))
|
||||||
|
if call == "" || x.ADIF == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := Exception{
|
||||||
|
Call: call, Entity: x.Entity, ADIF: x.ADIF, CQZ: x.CQZ,
|
||||||
|
Cont: strings.ToUpper(strings.TrimSpace(x.Cont)),
|
||||||
|
Lat: parseFloat(x.Lat), Lon: parseFloat(x.Long),
|
||||||
|
Start: parseTime(x.Start), End: parseTime(x.End),
|
||||||
|
}
|
||||||
|
db.exceptions[call] = append(db.exceptions[call], e)
|
||||||
|
db.count++
|
||||||
|
case "prefix":
|
||||||
|
var x xlException // same shape; <call> holds the prefix string
|
||||||
|
if err := dec.DecodeElement(&x, &se); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx := strings.ToUpper(strings.TrimSpace(x.Call))
|
||||||
|
if pfx == "" || x.ADIF == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := Exception{
|
||||||
|
Call: pfx, Entity: x.Entity, ADIF: x.ADIF, CQZ: x.CQZ,
|
||||||
|
Cont: strings.ToUpper(strings.TrimSpace(x.Cont)),
|
||||||
|
Lat: parseFloat(x.Lat), Lon: parseFloat(x.Long),
|
||||||
|
Start: parseTime(x.Start), End: parseTime(x.End),
|
||||||
|
}
|
||||||
|
db.prefixes[pfx] = append(db.prefixes[pfx], e)
|
||||||
|
db.prefixCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePrefix returns the longest prefix entry matching a callsign and valid
|
||||||
|
// at the given date (cascade step 2). The callsign should already be normalized
|
||||||
|
// (operating affixes stripped); we still strip a trailing "/x" defensively.
|
||||||
|
func (db *DB) ResolvePrefix(call string, date time.Time) (Exception, bool) {
|
||||||
|
if db == nil {
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
if i := strings.LastIndex(c, "/"); i > 0 {
|
||||||
|
// Prefer the operating prefix when present (MM/DL1ABC → MM).
|
||||||
|
if pre, post := c[:i], c[i+1:]; len(pre) <= len(post) {
|
||||||
|
c = pre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for n := len(c); n >= 1; n-- {
|
||||||
|
for _, e := range db.prefixes[c[:n]] {
|
||||||
|
if e.covers(date) {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFull runs the ClubLog cascade: a full-callsign Exception first, then
|
||||||
|
// the longest valid Prefix. Returns the matched record and its source
|
||||||
|
// ("exception" | "prefix"), or ok=false when ClubLog has nothing (caller falls
|
||||||
|
// back to cty.dat).
|
||||||
|
func (db *DB) ResolveFull(call string, date time.Time) (e Exception, source string, ok bool) {
|
||||||
|
if db == nil {
|
||||||
|
return Exception{}, "", false
|
||||||
|
}
|
||||||
|
if e, ok := db.Resolve(call, date); ok {
|
||||||
|
return e, "exception", true
|
||||||
|
}
|
||||||
|
if e, ok := db.ResolvePrefix(call, date); ok {
|
||||||
|
return e, "prefix", true
|
||||||
|
}
|
||||||
|
return Exception{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the exception for a callsign valid at the given date, if any.
|
||||||
|
// It tries the call as-is, then with a trailing "/x" affix stripped (so
|
||||||
|
// VK2/SP9FIH/P still matches the VK2/SP9FIH exception).
|
||||||
|
func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
|
||||||
|
if db == nil {
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
for _, key := range candidates(c) {
|
||||||
|
for _, e := range db.exceptions[key] {
|
||||||
|
if e.covers(date) {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidates yields the call and a version with one trailing affix removed.
|
||||||
|
func candidates(c string) []string {
|
||||||
|
out := []string{c}
|
||||||
|
if i := strings.LastIndex(c, "/"); i > 0 {
|
||||||
|
suffix := c[i+1:]
|
||||||
|
// Only strip short operational affixes, not a real prefix override
|
||||||
|
// (e.g. keep "VK2/SP9FIH" intact; strip "VK2/SP9FIH/P").
|
||||||
|
switch suffix {
|
||||||
|
case "P", "M", "MM", "AM", "QRP", "A":
|
||||||
|
out = append(out, c[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(s string) time.Time {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var f float64
|
||||||
|
fmt.Sscanf(s, "%g", &f)
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ type Spot struct {
|
|||||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
|
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||||
|
POTAName string `json:"pota_name,omitempty"` // park name
|
||||||
}
|
}
|
||||||
|
|
||||||
// State enumerates the per-server lifecycle.
|
// State enumerates the per-server lifecycle.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Award reference lists (Parks On The Air, SOTA summits, WWFF, IOTA…).
|
||||||
|
-- Each row is one valid reference for an award, used to provide award totals,
|
||||||
|
-- reference names, and (later) per-QSO reference assignment + per-DXCC filtering.
|
||||||
|
-- Lists are downloaded/updated from each program's published file.
|
||||||
|
CREATE TABLE IF NOT EXISTS award_references (
|
||||||
|
award_code TEXT NOT NULL,
|
||||||
|
ref_code TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
dxcc INTEGER NOT NULL DEFAULT 0,
|
||||||
|
grp TEXT NOT NULL DEFAULT '',
|
||||||
|
subgrp TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (award_code, ref_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_award_ref_dxcc ON award_references(award_code, dxcc);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Richer per-reference metadata, mirroring Log4OM's reference editor:
|
||||||
|
-- a per-reference regexp, validity window, score/bonus, grid, alias, a
|
||||||
|
-- "valid" flag, and a multi-DXCC list (JSON array) on top of the single
|
||||||
|
-- primary dxcc kept for fast filtering.
|
||||||
|
ALTER TABLE award_references ADD COLUMN dxcc_list TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE award_references ADD COLUMN pattern TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE award_references ADD COLUMN valid INTEGER NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE award_references ADD COLUMN valid_from TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE award_references ADD COLUMN valid_to TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE award_references ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE award_references ADD COLUMN bonus INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE award_references ADD COLUMN gridsquare TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE award_references ADD COLUMN alias TEXT NOT NULL DEFAULT '';
|
||||||
+166
-318
@@ -1,333 +1,181 @@
|
|||||||
package dxcc
|
package dxcc
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// dxccByName maps cty.dat entity names to ADIF DXCC entity numbers
|
// The dxccByName table itself lives in dxcc_names_gen.go (generated by joining
|
||||||
// (ARRL DXCC List). cty.dat itself doesn't carry this number — it's a
|
// cty.dat to the authoritative ARRL/ADIF entity list). cty.dat doesn't carry
|
||||||
// separate ARRL-maintained list. We embed the current entities here so
|
// the ADIF DXCC number, so we map its entity names → numbers here to stamp
|
||||||
// QSO records can be stamped with MY_DXCC / DXCC at log time without a
|
// MY_DXCC / DXCC at log time without a network round-trip.
|
||||||
// network round-trip.
|
|
||||||
//
|
|
||||||
// Coverage: every current ARRL DXCC entity (340+). Deleted entities are
|
|
||||||
// included for legacy compatibility. The lookup is case-insensitive and
|
|
||||||
// space-tolerant on the caller side.
|
|
||||||
var dxccByName = map[string]int{
|
|
||||||
// 0xx
|
|
||||||
"sovereign military order of malta": 246,
|
|
||||||
"spratly is.": 247,
|
|
||||||
"sable i.": 211,
|
|
||||||
"st. paul i.": 252,
|
|
||||||
"hawaii": 110,
|
|
||||||
"agalega & st. brandon is.": 4,
|
|
||||||
"alaska": 6,
|
|
||||||
"american samoa": 9,
|
|
||||||
"amsterdam & st. paul is.": 10,
|
|
||||||
"andaman & nicobar is.": 11,
|
|
||||||
"anguilla": 12,
|
|
||||||
"antarctica": 13,
|
|
||||||
"armenia": 14,
|
|
||||||
"asiatic russia": 15,
|
|
||||||
"aves i.": 17,
|
|
||||||
"azerbaijan": 18,
|
|
||||||
"baker & howland is.": 20,
|
|
||||||
"balearic is.": 21,
|
|
||||||
"palmyra & jarvis is.": 22,
|
|
||||||
"central kiribati": 31,
|
|
||||||
"central african republic": 27,
|
|
||||||
"cape verde": 32,
|
|
||||||
"chagos is.": 33,
|
|
||||||
"chatham is.": 34,
|
|
||||||
"christmas i.": 35,
|
|
||||||
"clipperton i.": 36,
|
|
||||||
"cocos i.": 37,
|
|
||||||
"cocos (keeling) is.": 38,
|
|
||||||
"comoros": 39,
|
|
||||||
"crete": 40,
|
|
||||||
"crozet i.": 41,
|
|
||||||
"falkland is.": 141,
|
|
||||||
"chesterfield is.": 512,
|
|
||||||
"easter i.": 47,
|
|
||||||
"sint eustatius & saba": 519,
|
|
||||||
"ducie i.": 513,
|
|
||||||
"european russia": 54,
|
|
||||||
"farquhar": 55,
|
|
||||||
"fernando de noronha": 56,
|
|
||||||
"french equatorial africa": 57,
|
|
||||||
"french indo-china": 58,
|
|
||||||
"french polynesia": 175,
|
|
||||||
"djibouti": 382,
|
|
||||||
"gabon": 420,
|
|
||||||
"galapagos is.": 71,
|
|
||||||
"guantanamo bay": 105,
|
|
||||||
"guatemala": 76,
|
|
||||||
"guernsey": 106,
|
|
||||||
"guinea": 107,
|
|
||||||
"guyana": 129,
|
|
||||||
"hong kong": 321,
|
|
||||||
"howland & baker is.": 20,
|
|
||||||
"isle of man": 114,
|
|
||||||
"itu hq": 117,
|
|
||||||
"iran": 330,
|
|
||||||
"iraq": 333,
|
|
||||||
"juan de nova & europa": 124,
|
|
||||||
"juan fernandez is.": 125,
|
|
||||||
"kaliningrad": 126,
|
|
||||||
"kerguelen is.": 131,
|
|
||||||
"kermadec is.": 133,
|
|
||||||
"kingman reef": 134,
|
|
||||||
"kuwait": 348,
|
|
||||||
"kyrgyzstan": 135,
|
|
||||||
"jersey": 122,
|
|
||||||
"laccadive is.": 142,
|
|
||||||
"laos": 143,
|
|
||||||
"lord howe i.": 147,
|
|
||||||
"market reef": 151,
|
|
||||||
"marquesas is.": 509,
|
|
||||||
"marshall is.": 168,
|
|
||||||
"mauritania": 444,
|
|
||||||
"mayotte": 169,
|
|
||||||
"mexico": 50,
|
|
||||||
"midway i.": 174,
|
|
||||||
"minami torishima": 177,
|
|
||||||
"monaco": 260,
|
|
||||||
"mongolia": 363,
|
|
||||||
"mount athos": 180,
|
|
||||||
"navassa i.": 182,
|
|
||||||
"new caledonia": 162,
|
|
||||||
"new zealand": 170,
|
|
||||||
"niue": 188,
|
|
||||||
"norfolk i.": 189,
|
|
||||||
"north cook is.": 191,
|
|
||||||
"north korea": 344,
|
|
||||||
"ogasawara": 192,
|
|
||||||
"oman": 370,
|
|
||||||
"palestine": 510,
|
|
||||||
"pratas i.": 505,
|
|
||||||
"qatar": 376,
|
|
||||||
"rotuma i.": 460,
|
|
||||||
"rwanda": 454,
|
|
||||||
"san andres & providencia": 216,
|
|
||||||
"south georgia i.": 235,
|
|
||||||
"south orkney is.": 238,
|
|
||||||
"south sandwich is.": 240,
|
|
||||||
"south shetland is.": 241,
|
|
||||||
"swains i.": 515,
|
|
||||||
"swaziland": 468,
|
|
||||||
"taiwan": 386,
|
|
||||||
"tajikistan": 262,
|
|
||||||
"thailand": 387,
|
|
||||||
"timor-leste": 511,
|
|
||||||
"tokelau is.": 270,
|
|
||||||
"tonga": 160,
|
|
||||||
"trindade & martim vaz is.": 273,
|
|
||||||
"tristan da cunha & gough is.": 274,
|
|
||||||
"tromelin i.": 276,
|
|
||||||
"tunisia": 474,
|
|
||||||
"turkmenistan": 280,
|
|
||||||
"turks & caicos is.": 89,
|
|
||||||
"tuvalu": 282,
|
|
||||||
"uk sov. base areas on cyprus": 283,
|
|
||||||
"united nations hq": 289,
|
|
||||||
"vatican city": 295,
|
|
||||||
"venezuela": 148,
|
|
||||||
"viet nam": 293,
|
|
||||||
"wake i.": 297,
|
|
||||||
"wallis & futuna is.": 298,
|
|
||||||
"western kiribati": 301,
|
|
||||||
"yemen": 492,
|
|
||||||
|
|
||||||
// Major populous entities
|
// EntityDXCC returns the ADIF DXCC entity number for the given cty.dat entity
|
||||||
"france": 227,
|
// name, or 0 when unknown (callers should then leave the field empty rather
|
||||||
"germany": 230,
|
// than guess). Case-insensitive and whitespace-tolerant.
|
||||||
"belgium": 209,
|
|
||||||
"netherlands": 263,
|
|
||||||
"luxembourg": 254,
|
|
||||||
"switzerland": 287,
|
|
||||||
"liechtenstein": 251,
|
|
||||||
"austria": 206,
|
|
||||||
"italy": 248,
|
|
||||||
// Sicily (IT9) and African Italy (IG9/IH9) are cty.dat WAE splits, not
|
|
||||||
// DXCC entities — they count as Italy (248). Sardinia (IS0) IS its own
|
|
||||||
// DXCC entity (225) and keeps its number.
|
|
||||||
"sicily": 248,
|
|
||||||
"african italy": 248,
|
|
||||||
"sardinia": 225,
|
|
||||||
"spain": 281,
|
|
||||||
"portugal": 272,
|
|
||||||
"andorra": 203,
|
|
||||||
"san marino": 278,
|
|
||||||
"corsica": 214,
|
|
||||||
"vatican": 295,
|
|
||||||
"england": 223,
|
|
||||||
"scotland": 279,
|
|
||||||
"wales": 294,
|
|
||||||
"northern ireland": 265,
|
|
||||||
"ireland": 245,
|
|
||||||
"shetland is.": 279,
|
|
||||||
"poland": 269,
|
|
||||||
"czech republic": 503,
|
|
||||||
"slovak republic": 504,
|
|
||||||
"hungary": 239,
|
|
||||||
"romania": 275,
|
|
||||||
"bulgaria": 212,
|
|
||||||
"greece": 236,
|
|
||||||
"dodecanese": 45,
|
|
||||||
"turkey": 390,
|
|
||||||
"european turkey": 390,
|
|
||||||
"asiatic turkey": 390,
|
|
||||||
"cyprus": 215,
|
|
||||||
"malta": 257,
|
|
||||||
"denmark": 221,
|
|
||||||
"faroe is.": 222,
|
|
||||||
"greenland": 237,
|
|
||||||
"sweden": 284,
|
|
||||||
"norway": 266,
|
|
||||||
"finland": 224,
|
|
||||||
"aland is.": 5,
|
|
||||||
"iceland": 242,
|
|
||||||
"estonia": 52,
|
|
||||||
"latvia": 145,
|
|
||||||
"lithuania": 146,
|
|
||||||
"belarus": 27,
|
|
||||||
"ukraine": 288,
|
|
||||||
"moldova": 179,
|
|
||||||
"georgia": 75,
|
|
||||||
"serbia": 296,
|
|
||||||
"montenegro": 514,
|
|
||||||
"slovenia": 499,
|
|
||||||
"croatia": 497,
|
|
||||||
"bosnia-herzegovina": 501,
|
|
||||||
"macedonia": 502,
|
|
||||||
"kosovo": 522,
|
|
||||||
"albania": 7,
|
|
||||||
"israel": 336,
|
|
||||||
"jordan": 342,
|
|
||||||
"lebanon": 354,
|
|
||||||
"syria": 384,
|
|
||||||
"saudi arabia": 378,
|
|
||||||
"united arab emirates": 391,
|
|
||||||
"bahrain": 304,
|
|
||||||
"egypt": 478,
|
|
||||||
"libya": 436,
|
|
||||||
"algeria": 400,
|
|
||||||
"morocco": 446,
|
|
||||||
"western sahara": 302,
|
|
||||||
"south africa": 462,
|
|
||||||
"namibia": 464,
|
|
||||||
"botswana": 402,
|
|
||||||
"zimbabwe": 452,
|
|
||||||
"zambia": 482,
|
|
||||||
"mozambique": 181,
|
|
||||||
"madagascar": 438,
|
|
||||||
"mauritius": 165,
|
|
||||||
"reunion i.": 453,
|
|
||||||
"seychelles": 379,
|
|
||||||
"kenya": 430,
|
|
||||||
"tanzania": 470,
|
|
||||||
"uganda": 286,
|
|
||||||
"ethiopia": 53,
|
|
||||||
"eritrea": 51,
|
|
||||||
"sudan": 466,
|
|
||||||
"south sudan republic of": 521,
|
|
||||||
"nigeria": 450,
|
|
||||||
"ghana": 424,
|
|
||||||
"cameroon": 406,
|
|
||||||
"senegal": 456,
|
|
||||||
"liberia": 434,
|
|
||||||
"sierra leone": 458,
|
|
||||||
"benin": 416,
|
|
||||||
"togo": 483,
|
|
||||||
"ivory coast": 428,
|
|
||||||
"mali": 442,
|
|
||||||
"niger": 187,
|
|
||||||
"chad": 410,
|
|
||||||
"japan": 339,
|
|
||||||
"south korea": 137,
|
|
||||||
"china": 318,
|
|
||||||
"india": 324,
|
|
||||||
"pakistan": 372,
|
|
||||||
"sri lanka": 315,
|
|
||||||
"nepal": 369,
|
|
||||||
"bangladesh": 305,
|
|
||||||
"bhutan": 306,
|
|
||||||
"myanmar": 309,
|
|
||||||
"west malaysia": 299,
|
|
||||||
"east malaysia": 46,
|
|
||||||
"singapore": 381,
|
|
||||||
"indonesia": 327,
|
|
||||||
"philippines": 375,
|
|
||||||
"brunei darussalam": 345,
|
|
||||||
"cambodia": 312,
|
|
||||||
"kazakhstan": 130,
|
|
||||||
"uzbekistan": 292,
|
|
||||||
"afghanistan": 3,
|
|
||||||
"maldives": 159,
|
|
||||||
"australia": 150,
|
|
||||||
"tasmania": 150,
|
|
||||||
"papua new guinea": 163,
|
|
||||||
"solomon is.": 185,
|
|
||||||
"vanuatu": 158,
|
|
||||||
"fiji": 176,
|
|
||||||
"samoa": 190,
|
|
||||||
"canada": 1,
|
|
||||||
"united states": 291,
|
|
||||||
"united states of america": 291,
|
|
||||||
"puerto rico": 202,
|
|
||||||
"us virgin is.": 285,
|
|
||||||
"british virgin is.": 91,
|
|
||||||
"cayman is.": 69,
|
|
||||||
"jamaica": 82,
|
|
||||||
"bahamas": 60,
|
|
||||||
"bermuda": 64,
|
|
||||||
"haiti": 78,
|
|
||||||
"dominican republic": 72,
|
|
||||||
"cuba": 70,
|
|
||||||
"barbados": 62,
|
|
||||||
"trinidad & tobago": 90,
|
|
||||||
"grenada": 77,
|
|
||||||
"st. lucia": 97,
|
|
||||||
"st. vincent": 98,
|
|
||||||
"dominica": 95,
|
|
||||||
"montserrat": 96,
|
|
||||||
"st. kitts & nevis": 249,
|
|
||||||
"antigua & barbuda": 94,
|
|
||||||
"guadeloupe": 79,
|
|
||||||
"martinique": 84,
|
|
||||||
"french guiana": 63,
|
|
||||||
"suriname": 140,
|
|
||||||
"colombia": 116,
|
|
||||||
"ecuador": 120,
|
|
||||||
"peru": 136,
|
|
||||||
"bolivia": 104,
|
|
||||||
"chile": 112,
|
|
||||||
"argentina": 100,
|
|
||||||
"uruguay": 144,
|
|
||||||
"paraguay": 132,
|
|
||||||
"brazil": 108,
|
|
||||||
"belize": 66,
|
|
||||||
"honduras": 80,
|
|
||||||
"el salvador": 74,
|
|
||||||
"nicaragua": 86,
|
|
||||||
"costa rica": 308,
|
|
||||||
"panama": 88,
|
|
||||||
}
|
|
||||||
|
|
||||||
// EntityDXCC returns the ADIF DXCC entity number for the given cty.dat
|
|
||||||
// entity name. Returns 0 when the name isn't in our table — callers
|
|
||||||
// should leave the field empty in that case rather than guess. The match
|
|
||||||
// is case-insensitive and tolerant of leading/trailing whitespace.
|
|
||||||
func EntityDXCC(name string) int {
|
func EntityDXCC(name string) int {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return dxccByName[strings.ToLower(strings.TrimSpace(name))]
|
// Fast path: exact (lower-cased) match against the cty.dat names.
|
||||||
|
if n := dxccByName[strings.ToLower(strings.TrimSpace(name))]; n != 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
// Fallback: canonicalise so abbreviation/spelling differences still match
|
||||||
|
// (e.g. an ADIF import that wrote "Lord Howe I." instead of cty.dat's
|
||||||
|
// "Lord Howe Island").
|
||||||
|
if n := dxccByCanon[canonEntity(name)]; n != 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
// Last resort: cty.dat pseudo-entities (Sicily, African Italy) report a
|
||||||
|
// parent DXCC entity for the number.
|
||||||
|
if c := CanonicalEntityName(name); !strings.EqualFold(c, name) {
|
||||||
|
return dxccByName[strings.ToLower(strings.TrimSpace(c))]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameByDXCC reverses dxccByName (number → a representative entity name),
|
||||||
|
// built once. When several names share a number, the longest (usually the most
|
||||||
|
// complete) wins. Names are Title-cased for display.
|
||||||
|
var nameByDXCC = func() map[int]string {
|
||||||
|
m := make(map[int]string, len(dxccByName))
|
||||||
|
for name, num := range dxccByName {
|
||||||
|
if cur, ok := m[num]; !ok || len(name) > len(cur) {
|
||||||
|
m[num] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// NameForDXCC returns a display name for an ADIF DXCC entity number, or "" if
|
||||||
|
// unknown.
|
||||||
|
func NameForDXCC(n int) string {
|
||||||
|
name, ok := nameByDXCC[n]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Title(name) //nolint:staticcheck // ASCII entity names
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneByCallDistrict returns the CQ and ITU zone for a callsign in a country
|
||||||
|
// that is split across zones by call district (USA, Australia…). cty.dat and
|
||||||
|
// ClubLog's cty.xml only carry one zone per entity, so loggers apply this
|
||||||
|
// district→zone convention to get e.g. W6 = CQ3/ITU6 instead of the entity
|
||||||
|
// default CQ5/ITU8. ok=false means no district rule applies (use the entity
|
||||||
|
// default). The district is the first digit of the callsign.
|
||||||
|
func ZoneByCallDistrict(adif int, call string) (cqz, ituz int, ok bool) {
|
||||||
|
d := districtDigit(call)
|
||||||
|
if d < 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
switch adif {
|
||||||
|
case 291: // United States — standard district defaults (state-level
|
||||||
|
// exceptions exist, but this matches what Log4OM/DXKeeper default to).
|
||||||
|
if z, o := usDistrictZones[d]; o {
|
||||||
|
return z[0], z[1], true
|
||||||
|
}
|
||||||
|
case 150: // Australia — VK6/VK8 (west/north) are CQ29/ITU58, rest CQ30/ITU59.
|
||||||
|
if d == 6 || d == 8 {
|
||||||
|
return 29, 58, true
|
||||||
|
}
|
||||||
|
return 30, 59, true
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// usDistrictZones maps a US call district digit to its {CQ, ITU} zone.
|
||||||
|
var usDistrictZones = map[int][2]int{
|
||||||
|
0: {4, 7}, 1: {5, 8}, 2: {5, 8}, 3: {5, 8}, 4: {5, 8},
|
||||||
|
5: {4, 7}, 6: {3, 6}, 7: {3, 6}, 8: {5, 8}, 9: {4, 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstDigit returns the first 0-9 digit in a callsign, or -1 if none.
|
||||||
|
func firstDigit(call string) int {
|
||||||
|
for i := 0; i < len(call); i++ {
|
||||||
|
if call[i] >= '0' && call[i] <= '9' {
|
||||||
|
return int(call[i] - '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// districtDigit returns the effective call-area digit: a trailing "/N" (single
|
||||||
|
// digit) re-homes the call to area N (W6ABC/7 → area 7), otherwise the first
|
||||||
|
// digit of the call.
|
||||||
|
func districtDigit(call string) int {
|
||||||
|
if i := strings.LastIndex(call, "/"); i >= 0 && i == len(call)-2 {
|
||||||
|
if c := call[len(call)-1]; c >= '0' && c <= '9' {
|
||||||
|
return int(c - '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstDigit(call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntityNumberName pairs a DXCC entity number with its display name.
|
||||||
|
type EntityNumberName struct {
|
||||||
|
Num int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllEntities returns every known DXCC entity (number + display name), sorted by
|
||||||
|
// number. Used to seed the DXCC award's reference list.
|
||||||
|
func AllEntities() []EntityNumberName {
|
||||||
|
out := make([]EntityNumberName, 0, len(nameByDXCC))
|
||||||
|
for num := range nameByDXCC {
|
||||||
|
out = append(out, EntityNumberName{Num: num, Name: NameForDXCC(num)})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Num < out[j].Num })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dxccByCanon is dxccByName re-keyed by the canonical entity form, built once.
|
||||||
|
var dxccByCanon = func() map[string]int {
|
||||||
|
m := make(map[string]int, len(dxccByName))
|
||||||
|
for name, num := range dxccByName {
|
||||||
|
m[canonEntity(name)] = num
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// canonEntity reduces an entity name to a canonical token stream, expanding the
|
||||||
|
// common abbreviations that differ between naming conventions and normalising
|
||||||
|
// punctuation / "&".
|
||||||
|
func canonEntity(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
s = strings.ReplaceAll(s, "&", " and ")
|
||||||
|
fields := strings.FieldsFunc(s, func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case ' ', '.', ',', '-', '\'', '(', ')', '/':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
for i, w := range fields {
|
||||||
|
switch w {
|
||||||
|
case "i":
|
||||||
|
fields[i] = "island"
|
||||||
|
case "is":
|
||||||
|
fields[i] = "islands"
|
||||||
|
case "st":
|
||||||
|
fields[i] = "saint"
|
||||||
|
case "mt":
|
||||||
|
fields[i] = "mount"
|
||||||
|
case "rep":
|
||||||
|
fields[i] = "republic"
|
||||||
|
case "dem":
|
||||||
|
fields[i] = "democratic"
|
||||||
|
case "fed":
|
||||||
|
fields[i] = "federal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(fields, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ctyEntityAliases maps cty.dat's non-DXCC pseudo-entities — the CQ-zone /
|
// ctyEntityAliases maps cty.dat's non-DXCC pseudo-entities — the CQ-zone /
|
||||||
// WAE splits AD1C lists separately — onto the ARRL DXCC entity they belong
|
// WAE splits AD1C lists separately — onto the ARRL DXCC entity they belong
|
||||||
// to. cty.dat reports e.g. "Sicily" or "Sardinia" so contesters get the
|
// to. cty.dat reports e.g. "Sicily" so contesters get the right zones, but
|
||||||
// right zones, but for DXCC (and the COUNTRY field) they are Italy. Add an
|
// for DXCC (and the COUNTRY field) they are Italy.
|
||||||
// entry here for any other split that should report its parent entity.
|
|
||||||
var ctyEntityAliases = map[string]string{
|
var ctyEntityAliases = map[string]string{
|
||||||
"sicily": "Italy",
|
"sicily": "Italy",
|
||||||
"african italy": "Italy",
|
"african italy": "Italy",
|
||||||
|
|||||||
+54
-11
@@ -139,7 +139,15 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
|
|||||||
if e, ok := db.exact[call]; ok {
|
if e, ok := db.exact[call]; ok {
|
||||||
return materialize(e), true
|
return materialize(e), true
|
||||||
}
|
}
|
||||||
|
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
|
||||||
|
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
|
||||||
|
// cty.dat carries a bare "KG4" prefix for Guantanamo, so for the other
|
||||||
|
// suffix lengths we must skip it and fall through to the USA prefixes.
|
||||||
|
skipKG4 := strings.HasPrefix(call, "KG4") && len(call) != len("KG4")+2
|
||||||
for _, p := range db.byPrefix {
|
for _, p := range db.byPrefix {
|
||||||
|
if skipKG4 && p.prefix == "KG4" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if strings.HasPrefix(call, p.prefix) {
|
if strings.HasPrefix(call, p.prefix) {
|
||||||
return materialize(p), true
|
return materialize(p), true
|
||||||
}
|
}
|
||||||
@@ -262,22 +270,33 @@ func stripAnnotation(s string, open, close rune, cb func(string)) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
|
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
|
||||||
// matching. /P /M /MM /AM /QRP /A and single-digit area changes (/5 …) all
|
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
|
||||||
// keep the operator's home DXCC.
|
// operator's home DXCC. NOTE: "MM" and "AM" are NOT here — a TRAILING /MM or
|
||||||
|
// /AM (maritime/aeronautical mobile) means "no DXCC entity", while a LEADING
|
||||||
|
// "MM" is the Scotland operating prefix; both are handled in normalizeCallsign.
|
||||||
var suffixModifiers = map[string]bool{
|
var suffixModifiers = map[string]bool{
|
||||||
"P": true, "M": true, "MM": true, "AM": true, "QRP": true, "A": true,
|
"P": true, "M": true, "QRP": true, "A": true,
|
||||||
"PM": true, "LH": true,
|
"PM": true, "LH": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeCallsign uppercases, trims, and resolves the "active" call when
|
// normalizeCallsign uppercases, trims, and resolves the "active" call when
|
||||||
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE).
|
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). Returns "" for
|
||||||
|
// maritime/aeronautical mobile (.../MM, .../AM), which count for no DXCC.
|
||||||
func normalizeCallsign(s string) string {
|
func normalizeCallsign(s string) string {
|
||||||
s = strings.ToUpper(strings.TrimSpace(s))
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
if !strings.ContainsRune(s, '/') {
|
if !strings.ContainsRune(s, '/') {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
parts := strings.Split(s, "/")
|
parts := strings.Split(s, "/")
|
||||||
|
// A trailing /MM or /AM is maritime/aeronautical mobile → no DXCC entity.
|
||||||
|
// (A leading "MM" is the Scotland prefix and must NOT trigger this.)
|
||||||
|
for i, p := range parts {
|
||||||
|
if i > 0 && (p == "MM" || p == "AM") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
keep := parts[:0]
|
keep := parts[:0]
|
||||||
|
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
@@ -285,21 +304,45 @@ func normalizeCallsign(s string) string {
|
|||||||
if suffixModifiers[p] {
|
if suffixModifiers[p] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(p) == 1 && p >= "0" && p <= "9" {
|
if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
|
||||||
|
areaDigit = p[0]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keep = append(keep, p)
|
keep = append(keep, p)
|
||||||
}
|
}
|
||||||
|
var main string
|
||||||
switch len(keep) {
|
switch len(keep) {
|
||||||
case 0:
|
case 0:
|
||||||
return s
|
return s
|
||||||
case 1:
|
case 1:
|
||||||
return keep[0]
|
main = keep[0]
|
||||||
|
default:
|
||||||
|
// Two non-modifier parts → operating-from prefix wins (shorter one).
|
||||||
|
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 → W6.
|
||||||
|
if len(keep[0]) <= len(keep[1]) {
|
||||||
|
main = keep[0]
|
||||||
|
} else {
|
||||||
|
main = keep[1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Two non-modifier parts → operating-from prefix wins (shorter one).
|
// Apply the call-area digit: "/N" replaces the area digit of the base call,
|
||||||
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 shorter → W6.
|
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
|
||||||
if len(keep[0]) <= len(keep[1]) {
|
// Ecuador). This is the same class of rule as KG4 and /MM.
|
||||||
return keep[0]
|
if areaDigit != 0 {
|
||||||
|
main = replaceFirstDigit(main, areaDigit)
|
||||||
}
|
}
|
||||||
return keep[1]
|
return main
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to
|
||||||
|
// apply a "/N" call-area change). Returns the call unchanged if it has no digit.
|
||||||
|
func replaceFirstDigit(call string, d byte) string {
|
||||||
|
b := []byte(call)
|
||||||
|
for i := range b {
|
||||||
|
if b[i] >= '0' && b[i] <= '9' {
|
||||||
|
b[i] = d
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return call
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package dxcc
|
||||||
|
|
||||||
|
// dxccByName maps cty.dat entity names (lower-cased) to ADIF DXCC entity
|
||||||
|
// numbers. Generated by joining cty.dat to the authoritative ARRL/ADIF entity
|
||||||
|
// list (k0swe/dxcc-json) by primary prefix + canonical name. 344 entities.
|
||||||
|
var dxccByName = map[string]int{
|
||||||
|
"afghanistan": 3,
|
||||||
|
"agalega & st. brandon": 4,
|
||||||
|
"aland islands": 5,
|
||||||
|
"alaska": 6,
|
||||||
|
"albania": 7,
|
||||||
|
"algeria": 400,
|
||||||
|
"american samoa": 9,
|
||||||
|
"amsterdam & st. paul is.": 10,
|
||||||
|
"andaman & nicobar is.": 11,
|
||||||
|
"andorra": 203,
|
||||||
|
"angola": 401,
|
||||||
|
"anguilla": 12,
|
||||||
|
"annobon island": 195,
|
||||||
|
"antarctica": 13,
|
||||||
|
"antigua & barbuda": 94,
|
||||||
|
"argentina": 100,
|
||||||
|
"armenia": 14,
|
||||||
|
"aruba": 91,
|
||||||
|
"ascension island": 205,
|
||||||
|
"asiatic russia": 15,
|
||||||
|
"asiatic turkey": 390,
|
||||||
|
"austral islands": 508,
|
||||||
|
"australia": 150,
|
||||||
|
"austria": 206,
|
||||||
|
"aves island": 17,
|
||||||
|
"azerbaijan": 18,
|
||||||
|
"azores": 149,
|
||||||
|
"bahamas": 60,
|
||||||
|
"bahrain": 304,
|
||||||
|
"baker & howland islands": 20,
|
||||||
|
"balearic islands": 21,
|
||||||
|
"banaba island": 490,
|
||||||
|
"bangladesh": 305,
|
||||||
|
"barbados": 62,
|
||||||
|
"bear island": 259,
|
||||||
|
"belarus": 27,
|
||||||
|
"belgium": 209,
|
||||||
|
"belize": 66,
|
||||||
|
"benin": 416,
|
||||||
|
"bermuda": 64,
|
||||||
|
"bhutan": 306,
|
||||||
|
"bolivia": 104,
|
||||||
|
"bonaire": 520,
|
||||||
|
"bosnia-herzegovina": 501,
|
||||||
|
"botswana": 402,
|
||||||
|
"bouvet": 24,
|
||||||
|
"brazil": 108,
|
||||||
|
"british virgin islands": 65,
|
||||||
|
"brunei darussalam": 345,
|
||||||
|
"bulgaria": 212,
|
||||||
|
"burkina faso": 480,
|
||||||
|
"burundi": 404,
|
||||||
|
"cambodia": 312,
|
||||||
|
"cameroon": 406,
|
||||||
|
"canada": 1,
|
||||||
|
"canary islands": 29,
|
||||||
|
"cape verde": 409,
|
||||||
|
"cayman islands": 69,
|
||||||
|
"central african republic": 408,
|
||||||
|
"central kiribati": 31,
|
||||||
|
"ceuta & melilla": 32,
|
||||||
|
"chad": 410,
|
||||||
|
"chagos islands": 33,
|
||||||
|
"chatham islands": 34,
|
||||||
|
"chesterfield islands": 512,
|
||||||
|
"chile": 112,
|
||||||
|
"china": 318,
|
||||||
|
"christmas island": 35,
|
||||||
|
"clipperton island": 36,
|
||||||
|
"cocos (keeling) islands": 38,
|
||||||
|
"cocos island": 37,
|
||||||
|
"colombia": 116,
|
||||||
|
"comoros": 411,
|
||||||
|
"conway reef": 489,
|
||||||
|
"corsica": 214,
|
||||||
|
"costa rica": 308,
|
||||||
|
"cote d'ivoire": 428,
|
||||||
|
"crete": 40,
|
||||||
|
"croatia": 497,
|
||||||
|
"crozet island": 41,
|
||||||
|
"cuba": 70,
|
||||||
|
"curacao": 517,
|
||||||
|
"cyprus": 215,
|
||||||
|
"czech republic": 503,
|
||||||
|
"dem. rep. of the congo": 414,
|
||||||
|
"denmark": 221,
|
||||||
|
"desecheo island": 43,
|
||||||
|
"djibouti": 382,
|
||||||
|
"dodecanese": 45,
|
||||||
|
"dominica": 95,
|
||||||
|
"dominican republic": 72,
|
||||||
|
"dpr of korea": 344,
|
||||||
|
"ducie island": 513,
|
||||||
|
"east malaysia": 46,
|
||||||
|
"easter island": 47,
|
||||||
|
"eastern kiribati": 48,
|
||||||
|
"ecuador": 120,
|
||||||
|
"egypt": 478,
|
||||||
|
"el salvador": 74,
|
||||||
|
"england": 223,
|
||||||
|
"equatorial guinea": 49,
|
||||||
|
"eritrea": 51,
|
||||||
|
"estonia": 52,
|
||||||
|
"ethiopia": 53,
|
||||||
|
"european russia": 54,
|
||||||
|
"european turkey": 390,
|
||||||
|
"falkland islands": 141,
|
||||||
|
"faroe islands": 222,
|
||||||
|
"fed. rep. of germany": 230,
|
||||||
|
"fernando de noronha": 56,
|
||||||
|
"fiji": 176,
|
||||||
|
"finland": 224,
|
||||||
|
"france": 227,
|
||||||
|
"franz josef land": 61,
|
||||||
|
"french guiana": 63,
|
||||||
|
"french polynesia": 175,
|
||||||
|
"gabon": 420,
|
||||||
|
"galapagos islands": 71,
|
||||||
|
"georgia": 75,
|
||||||
|
"ghana": 424,
|
||||||
|
"gibraltar": 233,
|
||||||
|
"glorioso islands": 99,
|
||||||
|
"greece": 236,
|
||||||
|
"greenland": 237,
|
||||||
|
"grenada": 77,
|
||||||
|
"guadeloupe": 79,
|
||||||
|
"guam": 103,
|
||||||
|
"guantanamo bay": 105,
|
||||||
|
"guatemala": 76,
|
||||||
|
"guernsey": 106,
|
||||||
|
"guinea": 107,
|
||||||
|
"guinea-bissau": 109,
|
||||||
|
"guyana": 129,
|
||||||
|
"haiti": 78,
|
||||||
|
"hawaii": 110,
|
||||||
|
"heard island": 111,
|
||||||
|
"honduras": 80,
|
||||||
|
"hong kong": 321,
|
||||||
|
"hungary": 239,
|
||||||
|
"iceland": 242,
|
||||||
|
"india": 324,
|
||||||
|
"indonesia": 327,
|
||||||
|
"iran": 330,
|
||||||
|
"iraq": 333,
|
||||||
|
"ireland": 245,
|
||||||
|
"isle of man": 114,
|
||||||
|
"israel": 336,
|
||||||
|
"italy": 248,
|
||||||
|
"itu hq": 117,
|
||||||
|
"jamaica": 82,
|
||||||
|
"jan mayen": 118,
|
||||||
|
"japan": 339,
|
||||||
|
"jersey": 122,
|
||||||
|
"johnston island": 123,
|
||||||
|
"jordan": 342,
|
||||||
|
"juan de nova, europa": 124,
|
||||||
|
"juan fernandez islands": 125,
|
||||||
|
"kaliningrad": 126,
|
||||||
|
"kazakhstan": 130,
|
||||||
|
"kenya": 430,
|
||||||
|
"kerguelen islands": 131,
|
||||||
|
"kermadec islands": 133,
|
||||||
|
"kingdom of eswatini": 468,
|
||||||
|
"kure island": 138,
|
||||||
|
"kuwait": 348,
|
||||||
|
"kyrgyzstan": 135,
|
||||||
|
"lakshadweep islands": 142,
|
||||||
|
"laos": 143,
|
||||||
|
"latvia": 145,
|
||||||
|
"lebanon": 354,
|
||||||
|
"lesotho": 432,
|
||||||
|
"liberia": 434,
|
||||||
|
"libya": 436,
|
||||||
|
"liechtenstein": 251,
|
||||||
|
"lithuania": 146,
|
||||||
|
"lord howe island": 147,
|
||||||
|
"luxembourg": 254,
|
||||||
|
"macao": 152,
|
||||||
|
"macquarie island": 153,
|
||||||
|
"madagascar": 438,
|
||||||
|
"madeira islands": 256,
|
||||||
|
"malawi": 440,
|
||||||
|
"maldives": 159,
|
||||||
|
"mali": 442,
|
||||||
|
"malpelo island": 161,
|
||||||
|
"malta": 257,
|
||||||
|
"mariana islands": 166,
|
||||||
|
"market reef": 167,
|
||||||
|
"marquesas islands": 509,
|
||||||
|
"marshall islands": 168,
|
||||||
|
"martinique": 84,
|
||||||
|
"mauritania": 444,
|
||||||
|
"mauritius": 165,
|
||||||
|
"mayotte": 169,
|
||||||
|
"mellish reef": 171,
|
||||||
|
"mexico": 50,
|
||||||
|
"micronesia": 173,
|
||||||
|
"midway island": 174,
|
||||||
|
"minami torishima": 177,
|
||||||
|
"moldova": 179,
|
||||||
|
"monaco": 260,
|
||||||
|
"mongolia": 363,
|
||||||
|
"montenegro": 514,
|
||||||
|
"montserrat": 96,
|
||||||
|
"morocco": 446,
|
||||||
|
"mount athos": 180,
|
||||||
|
"mozambique": 181,
|
||||||
|
"myanmar": 309,
|
||||||
|
"n.z. subantarctic is.": 16,
|
||||||
|
"namibia": 464,
|
||||||
|
"nauru": 157,
|
||||||
|
"navassa island": 182,
|
||||||
|
"nepal": 369,
|
||||||
|
"netherlands": 263,
|
||||||
|
"new caledonia": 162,
|
||||||
|
"new zealand": 170,
|
||||||
|
"nicaragua": 86,
|
||||||
|
"niger": 187,
|
||||||
|
"nigeria": 450,
|
||||||
|
"niue": 188,
|
||||||
|
"norfolk island": 189,
|
||||||
|
"north cook islands": 191,
|
||||||
|
"north macedonia": 502,
|
||||||
|
"northern ireland": 265,
|
||||||
|
"norway": 266,
|
||||||
|
"ogasawara": 192,
|
||||||
|
"oman": 370,
|
||||||
|
"pakistan": 372,
|
||||||
|
"palau": 22,
|
||||||
|
"palestine": 510,
|
||||||
|
"palmyra & jarvis islands": 197,
|
||||||
|
"panama": 88,
|
||||||
|
"papua new guinea": 163,
|
||||||
|
"paraguay": 132,
|
||||||
|
"peru": 136,
|
||||||
|
"peter 1 island": 199,
|
||||||
|
"philippines": 375,
|
||||||
|
"pitcairn island": 172,
|
||||||
|
"poland": 269,
|
||||||
|
"portugal": 272,
|
||||||
|
"pr. edward & marion is.": 201,
|
||||||
|
"pratas island": 505,
|
||||||
|
"puerto rico": 202,
|
||||||
|
"qatar": 376,
|
||||||
|
"republic of korea": 137,
|
||||||
|
"republic of kosovo": 522,
|
||||||
|
"republic of south sudan": 521,
|
||||||
|
"republic of the congo": 412,
|
||||||
|
"reunion island": 453,
|
||||||
|
"revillagigedo": 204,
|
||||||
|
"rodriguez island": 207,
|
||||||
|
"romania": 275,
|
||||||
|
"rotuma island": 460,
|
||||||
|
"rwanda": 454,
|
||||||
|
"saba & st. eustatius": 519,
|
||||||
|
"sable island": 211,
|
||||||
|
"samoa": 190,
|
||||||
|
"san andres & providencia": 216,
|
||||||
|
"san felix & san ambrosio": 217,
|
||||||
|
"san marino": 278,
|
||||||
|
"sao tome & principe": 219,
|
||||||
|
"sardinia": 225,
|
||||||
|
"saudi arabia": 378,
|
||||||
|
"scarborough reef": 506,
|
||||||
|
"scotland": 279,
|
||||||
|
"senegal": 456,
|
||||||
|
"serbia": 296,
|
||||||
|
"seychelles": 379,
|
||||||
|
"shetland islands": 279,
|
||||||
|
"sierra leone": 458,
|
||||||
|
"singapore": 381,
|
||||||
|
"sint maarten": 518,
|
||||||
|
"slovak republic": 504,
|
||||||
|
"slovenia": 499,
|
||||||
|
"solomon islands": 185,
|
||||||
|
"somalia": 232,
|
||||||
|
"south africa": 462,
|
||||||
|
"south cook islands": 234,
|
||||||
|
"south georgia island": 235,
|
||||||
|
"south orkney islands": 238,
|
||||||
|
"south sandwich islands": 240,
|
||||||
|
"south shetland islands": 241,
|
||||||
|
"sov mil order of malta": 246,
|
||||||
|
"spain": 281,
|
||||||
|
"spratly islands": 247,
|
||||||
|
"sri lanka": 315,
|
||||||
|
"st. barthelemy": 516,
|
||||||
|
"st. helena": 250,
|
||||||
|
"st. kitts & nevis": 249,
|
||||||
|
"st. lucia": 97,
|
||||||
|
"st. martin": 213,
|
||||||
|
"st. paul island": 252,
|
||||||
|
"st. peter & st. paul": 253,
|
||||||
|
"st. pierre & miquelon": 277,
|
||||||
|
"st. vincent": 98,
|
||||||
|
"sudan": 466,
|
||||||
|
"suriname": 140,
|
||||||
|
"svalbard": 259,
|
||||||
|
"swains island": 515,
|
||||||
|
"sweden": 284,
|
||||||
|
"switzerland": 287,
|
||||||
|
"syria": 384,
|
||||||
|
"taiwan": 386,
|
||||||
|
"tajikistan": 262,
|
||||||
|
"tanzania": 470,
|
||||||
|
"temotu province": 507,
|
||||||
|
"thailand": 387,
|
||||||
|
"the gambia": 422,
|
||||||
|
"timor - leste": 511,
|
||||||
|
"togo": 483,
|
||||||
|
"tokelau islands": 270,
|
||||||
|
"tonga": 160,
|
||||||
|
"trindade & martim vaz": 273,
|
||||||
|
"trinidad & tobago": 90,
|
||||||
|
"tristan da cunha & gough": 274,
|
||||||
|
"tromelin island": 276,
|
||||||
|
"tunisia": 474,
|
||||||
|
"turkmenistan": 280,
|
||||||
|
"turks & caicos islands": 89,
|
||||||
|
"tuvalu": 282,
|
||||||
|
"uganda": 286,
|
||||||
|
"uk base areas on cyprus": 283,
|
||||||
|
"ukraine": 288,
|
||||||
|
"united arab emirates": 391,
|
||||||
|
"united nations hq": 289,
|
||||||
|
"united states": 291,
|
||||||
|
"uruguay": 144,
|
||||||
|
"us virgin islands": 285,
|
||||||
|
"uzbekistan": 292,
|
||||||
|
"vanuatu": 158,
|
||||||
|
"vatican city": 295,
|
||||||
|
"venezuela": 148,
|
||||||
|
"vienna intl ctr": 206,
|
||||||
|
"vietnam": 293,
|
||||||
|
"wake island": 297,
|
||||||
|
"wales": 294,
|
||||||
|
"wallis & futuna islands": 298,
|
||||||
|
"west malaysia": 299, // hand-fixed: generation joined it to 155 by mistake (9M2 = ADIF 299)
|
||||||
|
"western kiribati": 301,
|
||||||
|
"western sahara": 302,
|
||||||
|
"willis island": 303,
|
||||||
|
"yemen": 492,
|
||||||
|
"zambia": 482,
|
||||||
|
"zimbabwe": 452,
|
||||||
|
}
|
||||||
@@ -54,6 +54,96 @@ func TestLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A "/N" call-area suffix can change the DXCC entity: HD5MW/8 re-homes to the
|
||||||
|
// HD8 area (Galápagos), not the base call's Ecuador.
|
||||||
|
func TestCallAreaSuffix(t *testing.T) {
|
||||||
|
const cty = `Ecuador: 10: 12: SA: -1.40: 78.40: 5.0: HC:
|
||||||
|
HC,HD;
|
||||||
|
Galapagos Islands: 10: 12: SA: 0.00: 91.00: 6.0: HC8:
|
||||||
|
HC8,HD8;
|
||||||
|
`
|
||||||
|
db, err := Load(strings.NewReader(cty))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
cases := map[string]string{
|
||||||
|
"HD5MW": "Ecuador",
|
||||||
|
"HD5MW/8": "Galapagos Islands",
|
||||||
|
"HC2AO": "Ecuador",
|
||||||
|
"HD8M": "Galapagos Islands",
|
||||||
|
"HC1WW/8": "Galapagos Islands",
|
||||||
|
}
|
||||||
|
for call, want := range cases {
|
||||||
|
m, ok := db.Lookup(call)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: no match", call)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Entity.Name != want {
|
||||||
|
t.Errorf("%s: got %q, want %q", call, m.Entity.Name, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// US/VK call-district zone refinement (W6 = CQ3/ITU6, not the entity default).
|
||||||
|
func TestZoneByCallDistrict(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
adif int
|
||||||
|
call string
|
||||||
|
cqz, ituz int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{291, "W6XYZ", 3, 6, true},
|
||||||
|
{291, "K7AB", 3, 6, true},
|
||||||
|
{291, "W4ABC", 5, 8, true},
|
||||||
|
{291, "N0CALL", 4, 7, true},
|
||||||
|
{291, "AA5XX", 4, 7, true},
|
||||||
|
{150, "VK6AA", 29, 58, true}, // West Australia
|
||||||
|
{150, "VK3XY", 30, 59, true}, // Victoria
|
||||||
|
{230, "DL1ABC", 0, 0, false}, // Germany: no district rule
|
||||||
|
{291, "WABC", 0, 0, false}, // no digit
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
cqz, ituz, ok := ZoneByCallDistrict(c.adif, c.call)
|
||||||
|
if ok != c.ok || (ok && (cqz != c.cqz || ituz != c.ituz)) {
|
||||||
|
t.Errorf("ZoneByCallDistrict(%d,%q) = %d/%d ok=%v, want %d/%d ok=%v",
|
||||||
|
c.adif, c.call, cqz, ituz, ok, c.cqz, c.ituz, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KG4 is Guantanamo Bay only with a 2-character suffix (KG4XX); 1- or 3-char
|
||||||
|
// suffixes (KG4W, KG4ABC) are continental USA. cty.dat carries a bare "KG4"
|
||||||
|
// prefix, so the resolver must apply the suffix-length rule.
|
||||||
|
func TestKG4SuffixRule(t *testing.T) {
|
||||||
|
const cty = `United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
|
||||||
|
K,N,W;
|
||||||
|
Guantanamo Bay: 08: 11: NA: 19.92: 75.18: -5.0: KG4:
|
||||||
|
KG4;
|
||||||
|
`
|
||||||
|
db, err := Load(strings.NewReader(cty))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
cases := map[string]string{
|
||||||
|
"KG4W": "United States", // 1-char suffix
|
||||||
|
"KG4AA": "Guantanamo Bay", // 2-char suffix
|
||||||
|
"KG4ABC": "United States", // 3-char suffix
|
||||||
|
"KG4": "United States", // no suffix
|
||||||
|
"KG4W/P": "United States", // modifier stripped, still 1-char
|
||||||
|
}
|
||||||
|
for call, want := range cases {
|
||||||
|
m, ok := db.Lookup(call)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: no match", call)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Entity.Name != want {
|
||||||
|
t.Errorf("%s: got %q, want %q", call, m.Entity.Name, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a
|
// cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a
|
||||||
// leading '*'; the parser must fold those into their parent DXCC entity
|
// leading '*'; the parser must fold those into their parent DXCC entity
|
||||||
// "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone.
|
// "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone.
|
||||||
@@ -108,9 +198,14 @@ func TestNormalize(t *testing.T) {
|
|||||||
"f4bpo": "F4BPO",
|
"f4bpo": "F4BPO",
|
||||||
" F4BPO ": "F4BPO",
|
" F4BPO ": "F4BPO",
|
||||||
"F4BPO/P": "F4BPO",
|
"F4BPO/P": "F4BPO",
|
||||||
"F4BPO/MM": "F4BPO",
|
"F4BPO/MM": "", // maritime mobile → no DXCC entity
|
||||||
"F4BPO/5": "F4BPO",
|
"F4BPO/AM": "", // aeronautical mobile → no DXCC entity
|
||||||
|
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||||
|
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||||
|
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||||
"DL/F4BPO": "DL",
|
"DL/F4BPO": "DL",
|
||||||
|
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
||||||
|
"MM/LY3X/P": "MM",
|
||||||
"F4BPO/W6": "W6",
|
"F4BPO/W6": "W6",
|
||||||
"VK9/F4BPO": "VK9",
|
"VK9/F4BPO": "VK9",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -53,7 +53,9 @@ const (
|
|||||||
//
|
//
|
||||||
// QRZ.com → APIKey, ForceStationCallsign
|
// QRZ.com → APIKey, ForceStationCallsign
|
||||||
// Club Log → Email, Password, Callsign, APIKey
|
// Club Log → Email, Password, Callsign, APIKey
|
||||||
// LoTW → TQSLPath, StationLocation, KeyPassword (signs+uploads via TQSL)
|
// LoTW → TQSLPath, StationLocation, ForceStationCallsign, KeyPassword
|
||||||
|
// (signs+uploads via TQSL; ForceStationCallsign overrides
|
||||||
|
// STATION_CALLSIGN so one cert can sign F4BPO / F4BPO/P / TM2Q)
|
||||||
//
|
//
|
||||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||||
@@ -63,7 +65,7 @@ type ServiceConfig struct {
|
|||||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||||
Password string `json:"password"` // Club Log account / LoTW website password
|
Password string `json:"password"` // Club Log account / LoTW website password
|
||||||
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
||||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
|
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package extsvc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,6 +10,29 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// baseCall extracts the operator's base callsign from a possibly-affixed call:
|
||||||
|
// for slashed forms (F4BPO/P, FW/F4BPO, 9A/F4BPO/P) it returns the longest
|
||||||
|
// token, which is the real call; otherwise the call itself. Upper-cased.
|
||||||
|
func baseCall(s string) string {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
for _, part := range strings.Split(s, "/") {
|
||||||
|
if len(part) > len(best) {
|
||||||
|
best = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameBaseCall reports whether two callsigns belong to the same operator,
|
||||||
|
// ignoring portable prefixes/suffixes (F4BPO/P == F4BPO, FW/F4BPO == F4BPO).
|
||||||
|
func sameBaseCall(a, b string) bool {
|
||||||
|
return baseCall(a) == baseCall(b)
|
||||||
|
}
|
||||||
|
|
||||||
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||||
// keeps the upload-scheduling logic testable.
|
// keeps the upload-scheduling logic testable.
|
||||||
@@ -33,6 +57,11 @@ type Deps struct {
|
|||||||
// Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO.
|
// Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO.
|
||||||
ShouldUpload func(svc Service, id int64) bool
|
ShouldUpload func(svc Service, id int64) bool
|
||||||
|
|
||||||
|
// StationCallOf returns the QSO's STATION_CALLSIGN. Used to guard against
|
||||||
|
// uploading a QSO into a logbook for a different callsign (the force-call
|
||||||
|
// option would otherwise silently relabel it). "" → no station call known.
|
||||||
|
StationCallOf func(id int64) string
|
||||||
|
|
||||||
// Logf is an optional diagnostic logger.
|
// Logf is an optional diagnostic logger.
|
||||||
Logf func(format string, args ...any)
|
Logf func(format string, args ...any)
|
||||||
}
|
}
|
||||||
@@ -196,7 +225,8 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
|||||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
|
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rec, ok := m.deps.BuildADIF(id, ""); ok {
|
// Override STATION_CALLSIGN so /P etc. signs against the base cert.
|
||||||
|
if rec, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign); ok {
|
||||||
records = append(records, rec)
|
records = append(records, rec)
|
||||||
kept = append(kept, id)
|
kept = append(kept, id)
|
||||||
}
|
}
|
||||||
@@ -235,6 +265,33 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Station-callsign guard. Each logbook belongs to one callsign:
|
||||||
|
// QRZ/LoTW → the ForceStationCallsign (the call this logbook signs as)
|
||||||
|
// Club Log → the logbook Callsign param
|
||||||
|
// If the QSO's own STATION_CALLSIGN is a DIFFERENT operator, uploading
|
||||||
|
// would push it into the wrong logbook (and the force-call option would
|
||||||
|
// silently relabel it). Block it with a clear error. Portable variants of
|
||||||
|
// the SAME call (F4BPO/P, FW/F4BPO…) are allowed.
|
||||||
|
owner := ""
|
||||||
|
switch svc {
|
||||||
|
case ServiceQRZ, ServiceLoTW:
|
||||||
|
owner = cfg.ForceStationCallsign
|
||||||
|
case ServiceClublog:
|
||||||
|
owner = cfg.Callsign
|
||||||
|
}
|
||||||
|
if owner != "" && m.deps.StationCallOf != nil {
|
||||||
|
qcall := m.deps.StationCallOf(id)
|
||||||
|
if qcall != "" && !sameBaseCall(qcall, owner) {
|
||||||
|
err := fmt.Errorf("station callsign %s does not match %s logbook %s — not uploaded",
|
||||||
|
strings.ToUpper(qcall), svc, strings.ToUpper(owner))
|
||||||
|
m.logf("extsvc: %s upload of QSO %d BLOCKED: %v", svc, id, err)
|
||||||
|
if m.deps.NotifyError != nil {
|
||||||
|
m.deps.NotifyError(svc, id, err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -259,8 +316,9 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
}
|
}
|
||||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
// LoTW signs the QSO's own station call via TQSL — no override.
|
// LoTW signs via TQSL; an optional force-call overrides STATION_CALLSIGN
|
||||||
record, ok := m.deps.BuildADIF(id, "")
|
// so the same cert can sign F4BPO, F4BPO/P, TM2Q… per profile.
|
||||||
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false
|
||||||
|
|||||||
+15
-2
@@ -175,7 +175,7 @@ func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("qrz: bad response: %w", err)
|
return "", fmt.Errorf("qrz: bad response: %w", err)
|
||||||
}
|
}
|
||||||
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
|
status := qrzStatusField(vals)
|
||||||
if status == "AUTH" || status == "FAIL" {
|
if status == "AUTH" || status == "FAIL" {
|
||||||
reason := strings.TrimSpace(vals.Get("REASON"))
|
reason := strings.TrimSpace(vals.Get("REASON"))
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
@@ -201,7 +201,11 @@ func parseQRZResponse(body string) (UploadResult, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err)
|
return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err)
|
||||||
}
|
}
|
||||||
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
|
// The QRZ Logbook API returns the outcome in RESULT (=OK/FAIL/AUTH).
|
||||||
|
// Accept STATUS as a fallback for robustness, but RESULT is the real
|
||||||
|
// field — reading only STATUS made every INSERT (incl. successful ones)
|
||||||
|
// look like it failed with an empty status.
|
||||||
|
status := qrzStatusField(vals)
|
||||||
reason := strings.TrimSpace(vals.Get("REASON"))
|
reason := strings.TrimSpace(vals.Get("REASON"))
|
||||||
logID := strings.TrimSpace(vals.Get("LOGID"))
|
logID := strings.TrimSpace(vals.Get("LOGID"))
|
||||||
|
|
||||||
@@ -222,6 +226,15 @@ func parseQRZResponse(body string) (UploadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qrzStatusField returns the QRZ outcome code, preferring RESULT (the
|
||||||
|
// Logbook API's real field) and falling back to STATUS.
|
||||||
|
func qrzStatusField(vals url.Values) string {
|
||||||
|
if v := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT"))); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
|
||||||
|
}
|
||||||
|
|
||||||
// isDuplicateReason recognises the various phrasings QRZ uses when a QSO is
|
// isDuplicateReason recognises the various phrasings QRZ uses when a QSO is
|
||||||
// already present.
|
// already present.
|
||||||
func isDuplicateReason(reason string) bool {
|
func isDuplicateReason(reason string) bool {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package pota
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hunterLogURL is the authenticated POTA user logbook endpoint (paginated).
|
||||||
|
// hunterOnly=1 restricts it to the user's chaser/hunter QSOs.
|
||||||
|
const hunterLogURL = "https://api.pota.app/user/logbook?hunterOnly=1&page=%d&size=%d"
|
||||||
|
|
||||||
|
// HunterQSO is one entry from the POTA hunter log: a contact the user made with
|
||||||
|
// a park activator, carrying the park reference to stamp onto the local QSO.
|
||||||
|
type HunterQSO struct {
|
||||||
|
Worked string `json:"worked"` // activator callsign (the station worked)
|
||||||
|
Date time.Time `json:"date"` // QSO date/time (UTC)
|
||||||
|
Band string `json:"band"` // ADIF band, e.g. "20m"
|
||||||
|
Mode string `json:"mode"` // logged mode
|
||||||
|
Reference string `json:"reference"` // park ref, e.g. "US-2072"
|
||||||
|
}
|
||||||
|
|
||||||
|
// hunterEntry mirrors the POTA API logbook record (fields we use).
|
||||||
|
//
|
||||||
|
// IMPORTANT: POTA logbook entries come from the ACTIVATOR's uploaded log, so
|
||||||
|
// "station_callsign" is the activator (the park station you worked) and
|
||||||
|
// "worked_callsign" is YOU (the hunter, whom the activator worked). To match a
|
||||||
|
// local QSO — whose callsign field holds the activator — we key on
|
||||||
|
// station_callsign, NOT worked_callsign.
|
||||||
|
type hunterEntry struct {
|
||||||
|
StationCallsign string `json:"station_callsign"` // the activator (park station)
|
||||||
|
WorkedCallsign string `json:"worked_callsign"` // the hunter (you)
|
||||||
|
QSODateTime string `json:"qsoDateTime"`
|
||||||
|
Band string `json:"band"`
|
||||||
|
LoggedMode string `json:"loggedMode"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchHunterLog downloads the user's entire POTA hunter log, page by page,
|
||||||
|
// using their pota.app session token as the Authorization header. logf may be
|
||||||
|
// nil. Returns a friendly error on an expired/invalid token (HTTP 401/403).
|
||||||
|
func FetchHunterLog(ctx context.Context, token string, logf func(string, ...any)) ([]HunterQSO, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("no POTA token — paste it from pota.app (DevTools → Network → Authorization header)")
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
const size = 100
|
||||||
|
const maxPages = 5000 // safety bound
|
||||||
|
|
||||||
|
var out []HunterQSO
|
||||||
|
total := -1
|
||||||
|
for page := 1; page <= maxPages; page++ {
|
||||||
|
url := fmt.Sprintf(hunterLogURL, page, size)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("POTA fetch: %w", err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("POTA rejected the token (HTTP %d) — it has likely expired; re-copy it from pota.app", resp.StatusCode)
|
||||||
|
case resp.StatusCode != http.StatusOK:
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("POTA HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Entries []hunterEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("POTA decode: %w", err)
|
||||||
|
}
|
||||||
|
total = body.Count
|
||||||
|
for _, e := range body.Entries {
|
||||||
|
ref := strings.ToUpper(strings.TrimSpace(e.Reference))
|
||||||
|
// The activator is the station we worked → station_callsign.
|
||||||
|
act := strings.ToUpper(strings.TrimSpace(e.StationCallsign))
|
||||||
|
if act == "" {
|
||||||
|
act = strings.ToUpper(strings.TrimSpace(e.WorkedCallsign)) // fallback
|
||||||
|
}
|
||||||
|
if ref == "" || act == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, HunterQSO{
|
||||||
|
Worked: act,
|
||||||
|
Date: parseHunterTime(e.QSODateTime),
|
||||||
|
Band: strings.ToLower(strings.TrimSpace(e.Band)),
|
||||||
|
Mode: strings.ToUpper(strings.TrimSpace(e.LoggedMode)),
|
||||||
|
Reference: ref,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if logf != nil {
|
||||||
|
logf("pota: hunter log page %d (%d/%d)", page, len(out), total)
|
||||||
|
}
|
||||||
|
if len(body.Entries) == 0 || (total >= 0 && len(out) >= total) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHunterTime parses POTA's qsoDateTime, tolerating ISO variants (with/out
|
||||||
|
// 'T', timezone, or fractional seconds). POTA times are UTC.
|
||||||
|
func parseHunterTime(s string) time.Time {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
for _, layout := range []string{
|
||||||
|
time.RFC3339Nano, time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05.000Z", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05",
|
||||||
|
"2006-01-02 15:04:05.000", "2006-01-02 15:04:05", "2006-01-02 15:04:05Z",
|
||||||
|
"2006-01-02T15:04Z", "2006-01-02T15:04", "2006-01-02 15:04",
|
||||||
|
} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Package pota polls the Parks On The Air activator-spots API and exposes a
|
||||||
|
// fast in-memory lookup so DX-cluster spots can be tagged "this station is
|
||||||
|
// currently activating a park". No API key required.
|
||||||
|
package pota
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const apiURL = "https://api.pota.app/spot/activator"
|
||||||
|
|
||||||
|
// Info is the park data we surface for a currently-active activator.
|
||||||
|
type Info struct {
|
||||||
|
Reference string `json:"reference"` // park id, e.g. "US-2072"
|
||||||
|
ParkName string `json:"park_name"` // human name
|
||||||
|
LocationDesc string `json:"location_desc"` // e.g. "US-NY"
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiSpot is the subset of the POTA API record we read.
|
||||||
|
type apiSpot struct {
|
||||||
|
Activator string `json:"activator"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
ParkName string `json:"parkName"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LocationDesc string `json:"locationDesc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache holds the latest activator set, refreshed in the background.
|
||||||
|
type Cache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
byCall map[string]Info // base callsign (upper) → info
|
||||||
|
client *http.Client
|
||||||
|
logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a cache. logf may be nil.
|
||||||
|
func New(logf func(string, ...any)) *Cache {
|
||||||
|
return &Cache{
|
||||||
|
byCall: map[string]Info{},
|
||||||
|
client: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
logf: logf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run refreshes immediately, then every 60 s until ctx is cancelled.
|
||||||
|
func (c *Cache) Run(ctx context.Context) {
|
||||||
|
c.refresh(ctx)
|
||||||
|
t := time.NewTicker(60 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
c.refresh(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) log(format string, a ...any) {
|
||||||
|
if c.logf != nil {
|
||||||
|
c.logf(format, a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) refresh(ctx context.Context) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
c.log("pota: request: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.log("pota: fetch: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.log("pota: http %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var spots []apiSpot
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&spots); err != nil {
|
||||||
|
c.log("pota: decode: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := make(map[string]Info, len(spots))
|
||||||
|
for _, s := range spots {
|
||||||
|
call := baseCall(s.Activator)
|
||||||
|
if call == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(s.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(s.ParkName)
|
||||||
|
}
|
||||||
|
// Keep the first reference seen for a call (most-recent-first ordering
|
||||||
|
// from the API), but don't clobber with a blank.
|
||||||
|
if _, exists := m[call]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[call] = Info{Reference: s.Reference, ParkName: name, LocationDesc: s.LocationDesc}
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.byCall = m
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.log("pota: %d active activators", len(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns park info for a callsign if it's currently activating.
|
||||||
|
func (c *Cache) Lookup(call string) (Info, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if len(c.byCall) == 0 {
|
||||||
|
return Info{}, false
|
||||||
|
}
|
||||||
|
i, ok := c.byCall[baseCall(call)]
|
||||||
|
return i, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseCall is the exported callsign normaliser used for hunter-log matching.
|
||||||
|
func BaseCall(s string) string { return baseCall(s) }
|
||||||
|
|
||||||
|
// baseCall normalises a callsign for matching: upper-cased, and when it carries
|
||||||
|
// "/" segments (F4BPO/P, HB9/F4BPO) we take the longest segment, which is
|
||||||
|
// almost always the home call.
|
||||||
|
func baseCall(s string) string {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
for _, part := range strings.Split(s, "/") {
|
||||||
|
if len(part) > len(best) {
|
||||||
|
best = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
+288
-44
@@ -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.
|
||||||
@@ -553,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
|
||||||
@@ -569,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"`
|
||||||
@@ -615,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.
|
||||||
@@ -640,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{},
|
||||||
@@ -655,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)
|
||||||
@@ -668,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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 0x00–0x1F 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); 0x80–0xBF 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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user