feat: Added support for US Counties in OpsLog / Extra feature with DXHunter

This commit is contained in:
2026-07-17 13:23:35 +02:00
parent 1a155e3627
commit dd3b51a2ae
14 changed files with 4127 additions and 3 deletions
+161 -1
View File
@@ -54,6 +54,7 @@ import (
"hamlog/internal/settings"
"hamlog/internal/solar"
"hamlog/internal/steppir"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer"
@@ -437,6 +438,7 @@ type App struct {
clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
@@ -761,6 +763,14 @@ func (a *App) startup(ctx context.Context) {
}
}
a.settings.SetProfile(active.ID)
// US county resolver — its own local SQLite (data/uls.db), populated on demand
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
// fatal: county resolution simply stays inert until the operator downloads it.
if store, err := uls.Open(filepath.Join(a.dataDir, "uls.db")); err != nil {
applog.Printf("uls: open failed (county resolution disabled): %v", err)
} else {
a.uls = store
}
a.awardRefs = awardref.NewRepo(conn)
a.qslTemplates = qslcard.NewRepo(conn)
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
@@ -1815,6 +1825,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}()
a.applyStationDefaults(&q, true)
a.applyDXCCNumber(&q)
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
@@ -4244,7 +4255,7 @@ func freeAwardCode(code string, taken map[string]int) string {
// builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
const builtinRefsVersion = "2"
const builtinRefsVersion = "3"
// seedBuiltinReferences populates the reference lists of built-in awards.
// - First run (no version stored), or already up to date: seed only awards that
@@ -7793,6 +7804,155 @@ func (a *App) GetSlotStats() qso.SlotStats {
return st
}
// --- US Counties (ULS) -----------------------------------------------------
// applyULSCounty fills a US QSO's blank county (and blank grid) from the offline
// ULS store. Non-US QSOs, an un-downloaded store, or already-filled fields make
// it a no-op — a value the operator or QRZ supplied is never overwritten, since
// the ZIP-derived county is only ~98% and the logged one is usually better. The
// award normalises whatever shape cnty holds, so stamping "ST,County" is safe.
func (a *App) applyULSCounty(q *qso.QSO) {
if a.uls == nil || q.DXCC == nil {
return
}
switch *q.DXCC {
case 291, 110, 6: // United States, Hawaii, Alaska
default:
return
}
haveCounty := strings.TrimSpace(q.County) != ""
haveGrid := strings.TrimSpace(q.Grid) != ""
if haveCounty && haveGrid {
return
}
loc, ok := a.uls.Resolve(q.Callsign)
if !ok {
return
}
if !haveCounty {
q.County = loc.CNTY()
if strings.TrimSpace(q.State) == "" {
q.State = loc.State
}
}
if !haveGrid && loc.Grid != "" {
q.Grid = loc.Grid
}
}
// ULSStatusResult reports whether the county database is loaded, and how fresh.
type ULSStatusResult struct {
Count int `json:"count"`
UpdatedAt string `json:"updated_at"` // RFC3339, empty if never downloaded
}
// ULSStatus returns the state of the offline US county database.
func (a *App) ULSStatus() ULSStatusResult {
if a.uls == nil {
return ULSStatusResult{}
}
var updated string
if t := a.uls.UpdatedAt(); !t.IsZero() {
updated = t.Format(time.RFC3339)
}
return ULSStatusResult{Count: a.uls.Count(), UpdatedAt: updated}
}
// DownloadULSCounties downloads and (re)builds the offline US county database in
// the background, emitting "uls:progress" ({stage,pct}) and "uls:done" ({ok,
// error,count}) so the Settings panel can show progress and reuse one flow.
func (a *App) DownloadULSCounties() error {
if a.uls == nil {
return fmt.Errorf("county database unavailable")
}
go func() {
defer func() {
if r := recover(); r != nil {
applog.Printf("PANIC in DownloadULSCounties: %v\n%s", r, debug.Stack())
wruntime.EventsEmit(a.ctx, "uls:done", map[string]any{"ok": false, "error": fmt.Sprintf("%v", r)})
}
}()
err := a.uls.Import(a.ctx, a.dataDir, func(stage string, pct int) {
wruntime.EventsEmit(a.ctx, "uls:progress", map[string]any{"stage": stage, "pct": pct})
})
res := map[string]any{"ok": err == nil, "count": a.uls.Count()}
if err != nil {
applog.Printf("uls: import failed: %v", err)
res["error"] = err.Error()
} else {
applog.Printf("uls: import complete — %d callsigns", a.uls.Count())
}
wruntime.EventsEmit(a.ctx, "uls:done", res)
}()
return nil
}
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
type BackfillUSCountiesResult struct {
Scanned int `json:"scanned"` // US QSOs examined
County int `json:"county"` // QSOs that gained a county
Grid int `json:"grid"` // QSOs that gained a grid
}
// BackfillUSCounties resolves county (and grid) for existing US QSOs that lack
// them, using the offline ULS store. It only FILLS blanks — an existing county
// is left as the operator logged it. Returns how many rows were updated.
func (a *App) BackfillUSCounties() (BackfillUSCountiesResult, error) {
var res BackfillUSCountiesResult
if a.uls == nil || a.uls.Count() == 0 {
return res, fmt.Errorf("county database not downloaded")
}
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
if err != nil {
return res, err
}
for i := range rows {
q := &rows[i]
if q.DXCC == nil {
continue
}
switch *q.DXCC {
case 291, 110, 6:
default:
continue
}
res.Scanned++
needCounty := strings.TrimSpace(q.County) == ""
needGrid := strings.TrimSpace(q.Grid) == ""
if !needCounty && !needGrid {
continue
}
loc, ok := a.uls.Resolve(q.Callsign)
if !ok {
continue
}
changed := false
if needCounty && loc.CNTY() != "" {
q.County = loc.CNTY()
if strings.TrimSpace(q.State) == "" {
q.State = loc.State
}
res.County++
changed = true
}
if needGrid && loc.Grid != "" {
q.Grid = loc.Grid
res.Grid++
changed = true
}
if changed {
if err := a.qso.Update(a.ctx, *q); err != nil {
applog.Printf("uls backfill: update qso %d failed: %v", q.ID, err)
}
}
}
a.invalidateAwardStats() // county changes affect the US Counties matrix
return res, nil
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same