From 16e780c2df45930dca8306f929c6535752aa2f73 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 9 Jul 2026 19:32:32 +0200 Subject: [PATCH] fix: Added LOTW badge for Lotw users colored depending on their last upload --- app.go | 71 ++++++++- frontend/src/App.tsx | 32 ++++ frontend/src/components/SettingsModal.tsx | 28 ++++ frontend/src/lib/i18n.tsx | 4 + frontend/wailsjs/go/main/App.d.ts | 7 + frontend/wailsjs/go/main/App.js | 12 ++ frontend/wailsjs/go/models.ts | 35 +++++ internal/clublog/clublog.go | 17 +++ internal/clublog/clublog_test.go | 50 ++++++ internal/clublog/manager.go | 16 ++ internal/dxcc/dxcc.go | 18 ++- internal/dxcc/dxcc_test.go | 3 + internal/lotwusers/lotwusers.go | 178 ++++++++++++++++++++++ 13 files changed, 465 insertions(+), 6 deletions(-) create mode 100644 internal/clublog/clublog_test.go create mode 100644 internal/lotwusers/lotwusers.go diff --git a/app.go b/app.go index cbe1f70..10214b0 100644 --- a/app.go +++ b/app.go @@ -41,6 +41,7 @@ import ( "hamlog/internal/netctl" "hamlog/internal/operating" "hamlog/internal/pota" + "hamlog/internal/lotwusers" "hamlog/internal/powergenius" "hamlog/internal/profile" "hamlog/internal/qslcard" @@ -422,6 +423,7 @@ type App struct { audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) 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) // NET Control: persistent net definitions/rosters (global JSON) + the live // session (in-memory only — active stations currently in QSO). @@ -768,6 +770,24 @@ func (a *App) startup(ctx context.Context) { // cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on // demand. Resolution applied only when the user enables it. a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir) + + // LoTW user-activity list (who's a LoTW user + last upload). Loads the cached + // CSV if present, and auto-downloads in the background when it's missing or + // older than a week (the ARRL list changes daily). Manual refresh is in + // Settings → LoTW. + a.lotwUsers = lotwusers.NewManager(dataDir) + go func() { + if a.lotwUsers.Count() == 0 || time.Since(a.lotwUsers.Updated()) > 7*24*time.Hour { + if n, err := a.lotwUsers.Download(context.Background()); err == nil { + applog.Printf("lotwusers: auto-downloaded %d LoTW users", n) + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "lotwusers:updated") + } + } else { + applog.Printf("lotwusers: auto-download failed: %v", err) + } + } + }() go func() { if err := a.clublog.EnsureLoaded(); err == nil { d, n := a.clublog.Info() @@ -1734,6 +1754,42 @@ func (a *App) RefreshSolar() { } } +// LoTWUserInfo reports whether a callsign is a LoTW user and how recently it +// uploaded — drives the colour-coded LoTW badge in the entry strip. +func (a *App) LoTWUserInfo(callsign string) lotwusers.Info { + if a.lotwUsers == nil { + return lotwusers.Info{DaysAgo: -1} + } + return a.lotwUsers.Lookup(callsign) +} + +// LoTWUsersStatus is the loaded-list summary for Settings (count + last refresh). +type LoTWUsersStatus struct { + Count int `json:"count"` + Updated string `json:"updated,omitempty"` // RFC3339, empty if never +} + +// GetLoTWUsersStatus returns how many LoTW callsigns are loaded and when. +func (a *App) GetLoTWUsersStatus() LoTWUsersStatus { + if a.lotwUsers == nil { + return LoTWUsersStatus{} + } + st := LoTWUsersStatus{Count: a.lotwUsers.Count()} + if u := a.lotwUsers.Updated(); !u.IsZero() { + st.Updated = u.UTC().Format(time.RFC3339) + } + return st +} + +// DownloadLoTWUsers fetches ARRL's LoTW user-activity list and caches it. +// Returns the number of callsigns loaded. +func (a *App) DownloadLoTWUsers() (int, error) { + if a.lotwUsers == nil { + return 0, fmt.Errorf("not initialized") + } + return a.lotwUsers.Download(a.ctx) +} + // StationInfoComputed bundles the data we resolve live from the // profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone, // lat/lon. Used by the Settings UI to show the "what will be stamped on @@ -5572,7 +5628,20 @@ func (a *App) applyClublogException(q *qso.QSO, force bool) bool { } e, ok := a.clublog.Resolve(q.Callsign, date) if !ok { - return false + // No exception COVERS this QSO's date. If the call nonetheless HAS a + // date-ranged exception (e.g. G1T = Scotland only from 2024-02-21), then + // cty.dat's date-blind "=G1T → Scotland" override is WRONG for an older + // QSO — resolve it by ClubLog's date-aware PREFIX table instead (G1 → + // England for a 2012 contact). Ordinary calls (no exception) are left to + // cty.dat. + if a.clublog.HasException(q.Callsign) { + if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok { + e, ok = pe, true + } + } + if !ok { + return false + } } q.Country = titleEntity(e.Entity) if e.Cont != "" { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 576cefd..3f8be17 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { ListClusterServers, ClusterSpotStatuses, SendClusterSpot, GetCATSettings, GetSolarData, + LoTWUserInfo, OperatingDefaultForBand, LogUDPLoggedADIF, ListCountries, @@ -383,6 +384,19 @@ export default function App() { const id = window.setInterval(load, 60 * 60 * 1000); // hourly fallback; the event refreshes it live return () => { off(); window.clearInterval(id); }; }, []); + // LoTW-user lookup for the entered callsign (from the cached ARRL activity list), + // debounced. Drives a colour-coded LoTW badge by last-upload recency. + const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null); + useEffect(() => { + const c = callsign.trim(); + if (!c) { setLotwInfo(null); return; } + const run = () => { LoTWUserInfo(c).then((r) => setLotwInfo(r as any)).catch(() => setLotwInfo(null)); }; + const t = window.setTimeout(run, 300); + // Re-run when the background auto-download finishes (so a call typed before + // the list loaded gets its badge without re-typing). + const off = EventsOn('lotwusers:updated', run); + return () => { window.clearTimeout(t); off(); }; + }, [callsign]); const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false }); const [agStatus, setAgStatus] = useState({ connected: false, port_a: 0, port_b: 0, antennas: [] }); @@ -2743,6 +2757,23 @@ export default function App() { { setGrid(e.target.value); markEdited('grid'); }} /> ); + // LoTW-user badge: shown only when the entered call is a LoTW user, coloured by + // last-upload recency (green < 1 week · amber 1–4 weeks · red > 30 days). The + // tooltip shows the exact last-upload date. Needs the list downloaded once + // (Settings → LoTW); until then no badge appears. + const lotwBlock = (lotwInfo && lotwInfo.is_user) ? (() => { + const d = lotwInfo.days_ago ?? -1; + const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15' + : d < 30 ? 'border-warning text-warning bg-warning/15' + : 'border-danger text-danger bg-danger/15'; + return ( +
+ + LoTW + +
+ ); + })() : null; // A discreet spot-alert LED (bell) that fills the gap at the right of the Grid // row instead of the intrusive floating cards. It lights + shows a count when // alerts are pending; click it to see the last 3 (each clickable to tune). @@ -3706,6 +3737,7 @@ export default function App() { {qthBlock} {gridBlock} + {lotwBlock} {alertLedBlock} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e97488c..c92aaf1 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -37,6 +37,7 @@ import { GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, GetPOTAToken, SavePOTAToken, TestLoTWUpload, ListTQSLStationLocations, + DownloadLoTWUsers, GetLoTWUsersStatus, ComputeStationInfo, GetUIPref, SetUIPref, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, @@ -996,6 +997,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [clublogTesting, setClublogTesting] = useState(false); const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null); const [lotwTesting, setLotwTesting] = useState(false); + // LoTW user-activity list (for the callsign badge): count + last refresh. + const [lotwUsers, setLotwUsers] = useState<{ count: number; updated?: string }>({ count: 0 }); + const [lotwUsersBusy, setLotwUsersBusy] = useState(false); + useEffect(() => { GetLoTWUsersStatus().then((s) => setLotwUsers(s as any)).catch(() => {}); }, []); + const downloadLotwUsers = async () => { + setLotwUsersBusy(true); + try { await DownloadLoTWUsers(); const s = await GetLoTWUsersStatus(); setLotwUsers(s as any); } + catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); } + finally { setLotwUsersBusy(false); } + }; const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hrdlogTesting, setHrdlogTesting] = useState(false); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); @@ -3551,6 +3562,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} + + {/* LoTW user list — powers the colour-coded "LoTW" badge next to the + entered callsign (green < 1 week · amber 1–4 weeks · red > 30 days). */} +
+ +

{t('lotw.usersHint')}

+
+ + + {lotwUsers.count > 0 + ? t('lotw.usersLoaded', { n: lotwUsers.count.toLocaleString(), date: lotwUsers.updated ? new Date(lotwUsers.updated).toLocaleDateString() : '?' }) + : t('lotw.usersNone')} + +
+
) : extSvcTab === 'pota' ? (
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 7f4b5d9..b04cc62 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -14,6 +14,7 @@ type Dict = Record; const en: Dict = { // Menu bar 'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather', + 'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)', 'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools', 'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…', 'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit', @@ -147,6 +148,7 @@ const en: Dict = { 'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.', // External services (repeated labels) 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign', + 'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.', 'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)', // Profiles panel 'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile', @@ -225,6 +227,7 @@ const en: Dict = { const fr: Dict = { 'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale', + 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils', 'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…', 'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter', @@ -342,6 +345,7 @@ const fr: Dict = { 'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.", 'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.", 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet', + 'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.', 'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)', 'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil', 'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index f9ce496..6c20d83 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -18,6 +18,7 @@ import {audio} from '../models'; import {contest} from '../models'; import {operating} from '../models'; import {udp} from '../models'; +import {lotwusers} from '../models'; import {lookup} from '../models'; import {netctl} from '../models'; @@ -139,6 +140,8 @@ export function DownloadClublogCty():Promise; export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise; +export function DownloadLoTWUsers():Promise; + export function DuplicateProfile(arg1:number,arg2:string):Promise; export function ExportADIF(arg1:string,arg2:boolean):Promise; @@ -319,6 +322,8 @@ export function GetListsSettings():Promise; export function GetLiveStatusEnabled():Promise; +export function GetLoTWUsersStatus():Promise; + export function GetLogFilePath():Promise; export function GetLogbookRevision():Promise; @@ -489,6 +494,8 @@ export function ListTQSLStationLocations():Promise export function ListUDPIntegrations():Promise>; +export function LoTWUserInfo(arg1:string):Promise; + export function LogUDPLoggedADIF(arg1:string):Promise; export function LookupCallsign(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 04f98d3..1040087 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -238,6 +238,10 @@ export function DownloadConfirmations(arg1, arg2, arg3) { return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3); } +export function DownloadLoTWUsers() { + return window['go']['main']['App']['DownloadLoTWUsers'](); +} + export function DuplicateProfile(arg1, arg2) { return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); } @@ -598,6 +602,10 @@ export function GetLiveStatusEnabled() { return window['go']['main']['App']['GetLiveStatusEnabled'](); } +export function GetLoTWUsersStatus() { + return window['go']['main']['App']['GetLoTWUsersStatus'](); +} + export function GetLogFilePath() { return window['go']['main']['App']['GetLogFilePath'](); } @@ -938,6 +946,10 @@ export function ListUDPIntegrations() { return window['go']['main']['App']['ListUDPIntegrations'](); } +export function LoTWUserInfo(arg1) { + return window['go']['main']['App']['LoTWUserInfo'](arg1); +} + export function LogUDPLoggedADIF(arg1) { return window['go']['main']['App']['LogUDPLoggedADIF'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e2f3aea..f9c5527 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1154,6 +1154,27 @@ export namespace lookup { } +export namespace lotwusers { + + export class Info { + is_user: boolean; + last_upload?: string; + days_ago: number; + + static createFrom(source: any = {}) { + return new Info(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.is_user = source["is_user"]; + this.last_upload = source["last_upload"]; + this.days_ago = source["days_ago"]; + } + } + +} + export namespace main { export class AntGeniusSettings { @@ -1738,6 +1759,20 @@ export namespace main { return a; } } + export class LoTWUsersStatus { + count: number; + updated?: string; + + static createFrom(source: any = {}) { + return new LoTWUsersStatus(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.count = source["count"]; + this.updated = source["updated"]; + } + } export class LookupSettings { qrz_user: string; qrz_password: string; diff --git a/internal/clublog/clublog.go b/internal/clublog/clublog.go index 6c367d9..aaac074 100644 --- a/internal/clublog/clublog.go +++ b/internal/clublog/clublog.go @@ -202,6 +202,23 @@ func (db *DB) Resolve(call string, date time.Time) (Exception, bool) { return Exception{}, false } +// HasException reports whether the callsign has ANY full-call exception (at any +// date). Used to tell a genuinely date-sensitive call (G1T: Scotland only from +// 2024) — where cty.dat's date-blind exact-call override is wrong for older +// QSOs — from an ordinary call cty.dat handles fine. +func (db *DB) HasException(call string) bool { + if db == nil { + return false + } + c := strings.ToUpper(strings.TrimSpace(call)) + for _, key := range candidates(c) { + if len(db.exceptions[key]) > 0 { + return true + } + } + return false +} + // candidates yields the call and a version with one trailing affix removed. func candidates(c string) []string { out := []string{c} diff --git a/internal/clublog/clublog_test.go b/internal/clublog/clublog_test.go new file mode 100644 index 0000000..a45b4a2 --- /dev/null +++ b/internal/clublog/clublog_test.go @@ -0,0 +1,50 @@ +package clublog + +import ( + "testing" + "time" +) + +func d(s string) time.Time { + t, _ := time.Parse("2006-01-02", s) + return t +} + +// G1T is a contest call: Scotland ONLY from 2024-02-21; before that it's England +// by prefix. cty.dat's date-blind "=G1T → Scotland" override is wrong for a 2012 +// QSO — the date-aware ClubLog cascade must give England then, Scotland now. +func TestG1TDatedException(t *testing.T) { + db := &DB{ + exceptions: map[string][]Exception{ + "G1T": {{Call: "G1T", Entity: "Scotland", ADIF: 279, Start: d("2024-02-21")}}, + }, + prefixes: map[string][]Exception{ + "G": {{Entity: "England", ADIF: 223}}, + }, + } + + // 2012 QSO: exception doesn't cover it, but the call HAS an exception → + // caller should use the prefix (England). + if _, ok := db.Resolve("G1T", d("2012-07-29")); ok { + t.Fatalf("2012: exception should NOT cover a pre-2024 date") + } + if !db.HasException("G1T") { + t.Fatalf("HasException(G1T) should be true") + } + pe, pok := db.ResolvePrefix("G1T", d("2012-07-29")) + if !pok || pe.Entity != "England" { + t.Fatalf("2012 prefix: got %q ok=%v, want England", pe.Entity, pok) + } + + // 2025 QSO: the exception covers it → Scotland. + e, ok := db.Resolve("G1T", d("2025-01-01")) + if !ok || e.Entity != "Scotland" { + t.Fatalf("2025: got %q ok=%v, want Scotland", e.Entity, ok) + } + + // An ordinary call with no exception → HasException false (caller leaves it + // to cty.dat). + if db.HasException("G4ABC") { + t.Fatalf("HasException(G4ABC) should be false") + } +} diff --git a/internal/clublog/manager.go b/internal/clublog/manager.go index 30636fd..7bbdcc5 100644 --- a/internal/clublog/manager.go +++ b/internal/clublog/manager.go @@ -125,3 +125,19 @@ func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) { } return db.Resolve(call, date) } + +// ResolvePrefix resolves a call by ClubLog's date-ranged PREFIX table. +func (m *Manager) ResolvePrefix(call string, date time.Time) (Exception, bool) { + m.mu.RLock() + db := m.db + m.mu.RUnlock() + return db.ResolvePrefix(call, date) +} + +// HasException reports whether the call has any full-call exception (any date). +func (m *Manager) HasException(call string) bool { + m.mu.RLock() + db := m.db + m.mu.RUnlock() + return db.HasException(call) +} diff --git a/internal/dxcc/dxcc.go b/internal/dxcc/dxcc.go index 3210f72..d54802f 100644 --- a/internal/dxcc/dxcc.go +++ b/internal/dxcc/dxcc.go @@ -339,17 +339,25 @@ func normalizeCallsign(s string) string { // which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not // Ecuador). This is the same class of rule as KG4 and /MM. if areaDigit != 0 { - main = replaceFirstDigit(main, areaDigit) + main = replaceAreaDigit(main, areaDigit) } return main } -// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to -// apply a "/N" call-area change). Returns the call unchanged if it has no digit. -func replaceFirstDigit(call string, d byte) string { +// replaceAreaDigit substitutes the CALL-AREA digit of a call with d (used to +// apply a "/N" call-area change). The area digit is the first digit that comes +// AFTER a prefix letter — NOT a leading digit that is part of a digit-first +// prefix (7X, 3A, 4X, 9A, 2E…). So 7X2ARA/4 → 7X4ARA (still Algeria, area 4), +// NOT 4X2ARA (which is Israel — the old first-digit bug). Returns the call +// unchanged if it has no such digit. +func replaceAreaDigit(call string, d byte) string { b := []byte(call) + seenLetter := false for i := range b { - if b[i] >= '0' && b[i] <= '9' { + switch { + case b[i] >= 'A' && b[i] <= 'Z': + seenLetter = true + case b[i] >= '0' && b[i] <= '9' && seenLetter: b[i] = d return string(b) } diff --git a/internal/dxcc/dxcc_test.go b/internal/dxcc/dxcc_test.go index daaf8fb..4935f9f 100644 --- a/internal/dxcc/dxcc_test.go +++ b/internal/dxcc/dxcc_test.go @@ -219,6 +219,9 @@ func TestNormalize(t *testing.T) { "F4BPO/M": "F4BPO", // plain mobile keeps the home entity "F4BPO/5": "F5BPO", // "/5" re-homes to call area 5 "HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8) + "7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel) + "3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3 + "4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4 "DL/F4BPO": "DL", "MM/KA9P": "MM", // leading MM = Scotland operating prefix "MM/LY3X/P": "MM", diff --git a/internal/lotwusers/lotwusers.go b/internal/lotwusers/lotwusers.go new file mode 100644 index 0000000..88ed3f4 --- /dev/null +++ b/internal/lotwusers/lotwusers.go @@ -0,0 +1,178 @@ +// Package lotwusers tracks which callsigns are LoTW users and when they last +// uploaded, from ARRL's public "lotw-user-activity.csv" feed. OpsLog shows a +// colour-coded badge next to the entered callsign (recent upload = likely to +// confirm on LoTW). The list is cached on disk so it survives restarts. +package lotwusers + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// feedURL is ARRL's public LoTW last-upload activity list (CSV: +// "CALLSIGN,YYYY-MM-DD,HH:MM:SS", one line per registered LoTW callsign). +const feedURL = "https://lotw.arrl.org/lotw-user-activity.csv" + +const cacheFile = "lotw-user-activity.csv" + +// Info is a lookup result for one callsign. +type Info struct { + IsUser bool `json:"is_user"` + LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD + DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown) +} + +// Manager holds the parsed list + cache location. +type Manager struct { + mu sync.RWMutex + users map[string]time.Time // UPPER(callsign) → last-upload date (UTC) + updated time.Time // when the cache was last refreshed + dir string + client *http.Client +} + +// NewManager loads any on-disk cache and returns a ready manager. +func NewManager(dataDir string) *Manager { + m := &Manager{ + users: map[string]time.Time{}, + dir: dataDir, + client: &http.Client{Timeout: 90 * time.Second}, + } + m.loadCache() + return m +} + +func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) } + +func (m *Manager) loadCache() { + data, err := os.ReadFile(m.path()) + if err != nil { + return + } + m.parse(data) + if fi, e := os.Stat(m.path()); e == nil { + m.mu.Lock() + m.updated = fi.ModTime() + m.mu.Unlock() + } +} + +// Download fetches the latest list, caches it to disk and replaces the in-memory +// map. Returns the number of callsigns loaded. +func (m *Manager) Download(ctx context.Context) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("User-Agent", "OpsLog") + resp, err := m.client.Do(req) + if err != nil { + return 0, fmt.Errorf("lotwusers: request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("lotwusers: http %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024)) + if err != nil { + return 0, fmt.Errorf("lotwusers: read: %w", err) + } + n := m.parse(body) + if n == 0 { + return 0, fmt.Errorf("lotwusers: feed parsed to 0 callsigns") + } + _ = os.WriteFile(m.path(), body, 0o644) // best-effort cache + m.mu.Lock() + m.updated = time.Now() + m.mu.Unlock() + return n, nil +} + +// parse loads the CSV bytes into the map and returns the count. +func (m *Manager) parse(data []byte) int { + users := make(map[string]time.Time, 1<<20) + sc := bufio.NewScanner(bytes.NewReader(data)) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + parts := strings.Split(line, ",") + if len(parts) < 2 { + continue + } + call := strings.ToUpper(strings.TrimSpace(parts[0])) + if call == "" || strings.EqualFold(call, "callsign") { + continue // header row + } + t, err := time.Parse("2006-01-02", strings.TrimSpace(parts[1])) + if err != nil { + continue + } + users[call] = t + } + if len(users) == 0 { + return 0 + } + m.mu.Lock() + m.users = users + m.mu.Unlock() + return len(users) +} + +// Lookup reports whether callsign is a LoTW user and how long ago it uploaded. +// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P") +// the longest slash-separated segment — the base call LoTW is keyed on. +func (m *Manager) Lookup(callsign string) Info { + c := strings.ToUpper(strings.TrimSpace(callsign)) + if c == "" { + return Info{DaysAgo: -1} + } + m.mu.RLock() + defer m.mu.RUnlock() + if len(m.users) == 0 { + return Info{DaysAgo: -1} + } + t, ok := m.users[c] + if !ok && strings.Contains(c, "/") { + base := "" + for _, seg := range strings.Split(c, "/") { + if len(seg) > len(base) { + base = seg + } + } + t, ok = m.users[base] + } + if !ok { + return Info{DaysAgo: -1} + } + days := int(time.Since(t).Hours() / 24) + if days < 0 { + days = 0 + } + return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days} +} + +// Count returns how many callsigns are loaded. +func (m *Manager) Count() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.users) +} + +// Updated returns when the list was last refreshed (zero if never). +func (m *Manager) Updated() time.Time { + m.mu.RLock() + defer m.mu.RUnlock() + return m.updated +}