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
+166
View File
@@ -21,7 +21,9 @@ import (
"hamlog/internal/audio"
"hamlog/internal/cat"
"hamlog/internal/clublog"
"hamlog/internal/award"
"hamlog/internal/cluster"
"hamlog/internal/pota"
"hamlog/internal/db"
"hamlog/internal/email"
"hamlog/internal/extsvc"
@@ -89,6 +91,8 @@ const (
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
// E-mail / SMTP — send QSO recordings to the correspondent.
@@ -324,6 +328,7 @@ type App struct {
cat *cat.Manager
dxcc *dxcc.Manager
cluster *cluster.Manager
pota *pota.Cache
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
@@ -561,6 +566,11 @@ func (a *App) startup(ctx context.Context) {
})
a.reloadCAT()
// POTA: background poller of api.pota.app so cluster spots can be tagged
// when the DX station is currently activating a park. Best-effort.
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
go a.pota.Run(a.ctx)
// DX Cluster (multi-server): the spot callback enriches each spot
// with country + continent via cty.dat BEFORE emitting it, so the UI
// renders the row with all metadata already filled (no flicker of
@@ -581,6 +591,13 @@ func (a *App) startup(ctx context.Context) {
}
}
}
// POTA: tag the spot when the DX station is currently activating a park.
if a.pota != nil {
if info, ok := a.pota.Lookup(s.DXCall); ok {
s.POTARef = info.Reference
s.POTAName = info.ParkName
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
}
@@ -1155,6 +1172,17 @@ func (a *App) applyStationDefaults(q *qso.QSO) {
if q.Operator == "" {
q.Operator = p.Operator
}
// OWNER_CALLSIGN is a valid ADIF field but not a promoted column, so it
// lives in Extras (exported verbatim, round-trips, and is filterable via
// json_extract). Stamp it from the active profile when set.
if strings.TrimSpace(p.OwnerCallsign) != "" {
if q.Extras == nil {
q.Extras = map[string]string{}
}
if q.Extras["OWNER_CALLSIGN"] == "" {
q.Extras["OWNER_CALLSIGN"] = p.OwnerCallsign
}
}
if q.MyGrid == "" {
q.MyGrid = p.MyGrid
}
@@ -1275,6 +1303,115 @@ func (a *App) CountQSO() (int64, error) {
return a.qso.Count(a.ctx)
}
// awardDefs returns the user's stored award definitions, seeding the built-in
// defaults on first use.
func (a *App) awardDefs() []award.Def {
if a.settings != nil {
if s, _ := a.settings.Get(a.ctx, keyAwardDefs); strings.TrimSpace(s) != "" {
var defs []award.Def
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
return defs
}
}
}
return award.Defaults()
}
// GetAwardDefs returns the (editable) award definitions.
func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }
// AwardFields lists the scannable QSO fields for the award editor.
func (a *App) AwardFields() []string { return award.Fields() }
// SaveAwardDefs persists edited award definitions.
func (a *App) SaveAwardDefs(defs []award.Def) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(defs)
if err != nil {
return err
}
return a.settings.Set(a.ctx, keyAwardDefs, string(b))
}
// ResetAwardDefs restores the built-in defaults.
func (a *App) ResetAwardDefs() ([]award.Def, error) {
d := award.Defaults()
if err := a.SaveAwardDefs(d); err != nil {
return nil, err
}
return d, nil
}
// GetAwards computes award progress (worked/confirmed) across the whole log.
func (a *App) GetAwards() ([]award.Result, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
var all []qso.QSO
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
all = append(all, q)
return nil
}); err != nil {
return nil, err
}
nameOf := func(field, ref string) string {
switch field {
case "dxcc":
if n, err := strconv.Atoi(ref); err == nil {
return dxcc.NameForDXCC(n)
}
case "cont":
return continentName(ref)
}
return ""
}
return award.Compute(a.awardDefs(), all, nameOf), nil
}
func continentName(code string) string {
switch strings.ToUpper(code) {
case "AF":
return "Africa"
case "AN":
return "Antarctica"
case "AS":
return "Asia"
case "EU":
return "Europe"
case "NA":
return "North America"
case "OC":
return "Oceania"
case "SA":
return "South America"
}
return ""
}
// ListQSOFiltered returns QSOs matching the advanced filter builder.
func (a *App) ListQSOFiltered(f qso.QueryFilter) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.qso.ListFiltered(a.ctx, f)
}
// CountQSOFiltered returns how many QSOs match the filter (ignoring the row
// limit) so the UI can show "showing 500 of 1,234 matches".
func (a *App) CountQSOFiltered(f qso.QueryFilter) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
return a.qso.CountFiltered(a.ctx, f)
}
// FilterFields exposes the whitelisted filterable columns to the frontend.
func (a *App) FilterFields() []string {
return qso.FilterableFields()
}
func (a *App) GetQSO(id int64) (qso.QSO, error) {
if a.qso == nil {
return qso.QSO{}, fmt.Errorf("db not initialized")
@@ -1444,6 +1581,35 @@ func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult,
return ex.ExportFile(a.ctx, path)
}
// ExportADIFFiltered writes the QSOs matching the current filter to path, with
// NO row limit (the on-screen list is capped by the threshold; this is not).
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter) (adif.ExportResult, error) {
if a.qso == nil {
return adif.ExportResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return adif.ExportResult{}, fmt.Errorf("empty path")
}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
return ex.ExportFileFiltered(a.ctx, path, f)
}
// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
// operator highlighted in the grid).
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64) (adif.ExportResult, error) {
if a.qso == nil {
return adif.ExportResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return adif.ExportResult{}, fmt.Errorf("empty path")
}
if len(ids) == 0 {
return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
return ex.ExportFileByIDs(a.ctx, path, ids)
}
// --- Lookup bindings ---
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
+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;
+32 -9
View File
@@ -36,29 +36,52 @@ type Exporter struct {
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.
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)
if err != nil {
return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
}
defer f.Close()
count, err := e.Export(ctx, f)
count, err := e.writeDoc(ctx, f, iter)
if err != nil {
return ExportResult{Path: path, Count: count}, err
}
info, _ := f.Stat()
return ExportResult{
Path: path,
Count: count,
SizeKB: info.Size() / 1024,
}, nil
return ExportResult{Path: path, Count: count, SizeKB: info.Size() / 1024}, nil
}
// Export writes a complete ADIF document (header + records + EOF) to w.
// Returns the number of QSOs successfully written.
// Export writes a complete ADIF document (header + records + EOF) to w for
// every QSO. Returns the number of QSOs written.
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)
defer bw.Flush()
@@ -76,7 +99,7 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
count := 0
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
err := iter(ctx, func(q qso.QSO) error {
writeRecord(bw, q, e.IncludeAppFields)
count++
return nil
+360
View File
@@ -0,0 +1,360 @@
// 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"
)
// Def defines one award.
type Def struct {
Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name
Field string `json:"field"` // QSO field to scan (see fieldRaw)
Pattern string `json:"pattern"` // optional Go regexp; group 1 = reference
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
Confirm []string `json:"confirm"` // accepted confirmations: lotw|qsl|eqsl
Total int `json:"total"` // known denominator (0 = unknown)
Builtin bool `json:"builtin"` // shipped default (informational)
}
// Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def {
return []Def{
{Code: "DXCC", Name: "DX Century Club", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340, Builtin: true},
{Code: "WAS", Name: "Worked All States", Field: "state", DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Total: 50, Builtin: true},
{Code: "WAZ", Name: "Worked All Zones (CQ)", Field: "cqz", Confirm: []string{"lotw", "qsl"}, Total: 40, Builtin: true},
{Code: "WAC", Name: "Worked All Continents", Field: "cont", Confirm: []string{"lotw", "qsl", "eqsl"}, Total: 6, Builtin: true},
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Field: "prefix", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
{Code: "DDFM", Name: "Départements Français Métropolitains", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: []string{"lotw", "qsl"}, Total: 96, Builtin: true},
{Code: "IOTA", Name: "Islands On The Air", Field: "iota", Confirm: []string{"qsl"}, Total: 0, Builtin: true},
{Code: "POTA", Name: "Parks On The Air", Field: "pota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
{Code: "SOTA", Name: "Summits On The Air", Field: "sota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
{Code: "WWFF", Name: "World Wide Flora & Fauna", Field: "wwff", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
}
}
// 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"`
Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"`
Bands []string `json:"bands"`
ConfirmedBands []string `json:"confirmed_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"`
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{}
anyConfirmed bool
}
// Compute runs every definition over the QSOs in a single pass.
func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
// Pre-compile patterns once per award (not per QSO).
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] != "" {
continue
}
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
continue
}
refs := refValues(d, res[i], q)
if len(refs) == 0 {
continue
}
band := strings.ToLower(strings.TrimSpace(q.Band))
isConf := confirmed(q, d.Confirm)
for _, ref := range refs {
a := agg[i][ref]
if a == nil {
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}}
agg[i][ref] = a
}
if band != "" {
a.bands[band] = struct{}{}
}
if isConf {
a.anyConfirmed = true
if band != "" {
a.confirmedBands[band] = struct{}{}
}
}
}
}
}
out := make([]Result, len(defs))
for i := range defs {
d := &defs[i]
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++
}
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
if nameOf != nil {
rf.Name = nameOf(d.Field, ref)
}
r.Refs = append(r.Refs, rf)
for b := range a.bands {
bandWorked[b]++
}
for b := range a.confirmedBands {
bandConfirmed[b]++
}
}
sort.Slice(r.Refs, func(a, b int) bool {
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
return r.Refs[a].Confirmed
}
return 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
}
// refValues extracts the reference(s) a QSO contributes to an award.
func refValues(d *Def, re *regexp.Regexp, q *qso.QSO) []string {
raw := fieldRaw(d.Field, q)
if strings.TrimSpace(raw) == "" {
return nil
}
if re == nil {
return []string{normalizeRef(raw)}
}
matches := re.FindAllStringSubmatch(raw, -1)
if len(matches) == 0 {
return nil
}
seen := map[string]struct{}{}
var out []string
for _, m := range matches {
ref := m[0]
if len(m) > 1 && m[1] != "" {
ref = m[1]
}
ref = normalizeRef(ref)
if ref == "" {
continue
}
if _, dup := seen[ref]; dup {
continue
}
seen[ref] = struct{}{}
out = append(out, ref)
}
return out
}
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
// 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
}
+75
View File
@@ -0,0 +1,75 @@
package award
import (
"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)
by := map[string]Result{}
for _, r := range res {
by[r.Code] = r
}
dxcc := by["DXCC"]
if dxcc.Worked != 3 { // USA, Germany, France
t.Errorf("DXCC worked = %d, want 3", dxcc.Worked)
}
// DXCC confirms on lotw|qsl → USA(lotw) + Germany(qsl) + France(qsl via F5ABC).
if dxcc.Confirmed != 3 {
t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed)
}
was := by["WAS"]
if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter)
t.Errorf("WAS worked = %d, want 2", was.Worked)
}
// DDFM scans the Note field with pattern D(\d{1,2}[AB]?): 74 and 2A.
ddfm := by["DDFM"]
if ddfm.Worked != 2 {
t.Errorf("DDFM worked = %d, want 2 (refs %v)", ddfm.Worked, refCodes(ddfm))
}
if ddfm.Confirmed != 1 { // 2A confirmed via QSL; 74 only eQSL (not accepted)
t.Errorf("DDFM confirmed = %d, want 1", ddfm.Confirmed)
}
}
func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
out = append(out, rf.Ref)
}
return out
}
+119
View File
@@ -0,0 +1,119 @@
package award
import "strings"
// wpxPrefix derives the CQ WPX prefix from a callsign. This is an approximation
// of the official WPX rules — good enough to count distinct prefixes worked:
// - standard call: letters+digits up to and including the LAST digit of the
// first group (F4BPO→F4, EA8ABC→EA8, 9A1AA→9A1, OH2BH→OH2)
// - no digit: first two letters + "0" (RAEM→RA0)
// - portable "A/B": a short alpha(+digit) segment is treated as the prefix
// designator; a lone-digit segment replaces the call's digit (F4BPO/9→F9)
func wpxPrefix(call string) string {
c := strings.ToUpper(strings.TrimSpace(call))
if c == "" {
return ""
}
if strings.Contains(c, "/") {
return portablePrefix(c)
}
return standardPrefix(c)
}
func portablePrefix(c string) string {
parts := strings.Split(c, "/")
// Drop pure operating-modifier suffixes.
kept := make([]string, 0, len(parts))
for _, p := range parts {
switch p {
case "P", "M", "MM", "AM", "QRP", "A", "R", "B", "LH":
continue
}
if p != "" {
kept = append(kept, p)
}
}
if len(kept) == 0 {
kept = parts
}
// Pick a base = the longest segment (the actual call).
base := kept[0]
for _, p := range kept[1:] {
if len(p) > len(base) {
base = p
}
}
for _, p := range kept {
if p == base {
continue
}
if isAllDigits(p) {
// Lone digit replaces the call's region digit: F4BPO/9 → F9.
return replaceLastDigit(standardPrefix(base), p)
}
if len(p) <= 4 && hasLetter(p) {
// Prefix designator wins: VP8/F4BPO → VP8 (+digit if missing).
return ensureTrailingDigit(p)
}
}
return standardPrefix(base)
}
// standardPrefix applies the basic WPX rule to a plain callsign: the prefix is
// the call up to and including its last digit (9A1AA→9A1, EA8ABC→EA8). Standard
// callsigns carry no digit in the suffix, so "last digit" is the prefix digit.
func standardPrefix(c string) string {
lastDigit := -1
for i := 0; i < len(c); i++ {
if c[i] >= '0' && c[i] <= '9' {
lastDigit = i
}
}
if lastDigit < 0 {
// No digit at all: first two letters + 0.
if len(c) >= 2 {
return c[:2] + "0"
}
return c + "0"
}
return c[:lastDigit+1]
}
func ensureTrailingDigit(p string) string {
for i := 0; i < len(p); i++ {
if p[i] >= '0' && p[i] <= '9' {
return p
}
}
return p + "0"
}
func replaceLastDigit(prefix, digit string) string {
for i := len(prefix) - 1; i >= 0; i-- {
if prefix[i] >= '0' && prefix[i] <= '9' {
return prefix[:i] + digit
}
}
return prefix + digit
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
func hasLetter(s string) bool {
for i := 0; i < len(s); i++ {
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
return true
}
}
return false
}
+2
View File
@@ -59,6 +59,8 @@ type Spot struct {
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"`
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.
+23
View File
@@ -32,6 +32,29 @@ func EntityDXCC(name string) int {
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
}
// 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))
+144
View File
@@ -0,0 +1,144 @@
// Package pota polls the Parks On The Air activator-spots API and exposes a
// fast in-memory lookup so DX-cluster spots can be tagged "this station is
// currently activating a park". No API key required.
package pota
import (
"context"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
)
const apiURL = "https://api.pota.app/spot/activator"
// Info is the park data we surface for a currently-active activator.
type Info struct {
Reference string `json:"reference"` // park id, e.g. "US-2072"
ParkName string `json:"park_name"` // human name
LocationDesc string `json:"location_desc"` // e.g. "US-NY"
}
// apiSpot is the subset of the POTA API record we read.
type apiSpot struct {
Activator string `json:"activator"`
Reference string `json:"reference"`
ParkName string `json:"parkName"`
Name string `json:"name"`
LocationDesc string `json:"locationDesc"`
}
// Cache holds the latest activator set, refreshed in the background.
type Cache struct {
mu sync.RWMutex
byCall map[string]Info // base callsign (upper) → info
client *http.Client
logf func(string, ...any)
}
// New creates a cache. logf may be nil.
func New(logf func(string, ...any)) *Cache {
return &Cache{
byCall: map[string]Info{},
client: &http.Client{Timeout: 20 * time.Second},
logf: logf,
}
}
// Run refreshes immediately, then every 60 s until ctx is cancelled.
func (c *Cache) Run(ctx context.Context) {
c.refresh(ctx)
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.refresh(ctx)
}
}
}
func (c *Cache) log(format string, a ...any) {
if c.logf != nil {
c.logf(format, a...)
}
}
func (c *Cache) refresh(ctx context.Context) {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
c.log("pota: request: %v", err)
return
}
resp, err := c.client.Do(req)
if err != nil {
c.log("pota: fetch: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.log("pota: http %d", resp.StatusCode)
return
}
var spots []apiSpot
if err := json.NewDecoder(resp.Body).Decode(&spots); err != nil {
c.log("pota: decode: %v", err)
return
}
m := make(map[string]Info, len(spots))
for _, s := range spots {
call := baseCall(s.Activator)
if call == "" {
continue
}
name := strings.TrimSpace(s.Name)
if name == "" {
name = strings.TrimSpace(s.ParkName)
}
// Keep the first reference seen for a call (most-recent-first ordering
// from the API), but don't clobber with a blank.
if _, exists := m[call]; exists {
continue
}
m[call] = Info{Reference: s.Reference, ParkName: name, LocationDesc: s.LocationDesc}
}
c.mu.Lock()
c.byCall = m
c.mu.Unlock()
c.log("pota: %d active activators", len(m))
}
// Lookup returns park info for a callsign if it's currently activating.
func (c *Cache) Lookup(call string) (Info, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if len(c.byCall) == 0 {
return Info{}, false
}
i, ok := c.byCall[baseCall(call)]
return i, ok
}
// baseCall normalises a callsign for matching: upper-cased, and when it carries
// "/" segments (F4BPO/P, HB9/F4BPO) we take the longest segment, which is
// almost always the home call.
func baseCall(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if s == "" {
return ""
}
if !strings.Contains(s, "/") {
return s
}
best := ""
for _, part := range strings.Split(s, "/") {
if len(part) > len(best) {
best = part
}
}
return best
}
+243
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"time"
)
@@ -582,6 +583,248 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
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:
// - by exact callsign → shown as "this call worked N×"
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT