diff --git a/app.go b/app.go index d3e7ab0..72763c2 100644 --- a/app.go +++ b/app.go @@ -38,6 +38,7 @@ import ( "hamlog/internal/cwdecode" "hamlog/internal/db" "hamlog/internal/dxcc" + "hamlog/internal/dxped" "hamlog/internal/email" "hamlog/internal/extsvc" "hamlog/internal/geo" @@ -805,6 +806,9 @@ type App struct { solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign) scp *scp.Manager // Super Check Partial / N+1 callsign master list + // dxped reads the ADXO announcements and the DX-World feed for the + // DXpeditions tab. Built lazily: nothing fetches until the tab is opened. + dxped *dxped.Manager // NET Control: persistent net definitions/rosters (global JSON) + the live // session (in-memory only — active stations currently in QSO). diff --git a/app_dxped.go b/app_dxped.go new file mode 100644 index 0000000..599e189 --- /dev/null +++ b/app_dxped.go @@ -0,0 +1,146 @@ +package main + +import ( + "strings" + + "hamlog/internal/applog" + "hamlog/internal/dxped" +) + +// ── DXpeditions (ADXO announcements + DX-World news) ──────────────────── +// +// What a logger can say that a news reader cannot: whether the announced +// operation is worth chasing. Every activation is judged against THIS log — +// the same verdict the cluster paints on a spot — so the list reads as "what I +// still need", not "what is on the air". + +// DXpedition is one announced operation plus what it is worth here. +type DXpedition struct { + dxped.Activation + // Status is the strongest verdict across the announced callsigns, bands and + // modes: "new" (entity never worked) beats "new-band-mode", which beats + // "new-band", "new-mode", "new-slot", and finally "worked". Empty when the + // callsign resolves to no entity — a prefix ADXO knows and cty.dat does not. + Status string `json:"status_chase"` + // Unconfirmed marks a need that is only a missing QSL, so the badge can be + // drawn dimmed exactly as it is in the cluster and the decode list. + Unconfirmed bool `json:"unconfirmed"` +} + +// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition +// list shows ONE badge, so the ranking is the whole decision. +var chaseRank = map[string]int{ + "new": 6, + "new-band-mode": 5, + "new-band": 4, + "new-mode": 3, + "new-slot": 2, + "worked": 1, +} + +// GetDXpeditions returns the announced operations, freshest feed permitting, +// each carrying its chase verdict. +func (a *App) GetDXpeditions() ([]DXpedition, error) { + if a.dxped == nil { + a.dxped = dxped.New() + } + acts, err := a.dxped.Activations(a.ctx) + if err != nil { + applog.Printf("dxped: adxo fetch: %v", err) + if len(acts) == 0 { + return nil, err + } + // Stale data with a logged error beats an empty tab. + } + out := make([]DXpedition, 0, len(acts)) + for _, act := range acts { + out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false}) + } + a.judgeDXpeditions(out) + return out, nil +} + +// judgeDXpeditions fills in the chase verdict, in place. +// +// One ClusterSpotStatuses call for the whole list rather than one per row: the +// worked-index it builds is the expensive part (a full pass over the log), and +// asking it forty times to answer forty rows was the difference between a tab +// that opens and a tab that stalls a remote MySQL for a second. +func (a *App) judgeDXpeditions(list []DXpedition) { + if a.qso == nil || len(list) == 0 { + return + } + type slot struct{ row int } + var queries []SpotQuery + var owners []slot + for i, d := range list { + calls := d.Calls + if len(calls) == 0 { + if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" { + calls = []string{c} + } + } + for _, call := range calls { + // No announced band/mode: ask the entity-level question alone. + if len(d.Bands) == 0 && len(d.Modes) == 0 { + queries = append(queries, SpotQuery{Call: call}) + owners = append(owners, slot{i}) + continue + } + bands := d.Bands + if len(bands) == 0 { + bands = []string{""} + } + modes := d.Modes + if len(modes) == 0 { + modes = []string{""} + } + for _, b := range bands { + for _, m := range modes { + queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m}) + owners = append(owners, slot{i}) + } + } + } + } + if len(queries) == 0 { + return + } + res := a.ClusterSpotStatuses(queries) + for i, r := range res { + if i >= len(owners) { + break + } + row := owners[i].row + if chaseRank[r.Status] > chaseRank[list[row].Status] { + list[row].Status = r.Status + list[row].Unconfirmed = r.UnconfStatus + } + } +} + +// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out +// of each one so the reader can act on them. +func (a *App) GetDXWorldNews() ([]dxped.News, error) { + if a.dxped == nil { + a.dxped = dxped.New() + } + news, err := a.dxped.News(a.ctx) + if err != nil { + applog.Printf("dxped: dx-world fetch: %v", err) + if len(news) == 0 { + return nil, err + } + } + return news, nil +} + +// RefreshDXpeditions drops both caches so the next read goes to the network. +// Wired to the tab's refresh button: an operator who has just read of a landing +// on a cluster should not wait out the cache to see it here. +func (a *App) RefreshDXpeditions() { + if a.dxped == nil { + a.dxped = dxped.New() + } + a.dxped.Invalidate() +} diff --git a/app_watchlist.go b/app_watchlist.go index d93eee2..8d13fbf 100644 --- a/app_watchlist.go +++ b/app_watchlist.go @@ -55,7 +55,11 @@ func (a *App) WatchlistAdd(callsign string, contest bool) error { if a.watchlist == nil { return fmt.Errorf("watchlist not initialized") } - return a.watchlist.Add(callsign, contest) + if err := a.watchlist.Add(callsign, contest); err != nil { + return err + } + a.notifyWatchlist(callsign, true) + return nil } // WatchlistRemove deletes an entry. @@ -63,7 +67,27 @@ func (a *App) WatchlistRemove(callsign string) error { if a.watchlist == nil { return fmt.Errorf("watchlist not initialized") } - return a.watchlist.Remove(callsign) + if err := a.watchlist.Remove(callsign); err != nil { + return err + } + a.notifyWatchlist(callsign, false) + return nil +} + +// notifyWatchlist announces a membership change to the UI. +// +// Emitted from the BINDINGS rather than from the watchlist panel, because a +// call can now be added from three places (the panel, the cluster's menu, the +// DXpeditions tab) and an operator who added one from the cluster saw nothing +// at all — the confirmation lived inside a panel they were not looking at. +func (a *App) notifyWatchlist(callsign string, added bool) { + if a.ctx == nil { + return + } + wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{ + "call": strings.ToUpper(strings.TrimSpace(callsign)), + "added": added, + }) } // WatchlistSetNotify arms the existing alert path (sound + toast) for an entry. diff --git a/changelog.json b/changelog.json index e8aa015..31b5f24 100644 --- a/changelog.json +++ b/changelog.json @@ -8,7 +8,9 @@ "Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online.", "LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county.", "DX Cluster: two new chase switches — Chase US counties and Chase new prefixes — and unchecking Chase new grids now also withdraws the NEW GRID badge. Each switch removes its badge from the spots AND its chip from the status filters, like Chase POTA always did.", - "Confirmations: a HamQTH row — sent only, defaulting to R (to upload), since HamQTH publishes no confirmations to receive. Setting such a default no longer disables the auto-upload it was meant to arm (it also affected HAMLOG.online)." + "Confirmations: a HamQTH row — sent only, defaulting to R (to upload), since HamQTH publishes no confirmations to receive. Setting such a default no longer disables the auto-upload it was meant to arm (it also affected HAMLOG.online).", + "New DXpeditions tab (Tools): the announced operations from NG3K’s ADXO next to the DX-World news feed. Every announcement is judged against YOUR log and carries one badge — NEW DXCC, NEW BAND, NEW SLOT… — with an “only what I need” filter, and one click adds its callsigns to the watchlist. Callsigns are mined out of the news headlines too, so they can be watched the same way.", + "Watchlist: adding or removing a callsign now raises the same kind of notification as a new version, instead of a message inside the watchlist page — a call can be added from the cluster or the DXpeditions tab, where that message was never seen." ], "fr": [ "Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance qui se ferme libère son verrou au lieu de la prendre de vitesse.", @@ -16,7 +18,9 @@ "Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online.", "LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf s’il a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout l’enregistrement). L’export cesse aussi de coller un nom d’état complet devant le comté.", "DX Cluster : deux nouvelles cases — Chasser les comtés US et Chasser les nouveaux préfixes — et décocher Chasser les nouveaux locators retire désormais aussi le badge NEW GRID. Chaque case enlève son badge des spots ET sa puce des filtres de statut, comme Chase POTA le faisait déjà.", - "Confirmations : une ligne HamQTH — envoi seulement, à R (à envoyer) par défaut, HamQTH ne publiant aucune confirmation à recevoir. Définir un tel défaut ne désactive plus l’upload automatique qu’il était censé armer (cela touchait aussi HAMLOG.online)." + "Confirmations : une ligne HamQTH — envoi seulement, à R (à envoyer) par défaut, HamQTH ne publiant aucune confirmation à recevoir. Définir un tel défaut ne désactive plus l’upload automatique qu’il était censé armer (cela touchait aussi HAMLOG.online).", + "Nouvel onglet DXpéditions (Outils) : les opérations annoncées par l’ADXO de NG3K à côté du fil d’actualités DX-World. Chaque annonce est jugée sur VOTRE log et porte un badge — NOUVEAU DXCC, NOUVELLE BANDE, NOUVEAU SLOT… — avec un filtre « seulement ce qu’il me manque », et un clic ajoute ses indicatifs à la watchlist. Les indicatifs sont aussi extraits des titres d’actualités pour être surveillés de la même façon.", + "Watchlist : ajouter ou retirer un indicatif déclenche désormais une notification du même type que celle des nouvelles versions, au lieu d’un message dans la page watchlist — un indicatif peut être ajouté depuis le cluster ou l’onglet DXpéditions, où ce message n’était jamais vu." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b8177a..7114f3c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock, - ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap, + ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap, } from 'lucide-react'; import { @@ -76,6 +76,7 @@ import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; import { FTMapPanel } from '@/components/FTMapPanel'; +import { DXpeditionsPanel } from '@/components/DXpeditionsPanel'; import { FirstRunModal } from '@/components/FirstRunModal'; import { QSOEditModal } from '@/components/QSOEditModal'; import { BandMap } from '@/components/BandMap'; @@ -1315,6 +1316,17 @@ export default function App() { setActiveTab((t) => (t === 'grids' ? 'recent' : t)); } const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1'); + const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1'); + function openDxpedTab() { + setDxpedTabOpen(true); + writeUiPref('opslog.dxpedTab', '1'); + setActiveTab('dxped'); + } + function closeDxpedTab() { + setDxpedTabOpen(false); + writeUiPref('opslog.dxpedTab', '0'); + setActiveTab((t) => (t === 'dxped' ? 'recent' : t)); + } function openFtmapTab() { setFtmapTabOpen(true); writeUiPref('opslog.ftmapTab', '1'); @@ -2346,6 +2358,18 @@ export default function App() { }, [showSettings]); const [showDuplicates, setShowDuplicates] = useState(false); const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null); + // Watchlist membership changes announce themselves here rather than inside + // the watchlist panel: a call can be added from the cluster or the + // DXpeditions tab, and the old inline message lived on a page nobody was + // looking at. + const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null); + const wlNoticeTimer = useRef(undefined); + useEffect(() => EventsOn('watchlist:changed', (e: any) => { + if (!e?.call) return; + setWlNotice({ call: String(e.call), added: !!e.added }); + if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current); + wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000); + }), []); const [checkingUpdate, setCheckingUpdate] = useState(false); // Fresh update check on demand (opening About), so it never shows a stale // "you're up to date". Clears updateInfo when the latest check finds nothing. @@ -5126,6 +5150,7 @@ export default function App() { ]}, { name: 'tools', label: t('menu.tools'), items: [ { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, + { type: 'item', label: t('dxp.tab'), action: 'tools.dxped' }, { type: 'item', label: t('stats.tab'), action: 'tools.stats' }, { type: 'item', label: t('station.title'), action: 'tools.station' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, @@ -5180,6 +5205,7 @@ export default function App() { case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break; + case 'tools.dxped': openDxpedTab(); break; case 'tools.decodes': openDecodesTab(); break; case 'tools.ftmap': openFtmapTab(); break; case 'tools.grids': openGridsTab(); break; @@ -7076,6 +7102,26 @@ export default function App() { { setShowFirstRun(false); loadStation(); refresh(); }} /> )} + {wlNotice && ( +
+
+ +
+

+ {t(wlNotice.added ? 'wlnote.added' : 'wlnote.removed', { call: wlNotice.call })} +

+

{t('wlnote.hint')}

+
+ +
+
+ )} {updateInfo && (
@@ -7816,6 +7862,21 @@ export default function App() { )} + {dxpedTabOpen && ( + + {t('dxp.tab')} + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); closeDxpedTab(); }} + > + + + + )} {ftmapTabOpen && ( {t('ftmap.tab')} @@ -8429,6 +8490,15 @@ export default function App() { )} + {dxpedTabOpen && ( + + {activeTab === 'dxped' && ( +
+ +
+ )} +
+ )} {ftmapTabOpen && ( {activeTab === 'ftmap' && ( diff --git a/frontend/src/components/DXpeditionsPanel.tsx b/frontend/src/components/DXpeditionsPanel.tsx new file mode 100644 index 0000000..6a76e16 --- /dev/null +++ b/frontend/src/components/DXpeditionsPanel.tsx @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { RefreshCw, Star, ExternalLink } from 'lucide-react'; +import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App'; +import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; + +// DXpeditions — the two feeds the DX world announces itself on, side by side. +// +// Left: NG3K's ADXO, the structured announcements, each judged against THIS log +// so the list reads as "what I still need" rather than "what is on". Right: +// DX-World's headlines, whose callsigns are mined out of the title so they can +// be watched with the same one click. + +type DXped = { + dxcc: string; callsign: string; calls?: string[]; + start_date: string; end_date: string; + bands?: string[]; modes?: string[]; + qsl: string; operators: string; source: string; link: string; + status: string; // active | upcoming + status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | '' + unconfirmed?: boolean; +}; + +type News = { + title: string; link: string; pub_date: string; excerpt: string; + creator: string; image_url: string; tag: string; calls?: string[]; +}; + +// One badge per expedition, the strongest verdict winning — the same palette +// and the same words the cluster uses, so the two views teach one vocabulary. +const CHASE_BADGE: Record = { + 'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' }, + 'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' }, + 'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' }, + 'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' }, + 'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' }, + 'worked': { label: 'wl.worked', colour: 'var(--info)' }, +}; + +export function DXpeditionsPanel() { + const { t } = useI18n(); + const [peds, setPeds] = useState([]); + const [news, setNews] = useState([]); + const [watched, setWatched] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(''); + const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1'); + + const loadWatchlist = useCallback(async () => { + try { + const e: any[] = await WatchlistEntries(); + setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase()))); + } catch { /* the list simply shows every call as unwatched */ } + }, []); + + const load = useCallback(async () => { + setBusy(true); + setErr(''); + const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]); + if (p.status === 'fulfilled') setPeds((p.value as any) ?? []); + else setErr(String((p.reason as any)?.message ?? p.reason)); + if (n.status === 'fulfilled') setNews((n.value as any) ?? []); + setBusy(false); + }, []); + + useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]); + useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]); + + const refresh = async () => { await RefreshDXpeditions(); await load(); }; + + const addAll = async (calls: string[]) => { + for (const c of calls) { + if (!watched.has(c.toUpperCase())) { + try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ } + } + } + await loadWatchlist(); + }; + + const shown = useMemo( + () => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds), + [peds, neededOnly]); + + // A row's calls: the mined ones, else whatever the announcement called it. + const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []); + + return ( +
+ {/* ── Announcements (ADXO) ── */} +
+
+ {t('dxp.announced')} + {t('dxp.source', { name: 'NG3K ADXO' })} + + +
+ + {err &&
{err}
} + +
+ {shown.length === 0 && !busy && ( +

{t('dxp.none')}

+ )} + {shown.map((p, i) => { + const calls = callsOf(p); + const badge = CHASE_BADGE[p.status_chase]; + const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase())); + return ( +
+
+ {p.callsign || '—'} + {p.dxcc} + {p.status === 'active' && ( + + {t('dxp.onAir')} + + )} + {badge && ( + + {t(badge.label)} + + )} + + {p.start_date} → {p.end_date} + +
+ +
+ {!!p.bands?.length && {p.bands.join(' · ')}} + {!!p.modes?.length && {p.modes.join(' ')}} + {p.qsl && QSL: {p.qsl}} + {p.source && · {p.source}} +
+ {p.operators &&

{p.operators}

} + +
+ + {p.link && ( + + )} +
+
+ ); + })} +
+
+ + {/* ── News (DX-World) ── */} +
+
+ {t('dxp.news')} + {t('dxp.source', { name: 'DX-World' })} +
+
+ {news.length === 0 && !busy && ( +

{t('dxp.noNews')}

+ )} + {news.map((n, i) => ( +
+
+ {n.image_url && ( + { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> + )} +
+
+ {n.tag && ( + {n.tag} + )} + +
+

{n.excerpt}

+
+ {n.pub_date && ( + + {new Date(n.pub_date).toLocaleDateString()} + + )} + {(n.calls ?? []).map((c) => ( + + ))} +
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/WatchlistTab.tsx b/frontend/src/components/WatchlistTab.tsx index 8da5477..977a6f8 100644 --- a/frontend/src/components/WatchlistTab.tsx +++ b/frontend/src/components/WatchlistTab.tsx @@ -181,7 +181,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P try { await WatchlistAdd(c, addContest); setAddCall(''); - flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false); + // The confirmation is the app-level notice (App.tsx, on watchlist:changed) + // — saying it twice, once per place a call can be added from, was noise. await refresh(); } catch (e: any) { flash(String(e?.message ?? e), true); } }; @@ -190,7 +191,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P // entry is cheap to undo (type it again), so it does not earn a confirmation // the other two buttons do not have. const remove = async (call: string) => { - try { await WatchlistRemove(call); flash(t('wl.removed', { call }), false); await refresh(); } + try { await WatchlistRemove(call); await refresh(); } catch (e: any) { flash(String(e?.message ?? e), true); } }; diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 34854c7..8e58a1b 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -50,7 +50,7 @@ const en: Dict = { 'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Contest entry: judged per UTC day', 'wl.notifyOn': 'Alert me when this station is spotted', 'wl.notifyOff': 'Stop alerting for this station', 'wl.remove': 'Remove', 'wl.removed': '{call} removed from the watchlist.', 'wl.noSpots': 'No live spots', - 'wl.newDxcc': 'NEW DXCC', 'wl.worked': 'Worked', 'wl.needed': 'Needed!', 'wl.todayOk': 'Today ✓', 'wl.workToday': 'Work today!', + 'wl.newDxcc': 'NEW DXCC', 'dxp.tab': 'DXpeditions', 'dxp.announced': 'Announced operations', 'dxp.news': 'DX news', 'dxp.source': 'from {name}', 'dxp.neededOnly': 'Only what I need', 'dxp.refresh': 'Refresh', 'dxp.none': 'No announced operation — or the feed could not be read.', 'dxp.noNews': 'No news.', 'dxp.onAir': 'ON AIR', 'dxp.watch': 'Watch', 'dxp.watched': 'Watched', 'dxp.watchTip': 'Add to the watchlist', 'dxp.open': 'Open', 'wlnote.added': '{call} added to the watchlist', 'wlnote.removed': '{call} removed from the watchlist', 'wlnote.hint': 'Its spots are highlighted in the cluster.', 'wl.worked': 'Worked', 'wl.needed': 'Needed!', 'wl.todayOk': 'Today ✓', 'wl.workToday': 'Work today!', 'wl.spotTip': 'Click: fill the callsign · double-click: tune and work', 'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode', 'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear', @@ -574,7 +574,7 @@ const fr: Dict = { 'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Entrée contest : jugée par jour UTC', 'wl.notifyOn': "M'alerter quand cette station est spottée", 'wl.notifyOff': "Ne plus alerter pour cette station", 'wl.remove': 'Supprimer', 'wl.removed': '{call} retiré de la watchlist.', 'wl.noSpots': 'Aucun spot en cours', - 'wl.newDxcc': 'NOUV DXCC', 'wl.worked': 'Contacté', 'wl.needed': 'Manquant !', 'wl.todayOk': "Auj. ✓", 'wl.workToday': "À faire auj. !", + 'wl.newDxcc': 'NOUV DXCC', 'dxp.tab': 'DXpéditions', 'dxp.announced': 'Opérations annoncées', 'dxp.news': 'Actualités DX', 'dxp.source': 'depuis {name}', 'dxp.neededOnly': 'Seulement ce qu’il me manque', 'dxp.refresh': 'Actualiser', 'dxp.none': 'Aucune opération annoncée — ou le flux n’a pas pu être lu.', 'dxp.noNews': 'Aucune actualité.', 'dxp.onAir': 'EN COURS', 'dxp.watch': 'Surveiller', 'dxp.watched': 'Surveillé', 'dxp.watchTip': 'Ajouter à la watchlist', 'dxp.open': 'Ouvrir', 'wlnote.added': '{call} ajouté à la watchlist', 'wlnote.removed': '{call} retiré de la watchlist', 'wlnote.hint': 'Ses spots sont mis en évidence dans le cluster.', 'wl.worked': 'Contacté', 'wl.needed': 'Manquant !', 'wl.todayOk': "Auj. ✓", 'wl.workToday': "À faire auj. !", 'wl.spotTip': "Clic : remplir l'indicatif · double-clic : régler la radio et travailler", 'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest', 'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 6afc4ab..e6513a2 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -12,6 +12,7 @@ import {award} from '../models'; import {awardref} from '../models'; import {bandopen} from '../models'; import {cluster} from '../models'; +import {dxped} from '../models'; import {extsvc} from '../models'; import {powergenius} from '../models'; import {pskr} from '../models'; @@ -476,6 +477,10 @@ export function GetDVKMessages():Promise>; export function GetDVKStatus():Promise; +export function GetDXWorldNews():Promise>; + +export function GetDXpeditions():Promise>; + export function GetDataDir():Promise; export function GetDatabaseSettings():Promise; @@ -944,6 +949,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise; export function RefreshCtyDat():Promise; +export function RefreshDXpeditions():Promise; + export function RefreshKenwood():Promise; export function RefreshSolar():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index f76b59e..6789419 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -890,6 +890,14 @@ export function GetDVKStatus() { return window['go']['main']['App']['GetDVKStatus'](); } +export function GetDXWorldNews() { + return window['go']['main']['App']['GetDXWorldNews'](); +} + +export function GetDXpeditions() { + return window['go']['main']['App']['GetDXpeditions'](); +} + export function GetDataDir() { return window['go']['main']['App']['GetDataDir'](); } @@ -1826,6 +1834,10 @@ export function RefreshCtyDat() { return window['go']['main']['App']['RefreshCtyDat'](); } +export function RefreshDXpeditions() { + return window['go']['main']['App']['RefreshDXpeditions'](); +} + export function RefreshKenwood() { return window['go']['main']['App']['RefreshKenwood'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index f82b5ec..d5023ae 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1466,6 +1466,37 @@ export namespace contest { } +export namespace dxped { + + export class News { + title: string; + link: string; + pub_date: string; + excerpt: string; + creator: string; + image_url: string; + tag: string; + calls: string[]; + + static createFrom(source: any = {}) { + return new News(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.title = source["title"]; + this.link = source["link"]; + this.pub_date = source["pub_date"]; + this.excerpt = source["excerpt"]; + this.creator = source["creator"]; + this.image_url = source["image_url"]; + this.tag = source["tag"]; + this.calls = source["calls"]; + } + } + +} + export namespace extsvc { export class ServiceConfig { @@ -2674,6 +2705,44 @@ export namespace main { this.rec_slot = source["rec_slot"]; } } + export class DXpedition { + dxcc: string; + callsign: string; + calls: string[]; + start_date: string; + end_date: string; + bands: string[]; + modes: string[]; + qsl: string; + operators: string; + source: string; + link: string; + status: string; + status_chase: string; + unconfirmed: boolean; + + static createFrom(source: any = {}) { + return new DXpedition(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.dxcc = source["dxcc"]; + this.callsign = source["callsign"]; + this.calls = source["calls"]; + this.start_date = source["start_date"]; + this.end_date = source["end_date"]; + this.bands = source["bands"]; + this.modes = source["modes"]; + this.qsl = source["qsl"]; + this.operators = source["operators"]; + this.source = source["source"]; + this.link = source["link"]; + this.status = source["status"]; + this.status_chase = source["status_chase"]; + this.unconfirmed = source["unconfirmed"]; + } + } export class DatabaseSettings { path: string; default_path: string; diff --git a/internal/dxped/dxped.go b/internal/dxped/dxped.go new file mode 100644 index 0000000..2666b90 --- /dev/null +++ b/internal/dxped/dxped.go @@ -0,0 +1,546 @@ +// Package dxped reads the two feeds the DX world announces itself on. +// +// NG3K's ADXO is the STRUCTURED one: an RSS item per announced operation whose +// description is a fixed, dash-separated sentence — dates, entity, callsign, +// QSL route, source, then the operators/bands/modes prose. It is what a +// DXpedition list is actually made of. +// +// DX-World's feed is NEWS: WordPress posts with a headline and an excerpt. It +// carries no structure to act on, but the headline nearly always names the +// callsign, so the calls are mined from the title and the reader can act on +// them the same way (watchlist, chase status). +// +// Both are cached: the announcements change a few times a day, the news a few +// times an hour, and neither is worth a request per screen repaint. +package dxped + +import ( + "context" + "encoding/xml" + "fmt" + "html" + "io" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +const ( + adxoURL = "https://www.ng3k.com/adxo.xml" + dxworldURL = "https://dx-world.net/feed/" + + adxoTTL = 1 * time.Hour + dxworldTTL = 30 * time.Minute +) + +// Activation is one announced operation, as ADXO describes it. +type Activation struct { + DXCC string `json:"dxcc"` + Callsign string `json:"callsign"` // display form; "A, B" when several + Calls []string `json:"calls"` // the individual callsigns, normalised + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Bands []string `json:"bands"` + Modes []string `json:"modes"` + QSL string `json:"qsl"` + Operators string `json:"operators"` + Source string `json:"source"` + Link string `json:"link"` + Status string `json:"status"` // "active" | "upcoming" +} + +// News is one DX-World post. +type News struct { + Title string `json:"title"` + Link string `json:"link"` + PubDate string `json:"pub_date"` // RFC3339, "" when unparseable + Excerpt string `json:"excerpt"` + Creator string `json:"creator"` + ImageURL string `json:"image_url"` + Tag string `json:"tag"` // NEWS / UPDATE / NEW ACTIVITY… + Calls []string `json:"calls"` // callsigns mined from the headline +} + +// Manager holds both caches and fetches on demand. +type Manager struct { + mu sync.RWMutex + acts []Activation + actsAt time.Time + news []News + newsAt time.Time + client *http.Client + fetching sync.Mutex // one refresh at a time, whichever pane asked +} + +func New() *Manager { + return &Manager{client: &http.Client{Timeout: 30 * time.Second}} +} + +// Activations returns the cached announcements, refreshing when stale. +func (m *Manager) Activations(ctx context.Context) ([]Activation, error) { + m.mu.RLock() + fresh := time.Since(m.actsAt) < adxoTTL && m.acts != nil + out := m.acts + m.mu.RUnlock() + if fresh { + return out, nil + } + m.fetching.Lock() + defer m.fetching.Unlock() + // Someone else may have refreshed while we waited for the lock. + m.mu.RLock() + fresh = time.Since(m.actsAt) < adxoTTL && m.acts != nil + out = m.acts + m.mu.RUnlock() + if fresh { + return out, nil + } + acts, err := m.fetchADXO(ctx) + if err != nil { + // Stale beats empty: a feed that is down should not blank a list the + // operator was reading a minute ago. + m.mu.RLock() + defer m.mu.RUnlock() + return m.acts, err + } + m.mu.Lock() + m.acts, m.actsAt = acts, time.Now() + m.mu.Unlock() + return acts, nil +} + +// News returns the cached DX-World posts, refreshing when stale. +func (m *Manager) News(ctx context.Context) ([]News, error) { + m.mu.RLock() + fresh := time.Since(m.newsAt) < dxworldTTL && m.news != nil + out := m.news + m.mu.RUnlock() + if fresh { + return out, nil + } + m.fetching.Lock() + defer m.fetching.Unlock() + m.mu.RLock() + fresh = time.Since(m.newsAt) < dxworldTTL && m.news != nil + out = m.news + m.mu.RUnlock() + if fresh { + return out, nil + } + news, err := m.fetchDXWorld(ctx) + if err != nil { + m.mu.RLock() + defer m.mu.RUnlock() + return m.news, err + } + m.mu.Lock() + m.news, m.newsAt = news, time.Now() + m.mu.Unlock() + return news, nil +} + +// Invalidate drops both caches so the next read refetches. +func (m *Manager) Invalidate() { + m.mu.Lock() + m.actsAt, m.newsAt = time.Time{}, time.Time{} + m.mu.Unlock() +} + +func (m *Manager) get(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "OpsLog") + resp, err := m.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, 8<<20)) +} + +// ── ADXO ──────────────────────────────────────────────────────────────── + +type rssItem struct { + Title string `xml:"title"` + Description string `xml:"description"` + Link string `xml:"link"` + PubDate string `xml:"pubDate"` + Creator string `xml:"creator"` + Enclosure struct { + URL string `xml:"url,attr"` + } `xml:"enclosure"` + MediaContent struct { + URL string `xml:"url,attr"` + } `xml:"content"` +} + +type rssFeed struct { + Items []rssItem `xml:"channel>item"` +} + +func (m *Manager) fetchADXO(ctx context.Context) ([]Activation, error) { + body, err := m.get(ctx, adxoURL) + if err != nil { + return nil, fmt.Errorf("adxo: %w", err) + } + var feed rssFeed + if err := xml.Unmarshal(body, &feed); err != nil { + return nil, fmt.Errorf("adxo: parse: %w", err) + } + now := time.Now() + out := make([]Activation, 0, len(feed.Items)) + for _, it := range feed.Items { + a := parseActivation(it.Description, it.Link) + if a == nil { + continue + } + a.Status = activationStatus(a.StartDate, a.EndDate, now) + if a.Status == "ended" { + continue // the list is about what is on or coming + } + out = append(out, *a) + } + return out, nil +} + +var spaceRe = regexp.MustCompile(`\s+`) + +// parseActivation reads one ADXO description. Its shape is fixed and has been +// for twenty years: +// +// "Feb 17-Mar 30, 2026 -- Entity -- CALL -- QSL: route -- Source: who (date) +// -- By ops; bands; modes; notes" +func parseActivation(desc, link string) *Activation { + desc = spaceRe.ReplaceAllString(strings.NewReplacer("\n", " ", "\r", " ").Replace(desc), " ") + desc = strings.TrimSpace(html.UnescapeString(desc)) + if desc == "" { + return nil + } + parts := strings.Split(desc, " -- ") + if len(parts) < 3 { + return nil + } + a := &Activation{Link: link} + a.StartDate, a.EndDate = parseDateRange(strings.TrimSpace(parts[0])) + a.DXCC = strings.TrimSpace(parts[1]) + a.Callsign = strings.TrimSpace(parts[2]) + + for _, p := range parts[3:] { + p = strings.TrimSpace(p) + switch { + case strings.HasPrefix(p, "QSL:"): + a.QSL = strings.TrimSpace(strings.TrimPrefix(p, "QSL:")) + case strings.HasPrefix(p, "Source:"): + a.Source = strings.TrimSpace(strings.TrimPrefix(p, "Source:")) + } + } + + // The tail carries "By ; ; ; ". + tail := parts[len(parts)-1] + if i := strings.Index(tail, "By "); i >= 0 { + sub := strings.Split(tail[i+3:], ";") + if len(sub) > 0 { + a.Operators = strings.TrimSpace(sub[0]) + } + if len(sub) > 1 { + a.Bands = parseBands(sub[1]) + } + if len(sub) > 2 { + a.Modes = parseModes(sub[2]) + } + } + + // The callsign field often holds only the PREFIX; the real calls hide in the + // operators prose as "W2APF as PJ2/W2APF". Prefer those when present. + if calls := callsAfterAs(a.Operators); len(calls) > 0 { + prefix := strings.ToUpper(strings.TrimSpace(parts[2])) + for i := range calls { + calls[i] = normalizeCall(calls[i], prefix) + } + a.Calls = calls + a.Callsign = strings.Join(calls, ", ") + } else if c := strings.ToUpper(a.Callsign); plausibleCall(c) { + a.Calls = []string{c} + } + return a +} + +// parseDateRange handles the two forms ADXO writes: "Feb 17-Mar 30, 2026" and +// "Mar 3-20, 2026" (the month carried over). +var ( + fullRangeRe = regexp.MustCompile(`(?i)(\w+ \d+)\s*-\s*(\w+ \d+),\s*(\d{4})`) + shortRangeRe = regexp.MustCompile(`(?i)(\w+) (\d+)\s*-\s*(\d+),\s*(\d{4})`) + singleDayRe = regexp.MustCompile(`(?i)(\w+ \d+),\s*(\d{4})`) +) + +func parseDateRange(s string) (start, end string) { + if m := fullRangeRe.FindStringSubmatch(s); m != nil { + return m[1] + ", " + m[3], m[2] + ", " + m[3] + } + if m := shortRangeRe.FindStringSubmatch(s); m != nil { + return m[1] + " " + m[2] + ", " + m[4], m[1] + " " + m[3] + ", " + m[4] + } + if m := singleDayRe.FindStringSubmatch(s); m != nil { + return m[1] + ", " + m[2], m[1] + ", " + m[2] + } + return s, s +} + +func parseADXODate(s string) (time.Time, bool) { + for _, layout := range []string{"Jan 2, 2006", "January 2, 2006"} { + if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil { + return t, true + } + } + return time.Time{}, false +} + +func activationStatus(start, end string, now time.Time) string { + s, okS := parseADXODate(start) + e, okE := parseADXODate(end) + if !okS || !okE { + return "upcoming" // unreadable dates: keep it, an operator can read them + } + switch { + case now.Before(s): + return "upcoming" + // The end date is a DAY, so an operation is on until that day is over. + case now.After(e.Add(24 * time.Hour)): + return "ended" + default: + return "active" + } +} + +// callsAfterAs mines "…as CALL…" out of the operators prose: +// +// "W2APF as PJ2/W2APF" → PJ2/W2APF +// "SQ2RAD as VP2EAD, M0PLX as VP2ELX" → VP2EAD, VP2ELX +var asCallRe = regexp.MustCompile(`(?i)\bas\s+([A-Z0-9]+(?:/[A-Z0-9]+)*)`) + +func callsAfterAs(operators string) []string { + var out []string + seen := map[string]bool{} + for _, m := range asCallRe.FindAllStringSubmatch(operators, -1) { + c := strings.ToUpper(strings.TrimSpace(m[1])) + if !plausibleCall(c) || seen[c] { + continue + } + seen[c] = true + out = append(out, c) + } + return out +} + +// normalizeCall puts the DXCC prefix first: a cluster spot says JD1/JG8NQJ, and +// matching the log against JG8NQJ/JD1 would find nothing. +func normalizeCall(call, dxccPrefix string) string { + if dxccPrefix == "" || !strings.Contains(call, "/") { + return call + } + left, right, _ := strings.Cut(call, "/") + if strings.HasPrefix(right, dxccPrefix) && !strings.HasPrefix(left, dxccPrefix) { + return right + "/" + left + } + return call +} + +var ( + // A span carries its unit only once — "160-6m" is the commonest way ADXO + // states coverage, and reading it as "6m" alone loses the whole low end. + bandRe = regexp.MustCompile(`(?i)\b(?:(\d{1,4})\s*-\s*)?(\d{1,4})\s*(m|cm)\b`) + // The modes ADXO actually writes. A list beats a pattern here: "FT8" and + // "SSB" have no shape in common, and inventing one invites "QSL" as a mode. + knownModes = []string{"SSB", "CW", "FT8", "FT4", "RTTY", "PSK", "SSTV", "AM", "FM", "JT65", "JS8", "Q65", "MSK144", "DIGI", "DATA"} +) + +func parseBands(s string) []string { + var out []string + seen := map[string]bool{} + add := func(num, unit string) { + if num == "" { + return + } + b := strings.ToLower(num + unit) + if seen[b] { + return + } + seen[b] = true + out = append(out, b) + } + // A span keeps only its endpoints: ADXO states coverage in prose, and the + // two ends are the part it gives reliably. + for _, m := range bandRe.FindAllStringSubmatch(s, -1) { + add(m[1], m[3]) + add(m[2], m[3]) + } + return out +} + +func parseModes(s string) []string { + up := strings.ToUpper(s) + var out []string + seen := map[string]bool{} + for _, mode := range knownModes { + if seen[mode] { + continue + } + if regexp.MustCompile(`\b` + mode + `\b`).MatchString(up) { + seen[mode] = true + out = append(out, mode) + } + } + return out +} + +// ── DX-World ──────────────────────────────────────────────────────────── + +var ( + htmlTagRe = regexp.MustCompile(`<[^>]+>`) + tagPrefixRe = regexp.MustCompile(`(?i)^\s*\[([^\]]+)\]\s*`) +) + +func stripHTML(s string) string { + s = htmlTagRe.ReplaceAllString(s, " ") + s = html.UnescapeString(s) + return strings.TrimSpace(spaceRe.ReplaceAllString(s, " ")) +} + +func (m *Manager) fetchDXWorld(ctx context.Context) ([]News, error) { + body, err := m.get(ctx, dxworldURL) + if err != nil { + return nil, fmt.Errorf("dx-world: %w", err) + } + var feed rssFeed + if err := xml.Unmarshal(body, &feed); err != nil { + return nil, fmt.Errorf("dx-world: parse: %w", err) + } + out := make([]News, 0, len(feed.Items)) + for _, it := range feed.Items { + title := stripHTML(it.Title) + tag, title := splitTag(title) + n := News{ + Title: title, + Link: strings.TrimSpace(it.Link), + Creator: strings.TrimSpace(it.Creator), + Tag: tag, + Calls: callsInHeadline(title), + } + if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil { + n.PubDate = t.UTC().Format(time.RFC3339) + } else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil { + n.PubDate = t.UTC().Format(time.RFC3339) + } + ex := stripHTML(it.Description) + if t2, rest := splitTag(ex); t2 != "" { + if n.Tag == "" { + n.Tag = t2 + } + ex = rest + } + n.Excerpt = truncateRunes(ex, 400) + if u := strings.TrimSpace(it.Enclosure.URL); u != "" { + n.ImageURL = u + } else if u := strings.TrimSpace(it.MediaContent.URL); u != "" { + n.ImageURL = u + } + out = append(out, n) + } + return out, nil +} + +// splitTag pulls the leading "[UPDATE]" DX-World puts on most posts. +func splitTag(s string) (tag, rest string) { + m := tagPrefixRe.FindStringSubmatchIndex(s) + if m == nil { + return "", s + } + tag = strings.ToUpper(strings.TrimSpace(s[m[2]:m[3]])) + rest = strings.TrimSpace(s[m[1]:]) + rest = strings.TrimPrefix(strings.TrimPrefix(rest, "– "), "- ") + return tag, strings.TrimSpace(rest) +} + +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return strings.TrimSpace(string(r[:max])) + "…" +} + +// callsInHeadline mines callsigns out of a news headline — "3B7M, St Brandon" +// or "TX5S team lands". The reader can then chase or watch them, which is the +// whole reason a news feed sits next to the announcements. +func callsInHeadline(title string) []string { + var out []string + seen := map[string]bool{} + for _, tok := range strings.FieldsFunc(title, func(r rune) bool { + return !(r == '/' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) + }) { + c := strings.ToUpper(tok) + if !plausibleCall(c) || seen[c] { + continue + } + seen[c] = true + out = append(out, c) + } + return out +} + +var ( + bandTokenRe = regexp.MustCompile(`^\d{1,4}(M|CM)$`) + // Jargon shaped exactly like a callsign. Every one of these was seen in a + // real headline before it earned its place here. + notACall = map[string]bool{ + "FT8": true, "FT4": true, "JT65": true, "JT9": true, "JS8": true, "Q65": true, + "MSK144": true, "PSK31": true, "SSTV": true, "OQRS": true, "LOTW": true, + "IOTA": true, "SOTA": true, "POTA": true, "WWFF": true, "DXCC": true, + "CQWW": true, "CQWPX": true, "ARRL": true, "3D": true, "4K": true, + } +) + +// plausibleCall keeps tokens shaped like an amateur callsign: 3–12 characters +// of A–Z/0–9 (with optional /prefix or /suffix), at least one letter AND one +// digit, and a letter somewhere after the first digit — which is what separates +// a callsign from a band or a year. +func plausibleCall(s string) bool { + s = strings.ToUpper(strings.TrimSpace(s)) + if len(s) < 3 || len(s) > 12 || notACall[s] || bandTokenRe.MatchString(s) { + return false + } + // Judge the longest part — the real call in "PJ2/W2APF" either way. + base := s + if strings.Contains(s, "/") { + base = "" + for _, p := range strings.Split(s, "/") { + if len(p) > len(base) { + base = p + } + } + } + var hasLetter, hasDigit, letterAfterDigit bool + seenDigit := false + for _, r := range base { + switch { + case r >= 'A' && r <= 'Z': + hasLetter = true + if seenDigit { + letterAfterDigit = true + } + case r >= '0' && r <= '9': + hasDigit = true + seenDigit = true + default: + return false + } + } + return hasLetter && hasDigit && letterAfterDigit +} diff --git a/internal/dxped/dxped_test.go b/internal/dxped/dxped_test.go new file mode 100644 index 0000000..e3ecb18 --- /dev/null +++ b/internal/dxped/dxped_test.go @@ -0,0 +1,86 @@ +package dxped + +import ( + "reflect" + "testing" + "time" +) + +// Pinned against REAL feed text: the parser reads a fixed sentence, and a +// wrong split silently empties a DXpedition list rather than failing loudly. +func TestParseActivation(t *testing.T) { + desc := "Aug 24-31, 2026 -- St Kitts and Nevis -- V47JA -- QSL: LoTW -- " + + "Source: W5JON (Aug 1, 2026) -- By W5JON as V47JA fm Calypso Bay; 160-6m; SSB FT8; yagi, verticals" + a := parseActivation(desc, "https://www.qrz.com/lookup/v47ja") + if a == nil { + t.Fatal("parseActivation returned nil on a real ADXO description") + } + if a.DXCC != "St Kitts and Nevis" { + t.Errorf("DXCC = %q", a.DXCC) + } + if a.Callsign != "V47JA" { + t.Errorf("Callsign = %q, want V47JA (mined from 'as')", a.Callsign) + } + if a.QSL != "LoTW" { + t.Errorf("QSL = %q", a.QSL) + } + if a.StartDate != "Aug 24, 2026" || a.EndDate != "Aug 31, 2026" { + t.Errorf("dates = %q..%q", a.StartDate, a.EndDate) + } + if want := []string{"160m", "6m"}; !reflect.DeepEqual(a.Bands, want) { + t.Errorf("Bands = %v, want %v", a.Bands, want) + } + if want := []string{"SSB", "FT8"}; !reflect.DeepEqual(a.Modes, want) { + t.Errorf("Modes = %v, want %v", a.Modes, want) + } +} + +// The callsign column often holds only the prefix; the operators prose holds +// the real thing, and a slashed call must lead with the DXCC prefix or no spot +// will ever match it. +func TestCallsAfterAsAndNormalise(t *testing.T) { + if got := callsAfterAs("SQ2RAD as VP2EAD, M0PLX as VP2ELX"); !reflect.DeepEqual(got, []string{"VP2EAD", "VP2ELX"}) { + t.Errorf("callsAfterAs = %v", got) + } + if got := normalizeCall("JG8NQJ/JD1", "JD1"); got != "JD1/JG8NQJ" { + t.Errorf("normalizeCall = %q, want JD1/JG8NQJ", got) + } + if got := normalizeCall("PJ2/W2APF", "PJ2"); got != "PJ2/W2APF" { + t.Errorf("normalizeCall rewrote an already-correct call: %q", got) + } +} + +func TestActivationStatus(t *testing.T) { + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + cases := []struct{ start, end, want string }{ + {"Aug 24, 2026", "Aug 31, 2026", "active"}, + {"Sep 10, 2026", "Sep 20, 2026", "upcoming"}, + {"Aug 1, 2026", "Aug 10, 2026", "ended"}, + {"Aug 24, 2026", "Aug 27, 2026", "active"}, // ends TODAY: still on + {"garbage", "garbage", "upcoming"}, + } + for _, c := range cases { + if got := activationStatus(c.start, c.end, now); got != c.want { + t.Errorf("activationStatus(%q,%q) = %q, want %q", c.start, c.end, got, c.want) + } + } +} + +// Mining a headline must find calls without inventing them out of jargon. +func TestCallsInHeadline(t *testing.T) { + cases := []struct { + title string + want []string + }{ + {"3B7M, St Brandon", []string{"3B7M"}}, + {"TX5S team lands on Clipperton", []string{"TX5S"}}, + {"FT8 activity on 160m in 2026", nil}, + {"VP6D QSL via OQRS, LoTW", []string{"VP6D"}}, + {"JD1/JG8NQJ from Minami Torishima", []string{"JD1/JG8NQJ"}}, + } + for _, c := range cases { + if got := callsInHeadline(c.title); !reflect.DeepEqual(got, c.want) { + t.Errorf("callsInHeadline(%q) = %v, want %v", c.title, got, c.want) + } + } +}