From 88623f55dfdcb4f0a3e501f4a7ad83412f35e75e Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 5 Jun 2026 17:22:38 +0200 Subject: [PATCH] awards --- app.go | 166 ++++++++++ frontend/src/App.tsx | 116 +++++-- frontend/src/components/AwardEditor.tsx | 140 ++++++++ frontend/src/components/AwardsPanel.tsx | 192 +++++++++++ frontend/src/components/ClusterGrid.tsx | 9 + frontend/src/components/DetailsPanel.tsx | 4 +- frontend/src/components/FilterBuilder.tsx | 269 +++++++++++++++ frontend/src/components/MainMap.tsx | 4 +- frontend/src/components/QSOContextMenu.tsx | 30 +- frontend/src/components/RecentQSOsGrid.tsx | 6 +- frontend/wailsjs/go/main/App.d.ts | 21 ++ frontend/wailsjs/go/main/App.js | 40 +++ frontend/wailsjs/go/models.ts | 169 ++++++++++ internal/adif/export.go | 41 ++- internal/award/award.go | 360 +++++++++++++++++++++ internal/award/award_test.go | 75 +++++ internal/award/wpx.go | 119 +++++++ internal/cluster/cluster.go | 2 + internal/dxcc/adif_numbers.go | 23 ++ internal/pota/pota.go | 144 +++++++++ internal/qso/qso.go | 243 ++++++++++++++ 21 files changed, 2123 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/AwardEditor.tsx create mode 100644 frontend/src/components/AwardsPanel.tsx create mode 100644 frontend/src/components/FilterBuilder.tsx create mode 100644 internal/award/award.go create mode 100644 internal/award/award_test.go create mode 100644 internal/award/wpx.go create mode 100644 internal/pota/pota.go diff --git a/app.go b/app.go index 595e8fd..7476dd3 100644 --- a/app.go +++ b/app.go @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 002d34e..5ef4ffc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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({ conditions: [], match: 'AND' }); + const [matchCount, setMatchCount] = useState(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)} /> - - @@ -2264,14 +2286,36 @@ export default function App() { onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} + onExportSelected={exportSelectedADIF} + onExportFiltered={exportFilteredADIF} onRowSelected={(id) => setSelectedId(id)} />
- - Showing {qsos.length} of{' '} - {total} - {filterCallsign || filterBand || filterMode ? ' (filtered)' : ''} - +
+ + {(activeFilter.conditions?.length || filterCallsign) ? ( + + ) : null} + + Showing {qsos.length} of{' '} + {(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total} + {(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''} + +
{qsos.length >= qsoLimit && qsos.length < total && ( Limit reached — raise Max to see more. @@ -2616,10 +2660,8 @@ export default function App() { /> - - -
Awards
-
Module coming soon.
+ + @@ -2762,6 +2804,12 @@ export default function App() { onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }} /> )} + { setActiveFilter(f); setFilterOpen(false); }} + onClose={() => setFilterOpen(false)} + /> {showExportChoice && ( { if (!o) setShowExportChoice(false); }}> diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx new file mode 100644 index 0000000..d2c8382 --- /dev/null +++ b/frontend/src/components/AwardEditor.tsx @@ -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([]); + const [fields, setFields] = useState([]); + 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) => 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 ( + { if (!o) onClose(); }}> + + + Edit awards + + +
+

+ Each award scans one QSO field. Leave pattern empty to use the whole field value, + or enter a regular expression where group 1 is the reference — e.g. scan + the note field with {'D(\\d{1,2}[AB]?)'} so + "D74" counts department 74. +

+ {err &&
{err}
} + +
+ {defs.map((d, i) => ( +
+
+ patch(i, { code: e.target.value })} placeholder="CODE" /> + patch(i, { name: e.target.value })} placeholder="Award name" /> + {d.builtin && built-in} + +
+
+ + + + patch(i, { pattern: e.target.value })} placeholder="(optional regex, group 1 = ref)" /> + + + 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)" /> + + patch(i, { total: parseInt(e.target.value, 10) || 0 })} placeholder="0 = unknown" /> + + +
+ {CONFIRM_SRC.map((c) => ( + + ))} +
+
+
+ ))} +
+ + +
+ + + +
+ + + + +
+ ); +} diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx new file mode 100644 index 0000000..eefaf24 --- /dev/null +++ b/frontend/src/components/AwardsPanel.tsx @@ -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 ( +
+
+
+
+ ); +} + +export function AwardsPanel() { + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(''); + const [selected, setSelected] = useState('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 ( +
+ {/* Award list */} +
+
+ + Awards +
+ + +
+ setEditing(false)} onSaved={load} /> +
+ {err &&
{err}
} + {results.map((r) => ( + + ))} +
+
+ + {/* Detail */} +
+ {!current ? ( +
+ {loading ? 'Computing…' : 'No data'} +
+ ) : ( + <> +
+
+

{current.code}

+ {current.name} +
+
+ {current.worked} worked + {current.confirmed} confirmed + {current.total > 0 && ( + of {current.total} · {pct(current.confirmed, current.total)}% confirmed + )} +
+
+
+ + {/* Band breakdown */} + {current.bands.length > 0 && ( +
+
By band (confirmed / worked)
+
+ {current.bands.map((b) => ( +
+ {b.band}{' '} + {b.confirmed} + /{b.worked} +
+ ))} +
+
+ )} + + {/* References table */} +
+
+ + setRefSearch(e.target.value)} /> +
+ {filteredRefs.length} reference{filteredRefs.length > 1 ? 's' : ''} +
+
+ + + + + + + + + + + {filteredRefs.map((r) => ( + + + + + + + ))} + +
RefNameStatusBands
{r.ref}{r.name} + {r.confirmed ? ( + conf. + ) : ( + worked + )} + + {r.bands.map((b) => ( + {b} + ))} +
+
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index d52881e..a9d335c 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -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 {p.value}; }, }, + { + 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', diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index a33a07f..26720b3 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -205,10 +205,10 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, {open === 'my' && (
- + onChange({ ant_az: numOrUndef(e.target.value) })} /> - + onChange({ ant_el: numOrUndef(e.target.value) })} /> diff --git a/frontend/src/components/FilterBuilder.tsx b/frontend/src/components/FilterBuilder.tsx new file mode 100644 index 0000000..0ed4a33 --- /dev/null +++ b/frontend/src/components/FilterBuilder.tsx @@ -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 { + try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}'); } catch { return {}; } +} +function savePresets(p: Record) { + 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([]); + const [match, setMatch] = useState<'AND' | 'OR'>('AND'); + const [presets, setPresets] = useState>({}); + 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) => + 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 ( + { if (!o) apply(); }}> + + + QSO filter + + +
+ {/* Match mode + presets */} +
+ Match +
+ {(['AND', 'OR'] as const).map((m) => ( + + ))} +
+
+ {presetNames.length > 0 && ( + + )} +
+ + {/* Conditions */} +
+ {conditions.length === 0 && ( +
No conditions — the list shows all QSOs. Add one below.
+ )} + {conditions.map((c, i) => { + const needsValue = c.op !== 'empty' && c.op !== 'notempty'; + return ( +
+ {i === 0 ? 'WHERE' : match} + + + setCond(i, { value: e.target.value })} + onKeyDown={(e) => { if (e.key === 'Enter') apply(); }} + /> + +
+ ); + })} + +
+ + {/* Save preset */} +
+ setPresetName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} /> + +
+
+ + + + + + + +
+ ); +} diff --git a/frontend/src/components/MainMap.tsx b/frontend/src/components/MainMap.tsx index 2826610..fb8aa1a 100644 --- a/frontend/src/components/MainMap.tsx +++ b/frontend/src/components/MainMap.tsx @@ -115,7 +115,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) { return (
-
+
{path && (
@@ -126,7 +126,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
)}
-
+
{!gridToLatLon(toGrid) && (
diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index 78147d0..3b7f77b 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -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) && ( + <> +
+ {onExportSelected && ( + + )} + {onExportFiltered && ( + + )} + + )} + {onSendTo && ( <>
diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index eb30ec3..94d3f00 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -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(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -355,6 +357,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda onUpdateFromClublog={onUpdateFromClublog} onSendTo={onSendTo} onSendRecording={onSendRecording} + onExportSelected={onExportSelected} + onExportFiltered={onExportFiltered} /> diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 31ec576..ce9d48d 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -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; export function AddQSO(arg1:qso.QSO):Promise; +export function AwardFields():Promise>; + export function ClearLookupCache():Promise; export function ClusterSpotStatuses(arg1:Array):Promise>; @@ -29,6 +32,8 @@ export function ConnectClusterServer(arg1:number):Promise; export function CountQSO():Promise; +export function CountQSOFiltered(arg1:qso.QueryFilter):Promise; + export function CreateDatabase(arg1:string):Promise; export function DVKCancelRecord():Promise; @@ -71,12 +76,22 @@ export function DuplicateProfile(arg1:number,arg2:string):Promise; +export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise; + +export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array):Promise; + +export function FilterFields():Promise>; + export function FindQSOsForUpload(arg1:string,arg2:string):Promise>; export function GetActiveProfile():Promise; export function GetAudioSettings():Promise; +export function GetAwardDefs():Promise>; + +export function GetAwards():Promise>; + export function GetBackupSettings():Promise; export function GetCATSettings():Promise; @@ -141,6 +156,8 @@ export function ListProfiles():Promise>; export function ListQSO(arg1:qso.ListFilter):Promise>; +export function ListQSOFiltered(arg1:qso.QueryFilter):Promise>; + export function ListSerialPorts():Promise>; export function ListTQSLStationLocations():Promise>; @@ -179,6 +196,8 @@ export function RefreshCtyDat():Promise; export function ReloadUDPIntegrations():Promise>; +export function ResetAwardDefs():Promise>; + export function ResetDatabaseToDefault():Promise; export function RestartQSORecorder():Promise; @@ -195,6 +214,8 @@ export function SaveADIFFile():Promise; export function SaveAudioSettings(arg1:main.AudioSettings):Promise; +export function SaveAwardDefs(arg1:Array):Promise; + export function SaveBackupSettings(arg1:main.BackupSettings):Promise; export function SaveCATSettings(arg1:main.CATSettings):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index dcd8192..79e0675 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 64a06fa..dc58070 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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; diff --git a/internal/adif/export.go b/internal/adif/export.go index b99b4b6..97b16bc 100644 --- a/internal/adif/export.go +++ b/internal/adif/export.go @@ -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, " %s \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 diff --git a/internal/award/award.go b/internal/award/award.go new file mode 100644 index 0000000..b3be4d7 --- /dev/null +++ b/internal/award/award.go @@ -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 +} diff --git a/internal/award/award_test.go b/internal/award/award_test.go new file mode 100644 index 0000000..3ba6d06 --- /dev/null +++ b/internal/award/award_test.go @@ -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 +} diff --git a/internal/award/wpx.go b/internal/award/wpx.go new file mode 100644 index 0000000..435028f --- /dev/null +++ b/internal/award/wpx.go @@ -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 +} diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 70516f2..519602c 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -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. diff --git a/internal/dxcc/adif_numbers.go b/internal/dxcc/adif_numbers.go index 4b287a3..c7e2cd5 100644 --- a/internal/dxcc/adif_numbers.go +++ b/internal/dxcc/adif_numbers.go @@ -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)) diff --git a/internal/pota/pota.go b/internal/pota/pota.go new file mode 100644 index 0000000..cb8f74f --- /dev/null +++ b/internal/pota/pota.go @@ -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 +} diff --git a/internal/qso/qso.go b/internal/qso/qso.go index e95935b..218a42c 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -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