fix: Added LOTW badge for Lotw users colored depending on their last upload

This commit is contained in:
2026-07-09 19:32:32 +02:00
parent f3bf0b2f5c
commit 16e780c2df
13 changed files with 465 additions and 6 deletions
+69
View File
@@ -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
@@ -5571,9 +5627,22 @@ func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
date = time.Now().UTC()
}
e, ok := a.clublog.Resolve(q.Callsign, date)
if !ok {
// 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 != "" {
q.Continent = e.Cont
+32
View File
@@ -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<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
@@ -2743,6 +2757,23 @@ export default function App() {
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div>
);
// LoTW-user badge: shown only when the entered call is a LoTW user, coloured by
// last-upload recency (green < 1 week · amber 14 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 (
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}>
LoTW
</span>
</div>
);
})() : 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() {
</div>
{qthBlock}
{gridBlock}
{lotwBlock}
{alertLedBlock}
</div>
+28
View File
@@ -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
)}
</div>
</div>
{/* LoTW user list — powers the colour-coded "LoTW" badge next to the
entered callsign (green < 1 week · amber 14 weeks · red > 30 days). */}
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-sm font-medium">{t('lotw.usersTitle')}</Label>
<p className="text-[11px] text-muted-foreground">{t('lotw.usersHint')}</p>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={downloadLotwUsers} disabled={lotwUsersBusy}>
<ArrowDown className="size-3.5" /> {lotwUsersBusy ? t('lotw.usersDownloading') : t('lotw.usersDownload')}
</Button>
<span className="text-xs text-muted-foreground">
{lotwUsers.count > 0
? t('lotw.usersLoaded', { n: lotwUsers.count.toLocaleString(), date: lotwUsers.updated ? new Date(lotwUsers.updated).toLocaleDateString() : '?' })
: t('lotw.usersNone')}
</span>
</div>
</div>
</div>
) : extSvcTab === 'pota' ? (
<div className="space-y-4 max-w-2xl">
+4
View File
@@ -14,6 +14,7 @@ type Dict = Record<string, string>;
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 (12 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 14 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é (12 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 14 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 nest 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',
+7
View File
@@ -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<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
export function DownloadLoTWUsers():Promise<number>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
@@ -319,6 +322,8 @@ export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
export function GetLogFilePath():Promise<string>;
export function GetLogbookRevision():Promise<string>;
@@ -489,6 +494,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
+12
View File
@@ -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);
}
+35
View File
@@ -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;
+17
View File
@@ -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}
+50
View File
@@ -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")
}
}
+16
View File
@@ -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)
}
+13 -5
View File
@@ -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)
}
+3
View File
@@ -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",
+178
View File
@@ -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
}