This commit is contained in:
2026-06-05 17:22:38 +02:00
parent cf9dbf26f3
commit 88623f55df
21 changed files with 2123 additions and 50 deletions
+82 -34
View File
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X,
} from 'lucide-react';
import {
AddQSO, ListQSO, CountQSO,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
@@ -40,6 +40,8 @@ import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { MainMap } from '@/components/MainMap';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
@@ -435,8 +437,10 @@ export default function App() {
const [recording, setRecording] = useState(false);
const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState('');
const [filterBand, setFilterBand] = useState('');
const [filterMode, setFilterMode] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns).
const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
const [matchCount, setMatchCount] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState('recent');
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
const [qslTabOpen, setQslTabOpen] = useState(false);
@@ -667,20 +671,31 @@ export default function App() {
return () => window.clearInterval(id);
}, []);
// The full filter sent to the backend = the builder conditions + the quick
// callsign search box (always ANDed) + the on-screen row threshold.
const buildActiveFilter = useCallback((): QueryFilter => ({
quick_callsign: filterCallsign,
conditions: activeFilter.conditions ?? [],
match: activeFilter.match ?? 'AND',
limit: qsoLimit,
offset: 0,
}), [filterCallsign, activeFilter, qsoLimit]);
const refresh = useCallback(async () => {
try {
const list = await ListQSO({
callsign: filterCallsign, band: filterBand, mode: filterMode,
limit: qsoLimit, offset: 0,
} as any);
const f = buildActiveFilter();
const list = await ListQSOFiltered(f as any);
const n = await CountQSO();
const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
setQsos(list);
setTotal(n);
setMatchCount(matched);
setError('');
} catch (e: any) {
setError(String(e?.message ?? e));
}
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
}, [buildActiveFilter]);
// Refresh the Recent QSOs grid after external-service uploads stamp the
// sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
@@ -1181,6 +1196,27 @@ export default function App() {
try { await UploadQSOsManual(service, ids as any); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
// matches the current filter, bypassing the on-screen row threshold.
async function exportFilteredADIF() {
try {
const path = await SaveADIFFile();
if (!path) return;
const f = buildActiveFilter();
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any);
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Right-click "Export selected to ADIF": only the highlighted rows.
async function exportSelectedADIF(ids: number[]) {
if (ids.length === 0) return;
try {
const path = await SaveADIFFile();
if (!path) return;
const r = await ExportADIFSelected(path, false, ids as any);
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
function askDelete(id: number) {
const q = qsos.find((x) => x.id === id);
if (q) setDeletingQSO(q);
@@ -1394,7 +1430,7 @@ export default function App() {
case 'file.export': exportAdif(); break;
case 'file.deleteall': setShowDeleteAll(true); break;
case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setFilterBand(''); setFilterMode(''); break;
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
case 'edit.prefs': setShowSettings(true); break;
@@ -2190,20 +2226,6 @@ export default function App() {
value={filterCallsign}
onChange={(e) => setFilterCallsign(e.target.value)}
/>
<Select value={filterBand || '_'} onValueChange={(v) => setFilterBand(v === '_' ? '' : v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All bands</SelectItem>
{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
</SelectContent>
</Select>
<Select value={filterMode || '_'} onValueChange={(v) => setFilterMode(v === '_' ? '' : v)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All modes</SelectItem>
{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="size-3.5" /> Refresh
</Button>
@@ -2264,14 +2286,36 @@ export default function App() {
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF}
onRowSelected={(id) => setSelectedId(id)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{total}</span>
{filterCallsign || filterBand || filterMode ? ' (filtered)' : ''}
</span>
<div className="flex items-center gap-3">
<Button
variant={(activeFilter.conditions?.length || filterCallsign) ? 'default' : 'outline'}
size="sm"
className="h-7 px-2 text-[11px] gap-1"
onClick={() => setFilterOpen(true)}
title="Build an advanced filter"
>
<SlidersHorizontal className="size-3.5" /> Filters
{activeFilter.conditions?.length ? (
<span className="ml-0.5 rounded-full bg-primary-foreground/20 px-1.5 font-mono">{activeFilter.conditions.length}</span>
) : null}
</Button>
{(activeFilter.conditions?.length || filterCallsign) ? (
<button
className="text-muted-foreground hover:text-foreground underline decoration-dotted"
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button>
) : null}
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span>
</div>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-amber-700">Limit reached raise Max to see more.</span>
@@ -2616,10 +2660,8 @@ export default function App() {
/>
</TabsContent>
<TabsContent value="awards" className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
<Hash className="size-10 opacity-30" />
<div className="text-base font-semibold text-foreground/70">Awards</div>
<div className="text-xs">Module coming soon.</div>
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel />
</TabsContent>
</Tabs>
</section>
@@ -2762,6 +2804,12 @@ export default function App() {
onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
/>
)}
<FilterBuilder
open={filterOpen}
initial={activeFilter}
onApply={(f) => { setActiveFilter(f); setFilterOpen(false); }}
onClose={() => setFilterOpen(false)}
/>
{showExportChoice && (
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
<DialogContent className="max-w-lg px-6">
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save } 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 { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields } from '../../wailsjs/go/main/App';
export type AwardDef = {
code: string; name: string; field: string; pattern: string;
dxcc_filter: number[] | null; confirm: string[] | null; total: number; builtin?: boolean;
};
const CONFIRM_SRC = [{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }];
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
}
export function AwardEditor({ open, onClose, onSaved }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]);
const [fields, setFields] = useState<string[]>([]);
const [err, setErr] = useState('');
useEffect(() => {
if (!open) return;
setErr('');
Promise.all([GetAwardDefs(), AwardFields()])
.then(([d, f]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); })
.catch((e) => setErr(String(e?.message ?? e)));
}, [open]);
const patch = (i: number, p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === i ? { ...d, ...p } : d)));
const addAward = () => setDefs((ds) => [...ds, { code: 'NEW', name: 'New award', field: 'dxcc', pattern: '', dxcc_filter: null, confirm: ['lotw', 'qsl'], total: 0 }]);
const removeAward = (i: number) => setDefs((ds) => ds.filter((_, j) => j !== i));
const toggleConfirm = (i: number, id: string) => {
const cur = defs[i].confirm ?? [];
patch(i, { confirm: cur.includes(id) ? cur.filter((c) => c !== id) : [...cur, id] });
};
async function save() {
setErr('');
try {
// Normalise codes (uppercase, no blanks).
const clean = defs
.filter((d) => d.code.trim())
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [] }));
await SaveAwardDefs(clean as any);
onSaved();
onClose();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function reset() {
try { setDefs((await ResetAwardDefs()) as any); } catch (e: any) { setErr(String(e?.message ?? e)); }
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogHeader className="px-6 py-4">
<DialogTitle>Edit awards</DialogTitle>
</DialogHeader>
<div className="px-6 pb-2">
<p className="text-xs text-muted-foreground mb-3">
Each award scans one QSO <strong>field</strong>. Leave <strong>pattern</strong> empty to use the whole field value,
or enter a regular expression where <span className="font-mono">group&nbsp;1</span> is the reference e.g. scan
the <span className="font-mono">note</span> field with <span className="font-mono">{'D(\\d{1,2}[AB]?)'}</span> so
"D74" counts department 74.
</p>
{err && <div className="text-xs text-destructive mb-2">{err}</div>}
<div className="space-y-2 max-h-[55vh] overflow-auto pr-1">
{defs.map((d, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-2 bg-card">
<div className="flex items-center gap-2">
<Input className="h-8 w-24 font-mono font-semibold text-xs" value={d.code}
onChange={(e) => patch(i, { code: e.target.value })} placeholder="CODE" />
<Input className="h-8 flex-1 text-sm" value={d.name}
onChange={(e) => patch(i, { name: e.target.value })} placeholder="Award name" />
{d.builtin && <span className="text-[10px] text-muted-foreground border border-border rounded px-1.5 py-0.5">built-in</span>}
<button className="text-muted-foreground hover:text-destructive" title="Remove" onClick={() => removeAward(i)}>
<Trash2 className="size-4" />
</button>
</div>
<div className="grid grid-cols-[auto_1fr_auto_1fr] gap-x-3 gap-y-2 items-center text-xs">
<label className="text-muted-foreground">Field</label>
<Select value={d.field} onValueChange={(v) => patch(i, { field: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">
{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select>
<label className="text-muted-foreground">Pattern</label>
<Input className="h-8 font-mono text-xs" value={d.pattern}
onChange={(e) => patch(i, { pattern: e.target.value })} placeholder="(optional regex, group 1 = ref)" />
<label className="text-muted-foreground">DXCC filter</label>
<Input className="h-8 font-mono text-xs"
value={(d.dxcc_filter ?? []).join(', ')}
onChange={(e) => patch(i, { dxcc_filter: e.target.value.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isFinite(n)) })}
placeholder="e.g. 227 (empty = any)" />
<label className="text-muted-foreground">Total</label>
<Input type="number" className="h-8 font-mono text-xs w-28" value={d.total}
onChange={(e) => patch(i, { total: parseInt(e.target.value, 10) || 0 })} placeholder="0 = unknown" />
<label className="text-muted-foreground">Confirmed by</label>
<div className="col-span-3 flex items-center gap-4">
{CONFIRM_SRC.map((c) => (
<label key={c.id} className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={(d.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleConfirm(i, c.id)} />
{c.label}
</label>
))}
</div>
</div>
</div>
))}
</div>
<Button variant="outline" size="sm" className="h-8 mt-2" onClick={addAward}>
<Plus className="size-3.5 mr-1" /> Add award
</Button>
</div>
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
<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>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, CheckCircle2, Search, Pencil } from 'lucide-react';
import { GetAwards } 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; worked: boolean; confirmed: boolean;
bands: string[]; confirmed_bands: string[];
};
type AwardResult = {
code: string; name: string; dimension: string;
worked: number; confirmed: number; total: number;
bands: BandCount[]; refs: AwardRef[];
};
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>
);
}
export function AwardsPanel() {
const [results, setResults] = useState<AwardResult[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [selected, setSelected] = useState<string>('DXCC');
const [refSearch, setRefSearch] = useState('');
const [editing, setEditing] = useState(false);
async function load() {
setLoading(true);
setErr('');
try {
setResults((await GetAwards()) as any);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
const current = results.find((r) => r.code === selected) ?? results[0];
const filteredRefs = useMemo(() => {
if (!current) return [];
const q = refSearch.trim().toUpperCase();
if (!q) return current.refs;
return current.refs.filter((r) => r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q));
}, [current, refSearch]);
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="ghost" size="sm" className="h-7 px-2" onClick={load} disabled={loading} title="Recalculate">
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={load} />
<div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
{results.map((r) => (
<button
key={r.code}
onClick={() => { setSelected(r.code); setRefSearch(''); }}
className={cn(
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
current?.code === r.code && 'bg-accent/60',
)}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-semibold text-sm">{r.code}</span>
<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>
</div>
<div className="text-[11px] text-muted-foreground truncate mb-1">{r.name}</div>
<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>
{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 table */}
<div className="flex items-center gap-2 px-4 py-2">
<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>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} reference{filteredRefs.length > 1 ? 's' : ''}</span>
</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-20">Status</th>
<th className="py-1 font-medium">Bands</th>
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className="border-b border-border/30">
<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-[260px]">{r.name}</td>
<td className="py-1 pr-2">
{r.confirmed ? (
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="size-3" /> conf.</span>
) : (
<span className="text-amber-600">worked</span>
)}
</td>
<td className="py-1 font-mono text-muted-foreground">
{r.bands.map((b) => (
<span key={b} className={cn('mr-1', r.confirmed_bands.includes(b) && 'text-emerald-600 font-semibold')}>{b}</span>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
);
}
+9
View File
@@ -58,6 +58,8 @@ export type ClusterSpot = {
received_at: string;
raw: string;
repeats?: number;
pota_ref?: string;
pota_name?: string;
};
export type SpotStatusEntry = {
@@ -138,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>;
},
},
{
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',
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
+2 -2
View File
@@ -205,10 +205,10 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
{open === 'my' && (
<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) })} />
</Field>
<Field label="Ant. elevation (°)">
<Field label="Elevation (°)">
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
</Field>
<Field label="Ant. path">
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
// adds field/operator/value conditions, joins them with AND or OR, and can
// save/recall named presets. Closing the dialog applies the filter.
export type FilterOp =
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty';
export interface FilterCondition { field: string; op: FilterOp; value: string }
export interface QueryFilter {
quick_callsign?: string;
conditions: FilterCondition[];
match: 'AND' | 'OR';
limit?: number;
offset?: number;
}
// Curated field catalog. `value` MUST match a column in the backend whitelist
// (qso.FilterableFields); `type` only drives which operators/value input we show.
type FieldType = 'text' | 'number' | 'date';
const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'callsign', label: 'Callsign', type: 'text' },
{ value: 'qso_date', label: 'Date / time (UTC)', type: 'date' },
{ value: 'qso_date_off', label: 'End date / time', type: 'date' },
{ value: 'band', label: 'Band', type: 'text' },
{ value: 'band_rx', label: 'RX band', type: 'text' },
{ value: 'mode', label: 'Mode', type: 'text' },
{ value: 'submode', label: 'Submode', type: 'text' },
{ value: 'freq_hz', label: 'Frequency (Hz)', type: 'number' },
{ value: 'freq_rx_hz', label: 'RX frequency (Hz)', type: 'number' },
{ value: 'rst_sent', label: 'RST sent', type: 'text' },
{ value: 'rst_rcvd', label: 'RST rcvd', type: 'text' },
{ value: 'name', label: 'Name', type: 'text' },
{ value: 'qth', label: 'QTH', type: 'text' },
{ value: 'address', label: 'Address', type: 'text' },
{ value: 'email', label: 'E-mail', type: 'text' },
{ value: 'grid', label: 'Grid', type: 'text' },
{ value: 'country', label: 'Country', type: 'text' },
{ value: 'state', label: 'State', type: 'text' },
{ value: 'cnty', label: 'County', type: 'text' },
{ value: 'dxcc', label: 'DXCC #', type: 'number' },
{ value: 'cont', label: 'Continent', type: 'text' },
{ value: 'cqz', label: 'CQ zone', type: 'number' },
{ value: 'ituz', label: 'ITU zone', type: 'number' },
{ value: 'iota', label: 'IOTA', type: 'text' },
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
{ value: 'rig', label: 'Rig', type: 'text' },
{ value: 'ant', label: 'Antenna', type: 'text' },
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
{ value: 'qsl_rcvd', label: 'QSL rcvd', type: 'text' },
{ value: 'qsl_via', label: 'QSL via', type: 'text' },
{ value: 'lotw_sent', label: 'LoTW sent', type: 'text' },
{ value: 'lotw_rcvd', label: 'LoTW rcvd', type: 'text' },
{ value: 'eqsl_sent', label: 'eQSL sent', type: 'text' },
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
{ value: 'stx', label: 'Serial sent', type: 'number' },
{ value: 'prop_mode', label: 'Propagation mode', type: 'text' },
{ value: 'sat_name', label: 'Satellite', type: 'text' },
{ value: 'station_callsign', label: 'My callsign', type: 'text' },
{ value: 'operator', label: 'Operator', type: 'text' },
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
{ value: 'my_grid', label: 'My grid', type: 'text' },
{ value: 'my_country', label: 'My country', type: 'text' },
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
{ value: 'comment', label: 'Comment', type: 'text' },
{ value: 'notes', label: 'Notes', type: 'text' },
];
const OPS: { value: FilterOp; label: string }[] = [
{ value: 'eq', label: 'equals (=)' },
{ value: 'ne', label: 'not equal (≠)' },
{ value: 'contains', label: 'contains' },
{ value: 'startswith', label: 'starts with' },
{ value: 'endswith', label: 'ends with' },
{ value: 'gt', label: 'greater than (>)' },
{ value: 'lt', label: 'less than (<)' },
{ value: 'ge', label: 'greater or equal (≥)' },
{ value: 'le', label: 'less or equal (≤)' },
{ value: 'empty', label: 'is empty' },
{ value: 'notempty', label: 'is not empty' },
];
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
function opsFor(field: string): { value: FilterOp; label: string }[] {
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
return OPS.filter((o) => allow.includes(o.value));
}
const PRESETS_KEY = 'hamlog.filterPresets';
function loadPresets(): Record<string, QueryFilter> {
try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}'); } catch { return {}; }
}
function savePresets(p: Record<string, QueryFilter>) {
localStorage.setItem(PRESETS_KEY, JSON.stringify(p));
}
interface Props {
open: boolean;
initial: QueryFilter;
onApply: (f: QueryFilter) => void; // applies and closes
onClose: () => void;
}
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
const [conditions, setConditions] = useState<FilterCondition[]>([]);
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
const [presetName, setPresetName] = useState('');
// Seed from the active filter each time the dialog opens.
useEffect(() => {
if (!open) return;
setConditions(initial.conditions?.length ? initial.conditions.map((c) => ({ ...c })) : []);
setMatch(initial.match === 'OR' ? 'OR' : 'AND');
setPresets(loadPresets());
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const setCond = (i: number, patch: Partial<FilterCondition>) =>
setConditions((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c)));
const addCond = () => setConditions((cs) => [...cs, { field: 'callsign', op: 'contains', value: '' }]);
const removeCond = (i: number) => setConditions((cs) => cs.filter((_, j) => j !== i));
function buildFilter(): QueryFilter {
const clean = conditions.filter((c) => c.field && c.op);
return { ...initial, conditions: clean, match };
}
function apply() { onApply(buildFilter()); }
function saveCurrentPreset() {
const name = presetName.trim();
if (!name) return;
const next = { ...presets, [name]: buildFilter() };
savePresets(next);
setPresets(next);
setPresetName('');
}
function loadPreset(name: string) {
const f = presets[name];
if (!f) return;
setConditions(f.conditions?.map((c) => ({ ...c })) ?? []);
setMatch(f.match === 'OR' ? 'OR' : 'AND');
}
function deletePreset(name: string) {
const next = { ...presets };
delete next[name];
savePresets(next);
setPresets(next);
}
const presetNames = Object.keys(presets).sort();
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader className="px-6 py-4">
<DialogTitle>QSO filter</DialogTitle>
</DialogHeader>
<div className="px-6 py-4 space-y-5">
{/* Match mode + presets */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Match</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{(['AND', 'OR'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMatch(m)}
className={`px-3 py-1 text-xs font-medium ${match === m ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}`}>
{m === 'AND' ? 'ALL (AND)' : 'ANY (OR)'}
</button>
))}
</div>
<div className="flex-1" />
{presetNames.length > 0 && (
<Select onValueChange={loadPreset}>
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder="Load preset…" /></SelectTrigger>
<SelectContent>
{presetNames.map((n) => (
<SelectItem key={n} value={n}>
<span className="inline-flex items-center gap-2">
{n}
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Conditions */}
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
{conditions.length === 0 && (
<div className="text-xs text-muted-foreground py-4 text-center">No conditions the list shows all QSOs. Add one below.</div>
)}
{conditions.map((c, i) => {
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
return (
<div key={i} className="flex items-center gap-2">
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
<Select value={c.field} onValueChange={(v) => {
// Reset op if the new field type doesn't allow the current one.
const allowed = opsFor(v).map((o) => o.value);
setCond(i, { field: v, op: allowed.includes(c.op) ? c.op : allowed[0] });
}}>
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}
</SelectContent>
</Select>
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 text-xs"
disabled={!needsValue}
placeholder={needsValue ? 'value' : '—'}
value={c.value}
onChange={(e) => setCond(i, { value: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
/>
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title="Remove">
<X className="size-4" />
</button>
</div>
);
})}
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
<Plus className="size-3.5 mr-1" /> Add condition
</Button>
</div>
{/* Save preset */}
<div className="flex items-center gap-2 border-t border-border pt-3">
<Input className="h-8 w-56 text-xs" placeholder="Preset name…" value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
<Save className="size-3.5 mr-1" /> Save preset
</Button>
</div>
</div>
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
<Button variant="ghost" onClick={() => { setConditions([]); }}>Clear</Button>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={apply}>Apply &amp; close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -115,7 +115,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
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 rounded-lg overflow-hidden border border-border">
<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">
@@ -126,7 +126,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
</div>
)}
</div>
<div className="relative rounded-lg overflow-hidden border border-border">
<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">
+28 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail } from 'lucide-react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -11,6 +11,8 @@ type Props = {
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 }[] = [
@@ -22,7 +24,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
// 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 }: Props) {
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
useEffect(() => {
if (!menu) return;
const close = () => onClose();
@@ -91,6 +93,30 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
</>
)}
{(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" />
+5 -1
View File
@@ -52,6 +52,8 @@ type Props = {
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';
@@ -209,7 +211,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -355,6 +357,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered}
/>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
+21
View File
@@ -4,6 +4,7 @@ import {qso} from '../models';
import {main} from '../models';
import {profile} from '../models';
import {adif} from '../models';
import {award} from '../models';
import {cat} from '../models';
import {cluster} from '../models';
import {extsvc} from '../models';
@@ -17,6 +18,8 @@ export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AwardFields():Promise<Array<string>>;
export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
@@ -29,6 +32,8 @@ export function ConnectClusterServer(arg1:number):Promise<void>;
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>;
@@ -71,12 +76,22 @@ export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profil
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 GetActiveProfile():Promise<profile.Profile>;
export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAwardDefs():Promise<Array<award.Def>>;
export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>;
export function GetCATSettings():Promise<main.CATSettings>;
@@ -141,6 +156,8 @@ export function ListProfiles():Promise<Array<profile.Profile>>;
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>>;
@@ -179,6 +196,8 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function ReloadUDPIntegrations():Promise<Array<string>>;
export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
export function RestartQSORecorder():Promise<void>;
@@ -195,6 +214,8 @@ export function SaveADIFFile():Promise<string>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
+40
View File
@@ -10,6 +10,10 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
export function AwardFields() {
return window['go']['main']['App']['AwardFields']();
}
export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache']();
}
@@ -34,6 +38,10 @@ export function 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);
}
@@ -118,6 +126,18 @@ export function ExportADIF(arg1, arg2) {
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) {
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
}
@@ -130,6 +150,14 @@ export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings']();
}
export function GetAwardDefs() {
return window['go']['main']['App']['GetAwardDefs']();
}
export function GetAwards() {
return window['go']['main']['App']['GetAwards']();
}
export function GetBackupSettings() {
return window['go']['main']['App']['GetBackupSettings']();
}
@@ -258,6 +286,10 @@ export function 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']();
}
@@ -334,6 +366,10 @@ export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations']();
}
export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs']();
}
export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault']();
}
@@ -366,6 +402,10 @@ export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1);
}
export function SaveAwardDefs(arg1) {
return window['go']['main']['App']['SaveAwardDefs'](arg1);
}
export function SaveBackupSettings(arg1) {
return window['go']['main']['App']['SaveBackupSettings'](arg1);
}
+169
View File
@@ -64,6 +64,121 @@ export namespace audio {
}
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;
field: string;
pattern: string;
dxcc_filter: number[];
confirm: string[];
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.field = source["field"];
this.pattern = source["pattern"];
this.dxcc_filter = source["dxcc_filter"];
this.confirm = source["confirm"];
this.total = source["total"];
this.builtin = source["builtin"];
}
}
export class Ref {
ref: string;
name?: string;
worked: boolean;
confirmed: boolean;
bands: string[];
confirmed_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.worked = source["worked"];
this.confirmed = source["confirmed"];
this.bands = source["bands"];
this.confirmed_bands = source["confirmed_bands"];
}
}
export class Result {
code: string;
name: string;
field: string;
worked: number;
confirmed: 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.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 cat {
export class RigState {
@@ -1137,6 +1252,22 @@ export namespace qso {
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 {
callsign?: string;
band?: string;
@@ -1387,6 +1518,44 @@ export namespace qso {
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 {
id: number;
qso_date: string;