feat: Added support for US Counties in OpsLog / Extra feature with DXHunter
This commit is contained in:
@@ -54,6 +54,7 @@ import (
|
|||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
"hamlog/internal/solar"
|
"hamlog/internal/solar"
|
||||||
"hamlog/internal/steppir"
|
"hamlog/internal/steppir"
|
||||||
|
"hamlog/internal/uls"
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
"hamlog/internal/winkeyer"
|
"hamlog/internal/winkeyer"
|
||||||
|
|
||||||
@@ -437,6 +438,7 @@ type App struct {
|
|||||||
clusterEventCh chan clusterEvent
|
clusterEventCh chan clusterEvent
|
||||||
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
|
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
|
||||||
pota *pota.Cache
|
pota *pota.Cache
|
||||||
|
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
|
||||||
awardRefs *awardref.Repo
|
awardRefs *awardref.Repo
|
||||||
qslTemplates *qslcard.Repo
|
qslTemplates *qslcard.Repo
|
||||||
operating *operating.Repo
|
operating *operating.Repo
|
||||||
@@ -761,6 +763,14 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.settings.SetProfile(active.ID)
|
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.awardRefs = awardref.NewRepo(conn)
|
||||||
a.qslTemplates = qslcard.NewRepo(conn)
|
a.qslTemplates = qslcard.NewRepo(conn)
|
||||||
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
|
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.applyStationDefaults(&q, true)
|
||||||
a.applyDXCCNumber(&q)
|
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.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
a.applyQSLDefaults(&q)
|
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
|
// builtinRefsVersion is bumped whenever the built-in reference data changes
|
||||||
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
|
// (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.
|
// 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.
|
// seedBuiltinReferences populates the reference lists of built-in awards.
|
||||||
// - First run (no version stored), or already up to date: seed only awards that
|
// - 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
|
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
|
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
|
||||||
// matching local QSOs' received status. LoTW only for now (the canonical
|
// matching local QSOs' received status. LoTW only for now (the canonical
|
||||||
// confirmation system); runs in the background emitting the same
|
// confirmation system); runs in the background emitting the same
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// One-shot generator: reads the FIPS county CSV and emits
|
||||||
|
// internal/awardref/uscounties_gen.go. Not part of the build.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dxccForState maps the two US "states" that are separate DXCC entities.
|
||||||
|
func dxccForState(st string) int {
|
||||||
|
switch st {
|
||||||
|
case "AK":
|
||||||
|
return 6
|
||||||
|
case "HI":
|
||||||
|
return 110
|
||||||
|
default:
|
||||||
|
return 291
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// territories we exclude — USA-CA is the 50 states (+ DC).
|
||||||
|
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
f, err := os.Open(os.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
type row struct{ code, name string; dxcc int }
|
||||||
|
var rows []row
|
||||||
|
seen := map[string]bool{}
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Scan() // header
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
parts := strings.SplitN(line, ",", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(parts[1])
|
||||||
|
st := strings.TrimSpace(parts[2])
|
||||||
|
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// State header rows have an all-caps state NAME and state code "NA"
|
||||||
|
// (already skipped). County rows have a 2-letter state code.
|
||||||
|
code := award.USCountyKey(st, name)
|
||||||
|
if code == "" || seen[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[code] = true
|
||||||
|
rows = append(rows, row{code: code, name: name + ", " + st, dxcc: dxccForState(st)})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("// Code generated by cmd/cntygen from FIPS county data. DO NOT EDIT.\n")
|
||||||
|
b.WriteString("package awardref\n\n")
|
||||||
|
b.WriteString("// usCounties is the US Counties (USA-CA) reference list: one entry per county\n")
|
||||||
|
b.WriteString("// in the 50 states, keyed by the canonical \"STATE,COUNTY\" match code that\n")
|
||||||
|
b.WriteString("// award.usCountyKey produces from a QSO's state + cnty fields.\n")
|
||||||
|
fmt.Fprintf(&b, "func usCounties() []Ref {\n\treturn []Ref{\n")
|
||||||
|
for _, r := range rows {
|
||||||
|
fmt.Fprintf(&b, "\t\tref(%q, %q, %d),\n", r.code, r.name, r.dxcc)
|
||||||
|
}
|
||||||
|
b.WriteString("\t}\n}\n")
|
||||||
|
|
||||||
|
if err := os.WriteFile(os.Args[2], []byte(b.String()), 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("wrote %d counties to %s\n", len(rows), os.Args[2])
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
|
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
@@ -175,6 +176,7 @@ type SectionId =
|
|||||||
| 'backup'
|
| 'backup'
|
||||||
| 'database'
|
| 'database'
|
||||||
| 'autostart'
|
| 'autostart'
|
||||||
|
| 'uscounties'
|
||||||
| 'awards'
|
| 'awards'
|
||||||
| 'cat'
|
| 'cat'
|
||||||
| 'rotator'
|
| 'rotator'
|
||||||
@@ -223,6 +225,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
]},
|
]},
|
||||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||||
|
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||||
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
||||||
],
|
],
|
||||||
@@ -238,6 +241,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
|||||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||||
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||||
|
uscounties: 'sec.uscounties',
|
||||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
||||||
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
||||||
};
|
};
|
||||||
@@ -1010,6 +1014,34 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||||
finally { setLotwUsersBusy(false); }
|
finally { setLotwUsersBusy(false); }
|
||||||
};
|
};
|
||||||
|
// US Counties (offline FCC ULS) — download progress arrives via events.
|
||||||
|
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
|
||||||
|
const [ulsBusy, setUlsBusy] = useState(false);
|
||||||
|
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
|
||||||
|
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const [backfillBusy, setBackfillBusy] = useState(false);
|
||||||
|
const [backfillMsg, setBackfillMsg] = useState<string | null>(null);
|
||||||
|
useEffect(() => { ULSStatus().then((s) => setUlsStatus(s as any)).catch(() => {}); }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const off1 = EventsOn('uls:progress', (p: any) => setUlsProgress({ stage: p?.stage ?? '', pct: p?.pct ?? 0 }));
|
||||||
|
const off2 = EventsOn('uls:done', async (r: any) => {
|
||||||
|
setUlsBusy(false); setUlsProgress(null);
|
||||||
|
setUlsMsg(r?.ok ? { ok: true, text: t('uscty.done', { n: r?.count ?? 0 }) } : { ok: false, text: r?.error || 'error' });
|
||||||
|
try { const s = await ULSStatus(); setUlsStatus(s as any); } catch {}
|
||||||
|
});
|
||||||
|
return () => { off1(); off2(); };
|
||||||
|
}, []);
|
||||||
|
const downloadUls = async () => {
|
||||||
|
setUlsBusy(true); setUlsMsg(null); setUlsProgress({ stage: '', pct: 0 });
|
||||||
|
try { await DownloadULSCounties(); }
|
||||||
|
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
|
||||||
|
};
|
||||||
|
const runBackfill = async () => {
|
||||||
|
setBackfillBusy(true); setBackfillMsg(null);
|
||||||
|
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
|
||||||
|
catch (e: any) { setBackfillMsg(String(e?.message ?? e)); }
|
||||||
|
finally { setBackfillBusy(false); }
|
||||||
|
};
|
||||||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -4332,6 +4364,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function USCountiesPanel() {
|
||||||
|
const loaded = ulsStatus.count > 0;
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-1">{t('uscty.title')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{t('uscty.intro')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required-download notice */}
|
||||||
|
<div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground/90 leading-relaxed">
|
||||||
|
{t('uscty.needDownload')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status + download */}
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-medium">{t('uscty.dbStatus')}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{loaded
|
||||||
|
? t('uscty.loaded', { n: ulsStatus.count.toLocaleString(), date: ulsStatus.updated_at ? new Date(ulsStatus.updated_at).toLocaleDateString() : '—' })
|
||||||
|
: t('uscty.notLoaded')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={downloadUls} disabled={ulsBusy}>
|
||||||
|
{ulsBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : <ArrowDown className="size-3.5 mr-1.5" />}
|
||||||
|
{loaded ? t('uscty.update') : t('uscty.download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ulsProgress && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[11px] text-muted-foreground flex justify-between">
|
||||||
|
<span>{ulsProgress.stage}</span><span>{ulsProgress.pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded bg-muted overflow-hidden">
|
||||||
|
<div className="h-full bg-primary transition-all" style={{ width: `${ulsProgress.pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ulsMsg && (
|
||||||
|
<div className={cn('text-xs', ulsMsg.ok ? 'text-success' : 'text-destructive')}>{ulsMsg.text}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backfill existing QSOs */}
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2">
|
||||||
|
<div className="text-xs font-medium">{t('uscty.backfillTitle')}</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('uscty.backfillIntro')}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="sm" variant="secondary" onClick={runBackfill} disabled={backfillBusy || !loaded}>
|
||||||
|
{backfillBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||||
|
{t('uscty.backfillRun')}
|
||||||
|
</Button>
|
||||||
|
{backfillMsg && <span className="text-xs text-muted-foreground">{backfillMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Map sections to their content + icon (for placeholder).
|
// Map sections to their content + icon (for placeholder).
|
||||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||||
general: GeneralPanel,
|
general: GeneralPanel,
|
||||||
@@ -4348,6 +4442,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
database: DatabasePanel,
|
database: DatabasePanel,
|
||||||
|
uscounties: USCountiesPanel,
|
||||||
autostart: () => <AutostartPanelComponent />,
|
autostart: () => <AutostartPanelComponent />,
|
||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
|
|||||||
@@ -89,7 +89,21 @@ const en: Dict = {
|
|||||||
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
|
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
|
||||||
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
||||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
||||||
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
|
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
|
||||||
|
'uscty.title': 'US Counties (USA-CA)',
|
||||||
|
'uscty.intro': 'Resolve a US callsign to its county and grid offline, from the FCC ULS licence database. This powers the US Counties award and county hunting — including on CW/SSB, where a spot carries only a callsign.',
|
||||||
|
'uscty.needDownload': 'County resolution requires downloading the FCC database first (about 150 MB, stored locally). Nothing is resolved until you download it.',
|
||||||
|
'uscty.dbStatus': 'County database',
|
||||||
|
'uscty.loaded': '{n} callsigns · updated {date}',
|
||||||
|
'uscty.notLoaded': 'Not downloaded yet.',
|
||||||
|
'uscty.download': 'Download',
|
||||||
|
'uscty.update': 'Update',
|
||||||
|
'uscty.done': 'County database ready — {n} callsigns.',
|
||||||
|
'uscty.backfillTitle': 'Fill existing QSOs',
|
||||||
|
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
|
||||||
|
'uscty.backfillRun': 'Fill missing counties',
|
||||||
|
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
|
||||||
|
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
|
||||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||||
// General panel
|
// General panel
|
||||||
@@ -353,7 +367,21 @@ const fr: Dict = {
|
|||||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||||
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
||||||
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
||||||
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
|
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
|
||||||
|
'uscty.title': 'Comtés US (USA-CA)',
|
||||||
|
'uscty.intro': "Résout un indicatif US en comté et locator, hors-ligne, depuis la base de licences FCC ULS. Ça alimente le diplôme Comtés US et la chasse aux comtés — même en CW/SSB, où le spot ne porte qu'un indicatif.",
|
||||||
|
'uscty.needDownload': "La résolution des comtés nécessite d'abord de télécharger la base FCC (environ 150 Mo, stockée en local). Rien n'est résolu tant que tu ne l'as pas téléchargée.",
|
||||||
|
'uscty.dbStatus': 'Base des comtés',
|
||||||
|
'uscty.loaded': '{n} indicatifs · maj {date}',
|
||||||
|
'uscty.notLoaded': 'Pas encore téléchargée.',
|
||||||
|
'uscty.download': 'Télécharger',
|
||||||
|
'uscty.update': 'Mettre à jour',
|
||||||
|
'uscty.done': 'Base des comtés prête — {n} indicatifs.',
|
||||||
|
'uscty.backfillTitle': 'Compléter les QSO existants',
|
||||||
|
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
|
||||||
|
'uscty.backfillRun': 'Remplir les comtés manquants',
|
||||||
|
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
|
||||||
|
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
|
||||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||||
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||||
|
|||||||
Vendored
+6
@@ -64,6 +64,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
|
|||||||
|
|
||||||
export function AwardsFolder():Promise<string>;
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
|
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
@@ -150,6 +152,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
|
|||||||
|
|
||||||
export function DownloadLoTWUsers():Promise<number>;
|
export function DownloadLoTWUsers():Promise<number>;
|
||||||
|
|
||||||
|
export function DownloadULSCounties():Promise<void>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||||
@@ -808,6 +812,8 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
|||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||||
|
|
||||||
export function UltrabeamRetract():Promise<void>;
|
export function UltrabeamRetract():Promise<void>;
|
||||||
|
|
||||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export function AwardsFolder() {
|
|||||||
return window['go']['main']['App']['AwardsFolder']();
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BackfillUSCounties() {
|
||||||
|
return window['go']['main']['App']['BackfillUSCounties']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
@@ -258,6 +262,10 @@ export function DownloadLoTWUsers() {
|
|||||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadULSCounties() {
|
||||||
|
return window['go']['main']['App']['DownloadULSCounties']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DuplicateProfile(arg1, arg2) {
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1574,6 +1582,10 @@ export function TestUltrabeam(arg1) {
|
|||||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ULSStatus() {
|
||||||
|
return window['go']['main']['App']['ULSStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UltrabeamRetract() {
|
export function UltrabeamRetract() {
|
||||||
return window['go']['main']['App']['UltrabeamRetract']();
|
return window['go']['main']['App']['UltrabeamRetract']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1575,6 +1575,22 @@ export namespace main {
|
|||||||
this.to = source["to"];
|
this.to = source["to"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class BackfillUSCountiesResult {
|
||||||
|
scanned: number;
|
||||||
|
county: number;
|
||||||
|
grid: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BackfillUSCountiesResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.scanned = source["scanned"];
|
||||||
|
this.county = source["county"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BackupSettings {
|
export class BackupSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
folder: string;
|
folder: string;
|
||||||
@@ -2548,6 +2564,20 @@ export namespace main {
|
|||||||
this.my_pota_ref = source["my_pota_ref"];
|
this.my_pota_ref = source["my_pota_ref"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ULSStatusResult {
|
||||||
|
count: number;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ULSStatusResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.count = source["count"];
|
||||||
|
this.updated_at = source["updated_at"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class UltrabeamSettings {
|
export class UltrabeamSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -643,6 +643,59 @@ func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
|
|||||||
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||||
func EmissionOf(mode string) string { return emissionOf(mode) }
|
func EmissionOf(mode string) string { return emissionOf(mode) }
|
||||||
|
|
||||||
|
// USCountyKey normalises a QSO's state + cnty into the canonical "STATE,COUNTY"
|
||||||
|
// match code used by the US Counties (USA-CA) award, so the reference list (in
|
||||||
|
// that same form) matches whatever shape the logbook holds. It is the SINGLE
|
||||||
|
// source of truth: cmd/cntygen builds the reference codes by calling it, so the
|
||||||
|
// two sides can never drift.
|
||||||
|
//
|
||||||
|
// Real logs are a mess — "MA,MIDDLESEX", bare "Middlesex" with the state in its
|
||||||
|
// own column, mixed case, county-type suffixes, the odd "0", plus the FIPS list
|
||||||
|
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
|
||||||
|
// - if cnty already carries "ST,County", split on the first comma; else take
|
||||||
|
// the state from the STATE column;
|
||||||
|
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing
|
||||||
|
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e);
|
||||||
|
// collapse whitespace;
|
||||||
|
// - require a 2-letter state and a non-empty county, else no match ("").
|
||||||
|
func USCountyKey(state, cnty string) string {
|
||||||
|
s := strings.TrimSpace(cnty)
|
||||||
|
if s == "" || s == "0" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var st, co string
|
||||||
|
if i := strings.IndexByte(s, ','); i >= 0 {
|
||||||
|
st, co = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
|
||||||
|
} else {
|
||||||
|
st, co = strings.TrimSpace(state), s
|
||||||
|
}
|
||||||
|
co = strings.ToUpper(co)
|
||||||
|
co = strings.ReplaceAll(co, ".", "")
|
||||||
|
co = strings.ReplaceAll(co, "'", "")
|
||||||
|
co = strings.ReplaceAll(co, "-", " ")
|
||||||
|
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
|
||||||
|
if strings.HasSuffix(co, suf) {
|
||||||
|
co = strings.TrimSuffix(co, suf)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(co, "SAINTE "):
|
||||||
|
co = "STE " + co[len("SAINTE "):]
|
||||||
|
case strings.HasPrefix(co, "SAINT "):
|
||||||
|
co = "ST " + co[len("SAINT "):]
|
||||||
|
}
|
||||||
|
// Drop ALL internal spaces last: FIPS writes DeKalb/DuPage/LaSalle as one
|
||||||
|
// word, logs often split them ("De Kalb"). Removing spaces on both sides
|
||||||
|
// folds those together and can't collide two real counties in one state.
|
||||||
|
co = strings.ReplaceAll(co, " ", "")
|
||||||
|
st = strings.ToUpper(st)
|
||||||
|
if len(st) != 2 || co == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return st + "," + co
|
||||||
|
}
|
||||||
|
|
||||||
// labelRef fills a worked reference's name/group from the reference list (or the
|
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||||
// name resolver as a fallback).
|
// name resolver as a fallback).
|
||||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||||
@@ -1145,6 +1198,8 @@ func fieldRaw(field string, q *qso.QSO) string {
|
|||||||
return q.Callsign
|
return q.Callsign
|
||||||
case "state":
|
case "state":
|
||||||
return q.State
|
return q.State
|
||||||
|
case "us_county":
|
||||||
|
return USCountyKey(q.State, q.County)
|
||||||
case "cont":
|
case "cont":
|
||||||
return q.Continent
|
return q.Continent
|
||||||
case "country":
|
case "country":
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "USCOUNTIES",
|
||||||
|
"name": "US Counties (USA-CA)",
|
||||||
|
"description": "Worked US counties (CQ USA-CA). Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes.",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "us_county",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"ref_display": "name",
|
||||||
|
"dxcc_filter": [
|
||||||
|
291,
|
||||||
|
110,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 3077,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestUSCountyKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
state, cnty, want string
|
||||||
|
}{
|
||||||
|
{"MA", "MA,MIDDLESEX", "MA,MIDDLESEX"}, // LoTW "ST,County" shape
|
||||||
|
{"NJ", "Middlesex", "NJ,MIDDLESEX"}, // bare name + state column
|
||||||
|
{"TX", "Montgomery", "TX,MONTGOMERY"}, // title case
|
||||||
|
{"FL", "Saint Lucie", "FL,STLUCIE"}, // Saint→St, space dropped
|
||||||
|
{"FL", "St. Lucie", "FL,STLUCIE"}, // FIPS abbreviation, period dropped
|
||||||
|
{"AK", "Matanuska-Susitna", "AK,MATANUSKASUSITNA"}, // hyphen→space→dropped
|
||||||
|
{"IL", "De Kalb", "IL,DEKALB"}, // split name
|
||||||
|
{"IL", "DeKalb County", "IL,DEKALB"}, // suffix + one word
|
||||||
|
{"LA", "Acadia Parish", "LA,ACADIA"}, // parish suffix
|
||||||
|
{"", "Honolulu", ""}, // no state → no match
|
||||||
|
{"HI", "0", ""}, // garbage
|
||||||
|
{"HI", "", ""}, // empty
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := USCountyKey(c.state, c.cnty); got != c.want {
|
||||||
|
t.Errorf("USCountyKey(%q,%q) = %q, want %q", c.state, c.cnty, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
// - WAC → continent code ("EU", "NA", …)
|
// - WAC → continent code ("EU", "NA", …)
|
||||||
// - WAS → ADIF STATE code ("AL", …)
|
// - WAS → ADIF STATE code ("AL", …)
|
||||||
// - DDFM → "D06" (the award pattern captures the leading D)
|
// - DDFM → "D06" (the award pattern captures the leading D)
|
||||||
|
// - USCOUNTIES → canonical "STATE,COUNTY" key (see award.usCountyKey)
|
||||||
func BuiltinRefs(code string) ([]Ref, bool) {
|
func BuiltinRefs(code string) ([]Ref, bool) {
|
||||||
switch code {
|
switch code {
|
||||||
case "DXCC":
|
case "DXCC":
|
||||||
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
|
|||||||
return usStates().Refs, true
|
return usStates().Refs, true
|
||||||
case "DDFM":
|
case "DDFM":
|
||||||
return frenchDepartments(), true
|
return frenchDepartments(), true
|
||||||
|
case "USCOUNTIES":
|
||||||
|
return usCounties(), true
|
||||||
}
|
}
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
|||||||
|
// Package uls resolves a US amateur callsign to its county and grid, offline,
|
||||||
|
// from the FCC ULS licence database cross-referenced with a ZIP→county/lat-lon
|
||||||
|
// table. It backs the US Counties (USA-CA) award and county hunting: an FCC
|
||||||
|
// spot or a bare CW/SSB spot carries only a callsign, and this turns that into
|
||||||
|
// a county with no per-lookup API call.
|
||||||
|
//
|
||||||
|
// Data lives in its OWN local SQLite file (data/uls.db), never in the logbook:
|
||||||
|
// it is ~800k rows of static reference data that would only bloat the log (and
|
||||||
|
// crawl over a remote MySQL link). It is downloaded on demand, not shipped.
|
||||||
|
//
|
||||||
|
// Sources, both public and free:
|
||||||
|
// - FCC ULS Amateur, full database: l_amat.zip → EN.dat (pipe-delimited).
|
||||||
|
// Fields used: [4] call_sign, [17] state, [18] zip_code.
|
||||||
|
// - GeoNames US postal codes: US.zip → US.txt (tab-delimited).
|
||||||
|
// Fields used: [1] zip, [4] state, [5] county, [9] lat, [10] lon.
|
||||||
|
//
|
||||||
|
// The county from a ZIP is the ZIP's primary county — a ZIP can straddle a line,
|
||||||
|
// so this is ~98% right for fixed stations (rovers/portables need the from-air
|
||||||
|
// grid, which arrives separately via heard_geo). Good enough to hunt with.
|
||||||
|
package uls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default download URLs (overridable in Import for tests).
|
||||||
|
const (
|
||||||
|
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
|
||||||
|
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Location is a resolved callsign's home county + grid.
|
||||||
|
type Location struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
County string `json:"county"` // GeoNames county name (e.g. "Middlesex")
|
||||||
|
Grid string `json:"grid"` // 6-char Maidenhead from the ZIP centroid
|
||||||
|
}
|
||||||
|
|
||||||
|
// CNTY renders the ADIF "STATE,County" form for stamping a QSO's cnty field.
|
||||||
|
func (l Location) CNTY() string {
|
||||||
|
if l.State == "" || l.County == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.State + "," + l.County
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store owns the local uls.db connection.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
mu sync.RWMutex // guards the whole DB during a re-import (DELETE+bulk INSERT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens (creating if needed) the ULS SQLite store at path.
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS uls_callsign (
|
||||||
|
callsign TEXT PRIMARY KEY,
|
||||||
|
state TEXT NOT NULL DEFAULT '',
|
||||||
|
county TEXT NOT NULL DEFAULT '',
|
||||||
|
grid TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS uls_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT ''
|
||||||
|
);`); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
|
// Count returns how many callsigns are loaded.
|
||||||
|
func (s *Store) Count() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var n int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM uls_callsign`).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatedAt returns when the store was last imported (zero if never).
|
||||||
|
func (s *Store) UpdatedAt() time.Time {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var v string
|
||||||
|
if err := s.db.QueryRow(`SELECT value FROM uls_meta WHERE key='updated_at'`).Scan(&v); err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
t, _ := time.Parse(time.RFC3339, v)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve looks up a callsign's home county + grid. ok=false if unknown or the
|
||||||
|
// store is empty.
|
||||||
|
func (s *Store) Resolve(callsign string) (Location, bool) {
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
if call == "" {
|
||||||
|
return Location{}, false
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var l Location
|
||||||
|
err := s.db.QueryRow(`SELECT state, county, grid FROM uls_callsign WHERE callsign=?`, call).
|
||||||
|
Scan(&l.State, &l.County, &l.Grid)
|
||||||
|
if err != nil || l.County == "" {
|
||||||
|
return Location{}, false
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress reports import stages to the caller (0..100 within a stage).
|
||||||
|
type Progress func(stage string, pct int)
|
||||||
|
|
||||||
|
// zipRow is one ZIP's primary county + centroid.
|
||||||
|
type zipRow struct {
|
||||||
|
state, county string
|
||||||
|
lat, lon float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import downloads the FCC ULS + GeoNames data, rebuilds the callsign→county
|
||||||
|
// table, and records the timestamp. It replaces the table atomically: on any
|
||||||
|
// error the previous contents are left intact. tmpDir is where the (large) zips
|
||||||
|
// are streamed; "" uses the OS temp dir.
|
||||||
|
func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error {
|
||||||
|
if prog == nil {
|
||||||
|
prog = func(string, int) {}
|
||||||
|
}
|
||||||
|
if tmpDir == "" {
|
||||||
|
tmpDir = os.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) ZIP→county/lat-lon crosswalk (small).
|
||||||
|
prog("Downloading ZIP crosswalk", 0)
|
||||||
|
geoPath := filepath.Join(tmpDir, "opslog_geonames_us.zip")
|
||||||
|
if err := download(ctx, geoNamesURL, geoPath, nil); err != nil {
|
||||||
|
return fmt.Errorf("download GeoNames: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(geoPath)
|
||||||
|
prog("Parsing ZIP crosswalk", 50)
|
||||||
|
zipmap, err := parseGeoNames(geoPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse GeoNames: %w", err)
|
||||||
|
}
|
||||||
|
if len(zipmap) == 0 {
|
||||||
|
return fmt.Errorf("GeoNames crosswalk is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) FCC ULS full amateur database (large).
|
||||||
|
prog("Downloading FCC ULS database", 0)
|
||||||
|
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
|
||||||
|
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
|
||||||
|
return fmt.Errorf("download FCC ULS: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(amatPath)
|
||||||
|
|
||||||
|
// 3) Parse EN.dat, join the crosswalk, rebuild the table.
|
||||||
|
prog("Building county database", 0)
|
||||||
|
return s.rebuild(ctx, amatPath, zipmap, prog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuild streams EN.dat out of the FCC zip and replaces uls_callsign in one
|
||||||
|
// transaction (old data survives a failure).
|
||||||
|
func (s *Store) rebuild(ctx context.Context, amatZip string, zipmap map[string]zipRow, prog Progress) error {
|
||||||
|
zr, err := zip.OpenReader(amatZip)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open FCC zip: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
var en *zip.File
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if strings.EqualFold(filepath.Base(f.Name), "EN.dat") {
|
||||||
|
en = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if en == nil {
|
||||||
|
return fmt.Errorf("EN.dat not found in FCC zip")
|
||||||
|
}
|
||||||
|
rc, err := en.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open EN.dat: %w", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM uls_callsign`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO uls_callsign(callsign,state,county,grid) VALUES(?,?,?,?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
sc := bufio.NewScanner(rc)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
var n, kept int
|
||||||
|
for sc.Scan() {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
f := strings.Split(sc.Text(), "|")
|
||||||
|
if len(f) < 19 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(f[4]))
|
||||||
|
if call == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zip5 := zip5Of(f[18])
|
||||||
|
zr, ok := zipmap[zip5]
|
||||||
|
if !ok {
|
||||||
|
continue // no crosswalk entry → can't place it
|
||||||
|
}
|
||||||
|
state := strings.ToUpper(strings.TrimSpace(f[17]))
|
||||||
|
if state == "" {
|
||||||
|
state = zr.state
|
||||||
|
}
|
||||||
|
if _, err := stmt.ExecContext(ctx, call, state, zr.county, grid6(zr.lat, zr.lon)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept++
|
||||||
|
if kept%50000 == 0 {
|
||||||
|
prog("Building county database", int(math.Min(99, float64(kept)/8000)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return fmt.Errorf("read EN.dat: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO uls_meta(key,value) VALUES('updated_at',?)`,
|
||||||
|
time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prog("Done", 100)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGeoNames reads US.txt out of the GeoNames zip into a zip→row map,
|
||||||
|
// keeping the first (primary) county seen for each ZIP.
|
||||||
|
func parseGeoNames(zipPath string) (map[string]zipRow, error) {
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
var txt *zip.File
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if strings.EqualFold(filepath.Base(f.Name), "US.txt") {
|
||||||
|
txt = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if txt == nil {
|
||||||
|
return nil, fmt.Errorf("US.txt not found")
|
||||||
|
}
|
||||||
|
rc, err := txt.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
out := make(map[string]zipRow, 45000)
|
||||||
|
sc := bufio.NewScanner(rc)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
f := strings.Split(sc.Text(), "\t")
|
||||||
|
if len(f) < 11 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
zip5 := strings.TrimSpace(f[1])
|
||||||
|
if zip5 == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := out[zip5]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lat := parseFloat(f[9])
|
||||||
|
lon := parseFloat(f[10])
|
||||||
|
out[zip5] = zipRow{
|
||||||
|
state: strings.ToUpper(strings.TrimSpace(f[4])),
|
||||||
|
county: strings.TrimSpace(f[5]),
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// download streams url to dest, reporting percent when the content length is
|
||||||
|
// known and prog is non-nil.
|
||||||
|
func download(ctx context.Context, url, dest string, prog func(pct int)) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var body io.Reader = resp.Body
|
||||||
|
if prog != nil && resp.ContentLength > 0 {
|
||||||
|
body = &progReader{r: resp.Body, total: resp.ContentLength, prog: prog}
|
||||||
|
}
|
||||||
|
_, err = io.Copy(f, body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type progReader struct {
|
||||||
|
r io.Reader
|
||||||
|
total int64
|
||||||
|
read int64
|
||||||
|
last int
|
||||||
|
prog func(pct int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *progReader) Read(b []byte) (int, error) {
|
||||||
|
n, err := p.r.Read(b)
|
||||||
|
p.read += int64(n)
|
||||||
|
if pct := int(p.read * 100 / p.total); pct != p.last {
|
||||||
|
p.last = pct
|
||||||
|
p.prog(pct)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func zip5Of(z string) string {
|
||||||
|
z = strings.TrimSpace(z)
|
||||||
|
if len(z) > 5 {
|
||||||
|
z = z[:5]
|
||||||
|
}
|
||||||
|
return z
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(strings.TrimSpace(s), "%g", &v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// grid6 converts latitude/longitude to a 6-character Maidenhead locator.
|
||||||
|
func grid6(lat, lon float64) string {
|
||||||
|
if lat == 0 && lon == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lon += 180
|
||||||
|
lat += 90
|
||||||
|
if lon < 0 || lon >= 360 || lat < 0 || lat >= 180 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
f0 := int(lon / 20)
|
||||||
|
f1 := int(lat / 10)
|
||||||
|
sq0 := int(math.Mod(lon, 20) / 2)
|
||||||
|
sq1 := int(math.Mod(lat, 10) / 1)
|
||||||
|
su0 := int(math.Mod(lon, 2) / (2.0 / 24))
|
||||||
|
su1 := int(math.Mod(lat, 1) / (1.0 / 24))
|
||||||
|
return string([]byte{
|
||||||
|
byte('A' + f0), byte('A' + f1),
|
||||||
|
byte('0' + sq0), byte('0' + sq1),
|
||||||
|
byte('a' + su0), byte('a' + su1),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package uls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGrid6(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
lat, lon float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{38.90, -77.03, "FM18lw"}, // Washington DC
|
||||||
|
{40.71, -74.00, "FN30xr"}, // New York
|
||||||
|
{34.05, -118.24, "DM04vd"},// Los Angeles
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
|
||||||
|
t.Errorf("grid6(%v,%v)=%q want field/square %q", c.lat, c.lon, got, c.want[:4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseGeoNames runs against the real GeoNames US.zip if present in the
|
||||||
|
// scratchpad (downloaded during development); skipped otherwise.
|
||||||
|
func TestParseGeoNames(t *testing.T) {
|
||||||
|
path := os.Getenv("GEONAMES_ZIP")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set GEONAMES_ZIP to the US.zip path to run")
|
||||||
|
}
|
||||||
|
m, err := parseGeoNames(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(m) < 30000 {
|
||||||
|
t.Fatalf("expected >30k ZIPs, got %d", len(m))
|
||||||
|
}
|
||||||
|
// A known ZIP: 20500 = The White House, DC.
|
||||||
|
if r, ok := m["20500"]; !ok || r.state != "DC" {
|
||||||
|
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user