Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 | ||
|
|
14c87f7fa9 | ||
|
|
9d4ccb9254 | ||
|
|
d30b305ff2 | ||
|
|
5abe4bd0c3 | ||
|
|
3ed9f29d9a | ||
|
|
04eaa91bd8 | ||
|
|
443698b507 | ||
|
|
db2908b3ef | ||
|
|
0838c2ec89 | ||
|
|
80c5fdc095 | ||
|
|
a1be0dfe68 | ||
|
|
ac4039393d | ||
|
|
bb53085c21 | ||
|
|
031dfa8f46 | ||
|
|
6322a425d8 | ||
|
|
9f08df1c39 | ||
|
|
aeeb658269 | ||
|
|
8e088576c7 | ||
|
|
eb2ff8ed59 | ||
|
|
dd3b51a2ae | ||
|
|
1a155e3627 | ||
|
|
cd13921322 | ||
|
|
46e3619a38 | ||
|
|
dcf006905c | ||
|
|
7cf2dfeaf9 | ||
|
|
69229964f4 | ||
|
|
09848adddc |
+251
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||
// just what happens.
|
||||
//
|
||||
// Design notes:
|
||||
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||
// contacts appended AFTER you add the file come in.
|
||||
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||
// the next poll instead of importing a truncated QSO.
|
||||
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||
// auto-log) + automatic external-service upload.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const keyADIFMonitor = "adifmon.config"
|
||||
|
||||
// ADIFWatchFile is one monitored ADIF file.
|
||||
type ADIFWatchFile struct {
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||
// initialised": the first scan sets it to the file's current size so existing
|
||||
// history is skipped.
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||
type ADIFMonitorConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Files []ADIFWatchFile `json:"files"`
|
||||
}
|
||||
|
||||
const eorTag = "<eor>"
|
||||
|
||||
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||
var cfg ADIFMonitorConfig
|
||||
if a.settings == nil {
|
||||
return cfg
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||
b, _ := json.Marshal(cfg)
|
||||
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||
}
|
||||
|
||||
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||
a.adifMonMu.Lock()
|
||||
defer a.adifMonMu.Unlock()
|
||||
return a.loadADIFMonitorLocked()
|
||||
}
|
||||
|
||||
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||
// was added import); a file already present keeps its current read position.
|
||||
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||
a.adifMonMu.Lock()
|
||||
defer a.adifMonMu.Unlock()
|
||||
old := a.loadADIFMonitorLocked()
|
||||
oldOff := make(map[string]int64, len(old.Files))
|
||||
for _, f := range old.Files {
|
||||
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||
}
|
||||
for i := range cfg.Files {
|
||||
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||
} else {
|
||||
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||
}
|
||||
}
|
||||
a.saveADIFMonitorLocked(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Choose an ADIF file to monitor",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||
func (a *App) adifMonitorLoop() {
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
if a.ctx == nil || a.qso == nil {
|
||||
continue
|
||||
}
|
||||
a.scanADIFMonitors()
|
||||
}
|
||||
}
|
||||
|
||||
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||
// persisting advanced offsets.
|
||||
func (a *App) scanADIFMonitors() {
|
||||
a.adifMonMu.Lock()
|
||||
cfg := a.loadADIFMonitorLocked()
|
||||
a.adifMonMu.Unlock()
|
||||
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// path → new offset, for the files we advanced this pass.
|
||||
advanced := map[string]int64{}
|
||||
for i := range cfg.Files {
|
||||
f := &cfg.Files[i]
|
||||
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||
continue
|
||||
}
|
||||
fi, err := os.Stat(f.Path)
|
||||
if err != nil {
|
||||
continue // not present (yet) — try again next tick
|
||||
}
|
||||
size := fi.Size()
|
||||
if f.Offset < 0 {
|
||||
// First sight of this file → skip whatever history it already holds.
|
||||
advanced[f.Path] = size
|
||||
continue
|
||||
}
|
||||
if size < f.Offset {
|
||||
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||
}
|
||||
if size == f.Offset {
|
||||
continue // nothing new
|
||||
}
|
||||
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||
if newOff != f.Offset {
|
||||
advanced[f.Path] = newOff
|
||||
}
|
||||
if n > 0 {
|
||||
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(advanced) == 0 {
|
||||
return
|
||||
}
|
||||
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||
// file list: re-read the stored config and only patch offsets for paths that
|
||||
// still exist there.
|
||||
a.adifMonMu.Lock()
|
||||
cur := a.loadADIFMonitorLocked()
|
||||
for i := range cur.Files {
|
||||
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||
cur.Files[i].Offset = off
|
||||
}
|
||||
}
|
||||
a.saveADIFMonitorLocked(cur)
|
||||
a.adifMonMu.Unlock()
|
||||
}
|
||||
|
||||
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return from, 0
|
||||
}
|
||||
defer fh.Close()
|
||||
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||
return from, 0
|
||||
}
|
||||
buf := make([]byte, to-from)
|
||||
nRead, err := io.ReadFull(fh, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return from, 0
|
||||
}
|
||||
buf = buf[:nRead]
|
||||
|
||||
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||
// waits for the next poll.
|
||||
lower := bytes.ToLower(buf)
|
||||
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||
if last < 0 {
|
||||
return from, 0 // no complete record yet
|
||||
}
|
||||
end := last + len(eorTag)
|
||||
chunk := buf[:end]
|
||||
|
||||
count := 0
|
||||
for _, rec := range splitADIFRecords(chunk) {
|
||||
if strings.TrimSpace(rec) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||
count++
|
||||
}
|
||||
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||
}
|
||||
return from + int64(end), count
|
||||
}
|
||||
|
||||
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||
func splitADIFRecords(b []byte) []string {
|
||||
lower := bytes.ToLower(b)
|
||||
var out []string
|
||||
start := 0
|
||||
for {
|
||||
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
end := start + rel + len(eorTag)
|
||||
out = append(out, string(b[start:end]))
|
||||
start = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import (
|
||||
"hamlog/internal/settings"
|
||||
"hamlog/internal/solar"
|
||||
"hamlog/internal/steppir"
|
||||
"hamlog/internal/uls"
|
||||
"hamlog/internal/ultrabeam"
|
||||
"hamlog/internal/winkeyer"
|
||||
|
||||
@@ -434,9 +435,15 @@ type App struct {
|
||||
// to run inline in the read loop, so a single slow step stopped draining the
|
||||
// TCP socket and the whole feed fell behind the node (visible as the grid
|
||||
// lagging telnet). The read loop now just enqueues here; one worker does the work.
|
||||
clusterEventCh chan clusterEvent
|
||||
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
|
||||
// Unbounded FIFO: a burst grows the backlog instead of dropping spots.
|
||||
clusterEvents *clusterQueue
|
||||
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
|
||||
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
|
||||
// Loaded once, appended to on each log, rebuilt after bulk changes.
|
||||
wcbm map[string]struct{}
|
||||
wcbmMu sync.RWMutex
|
||||
pota *pota.Cache
|
||||
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
|
||||
awardRefs *awardref.Repo
|
||||
qslTemplates *qslcard.Repo
|
||||
operating *operating.Repo
|
||||
@@ -476,6 +483,10 @@ type App struct {
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
pttMu sync.Mutex
|
||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
||||
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
||||
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||
@@ -761,6 +772,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)
|
||||
@@ -792,6 +811,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
applog.Printf("startup: logbook backend = %s", backend)
|
||||
a.logDb = logbookConn
|
||||
a.qso = qso.NewRepo(logbookConn)
|
||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
||||
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
||||
|
||||
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||
// run downloads it from country-files.com in the background so startup
|
||||
@@ -877,6 +899,13 @@ func (a *App) startup(ctx context.Context) {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||
}
|
||||
a.emitRadioUDP(s)
|
||||
// Drive station relays by the current frequency/band (PstRotator-style
|
||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||
// feature is off; when on, run off this callback so a slow relay board never
|
||||
// stalls rig-state processing.
|
||||
if a.relayAutoOn.Load() {
|
||||
go a.applyRelayAuto(s.FreqHz, s.Band)
|
||||
}
|
||||
})
|
||||
a.reloadCAT()
|
||||
|
||||
@@ -890,10 +919,11 @@ func (a *App) startup(ctx context.Context) {
|
||||
// renders the row with all metadata already filled (no flicker of
|
||||
// empty Country / Cont columns while the batch status fetch runs).
|
||||
// Cluster events are processed OFF the socket-read goroutine (see clusterEvent /
|
||||
// clusterEventWorker). Sized large so ordinary traffic and even an SH/DX/100
|
||||
// burst never fill it; a full queue drops-and-counts rather than block the read
|
||||
// loop, which was the actual cause of the feed falling behind telnet.
|
||||
a.clusterEventCh = make(chan clusterEvent, 8192)
|
||||
// clusterEventWorker). The queue is UNBOUNDED so no spot is ever dropped: an
|
||||
// SH/DX or RBN burst grows the backlog and drains once enrichment catches up,
|
||||
// while the read loop still never blocks (that was the cause of the feed falling
|
||||
// behind telnet).
|
||||
a.clusterEvents = newClusterQueue()
|
||||
go a.clusterEventWorker()
|
||||
|
||||
a.cluster = cluster.NewManager(
|
||||
@@ -1815,6 +1845,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)
|
||||
@@ -1840,6 +1871,9 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
}
|
||||
if err == nil {
|
||||
q.ID = id
|
||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
||||
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||
a.saveQSORecording(&q)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
@@ -2385,6 +2419,22 @@ func (a *App) migrateAwardDefs() {
|
||||
a.setSettingGlobal(keyAwardEditsSeeded, "1")
|
||||
}
|
||||
|
||||
// One-time: the US Counties award was briefly carried under the code
|
||||
// "USCOUNTIES" before being renamed to the official "USA-CA". Drop the orphan
|
||||
// (def + references) so the operator isn't left with a stale, empty duplicate.
|
||||
// It never shipped beyond development, so this is unconditional.
|
||||
for i := 0; i < len(migrated); i++ {
|
||||
if strings.EqualFold(strings.TrimSpace(migrated[i].Code), "USCOUNTIES") {
|
||||
migrated = append(migrated[:i], migrated[i+1:]...)
|
||||
i--
|
||||
changed = true
|
||||
if a.awardRefs != nil {
|
||||
_, _ = a.awardRefs.ReplaceAll(a.ctx, "USCOUNTIES", nil)
|
||||
}
|
||||
applog.Printf("awards: removed legacy USCOUNTIES award (renamed to USA-CA)")
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile with the catalog: awards ADDED since this operator last saved, and
|
||||
// shipped awards whose definition we have since FIXED (a higher Version). Both
|
||||
// must be written back, or the editor would keep showing the old set.
|
||||
@@ -3348,6 +3398,10 @@ func (a *App) invalidateAwardStats() {
|
||||
a.awardSnap = nil
|
||||
a.awardSnapRev = ""
|
||||
a.awardSnapMu.Unlock()
|
||||
// Bulk QSO changes (import, delete, bulk edit) also land here — refresh the
|
||||
// worked-index so alert "needed" checks stay accurate. Async: never block the
|
||||
// mutation, and it's a single lightweight query.
|
||||
go a.rebuildWorkedIndex()
|
||||
}
|
||||
|
||||
// RescanAwards forces the next award computation to re-pull the logbook from the
|
||||
@@ -4244,7 +4298,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 = "5"
|
||||
|
||||
// seedBuiltinReferences populates the reference lists of built-in awards.
|
||||
// - First run (no version stored), or already up to date: seed only awards that
|
||||
@@ -4441,13 +4495,44 @@ func (a *App) UpdateQSO(q qso.QSO) error {
|
||||
// bound", so two empty strings mean the whole log. `to` is inclusive: a bare date
|
||||
// is stretched to 23:59:59 of that day, otherwise picking today as the end would
|
||||
// silently drop today's QSOs.
|
||||
func (a *App) GetLogStats(fromISO, toISO, contestID string, year int) (qso.Stats, error) {
|
||||
func (a *App) GetLogStats(fromISO, toISO, contestID string, year int, operator string) (qso.Stats, error) {
|
||||
if a.qso == nil {
|
||||
return qso.Stats{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
from := parseStatsBound(fromISO, false)
|
||||
to := parseStatsBound(toISO, true)
|
||||
return a.qso.Stats(a.ctx, from, to, contestID, year)
|
||||
return a.qso.Stats(a.ctx, from, to, contestID, year, operator)
|
||||
}
|
||||
|
||||
// GetOperators lists the distinct operators present in the log (upper-cased),
|
||||
// for the Statistics operator picker. "—" stands for QSOs the station owner
|
||||
// logged himself (empty OPERATOR). Sorted, owner-bucket last.
|
||||
func (a *App) GetOperators() ([]string, error) {
|
||||
if a.qso == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.Operators(a.ctx)
|
||||
}
|
||||
|
||||
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were
|
||||
// logged in the trailing 10 and 60 minutes.
|
||||
type QSORate struct {
|
||||
Last10 int `json:"last10"`
|
||||
Last60 int `json:"last60"`
|
||||
}
|
||||
|
||||
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes.
|
||||
// Cheap (scans only the most recent rows); polled by the header and refreshed on
|
||||
// each qso:logged event.
|
||||
func (a *App) GetQSORate() QSORate {
|
||||
if a.qso == nil {
|
||||
return QSORate{}
|
||||
}
|
||||
counts, err := a.qso.RecentRate(a.ctx, time.Now(), 10*time.Minute, 60*time.Minute)
|
||||
if err != nil || len(counts) < 2 {
|
||||
return QSORate{}
|
||||
}
|
||||
return QSORate{Last10: counts[0], Last60: counts[1]}
|
||||
}
|
||||
|
||||
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
|
||||
@@ -4680,7 +4765,9 @@ var bulkFieldColumns = map[string]string{
|
||||
"prop_mode": "prop_mode",
|
||||
"sat_name": "sat_name",
|
||||
"sat_mode": "sat_mode",
|
||||
// Contacted station activation refs / SIG
|
||||
// Contacted station location + activation refs / SIG
|
||||
"state": "state",
|
||||
"cnty": "cnty",
|
||||
"pota_ref": "pota_ref",
|
||||
"sota_ref": "sota_ref",
|
||||
"wwff_ref": "wwff_ref",
|
||||
@@ -5850,20 +5937,85 @@ type clusterEvent struct {
|
||||
line *cluster.Line
|
||||
}
|
||||
|
||||
// enqueueClusterEvent hands an event to the worker WITHOUT blocking. It runs on
|
||||
// the cluster session's socket-read goroutine: blocking here would stop draining
|
||||
// the TCP socket, the node's send buffer would fill, and the feed would fall
|
||||
// behind — the exact bug this indirection fixes. If the queue is full (processing
|
||||
// can't keep up), drop and count rather than stall the whole feed.
|
||||
func (a *App) enqueueClusterEvent(ev clusterEvent) {
|
||||
select {
|
||||
case a.clusterEventCh <- ev:
|
||||
default:
|
||||
n := atomic.AddInt64(&a.clusterDropped, 1)
|
||||
if n == 1 || n%100 == 0 {
|
||||
applog.Printf("cluster: processing backlog — dropped %d event(s); the feed is faster than enrichment/UI", n)
|
||||
}
|
||||
// clusterQueue is an unbounded FIFO between the socket-read goroutines (producers,
|
||||
// which must never block or the TCP feed stalls) and the single clusterEventWorker
|
||||
// (consumer). Unlike a bounded channel it NEVER drops an event: a burst just grows
|
||||
// the backlog, which drains once enrichment catches up.
|
||||
type clusterQueue struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
buf []clusterEvent
|
||||
head int // index of the next event to pop (waste reclaimed by compaction)
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newClusterQueue() *clusterQueue {
|
||||
q := &clusterQueue{}
|
||||
q.cond = sync.NewCond(&q.mu)
|
||||
return q
|
||||
}
|
||||
|
||||
// push appends an event and wakes the worker. Amortised O(1); never blocks, never
|
||||
// drops.
|
||||
func (q *clusterQueue) push(ev clusterEvent) {
|
||||
q.mu.Lock()
|
||||
if q.closed {
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
q.buf = append(q.buf, ev)
|
||||
q.mu.Unlock()
|
||||
q.cond.Signal()
|
||||
}
|
||||
|
||||
// pop blocks until an event is available, returning ok=false only once the queue
|
||||
// is closed AND fully drained.
|
||||
func (q *clusterQueue) pop() (clusterEvent, bool) {
|
||||
q.mu.Lock()
|
||||
for q.head == len(q.buf) && !q.closed {
|
||||
q.cond.Wait()
|
||||
}
|
||||
if q.head == len(q.buf) { // closed and drained
|
||||
q.mu.Unlock()
|
||||
return clusterEvent{}, false
|
||||
}
|
||||
ev := q.buf[q.head]
|
||||
q.buf[q.head] = clusterEvent{} // release pointers held by the consumed slot
|
||||
q.head++
|
||||
switch {
|
||||
case q.head == len(q.buf):
|
||||
// Fully drained → reset to reuse the backing array from the front.
|
||||
q.buf = q.buf[:0]
|
||||
q.head = 0
|
||||
case q.head > 1024 && q.head*2 >= len(q.buf):
|
||||
// Head waste is large → compact so a long sustained burst doesn't grow
|
||||
// the backing array without bound.
|
||||
n := copy(q.buf, q.buf[q.head:])
|
||||
for i := n; i < len(q.buf); i++ {
|
||||
q.buf[i] = clusterEvent{}
|
||||
}
|
||||
q.buf = q.buf[:n]
|
||||
q.head = 0
|
||||
}
|
||||
q.mu.Unlock()
|
||||
return ev, true
|
||||
}
|
||||
|
||||
// close wakes any waiting consumer so it can exit once the backlog is drained.
|
||||
func (q *clusterQueue) close() {
|
||||
q.mu.Lock()
|
||||
q.closed = true
|
||||
q.mu.Unlock()
|
||||
q.cond.Broadcast()
|
||||
}
|
||||
|
||||
// enqueueClusterEvent hands an event to the worker WITHOUT blocking and WITHOUT
|
||||
// dropping. It runs on the cluster session's socket-read goroutine: blocking here
|
||||
// would stop draining the TCP socket, the node's send buffer would fill, and the
|
||||
// feed would fall behind — the exact bug this indirection fixes. The queue is
|
||||
// unbounded, so a burst grows the backlog rather than losing a spot.
|
||||
func (a *App) enqueueClusterEvent(ev clusterEvent) {
|
||||
a.clusterEvents.push(ev)
|
||||
}
|
||||
|
||||
// clusterEventWorker drains clusterEventCh and does everything that used to run
|
||||
@@ -5871,7 +6023,11 @@ func (a *App) enqueueClusterEvent(ev clusterEvent) {
|
||||
// to the UI, run alert rules (which may query a remote MySQL) and mirror it to the
|
||||
// Flex panadapter — all serialised on this one goroutine, off the read path.
|
||||
func (a *App) clusterEventWorker() {
|
||||
for ev := range a.clusterEventCh {
|
||||
for {
|
||||
ev, ok := a.clusterEvents.pop()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ev.line != nil {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line)
|
||||
@@ -5968,21 +6124,60 @@ func (a *App) evaluateAlerts(s cluster.Spot) {
|
||||
|
||||
// isWorkedBandMode reports whether the exact callsign is already logged on the
|
||||
// given band+mode (used by rules with "skip worked before").
|
||||
// wcbmKey builds the worked-index key: "CALL|BAND|MODE", all upper-cased.
|
||||
func wcbmKey(call, band, mode string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(call)) + "|" +
|
||||
strings.ToUpper(strings.TrimSpace(band)) + "|" +
|
||||
strings.ToUpper(strings.TrimSpace(mode))
|
||||
}
|
||||
|
||||
// rebuildWorkedIndex loads the whole "CALL|BAND|MODE" worked-index into memory in
|
||||
// one pass. Called at startup and after bulk changes (import, delete, bulk edit).
|
||||
func (a *App) rebuildWorkedIndex() {
|
||||
if a.qso == nil {
|
||||
return
|
||||
}
|
||||
m, err := a.qso.WorkedCallBandModeKeys(a.ctx)
|
||||
if err != nil {
|
||||
applog.Printf("worked-index: rebuild failed: %v", err)
|
||||
return
|
||||
}
|
||||
a.wcbmMu.Lock()
|
||||
a.wcbm = m
|
||||
a.wcbmMu.Unlock()
|
||||
}
|
||||
|
||||
// noteWorked adds a just-logged QSO to the in-memory worked-index, so an alert
|
||||
// rule sees it as worked immediately without a full rebuild.
|
||||
func (a *App) noteWorked(call, band, mode string) {
|
||||
if strings.TrimSpace(call) == "" {
|
||||
return
|
||||
}
|
||||
a.wcbmMu.Lock()
|
||||
if a.wcbm == nil {
|
||||
a.wcbm = map[string]struct{}{}
|
||||
}
|
||||
a.wcbm[wcbmKey(call, band, mode)] = struct{}{}
|
||||
a.wcbmMu.Unlock()
|
||||
}
|
||||
|
||||
// isWorkedBandMode reports whether this exact call+band+mode is in the log,
|
||||
// answered from the in-memory index (no DB round-trip per spot). If the index
|
||||
// hasn't loaded yet it lazily builds it once.
|
||||
func (a *App) isWorkedBandMode(call, band, mode string) bool {
|
||||
if a.qso == nil || strings.TrimSpace(call) == "" {
|
||||
return false
|
||||
}
|
||||
rows, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Band: band, Mode: mode, Limit: 5})
|
||||
if err != nil {
|
||||
return false
|
||||
a.wcbmMu.RLock()
|
||||
ready := a.wcbm != nil
|
||||
a.wcbmMu.RUnlock()
|
||||
if !ready {
|
||||
a.rebuildWorkedIndex()
|
||||
}
|
||||
call = strings.ToUpper(strings.TrimSpace(call))
|
||||
for _, q := range rows {
|
||||
if strings.ToUpper(strings.TrimSpace(q.Callsign)) == call {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
a.wcbmMu.RLock()
|
||||
_, ok := a.wcbm[wcbmKey(call, band, mode)]
|
||||
a.wcbmMu.RUnlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// sendAlertEmail notifies the operator by e-mail that a wanted station was
|
||||
@@ -7664,7 +7859,15 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
// Name the QSOs in the failing batch so a per-record rejection
|
||||
// (e.g. a field value nginx's WAF blocks with a 403) can actually
|
||||
// be located — otherwise "batch FAILED" hides which contact it is.
|
||||
who := make([]string, 0, len(batch))
|
||||
for _, it := range batch {
|
||||
who = append(who, fmt.Sprintf("%s#%d", it.call, it.id))
|
||||
}
|
||||
emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
|
||||
applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -7760,9 +7963,222 @@ type ConfirmationItem struct {
|
||||
Country string `json:"country"`
|
||||
NewDXCC bool `json:"new_dxcc"`
|
||||
NewBand bool `json:"new_band"`
|
||||
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
|
||||
NewSlot bool `json:"new_slot"`
|
||||
}
|
||||
|
||||
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
|
||||
// Manager. It counts over the SAME data path as the DXCC award, using the
|
||||
// award's confirmation test (award.Confirmed with the DXCC sources lotw+qsl),
|
||||
// so the numbers agree with the DXCC award matrix. A slot is a distinct
|
||||
// DXCC × band (mode-agnostic) — i.e. the award's WORKED/CONFIRMED "Grand"
|
||||
// totals, NOT split per emission.
|
||||
func (a *App) GetSlotStats() qso.SlotStats {
|
||||
if a.qso == nil {
|
||||
return qso.SlotStats{}
|
||||
}
|
||||
// Delegate to the exact same matrix the DXCC award draws: the "WORKED"/
|
||||
// "CONFIRMED" (ALL-emission) rows give DXCC entities (Total) and slots =
|
||||
// entity × band (GrandTotal). Guaranteed to equal the award's Grand column.
|
||||
res, err := a.GetAwardStats("DXCC")
|
||||
if err != nil {
|
||||
return qso.SlotStats{}
|
||||
}
|
||||
var st qso.SlotStats
|
||||
for _, r := range res.Rows {
|
||||
switch r.Label {
|
||||
case "WORKED":
|
||||
st.SlotsWorked, st.DXCCWorked = r.GrandTotal, r.Total
|
||||
case "CONFIRMED":
|
||||
st.SlotsConfirmed, st.DXCCConfirmed = r.GrandTotal, r.Total
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
// Set of valid USA-CA county keys, so we can tell a good county from a bad one
|
||||
// (a wrong county for the state, a bare city, "0", …) and re-resolve only the
|
||||
// bad ones.
|
||||
validRef := map[string]bool{}
|
||||
if refs, ok := awardref.BuiltinRefs("USA-CA"); ok {
|
||||
for _, r := range refs {
|
||||
validRef[r.Code] = true
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
q := &rows[i]
|
||||
if q.DXCC == nil {
|
||||
continue
|
||||
}
|
||||
switch *q.DXCC {
|
||||
case 291, 110, 6:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
res.Scanned++
|
||||
// A county "needs" filling when it maps to NO valid USA-CA county: empty,
|
||||
// garbage, or a wrong county for the state. A legitimate DC operation
|
||||
// (state=DC) is left alone — DC simply isn't a USA-CA county, not an error.
|
||||
curKey := award.USCountyKey(q.State, q.County)
|
||||
stateUp := strings.ToUpper(strings.TrimSpace(q.State))
|
||||
needCounty := !validRef[curKey] && stateUp != "DC"
|
||||
needGrid := strings.TrimSpace(q.Grid) == ""
|
||||
if !needCounty && !needGrid {
|
||||
continue
|
||||
}
|
||||
loc, ok := a.uls.Resolve(q.Callsign)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changed := false
|
||||
if needCounty {
|
||||
// The logged county maps to no real USA-CA county (wrong state, wrong
|
||||
// county, empty…). Fix it from the ULS, PREFERRING to keep the operator's
|
||||
// county and correct only the state — their county often came from QRZ
|
||||
// and is finer than the ZIP-derived one (e.g. K1LZ logged ME,Middlesex →
|
||||
// Middlesex is a MA county, so → MA,Middlesex). Fall back to the ULS
|
||||
// county when even that is invalid. Never stamp an unrecognised value
|
||||
// (e.g. a Connecticut 2022 "planning region" GeoNames now returns). A
|
||||
// portable op keeps a suffix that won't resolve in the ULS, so only
|
||||
// fixed-station bad data is ever touched.
|
||||
name := q.County
|
||||
if i := strings.IndexByte(name, ','); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
var stState, stName string
|
||||
if name != "" && validRef[award.USCountyKey(loc.State, name)] {
|
||||
stState, stName = loc.State, name
|
||||
} else if validRef[award.USCountyKey(loc.State, loc.County)] {
|
||||
stState, stName = loc.State, loc.County
|
||||
}
|
||||
if stName != "" {
|
||||
q.State = stState
|
||||
q.County = stState + "," + stName
|
||||
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
|
||||
@@ -7887,12 +8303,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
Country: q.Country,
|
||||
}
|
||||
if dxccNum != 0 {
|
||||
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||
// confirmation is "new mode/slot" only if no digital mode was
|
||||
// confirmed there before (RTTY and FT8 are the same class here).
|
||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||
sets.DXCC[dxccNum] = true
|
||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||||
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
@@ -8047,12 +8468,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||||
}
|
||||
if dxccNum != 0 {
|
||||
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||
// confirmation is "new mode/slot" only if no digital mode was
|
||||
// confirmed there before (RTTY and FT8 are the same class here).
|
||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||
sets.DXCC[dxccNum] = true
|
||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||||
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
@@ -8163,12 +8589,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||||
}
|
||||
if dxccNum != 0 {
|
||||
// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
|
||||
// confirmation is "new mode/slot" only if no digital mode was
|
||||
// confirmed there before (RTTY and FT8 are the same class here).
|
||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
|
||||
sets.DXCC[dxccNum] = true
|
||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||||
sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
@@ -8373,6 +8804,19 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
||||
if r.QTH != "" {
|
||||
q.QTH = r.QTH
|
||||
}
|
||||
if r.Address != "" {
|
||||
q.Address = r.Address // full street address, not just the city (QTH)
|
||||
}
|
||||
if r.Email != "" {
|
||||
q.Email = r.Email
|
||||
}
|
||||
if r.QSLVia != "" {
|
||||
q.QSLVia = r.QSLVia
|
||||
}
|
||||
if r.Lat != 0 || r.Lon != 0 {
|
||||
lat, lon := r.Lat, r.Lon
|
||||
q.Lat, q.Lon = &lat, &lon
|
||||
}
|
||||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||
changed++
|
||||
}
|
||||
@@ -10747,6 +11191,14 @@ type motorAntenna interface {
|
||||
Retract() error
|
||||
LastSetKHz() int
|
||||
Status() motorStatus
|
||||
// Elements returns the per-element lengths (mm) when the antenna exposes them
|
||||
// (Ultrabeam), or nil when it doesn't (SteppIR tunes its elements from the
|
||||
// frequency and has no individual-element command).
|
||||
Elements() []int
|
||||
SetElement(num, lengthMm int) error
|
||||
// ReadElements queries the controller for the current element lengths on demand
|
||||
// (Ultrabeam CMD_READ_BANDS); SteppIR returns an error.
|
||||
ReadElements() ([]int, error)
|
||||
}
|
||||
|
||||
// ubAdapter / steppirAdapter wrap each concrete client to the shared interface,
|
||||
@@ -10760,6 +11212,14 @@ func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d)
|
||||
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||
func (a ubAdapter) Retract() error { return a.c.Retract() }
|
||||
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
|
||||
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
|
||||
func (a ubAdapter) Elements() []int {
|
||||
if st, err := a.c.GetStatus(); err == nil && st != nil {
|
||||
return st.ElementLengths
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (a ubAdapter) Status() motorStatus {
|
||||
st, err := a.c.GetStatus()
|
||||
if err != nil || st == nil {
|
||||
@@ -10776,6 +11236,13 @@ func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k
|
||||
func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||
func (a steppirAdapter) Retract() error { return a.c.Retract() }
|
||||
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||
func (a steppirAdapter) Elements() []int { return nil } // SteppIR: no per-element control
|
||||
func (a steppirAdapter) SetElement(_, _ int) error {
|
||||
return fmt.Errorf("individual element control is not available on the SteppIR")
|
||||
}
|
||||
func (a steppirAdapter) ReadElements() ([]int, error) {
|
||||
return nil, fmt.Errorf("individual element control is not available on the SteppIR")
|
||||
}
|
||||
func (a steppirAdapter) Status() motorStatus {
|
||||
st, err := a.c.GetStatus()
|
||||
if err != nil || st == nil {
|
||||
@@ -11062,19 +11529,22 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
|
||||
// direction control). Enabled mirrors the setting; the rest comes from the
|
||||
// device's most recent status poll.
|
||||
type UltrabeamStatusInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected bool `json:"connected"`
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Band int `json:"band"`
|
||||
Moving bool `json:"moving"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"` // "ultrabeam" | "steppir"
|
||||
Connected bool `json:"connected"`
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Band int `json:"band"`
|
||||
Moving bool `json:"moving"`
|
||||
Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported
|
||||
}
|
||||
|
||||
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
|
||||
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
|
||||
out := UltrabeamStatusInfo{}
|
||||
out := UltrabeamStatusInfo{Elements: []int{}}
|
||||
s, _ := a.GetUltrabeamSettings()
|
||||
out.Enabled = s.Enabled
|
||||
out.Type = s.Type
|
||||
if a.motorAnt == nil {
|
||||
return out
|
||||
}
|
||||
@@ -11084,9 +11554,31 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
|
||||
out.Frequency = st.Frequency
|
||||
out.Band = st.Band
|
||||
out.Moving = st.Moving
|
||||
if el := a.motorAnt.Elements(); el != nil {
|
||||
out.Elements = el
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MotorSetElement sets one element's length (mm) on an Ultrabeam. Returns an
|
||||
// error on the SteppIR, which has no per-element command.
|
||||
func (a *App) MotorSetElement(num, lengthMm int) error {
|
||||
if a.motorAnt == nil {
|
||||
return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
|
||||
}
|
||||
a.noteMotorMoveCommanded()
|
||||
return a.motorAnt.SetElement(num, lengthMm)
|
||||
}
|
||||
|
||||
// MotorReadElements queries the controller for the current element lengths (mm),
|
||||
// so the operator adjusts from the real values instead of blind. Ultrabeam only.
|
||||
func (a *App) MotorReadElements() ([]int, error) {
|
||||
if a.motorAnt == nil {
|
||||
return nil, fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
|
||||
}
|
||||
return a.motorAnt.ReadElements()
|
||||
}
|
||||
|
||||
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
|
||||
// 2=bidirectional (re-issues the current frequency with the new direction).
|
||||
func (a *App) SetUltrabeamDirection(direction int) error {
|
||||
@@ -11774,9 +12266,10 @@ func (a *App) GetClusterStatus() []cluster.ServerStatus {
|
||||
|
||||
// SpotQuery is one (call, band, mode) tuple sent for status colouring.
|
||||
type SpotQuery struct {
|
||||
Call string `json:"call"`
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
Call string `json:"call"`
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
POTARef string `json:"pota_ref,omitempty"` // park id if the spot is a POTA activation
|
||||
}
|
||||
|
||||
// SpotStatus is the per-tuple result. Status is one of:
|
||||
@@ -11798,6 +12291,12 @@ type SpotStatus struct {
|
||||
// (any band, any mode). Drives the per-call text highlight, in
|
||||
// addition to the entity-level Status (NEW / NEW BAND / …).
|
||||
WorkedCall bool `json:"worked_call"`
|
||||
// NewCounty/NewPOTA are ORTHOGONAL to Status: a station already worked for
|
||||
// its DXCC entity can still be a never-worked US county or POTA park. County
|
||||
// is resolved from the callsign via the offline ULS store (US only, and only
|
||||
// when that database has been downloaded); POTA from the spot's tagged park.
|
||||
NewCounty bool `json:"new_county"`
|
||||
NewPOTA bool `json:"new_pota"`
|
||||
}
|
||||
|
||||
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
||||
@@ -11842,6 +12341,10 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
// "I've already QSO'd this exact station" even when the band/mode
|
||||
// makes the entity check say "new-band" or "new-slot".
|
||||
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
|
||||
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
|
||||
// lookup) and worked POTA parks. Both built once per batch.
|
||||
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
||||
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
|
||||
for i, q := range spots {
|
||||
out[i] = SpotStatus{
|
||||
Call: q.Call,
|
||||
@@ -11851,6 +12354,23 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
|
||||
out[i].WorkedCall = true
|
||||
}
|
||||
// NEW POTA: the spot's tagged park, never worked before.
|
||||
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
|
||||
if _, done := workedPOTA[ref]; !done {
|
||||
out[i].NewPOTA = true
|
||||
}
|
||||
}
|
||||
// NEW COUNTY: resolve the callsign's home county from the offline ULS
|
||||
// store (US only; inert until downloaded) and flag if never worked.
|
||||
if a.uls != nil {
|
||||
if loc, ok := a.uls.Resolve(q.Call); ok {
|
||||
if key := award.USCountyKey(loc.State, loc.County); key != "" {
|
||||
if _, done := workedCounties[key]; !done {
|
||||
out[i].NewCounty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.dxcc == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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 entirely — plus DC, which USA-CA does not count as a
|
||||
// county.
|
||||
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true, "DC": true}
|
||||
|
||||
// excludedUSACA reports county-equivalents that the CQ USA-CA award does NOT
|
||||
// count as counties: the independent cities of Virginia and Carson City (NV).
|
||||
// (Baltimore MD and St. Louis MO ARE counted, so they are kept.) A contact in
|
||||
// one of these counts toward a bordering county under the award rules.
|
||||
func excludedUSACA(state, name string) bool {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
switch state {
|
||||
case "VA":
|
||||
return strings.HasSuffix(n, " city")
|
||||
case "NV":
|
||||
return n == "carson city"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if excludedUSACA(st, name) {
|
||||
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])
|
||||
}
|
||||
+179
-29
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
GetCATSettings,
|
||||
GetSolarData,
|
||||
GetQSORate,
|
||||
LoTWUserInfo,
|
||||
OperatingDefaultForBand,
|
||||
LogUDPLoggedADIF,
|
||||
@@ -1063,7 +1064,16 @@ export default function App() {
|
||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||
// different slots don't share the same colour.
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean }>>({});
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
|
||||
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||
// still need resolving without re-subscribing the cluster:spot listener.
|
||||
const spotStatusRef = useRef(spotStatus);
|
||||
useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
|
||||
// Incoming spots are staged here for a few ms, their status resolved, then
|
||||
// committed to `spots` together — so a row paints with its NEW BAND/MODE badge
|
||||
// already on, instead of flashing plain text then flipping to the pill.
|
||||
const pendingSpotsRef = useRef<ClusterSpot[]>([]);
|
||||
const pendingSpotTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
// === Modals ===
|
||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||
@@ -1076,6 +1086,20 @@ export default function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 });
|
||||
useEffect(() => {
|
||||
if (!showQsoRate) return;
|
||||
const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); };
|
||||
load();
|
||||
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
|
||||
// trailing windows roll forward even when nothing new is logged.
|
||||
const off = EventsOn('qso:logged', load);
|
||||
const id = window.setInterval(load, 30 * 1000);
|
||||
return () => { off(); window.clearInterval(id); };
|
||||
}, [showQsoRate]);
|
||||
// Optional deep-link: which Preferences section to open. Cleared on
|
||||
// close so the next plain "Preferences" launch reverts to default.
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
@@ -1290,20 +1314,28 @@ export default function App() {
|
||||
// surface the error — used by the startup retry, since the logbook DB (a remote
|
||||
// MySQL especially) can take a few seconds to connect while the UI is already
|
||||
// mounted, and we don't want to flash "db not available" during that window.
|
||||
const refreshSeqRef = useRef(0); // guards against an older refresh clobbering a newer one's data
|
||||
const refresh = useCallback(async (silent = false): Promise<boolean> => {
|
||||
// Monotonic guard: two refreshes can be in flight at once (e.g. the immediate
|
||||
// one after a UDP auto-log, which reads the QSO at "R", and the debounced one
|
||||
// after extsvc:uploaded, which reads it at "Y"). MySQL query latency can make
|
||||
// the OLDER one resolve LAST and clobber the newer data — the Club Log status
|
||||
// flicking R→Y→R. Only the most-recently-issued refresh is allowed to apply.
|
||||
const seq = ++refreshSeqRef.current;
|
||||
try {
|
||||
const f = buildActiveFilter();
|
||||
const list = await ListQSOFiltered(f as any);
|
||||
const n = await CountQSO();
|
||||
const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
|
||||
const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
|
||||
if (seq !== refreshSeqRef.current) return true; // a newer refresh superseded us — drop this stale result
|
||||
setQsos(list);
|
||||
setTotal(n);
|
||||
setMatchCount(matched);
|
||||
setError('');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (!silent) setError(String(e?.message ?? e));
|
||||
if (!silent && seq === refreshSeqRef.current) setError(String(e?.message ?? e));
|
||||
return false;
|
||||
}
|
||||
}, [buildActiveFilter]);
|
||||
@@ -1740,13 +1772,76 @@ export default function App() {
|
||||
const activeIds = new Set((sts ?? []).map((s) => s.server_id));
|
||||
setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
|
||||
});
|
||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
// Commit the staged spots: resolve the status for any slot we don't know yet
|
||||
// FIRST, then insert the rows — so they appear with the right badge already
|
||||
// painted instead of flashing plain text then flipping to a pill.
|
||||
const flushPendingSpots = async () => {
|
||||
pendingSpotTimer.current = undefined;
|
||||
const batch = pendingSpotsRef.current;
|
||||
pendingSpotsRef.current = [];
|
||||
if (batch.length === 0) return;
|
||||
// Resolve unknown statuses before the rows go in.
|
||||
try {
|
||||
const known = spotStatusRef.current;
|
||||
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of batch) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
if (seen.has(k) || known[k]) continue;
|
||||
seen.add(k);
|
||||
unknown.push({
|
||||
call: s.dx_call, band: s.band ?? '',
|
||||
mode: inferSpotMode(s.comment ?? '', s.freq_hz),
|
||||
pota_ref: (s as any).pota_ref ?? '',
|
||||
});
|
||||
}
|
||||
if (unknown.length > 0) {
|
||||
const res = await ClusterSpotStatuses(unknown as any);
|
||||
setSpotStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const r of res) {
|
||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||
next[k] = {
|
||||
status: r.status ?? '',
|
||||
country: r.country,
|
||||
continent: (r as any).continent,
|
||||
worked_call: !!(r as any).worked_call,
|
||||
new_county: !!(r as any).new_county,
|
||||
new_pota: !!(r as any).new_pota,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
// Now insert the staged rows. Same de-dupe as before: a station re-spotted
|
||||
// (RBN skimmers on a CQ, several nodes relaying) shows as ONE live row that
|
||||
// the freshest spot REPLACES and floats to the top. Historical (SH/DX
|
||||
// replay) rows are left alone.
|
||||
setSpots((arr) => {
|
||||
const next = [sp, ...arr];
|
||||
const key = (x: ClusterSpot) => `${(x.dx_call ?? '').toUpperCase()}|${(x.band ?? '').toLowerCase()}`;
|
||||
const hist = (x: ClusterSpot) => !!(x as any).historical;
|
||||
let next = arr;
|
||||
for (const sp of batch) {
|
||||
const k = key(sp);
|
||||
const filtered = hist(sp) ? next : next.filter((x) => hist(x) || key(x) !== k);
|
||||
next = [sp, ...filtered];
|
||||
}
|
||||
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
|
||||
});
|
||||
};
|
||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
// Stage the spot; a short timer resolves its status then commits it.
|
||||
pendingSpotsRef.current.push(sp);
|
||||
// 50 ms is enough to coalesce an RBN burst into one status lookup (the
|
||||
// worked-index is in memory, so resolving is near-instant) while staying
|
||||
// imperceptible.
|
||||
if (pendingSpotTimer.current === undefined) {
|
||||
pendingSpotTimer.current = window.setTimeout(flushPendingSpots, 50);
|
||||
}
|
||||
// Self-spot: someone spotted OUR callsign — show it in the shared header
|
||||
// toast (same place as the other notifications), not a separate banner.
|
||||
// Fired immediately (not staged) so the notification never lags the spot.
|
||||
const mine = myCallRef.current;
|
||||
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
||||
const by = cleanSpotter(sp.spotter ?? '') || '?';
|
||||
@@ -1754,7 +1849,10 @@ export default function App() {
|
||||
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
|
||||
}
|
||||
});
|
||||
return () => { unsubState?.(); unsubSpot?.(); };
|
||||
return () => {
|
||||
unsubState?.(); unsubSpot?.();
|
||||
if (pendingSpotTimer.current !== undefined) window.clearTimeout(pendingSpotTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -1823,7 +1921,16 @@ export default function App() {
|
||||
else setError('UDP auto-log: ' + msg);
|
||||
}
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
||||
// ADIF monitor imported new QSOs (backend file watcher) → refresh the grid
|
||||
// and show how many, from which file.
|
||||
const unsubAdifMon = EventsOn('adifmon:imported', async (p: any) => {
|
||||
const n = Number(p?.count ?? 0);
|
||||
if (n <= 0) return;
|
||||
await refresh();
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`);
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -1918,17 +2025,21 @@ export default function App() {
|
||||
async function wkSend(rawText: string) {
|
||||
setWkSent('');
|
||||
const resolved = resolveCW(rawText);
|
||||
// Trailing word space so two macros fired back-to-back don't run together in
|
||||
// the keyer buffer ("CQ" + "TEST" → "CQTEST"). The keyer keys a space as a
|
||||
// word gap at the CURRENT speed, so it scales with WPM automatically.
|
||||
const keyed = resolved ? resolved + ' ' : resolved;
|
||||
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
if (cwSourceRef.current === 'icom') {
|
||||
// The rig's keyer gives no busy echo back, so show the text we sent and,
|
||||
// for <LOGQSO>, wait the estimated send duration before logging.
|
||||
setWkSent(resolved);
|
||||
await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e)));
|
||||
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
||||
return;
|
||||
}
|
||||
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
|
||||
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
||||
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
||||
// is still going out. We'd like to wait for the busy flag to rise then fall,
|
||||
@@ -2010,14 +2121,14 @@ export default function App() {
|
||||
// "new-slot" because the lookup key carried mode="".
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(async () => {
|
||||
const unknown: { call: string; band: string; mode: string }[] = [];
|
||||
const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of spots) {
|
||||
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
if (seen.has(k) || spotStatus[k]) continue;
|
||||
seen.add(k);
|
||||
unknown.push({ call: s.dx_call, band: s.band ?? '', mode });
|
||||
unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '' });
|
||||
}
|
||||
if (unknown.length === 0) return;
|
||||
try {
|
||||
@@ -2031,6 +2142,8 @@ export default function App() {
|
||||
country: r.country,
|
||||
continent: (r as any).continent,
|
||||
worked_call: !!(r as any).worked_call,
|
||||
new_county: !!(r as any).new_county,
|
||||
new_pota: !!(r as any).new_pota,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
@@ -2866,12 +2979,12 @@ export default function App() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
||||
<div className="flex flex-col flex-1 min-w-[130px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
|
||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||
</div>
|
||||
);
|
||||
const gridBlock = (
|
||||
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
|
||||
<div className="flex flex-col w-[76px] shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
|
||||
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
</div>
|
||||
);
|
||||
@@ -2885,8 +2998,8 @@ export default function App() {
|
||||
: 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)}>
|
||||
<div className="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 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
||||
LoTW
|
||||
</span>
|
||||
</div>
|
||||
@@ -2901,7 +3014,7 @@ export default function App() {
|
||||
// worked-before check doesn't include these, and see what's waiting.
|
||||
const offlinePendingCount = offlineStatus.pending ?? 0;
|
||||
const offlineBlock = offlinePendingCount === 0 ? null : (
|
||||
<div className="relative self-end mb-0.5 shrink-0">
|
||||
<div className="relative shrink-0">
|
||||
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
|
||||
title={t('offline.tip', { n: offlinePendingCount })}
|
||||
className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
|
||||
@@ -2954,7 +3067,7 @@ export default function App() {
|
||||
const hasAlerts = recentAlerts.length > 0;
|
||||
// Only show the bell when there are pending alerts — hidden otherwise.
|
||||
const alertLedBlock = !hasAlerts ? null : (
|
||||
<div className="relative self-end mb-0.5 shrink-0">
|
||||
<div className="relative shrink-0">
|
||||
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
|
||||
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
|
||||
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
|
||||
@@ -3109,19 +3222,19 @@ export default function App() {
|
||||
const logButtons = (
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 h-3.5"> </Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected') && (
|
||||
<Button type="button" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8" title="Send a DX spot to the master cluster">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8 px-2.5 text-xs" title="Send a DX spot to the master cluster">
|
||||
<Satellite className="size-3.5" />
|
||||
{t('btn.spot')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8"
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
|
||||
title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
|
||||
<Eraser className="size-3.5" />
|
||||
{t('btn.clear')}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving} className="h-8">
|
||||
<Button size="sm" onClick={save} disabled={saving} className="h-8 px-3 text-xs">
|
||||
<Send className="size-3.5" />
|
||||
{saving ? '…' : t('btn.logQso')}
|
||||
</Button>
|
||||
@@ -3661,6 +3774,29 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
|
||||
is a fixed 6-column grid, so adding the meter as its own child pushed
|
||||
the last columns (profile / band map / compact) onto a 2nd row. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{showQsoRate && (
|
||||
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||
title={t('rate.title')}>
|
||||
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||
{/* Contest-style rate: QSOs/hour projected from each window
|
||||
(10-min count ×6; the 60-min count is already per hour). Numbers
|
||||
glow the brand accent when active, dim to muted when idle. */}
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
|
||||
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
|
||||
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Space-weather / propagation — compact, in the header. Live from N0NBH
|
||||
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
|
||||
onto each logged QSO. Always renders one element so the grid columns
|
||||
@@ -3672,6 +3808,14 @@ export default function App() {
|
||||
const geo = String(solar.geomag_field || '').toUpperCase();
|
||||
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
|
||||
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
|
||||
const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
|
||||
// Semantic colour by band condition: higher flux/sunspots = better HF
|
||||
// (green when strong); A and K measure geomagnetic disturbance, so LOW
|
||||
// is good (green quiet → yellow unsettled → red storm).
|
||||
const sfiCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n >= 120 ? 'text-success' : n >= 90 ? 'text-foreground' : 'text-warning'; };
|
||||
const ssnCls = (v: any) => { const n = num(v); return n != null && n >= 80 ? 'text-success' : 'text-foreground'; };
|
||||
const aCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 7 ? 'text-success' : n <= 15 ? 'text-warning' : 'text-danger'; };
|
||||
const kCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 2 ? 'text-success' : n <= 3 ? 'text-warning' : 'text-danger'; };
|
||||
const it = (label: string, val: any, cls = 'text-foreground') => (
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
|
||||
@@ -3679,15 +3823,16 @@ export default function App() {
|
||||
</span>
|
||||
);
|
||||
return (<>
|
||||
{it('SFI', solar.sfi)}
|
||||
{it('SSN', solar.ssn)}
|
||||
{it('A', solar.a_index)}
|
||||
{it('K', solar.k_index)}
|
||||
{it('SFI', solar.sfi, sfiCls(solar.sfi))}
|
||||
{it('SSN', solar.ssn, ssnCls(solar.ssn))}
|
||||
{it('A', solar.a_index, aCls(solar.a_index))}
|
||||
{it('K', solar.k_index, kCls(solar.k_index))}
|
||||
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
|
||||
</>);
|
||||
})()}
|
||||
</div>
|
||||
) : <span />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||
<Clock className="size-3" />
|
||||
@@ -3910,9 +4055,6 @@ export default function App() {
|
||||
</div>
|
||||
{qthBlock}
|
||||
{gridBlock}
|
||||
{lotwBlock}
|
||||
{offlineBlock}
|
||||
{alertLedBlock}
|
||||
</div>
|
||||
|
||||
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
||||
@@ -3929,11 +4071,19 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: TX freq, RX freq, RX band — plus the action buttons. */}
|
||||
{/* Bottom: TX freq, RX freq, RX band — then the alert LED + LoTW badge
|
||||
(vertically centred on the input row), then the action buttons. */}
|
||||
<div className="flex gap-2 items-end">
|
||||
{freqBlock}
|
||||
{rxFreqBlock}
|
||||
{bandRxBlock}
|
||||
{(alertLedBlock || lotwBlock || offlineBlock) && (
|
||||
<div className="flex items-center gap-2 h-9 self-end">
|
||||
{alertLedBlock}
|
||||
{lotwBlock}
|
||||
{offlineBlock}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto">{logButtons}</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -64,7 +64,9 @@ const FIELDS: FieldDef[] = [
|
||||
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||
// Contacted station (activation refs / SIG)
|
||||
// Contacted station (location / activation refs / SIG)
|
||||
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
|
||||
@@ -51,6 +51,8 @@ export type SpotStatusEntry = {
|
||||
country?: string;
|
||||
continent?: string;
|
||||
worked_call?: boolean;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -101,13 +103,43 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||
// there's nothing notable to show.
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||
type Badge = { text: string; fg: string; bg: string; bd: string };
|
||||
|
||||
// tok builds a badge from a semantic status token (success/warning/caution/
|
||||
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
||||
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
||||
function tok(name: string, text: string): Badge {
|
||||
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
||||
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
||||
}
|
||||
|
||||
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
||||
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
||||
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
||||
// null → plain text, no pill.
|
||||
function cellChip(value: any, name: string | null): any {
|
||||
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
||||
// callsign stays the same size as the plain callsigns next to it — just tinted
|
||||
// and rounded, not shrunk.
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
||||
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
||||
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
||||
}}>{txt}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||
case 'new-band': return tok('warning', t('clg2.newBand'));
|
||||
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
||||
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
||||
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,22 +149,22 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
sort: 'desc',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
||||
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
||||
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
||||
// the rest of the row across every theme instead of always shouting orange.
|
||||
cellStyle: (p: any): any => {
|
||||
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
||||
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
||||
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
||||
// with the rest of the row instead of always shouting a colour.
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
|
||||
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
|
||||
return { fontWeight: 700 };
|
||||
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
||||
const color = s?.worked_call ? 'var(--info)' : undefined;
|
||||
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
@@ -141,26 +173,42 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
||||
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||
defaultVisible: true,
|
||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||
// is obvious at the row level, not just a single coloured cell.
|
||||
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
||||
// and NEW POTA are orthogonal, so they stack as extra badges.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
||||
const parts: string[] = [];
|
||||
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
||||
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||
return parts.join(' ');
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const b = statusBadge(t, statusFor(p));
|
||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||
const s = statusFor(p);
|
||||
const badges: Badge[] = [];
|
||||
const b = statusBadge(t, s);
|
||||
if (b) badges.push(b);
|
||||
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
||||
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
||||
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{
|
||||
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
||||
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{b.text}</span>
|
||||
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||
{badges.map((bd, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
||||
fontWeight: 700, fontSize: 9,
|
||||
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{bd.text}</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
@@ -176,7 +224,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#166534' },
|
||||
cellStyle: { color: 'var(--success)' },
|
||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||
},
|
||||
{
|
||||
@@ -191,10 +239,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
||||
: undefined),
|
||||
// NEW BAND for this entity → small warning pill around the band text.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
@@ -203,16 +249,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
||||
cellStyle: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
||||
// Status badge text tells them apart.
|
||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
||||
return undefined;
|
||||
},
|
||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||
// that case is signalled by the Status badge alone.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
@@ -224,7 +265,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||
@@ -261,7 +302,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
]?.country ?? '',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||
@@ -270,31 +311,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||
]?.continent ?? '',
|
||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
||||
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
||||
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||
defaultVisible: true,
|
||||
cellStyle: { color: '#7a6b50' },
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||
|
||||
@@ -25,8 +25,8 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||
<Mic className="size-3.5 text-primary" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
|
||||
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
|
||||
{status.playing && <span className="text-[10px] text-danger font-medium">TX</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||
<Square className="size-3" /> {t('dvkp.stop')}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
@@ -189,7 +190,7 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||
return (
|
||||
<div className="flex items-center gap-2" title={title}>
|
||||
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
|
||||
<div ref={ref} tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
|
||||
<div ref={ref} data-offsetrow tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
|
||||
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
|
||||
'focus:outline-none focus:ring-2 focus:ring-info/50',
|
||||
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||
@@ -317,6 +318,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
|
||||
const off = !st.available;
|
||||
const rxOff = off || !st.rx_avail;
|
||||
|
||||
// Radio-style global RIT tuning: Ctrl+←/→ nudges the RIT offset (Ctrl+Shift =
|
||||
// ±100 Hz) from ANYWHERE — including while the callsign/RST entry field has
|
||||
// focus, which is where the operator is while working a station. Only the RIT
|
||||
// offset row itself (data-offsetrow) is skipped, since it handles its own
|
||||
// arrows. Text-field word navigation via Ctrl+← / → is overridden only while
|
||||
// RIT is actually engaged (guarded below), which is fine mid-QSO. Requires RIT
|
||||
// on and the RX available.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
const ae = document.activeElement as HTMLElement | null;
|
||||
if (ae && ae.hasAttribute('data-offsetrow')) return;
|
||||
if (!st.rit || rxOff) return;
|
||||
e.preventDefault();
|
||||
const mag = e.shiftKey ? 100 : 10;
|
||||
const next = (st.rit_freq || 0) + (e.key === 'ArrowRight' ? mag : -mag);
|
||||
change('rit_freq', next, () => FlexSetRITFreq(next));
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [st.rit, st.rit_freq, rxOff]);
|
||||
|
||||
// Clear the RIT offset back to 0 whenever a QSO is logged (any path: Log
|
||||
// button, keyer macro, WSJT-X), so the next station starts centred. Only acts
|
||||
// when there's actually a non-zero offset to clear.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('qso:logged', () => {
|
||||
if (st.rit && (st.rit_freq || 0) !== 0) change('rit_freq', 0, () => FlexSetRITFreq(0));
|
||||
});
|
||||
return () => off();
|
||||
}, [st.rit, st.rit_freq]);
|
||||
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { QSOForm } from '@/types';
|
||||
import {
|
||||
@@ -59,13 +60,13 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
|
||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||
|
||||
// Add/edit-contact dialog.
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
const [contact, setContact] = useState<Station>(emptyStation());
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
const activeGrid = useRef<any>(null);
|
||||
const rosterGrid = useRef<any>(null);
|
||||
|
||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||
@@ -230,18 +231,6 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
||||
], [t]);
|
||||
|
||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
@@ -292,26 +281,20 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
{t('ncp.onAirActive')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<QSOForm>
|
||||
ref={activeGrid}
|
||||
theme={hamlogTheme}
|
||||
rowData={active}
|
||||
columnDefs={activeCols}
|
||||
defaultColDef={defaultColDef}
|
||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||
animateRows={false}
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<RecentQSOsGrid
|
||||
rows={active}
|
||||
total={active.length}
|
||||
storageKey="net.onair"
|
||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||
onRowSelected={setSelectedActiveIds}
|
||||
/>
|
||||
</div>
|
||||
{isOpen && active.length > 0 && (
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
||||
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -335,7 +318,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{!wbCall ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||
) : wbBusy ? (
|
||||
@@ -343,30 +326,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
) : !wb || wb.count === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
||||
<tr className="text-left">
|
||||
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
|
||||
<th className="px-2 py-1 font-semibold">RST</th>
|
||||
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{((wb.entries ?? []) as any[]).map((q, i) => (
|
||||
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
|
||||
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
|
||||
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
|
||||
<td className="px-2 py-1">{q.band}</td>
|
||||
<td className="px-2 py-1">{q.mode}</td>
|
||||
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
|
||||
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<RecentQSOsGrid
|
||||
rows={(wb.entries ?? []) as any}
|
||||
total={wb.count}
|
||||
storageKey="net.wb"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
||||
|
||||
type Confirmation = {
|
||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
||||
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||
};
|
||||
|
||||
const SERVICES = [
|
||||
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
|
||||
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
|
||||
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
|
||||
const [stats, setStats] = useState<any>(
|
||||
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0, ph_worked: 0, ph_confirmed: 0, cw_worked: 0, cw_confirmed: 0, dig_worked: 0, dig_confirmed: 0 });
|
||||
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
|
||||
useEffect(() => { refreshCounts(); }, [refreshCounts]);
|
||||
|
||||
useEffect(() => {
|
||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||
setBusy(false);
|
||||
refreshCounts(); // a download may have added confirmations
|
||||
});
|
||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||
setConfirmations((list ?? []) as Confirmation[]);
|
||||
setViewMode('confirmations');
|
||||
refreshCounts();
|
||||
});
|
||||
return () => { offLog(); offDone(); offConf(); };
|
||||
}, []);
|
||||
}, [refreshCounts]);
|
||||
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
@@ -231,9 +240,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
|
||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||
switch (confFilter) {
|
||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
||||
case 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
|
||||
case 'dxcc': return c.new_dxcc;
|
||||
case 'band': return c.new_band;
|
||||
case 'mode': return c.new_mode;
|
||||
case 'slot': return c.new_slot;
|
||||
default: return true;
|
||||
}
|
||||
@@ -353,6 +363,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI), with the
|
||||
per-class breakdown so the totals are checkable. */}
|
||||
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
|
||||
<span className="font-semibold">{stats.slots_worked}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
|
||||
<span className="font-semibold">{stats.dxcc_worked}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
|
||||
</span>
|
||||
</div>
|
||||
{service === 'pota' && potaRes && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||
@@ -371,6 +397,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
|
||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -491,6 +518,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<td className="py-1 px-2">
|
||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
|
||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
@@ -175,6 +176,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
return base;
|
||||
}, [modes, qso.mode]);
|
||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
|
||||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||
useEffect(() => {
|
||||
GetListsSettings()
|
||||
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||
const splitHz = (hz?: number) => hz
|
||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||
@@ -404,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col w-20"><Label>S</Label>
|
||||
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||
<div className="flex flex-col w-20"><Label>R</Label>
|
||||
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||
title={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
|
||||
@@ -30,7 +30,12 @@ type Props = {
|
||||
// Bump this number to programmatically select every row (e.g. after a search
|
||||
// in the QSL Manager, where the default is "all selected").
|
||||
selectAllSignal?: number;
|
||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||
// reuse this grid elsewhere with its OWN independent column config.
|
||||
storageKey?: string;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
@@ -49,7 +54,7 @@ type Props = {
|
||||
awardCols?: { code: string; name: string }[];
|
||||
};
|
||||
|
||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||
|
||||
function fmtMhzDots(hz?: number): string {
|
||||
if (!hz) return '';
|
||||
@@ -233,10 +238,20 @@ const GRP_KEYS: Record<string, string> = {
|
||||
};
|
||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
// Award columns are governed SOLELY by the awardShown code-set, never by AG
|
||||
// Grid's saved column state. Stripping them here (on both save and restore)
|
||||
// stops a stale saved state from re-hiding a shown award column on every
|
||||
// awardCols rebuild — the desync that made award columns vanish mid-session.
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
// Column-layout persistence keys — scoped by storageKey so a reused instance
|
||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
@@ -276,7 +291,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||
const AWARD_SHOWN_KEY = 'hamlog.awardColsShown';
|
||||
const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
|
||||
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||
useEffect(() => {
|
||||
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||
@@ -334,21 +349,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const local = loadLocal(colStateKey);
|
||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
// Fall back to the portable DB copy when the local cache is empty
|
||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
loadRemote(colStateKey).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
||||
seedLocal(colStateKey, remote);
|
||||
}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||
}, []);
|
||||
|
||||
// The award columns load asynchronously; when they arrive (or change) the
|
||||
@@ -358,8 +373,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const local = loadLocal(colStateKey);
|
||||
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -458,6 +473,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
||||
onSelectionChanged={onSelectionChanged}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
preventDefaultOnContextMenu
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -38,9 +38,12 @@ import {
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -168,6 +171,7 @@ type SectionId =
|
||||
| 'confirmations'
|
||||
| 'external-services'
|
||||
| 'udp'
|
||||
| 'adifmon'
|
||||
| 'lookup'
|
||||
| 'lists-bands'
|
||||
| 'lists-modes'
|
||||
@@ -175,6 +179,7 @@ type SectionId =
|
||||
| 'backup'
|
||||
| 'database'
|
||||
| 'autostart'
|
||||
| 'uscounties'
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'rotator'
|
||||
@@ -183,6 +188,7 @@ type SectionId =
|
||||
| 'antgenius'
|
||||
| 'pgxl'
|
||||
| 'flex'
|
||||
| 'relayauto'
|
||||
| 'audio';
|
||||
|
||||
type TreeNode =
|
||||
@@ -200,6 +206,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
||||
];
|
||||
return [
|
||||
@@ -223,6 +230,8 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
]},
|
||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
||||
],
|
||||
@@ -238,8 +247,11 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||
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',
|
||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||
adifmon: 'sec.adifmon',
|
||||
uscounties: 'sec.uscounties',
|
||||
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',
|
||||
relayauto: 'sec.relayauto',
|
||||
};
|
||||
|
||||
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||
@@ -257,6 +269,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
database: 'Database',
|
||||
autostart: 'Autostart',
|
||||
udp: 'UDP integrations',
|
||||
adifmon: 'ADIF monitor',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
rotator: 'Rotator',
|
||||
@@ -265,6 +278,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
antgenius: 'Antenna Genius',
|
||||
pgxl: 'Power Genius',
|
||||
flex: 'FlexRadio',
|
||||
relayauto: 'Relay auto-control',
|
||||
audio: 'Audio devices',
|
||||
};
|
||||
|
||||
@@ -536,6 +550,7 @@ function AutostartPanelComponent() {
|
||||
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||
// can own its state; embedded inside GeneralPanel.
|
||||
function TelemetryToggle() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -545,8 +560,8 @@ function TelemetryToggle() {
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={on} disabled={!loaded}
|
||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||
Send anonymous usage statistics
|
||||
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
||||
{t('settings.telemetry')}
|
||||
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -556,6 +571,7 @@ function TelemetryToggle() {
|
||||
// events — a small web script on your server renders it for the QRZ page. Only
|
||||
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||
function LiveStatusToggle() {
|
||||
const { t } = useI18n();
|
||||
const [on, setOn] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -565,34 +581,195 @@ function LiveStatusToggle() {
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={on} disabled={!loaded}
|
||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
||||
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
|
||||
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
|
||||
// contact that arrives is imported and uploaded automatically like any log entry.
|
||||
type ADIFWatchFileUI = { path: string; enabled: boolean; offset: number };
|
||||
type ADIFMonitorCfgUI = { enabled: boolean; files: ADIFWatchFileUI[] };
|
||||
function ADIFMonitorPanel() {
|
||||
const { t } = useI18n();
|
||||
const [cfg, setCfg] = useState<ADIFMonitorCfgUI>({ enabled: false, files: [] });
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
GetADIFMonitor()
|
||||
.then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, files: (c.files ?? []) as ADIFWatchFileUI[] }); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
// Offsets are managed backend-side; SaveADIFMonitor ignores the ones we send and
|
||||
// keeps each existing file's read position (a new file starts at end-of-file).
|
||||
const persist = (next: ADIFMonitorCfgUI) => { setCfg(next); SaveADIFMonitor(next as any).catch(() => {}); };
|
||||
const addFile = async () => {
|
||||
try {
|
||||
const p = await PickADIFMonitorFile();
|
||||
if (!p || cfg.files.some((f) => f.path === p)) return;
|
||||
persist({ ...cfg, files: [...cfg.files, { path: p, enabled: true, offset: -1 }] });
|
||||
} catch { /* dialog cancelled */ }
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<p className="text-xs text-muted-foreground">{t('adifmon.hint')}</p>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => persist({ ...cfg, enabled: !!c })} />
|
||||
{t('adifmon.enable')}
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
{cfg.files.length === 0 && <p className="text-xs text-muted-foreground italic">{t('adifmon.empty')}</p>}
|
||||
{cfg.files.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
|
||||
<Checkbox checked={f.enabled}
|
||||
onCheckedChange={(c) => persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} />
|
||||
<span className="flex-1 font-mono text-xs truncate" title={f.path}>{f.path}</span>
|
||||
<button type="button" title={t('adifmon.remove')}
|
||||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => persist({ ...cfg, files: cfg.files.filter((_, idx) => idx !== i) })}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addFile}>
|
||||
<FolderOpen className="size-3.5 mr-1" /> {t('adifmon.add')}
|
||||
</Button>
|
||||
<p className="text-[11px] text-muted-foreground">{t('adifmon.note')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||
// off, a frequency window (kHz), or a set of bands.
|
||||
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
|
||||
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
|
||||
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
||||
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
|
||||
function RelayAutoPanel() {
|
||||
const { t } = useI18n();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [rules, setRules] = useState<RelayRuleUI[]>([]);
|
||||
const [devices, setDevices] = useState<StationDevUI[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
Promise.all([GetRelayAuto(), GetStationDevices()])
|
||||
.then(([cfg, devs]: any[]) => {
|
||||
setEnabled(!!cfg?.enabled);
|
||||
setRules((cfg?.rules ?? []) as RelayRuleUI[]);
|
||||
setDevices((devs ?? []) as StationDevUI[]);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
const save = (en: boolean, rs: RelayRuleUI[]) => { SaveRelayAuto({ enabled: en, rules: rs } as any).catch(() => {}); };
|
||||
const ruleFor = (dev: string, relay: number): RelayRuleUI =>
|
||||
rules.find((r) => r.device_id === dev && r.relay === relay) ?? { device_id: dev, relay, mode: 'off', freq_lo_khz: 0, freq_hi_khz: 0, bands: [] };
|
||||
// Apply a patch and persist (commit=true) or keep local only (commit=false, for
|
||||
// freq inputs that persist on blur so we don't switch relays on every keystroke).
|
||||
const patchRule = (dev: string, relay: number, patch: Partial<RelayRuleUI>, commit = true) => {
|
||||
const next = { ...ruleFor(dev, relay), ...patch };
|
||||
const others = rules.filter((r) => !(r.device_id === dev && r.relay === relay));
|
||||
const all = [...others, next];
|
||||
setRules(all);
|
||||
if (commit) save(enabled, all);
|
||||
};
|
||||
const toggleBand = (dev: string, relay: number, band: string) => {
|
||||
const cur = ruleFor(dev, relay);
|
||||
const has = cur.bands.includes(band);
|
||||
patchRule(dev, relay, { bands: has ? cur.bands.filter((b) => b !== band) : [...cur.bands, band] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<p className="text-xs text-muted-foreground">{t('relayauto.hint')}</p>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={enabled} disabled={!loaded} onCheckedChange={(c) => { const v = !!c; setEnabled(v); save(v, rules); }} />
|
||||
{t('relayauto.enable')}
|
||||
</label>
|
||||
|
||||
{devices.length === 0 && loaded && (
|
||||
<p className="text-xs text-muted-foreground italic">{t('relayauto.noDevices')}</p>
|
||||
)}
|
||||
|
||||
{devices.map((dev) => (
|
||||
<div key={dev.id} className="rounded-md border border-border">
|
||||
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
|
||||
const r = ruleFor(dev.id, relay);
|
||||
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
||||
return (
|
||||
<div key={relay} className="flex items-start gap-3 px-3 py-2">
|
||||
<span className="w-28 shrink-0 text-xs font-mono pt-1.5 truncate" title={label}>{label}</span>
|
||||
<Select value={r.mode || 'off'} onValueChange={(v) => patchRule(dev.id, relay, { mode: v })}>
|
||||
<SelectTrigger className="h-8 w-32 text-xs shrink-0"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">{t('relayauto.modeOff')}</SelectItem>
|
||||
<SelectItem value="freq">{t('relayauto.modeFreq')}</SelectItem>
|
||||
<SelectItem value="band">{t('relayauto.modeBand')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
{r.mode === 'freq' && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.from')}
|
||||
defaultValue={r.freq_lo_khz || ''}
|
||||
onChange={(e) => patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)}
|
||||
onBlur={() => save(enabled, rules)} />
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.to')}
|
||||
defaultValue={r.freq_hi_khz || ''}
|
||||
onChange={(e) => patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)}
|
||||
onBlur={() => save(enabled, rules)} />
|
||||
<span className="text-muted-foreground">kHz</span>
|
||||
</div>
|
||||
)}
|
||||
{r.mode === 'band' && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{RELAY_BANDS.map((b) => {
|
||||
const on = r.bands.includes(b);
|
||||
return (
|
||||
<button key={b} type="button" onClick={() => toggleBand(dev.id, relay, b)}
|
||||
className={cn('px-1.5 py-0.5 rounded text-[11px] font-mono border transition-colors',
|
||||
on ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||
// panes show, independently: the great-circle map, the locator street map, the
|
||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'map1', label: 'Map — great-circle + beam' },
|
||||
{ value: 'map2', label: 'Map — locator (street)' },
|
||||
{ value: 'cluster', label: 'Cluster spots' },
|
||||
{ value: 'worked', label: 'Worked before' },
|
||||
{ value: 'recent', label: 'Recent QSOs' },
|
||||
{ value: 'netcontrol', label: 'Net control' },
|
||||
];
|
||||
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [left, setLeft] = useState('map1');
|
||||
const [right, setRight] = useState('map2');
|
||||
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
||||
const options = [
|
||||
...MAIN_PANE_OPTIONS,
|
||||
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
|
||||
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
|
||||
].sort((a, b) => a.label.localeCompare(b.label));
|
||||
...MAIN_PANE_VALUES,
|
||||
...(flexAvailable ? ['flex'] : []),
|
||||
...(icomAvailable ? ['icom'] : []),
|
||||
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
useEffect(() => {
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
|
||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||
}, []);
|
||||
@@ -605,11 +782,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
||||
};
|
||||
return (
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
||||
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
|
||||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Left pane</span>
|
||||
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
|
||||
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -618,7 +795,7 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Right pane</span>
|
||||
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
|
||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -908,6 +1085,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||
// Password-encryption (secret vault) state.
|
||||
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
|
||||
@@ -941,6 +1119,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||
});
|
||||
const [emailMsg, setEmailMsg] = useState('');
|
||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||
@@ -1010,6 +1189,34 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||
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 [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
@@ -4164,6 +4371,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
||||
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={showQsoRate} onCheckedChange={(c) => { const v = !!c; setShowQsoRate(v); writeUiPref('opslog.showQsoRate', v ? '1' : '0'); }} />
|
||||
{t('gen.showQsoRate')} <span className="text-xs text-muted-foreground">{t('gen.showQsoRateHint')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||||
@@ -4293,7 +4504,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Label className="text-sm">{t('em.username')}</Label>
|
||||
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||||
<Label className="text-sm">{t('es.password')}</Label>
|
||||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<div className="relative">
|
||||
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
|
||||
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
disabled={!emailCfg.auth}>
|
||||
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
||||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||
<Label className="text-sm">{t('em.replyTo')}</Label>
|
||||
@@ -4332,6 +4551,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).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
general: GeneralPanel,
|
||||
@@ -4346,8 +4627,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
'lists-modes': ModesPanel,
|
||||
cluster: ClusterPanel,
|
||||
udp: UDPIntegrationsPanelWrapper,
|
||||
// Rendered as a real element (not called as a bare function) so its own hooks
|
||||
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
|
||||
// is a plain call and hook-holding panels must go through JSX like this.
|
||||
adifmon: () => <ADIFMonitorPanel />,
|
||||
relayauto: () => <RelayAutoPanel />,
|
||||
backup: BackupPanel,
|
||||
database: DatabasePanel,
|
||||
uscounties: USCountiesPanel,
|
||||
autostart: () => <AutostartPanelComponent />,
|
||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||
cat: CATPanel,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -11,6 +11,7 @@ import { RotorCompass } from '@/components/RotorCompass';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
@@ -91,6 +92,154 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
);
|
||||
}
|
||||
|
||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };
|
||||
|
||||
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||
|
||||
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||
// 1 = driven element, then Director 1, 2, 3…
|
||||
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||
if (i === 0) return t('station.reflector');
|
||||
if (i === 1) return t('station.driven');
|
||||
return `${t('station.director')} ${i - 1}`;
|
||||
}
|
||||
|
||||
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
||||
const [err, setErr] = useState('');
|
||||
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||
const [reading, setReading] = useState(false);
|
||||
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||
const [editVal, setEditVal] = useState('');
|
||||
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||
const isUB = ant.type !== 'steppir';
|
||||
|
||||
const readLengths = useCallback(async () => {
|
||||
setReading(true); setErr('');
|
||||
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setReading(false); }
|
||||
}, []);
|
||||
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||
// +/- starts from the real values rather than blind.
|
||||
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||
|
||||
// Send an absolute length to one element and remember it as the new baseline.
|
||||
const setLen = async (i: number, mm: number) => {
|
||||
const next = Math.max(0, Math.round(mm));
|
||||
const prev = lengths[i] ?? 0;
|
||||
setBusyEl(i); setErr('');
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||
try { await MotorSetElement(i, next); refetch(); }
|
||||
catch (e: any) {
|
||||
// Rejected by the controller — most often the element is at its travel
|
||||
// limit for this band (extending on a low band). Revert the optimistic
|
||||
// value so the display stays truthful, and explain the likely cause when
|
||||
// the failed move was an extension.
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||
}
|
||||
finally { setBusyEl(null); }
|
||||
};
|
||||
// Nudge one element by ±2 mm from its current known length.
|
||||
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||
const commitEdit = (i: number) => {
|
||||
const v = parseInt(editVal, 10);
|
||||
setEditingEl(null);
|
||||
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||
};
|
||||
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<AntennaIcon className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||
</div>
|
||||
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
||||
<div className="flex gap-1">
|
||||
{dirs.map(([d, lbl]) => (
|
||||
<button key={d} type="button" disabled={!ant.connected}
|
||||
onClick={() => run(SetUltrabeamDirection(d))}
|
||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(UltrabeamRetract())}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||
</button>
|
||||
|
||||
{isUB && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||
</button>
|
||||
</div>
|
||||
{lengths.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
|
||||
3-element beam only uses the first few. */}
|
||||
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
{editingEl === i ? (
|
||||
<input autoFocus type="number" value={editVal}
|
||||
onChange={(e) => setEditVal(e.target.value)}
|
||||
onBlur={() => commitEdit(i)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||
) : (
|
||||
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||
title={t('station.setExactLen')}
|
||||
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||
const { t } = useI18n();
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
@@ -98,11 +247,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||||
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||||
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
|
||||
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||||
const [order, setOrder] = useState<string[]>(() => {
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||
});
|
||||
const dragId = useRef<string | null>(null);
|
||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||
@@ -117,13 +268,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
const pollRot = useCallback(async () => {
|
||||
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
const pollAnt = useCallback(async () => {
|
||||
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||
useEffect(() => {
|
||||
poll(); pollRot();
|
||||
const id = window.setInterval(() => { poll(); pollRot(); }, 3000);
|
||||
poll(); pollRot(); pollAnt();
|
||||
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [poll, pollRot, devices.length]);
|
||||
}, [poll, pollRot, pollAnt, devices.length]);
|
||||
|
||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||
// Reorder so `dragged` lands just before `target`.
|
||||
@@ -217,21 +371,43 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
if (ant.enabled) {
|
||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||
}
|
||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||
|
||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||
const widgetIds = ordered.map((w) => w.id);
|
||||
|
||||
const noDevices = devices.length === 0 && !rot.enabled;
|
||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
|
||||
|
||||
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
||||
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||||
// fill however many columns are available.
|
||||
const gridCols: Record<string, string> = {
|
||||
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
|
||||
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||
{(['auto', '2', '3', '4'] as const).map((c) => (
|
||||
<button key={c} type="button"
|
||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{c === 'auto' ? t('station.colsAuto') : c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{noDevices && !editing && (
|
||||
@@ -240,7 +416,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 max-w-4xl md:grid-cols-2 items-start">
|
||||
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
|
||||
{ordered.map((w) => (
|
||||
<div key={w.id} draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
@@ -253,7 +429,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
|
||||
{!noDevices && (
|
||||
<p className="text-[11px] text-muted-foreground mt-2 max-w-4xl">{t('station.dragHint')}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
|
||||
import { GetLogStats, GetContestRuns, GetOperators } from '../../wailsjs/go/main/App';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -80,7 +80,7 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
|
||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||
// on a tooltip to get a number.
|
||||
|
||||
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
|
||||
function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
|
||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||
@@ -92,7 +92,7 @@ function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empt
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{top.map((d) => (
|
||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
||||
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||
<div
|
||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||
@@ -115,7 +115,7 @@ function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empt
|
||||
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||
// information.
|
||||
|
||||
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
|
||||
function VBars({ data, empty, height = 150, showValues, colorful }: { data: Bucket[]; empty: string; height?: number; showValues?: boolean; colorful?: boolean }) {
|
||||
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||
@@ -129,12 +129,13 @@ function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; h
|
||||
{data.map((d, i) => (
|
||||
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||
title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
||||
<span className={cn('text-[9px] font-semibold mb-0.5 tabular-nums whitespace-nowrap',
|
||||
showValues ? 'text-foreground' : 'text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity')}>
|
||||
{nf(d.count)}
|
||||
</span>
|
||||
<div
|
||||
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : 'var(--chart-1)' }}
|
||||
/>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||
{i % every === 0 ? d.key : ''}
|
||||
@@ -490,10 +491,14 @@ export function StatsPanel() {
|
||||
// that contest AND lets the window derive from its own span — no date typing.
|
||||
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||
const [contest, setContest] = useState('');
|
||||
// Operator picker: '' = all operators, "—" = station owner (empty OPERATOR).
|
||||
const [operators, setOperators] = useState<string[]>([]);
|
||||
const [operator, setOperator] = useState('');
|
||||
|
||||
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||
useEffect(() => { GetOperators().then((o: any) => setOperators((o ?? []) as string[])).catch(() => {}); }, []);
|
||||
|
||||
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
|
||||
const load = async (p: Period = period, f = from, t2 = to, c = contest, op = operator) => {
|
||||
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||
// with it — the backend derives them. Sending a period as well would be two
|
||||
// filters fighting over the same axis.
|
||||
@@ -501,7 +506,7 @@ export function StatsPanel() {
|
||||
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
|
||||
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0, op)) as any;
|
||||
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||
// once, here, rather than guarding at every use site and missing one.
|
||||
@@ -522,9 +527,9 @@ export function StatsPanel() {
|
||||
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||
useEffect(() => {
|
||||
if (!contest && period === 'custom' && !(from && to)) return;
|
||||
load(period, from, to, contest);
|
||||
load(period, from, to, contest, operator);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [period, from, to, contest]);
|
||||
}, [period, from, to, contest, operator]);
|
||||
|
||||
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||
@@ -569,6 +574,20 @@ export function StatsPanel() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Operator picker — narrows every stat (totals, DXCC, top countries,
|
||||
continent split, rate) to one operator from the log. "—" = the
|
||||
station owner (QSOs logged with no OPERATOR set). */}
|
||||
{operators.length > 1 && (
|
||||
<select value={operator} onChange={(e) => setOperator(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[180px]"
|
||||
title={t('stats.operatorTip')}>
|
||||
<option value="">{t('stats.allOperators')}</option>
|
||||
{operators.map((op) => (
|
||||
<option key={op} value={op}>{op === '—' ? t('stats.stationOwner') : op}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||
{([
|
||||
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||
@@ -705,7 +724,7 @@ export function StatsPanel() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||
<VBars data={stats.by_band} empty={empty} />
|
||||
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')}>
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||
@@ -718,7 +737,7 @@ export function StatsPanel() {
|
||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||
instead of truncating. */}
|
||||
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
||||
<Card title={t('stats.byOperator')}>
|
||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_operator} empty={empty} share />
|
||||
</div>
|
||||
@@ -728,7 +747,7 @@ export function StatsPanel() {
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')}>
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} />
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<Card title={t('stats.confirmations')} className="flex-1">
|
||||
|
||||
+94
-12
File diff suppressed because one or more lines are too long
@@ -14,3 +14,25 @@ export function sMeterRST(s: number, overDb: number, mode?: string): string {
|
||||
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
||||
return `5${strength}`;
|
||||
}
|
||||
|
||||
// RST dropdown lists, shared by the entry form and the QSO editor so both offer
|
||||
// the same per-mode choices (Settings → Modes → RST report lists).
|
||||
export type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||
|
||||
// rstCategory maps an ADIF mode to its RST family (phone / cw / digital).
|
||||
export function rstCategory(mode: string): keyof RSTLists {
|
||||
const m = (mode || '').toUpperCase();
|
||||
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
|
||||
if (digital.includes(m)) return 'digital';
|
||||
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
||||
return 'phone';
|
||||
}
|
||||
|
||||
// rstOptions returns the valid report choices for a mode from the user's
|
||||
// editable lists, with a tiny fallback before they load.
|
||||
export function rstOptions(mode: string, lists: RSTLists): string[] {
|
||||
const cat = rstCategory(mode);
|
||||
const l = lists[cat];
|
||||
if (l && l.length) return l;
|
||||
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||
import { writeUiPref } from './uiPref';
|
||||
import { GetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||
@@ -49,13 +50,44 @@ export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||
// Set once the operator changes the theme by hand, so the self-heal below
|
||||
// never clobbers a fresh choice with a value it read a moment earlier.
|
||||
const userPicked = useRef(false);
|
||||
|
||||
const setTheme = useCallback((t: ThemeChoice) => {
|
||||
userPicked.current = true;
|
||||
setThemeState(t);
|
||||
applyThemeToDom(t);
|
||||
writeUiPref(LS_KEY, t);
|
||||
}, []);
|
||||
|
||||
// Self-heal the persisted theme. The synchronous boot read (localStorage) can
|
||||
// miss it when the WebView cleared its storage, OR when syncPortablePrefs ran
|
||||
// while the backend was still starting (settings store not wired yet → GetUIPref
|
||||
// returned "" with no error, so nothing was restored) — the "restart lands on
|
||||
// the light theme sometimes" bug. Re-read the portable pref from the DB once the
|
||||
// backend is up and apply it, retrying briefly to ride out a slow startup.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let tries = 0;
|
||||
const load = () => {
|
||||
tries += 1;
|
||||
GetUIPref(LS_KEY).then((raw) => {
|
||||
if (cancelled || userPicked.current) return;
|
||||
const v = raw as ThemeChoice;
|
||||
if (v && ALL.includes(v)) {
|
||||
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||
setThemeState(v);
|
||||
return; // restored
|
||||
}
|
||||
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
|
||||
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||
useEffect(() => {
|
||||
if (theme !== 'auto') return;
|
||||
|
||||
@@ -19,6 +19,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.19.8';
|
||||
export const APP_VERSION = '0.20.0';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+27
-1
@@ -64,6 +64,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
|
||||
|
||||
export function AwardsFolder():Promise<string>;
|
||||
|
||||
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||
|
||||
export function BrowseExecutable():Promise<string>;
|
||||
|
||||
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 DownloadULSCounties():Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
|
||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||
@@ -274,6 +278,8 @@ export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAlertEmailTo():Promise<string>;
|
||||
@@ -352,7 +358,7 @@ export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
|
||||
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise<qso.Stats>;
|
||||
|
||||
export function GetLogbookRevision():Promise<string>;
|
||||
|
||||
@@ -364,6 +370,8 @@ export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||
|
||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||
|
||||
export function GetOperators():Promise<Array<string>>;
|
||||
|
||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||
|
||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||
@@ -376,12 +384,18 @@ export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||
|
||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
|
||||
export function GetQSORate():Promise<main.QSORate>;
|
||||
|
||||
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
|
||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||
|
||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
@@ -538,6 +552,10 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorReadElements():Promise<Array<number>>;
|
||||
|
||||
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function MoveDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||
@@ -586,6 +604,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
||||
|
||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PickADIFMonitorFile():Promise<string>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
@@ -676,6 +696,8 @@ export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||
|
||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||
|
||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||
@@ -720,6 +742,8 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
|
||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
@@ -802,6 +826,8 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||
|
||||
export function UltrabeamRetract():Promise<void>;
|
||||
|
||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||
|
||||
@@ -86,6 +86,10 @@ export function AwardsFolder() {
|
||||
return window['go']['main']['App']['AwardsFolder']();
|
||||
}
|
||||
|
||||
export function BackfillUSCounties() {
|
||||
return window['go']['main']['App']['BackfillUSCounties']();
|
||||
}
|
||||
|
||||
export function BrowseExecutable() {
|
||||
return window['go']['main']['App']['BrowseExecutable']();
|
||||
}
|
||||
@@ -258,6 +262,10 @@ export function DownloadLoTWUsers() {
|
||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||
}
|
||||
|
||||
export function DownloadULSCounties() {
|
||||
return window['go']['main']['App']['DownloadULSCounties']();
|
||||
}
|
||||
|
||||
export function DuplicateProfile(arg1, arg2) {
|
||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||
}
|
||||
@@ -506,6 +514,10 @@ export function FlexTune(arg1) {
|
||||
return window['go']['main']['App']['FlexTune'](arg1);
|
||||
}
|
||||
|
||||
export function GetADIFMonitor() {
|
||||
return window['go']['main']['App']['GetADIFMonitor']();
|
||||
}
|
||||
|
||||
export function GetActiveProfile() {
|
||||
return window['go']['main']['App']['GetActiveProfile']();
|
||||
}
|
||||
@@ -662,8 +674,8 @@ export function GetLogFilePath() {
|
||||
return window['go']['main']['App']['GetLogFilePath']();
|
||||
}
|
||||
|
||||
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
||||
export function GetLogStats(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function GetLogbookRevision() {
|
||||
@@ -686,6 +698,10 @@ export function GetOnlineOperators() {
|
||||
return window['go']['main']['App']['GetOnlineOperators']();
|
||||
}
|
||||
|
||||
export function GetOperators() {
|
||||
return window['go']['main']['App']['GetOperators']();
|
||||
}
|
||||
|
||||
export function GetPGXLSettings() {
|
||||
return window['go']['main']['App']['GetPGXLSettings']();
|
||||
}
|
||||
@@ -710,6 +726,14 @@ export function GetQSO(arg1) {
|
||||
return window['go']['main']['App']['GetQSO'](arg1);
|
||||
}
|
||||
|
||||
export function GetQSORate() {
|
||||
return window['go']['main']['App']['GetQSORate']();
|
||||
}
|
||||
|
||||
export function GetRelayAuto() {
|
||||
return window['go']['main']['App']['GetRelayAuto']();
|
||||
}
|
||||
|
||||
export function GetRotatorHeading() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
@@ -722,6 +746,10 @@ export function GetSecretStatus() {
|
||||
return window['go']['main']['App']['GetSecretStatus']();
|
||||
}
|
||||
|
||||
export function GetSlotStats() {
|
||||
return window['go']['main']['App']['GetSlotStats']();
|
||||
}
|
||||
|
||||
export function GetSolarData() {
|
||||
return window['go']['main']['App']['GetSolarData']();
|
||||
}
|
||||
@@ -1034,6 +1062,14 @@ export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function MotorReadElements() {
|
||||
return window['go']['main']['App']['MotorReadElements']();
|
||||
}
|
||||
|
||||
export function MotorSetElement(arg1, arg2) {
|
||||
return window['go']['main']['App']['MotorSetElement'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function MoveDatabase(arg1) {
|
||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||
}
|
||||
@@ -1130,6 +1166,10 @@ export function PGXLSetFanMode(arg1) {
|
||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||
}
|
||||
|
||||
export function PickADIFMonitorFile() {
|
||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||
}
|
||||
|
||||
export function PickAudioFolder() {
|
||||
return window['go']['main']['App']['PickAudioFolder']();
|
||||
}
|
||||
@@ -1310,6 +1350,10 @@ export function SaveADIFFile() {
|
||||
return window['go']['main']['App']['SaveADIFFile']();
|
||||
}
|
||||
|
||||
export function SaveADIFMonitor(arg1) {
|
||||
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAlertRule(arg1) {
|
||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||
}
|
||||
@@ -1398,6 +1442,10 @@ export function SaveQSLDefaults(arg1) {
|
||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRelayAuto(arg1) {
|
||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotatorSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||
}
|
||||
@@ -1562,6 +1610,10 @@ export function TestUltrabeam(arg1) {
|
||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||
}
|
||||
|
||||
export function ULSStatus() {
|
||||
return window['go']['main']['App']['ULSStatus']();
|
||||
}
|
||||
|
||||
export function UltrabeamRetract() {
|
||||
return window['go']['main']['App']['UltrabeamRetract']();
|
||||
}
|
||||
|
||||
@@ -1296,6 +1296,55 @@ export namespace lotwusers {
|
||||
|
||||
export namespace main {
|
||||
|
||||
export class ADIFWatchFile {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
offset: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ADIFWatchFile(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.path = source["path"];
|
||||
this.enabled = source["enabled"];
|
||||
this.offset = source["offset"];
|
||||
}
|
||||
}
|
||||
export class ADIFMonitorConfig {
|
||||
enabled: boolean;
|
||||
files: ADIFWatchFile[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ADIFMonitorConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.files = this.convertValues(source["files"], ADIFWatchFile);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class AntGeniusSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
@@ -1575,6 +1624,22 @@ export namespace main {
|
||||
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 {
|
||||
enabled: boolean;
|
||||
folder: string;
|
||||
@@ -2313,6 +2378,75 @@ export namespace main {
|
||||
this.pickable = source["pickable"];
|
||||
}
|
||||
}
|
||||
export class QSORate {
|
||||
last10: number;
|
||||
last60: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSORate(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.last10 = source["last10"];
|
||||
this.last60 = source["last60"];
|
||||
}
|
||||
}
|
||||
export class RelayAutoRule {
|
||||
device_id: string;
|
||||
relay: number;
|
||||
mode: string;
|
||||
freq_lo_khz: number;
|
||||
freq_hi_khz: number;
|
||||
bands: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RelayAutoRule(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.device_id = source["device_id"];
|
||||
this.relay = source["relay"];
|
||||
this.mode = source["mode"];
|
||||
this.freq_lo_khz = source["freq_lo_khz"];
|
||||
this.freq_hi_khz = source["freq_hi_khz"];
|
||||
this.bands = source["bands"];
|
||||
}
|
||||
}
|
||||
export class RelayAutoConfig {
|
||||
enabled: boolean;
|
||||
rules: RelayAutoRule[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RelayAutoConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.rules = this.convertValues(source["rules"], RelayAutoRule);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class RotatorHeading {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
@@ -2371,6 +2505,7 @@ export namespace main {
|
||||
call: string;
|
||||
band: string;
|
||||
mode: string;
|
||||
pota_ref?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SpotQuery(source);
|
||||
@@ -2381,6 +2516,7 @@ export namespace main {
|
||||
this.call = source["call"];
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.pota_ref = source["pota_ref"];
|
||||
}
|
||||
}
|
||||
export class SpotStatus {
|
||||
@@ -2391,6 +2527,8 @@ export namespace main {
|
||||
continent?: string;
|
||||
status: string;
|
||||
worked_call: boolean;
|
||||
new_county: boolean;
|
||||
new_pota: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SpotStatus(source);
|
||||
@@ -2405,6 +2543,8 @@ export namespace main {
|
||||
this.continent = source["continent"];
|
||||
this.status = source["status"];
|
||||
this.worked_call = source["worked_call"];
|
||||
this.new_county = source["new_county"];
|
||||
this.new_pota = source["new_pota"];
|
||||
}
|
||||
}
|
||||
export class StartupStatus {
|
||||
@@ -2548,6 +2688,20 @@ export namespace main {
|
||||
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 {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
@@ -2580,11 +2734,13 @@ export namespace main {
|
||||
}
|
||||
export class UltrabeamStatusInfo {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
connected: boolean;
|
||||
direction: number;
|
||||
frequency: number;
|
||||
band: number;
|
||||
moving: boolean;
|
||||
elements: number[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UltrabeamStatusInfo(source);
|
||||
@@ -2593,11 +2749,13 @@ export namespace main {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.connected = source["connected"];
|
||||
this.direction = source["direction"];
|
||||
this.frequency = source["frequency"];
|
||||
this.band = source["band"];
|
||||
this.moving = source["moving"];
|
||||
this.elements = source["elements"];
|
||||
}
|
||||
}
|
||||
export class UpdateInfo {
|
||||
@@ -3655,6 +3813,36 @@ export namespace qso {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SlotStats {
|
||||
slots_worked: number;
|
||||
slots_confirmed: number;
|
||||
dxcc_worked: number;
|
||||
dxcc_confirmed: number;
|
||||
ph_worked: number;
|
||||
ph_confirmed: number;
|
||||
cw_worked: number;
|
||||
cw_confirmed: number;
|
||||
dig_worked: number;
|
||||
dig_confirmed: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SlotStats(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.slots_worked = source["slots_worked"];
|
||||
this.slots_confirmed = source["slots_confirmed"];
|
||||
this.dxcc_worked = source["dxcc_worked"];
|
||||
this.dxcc_confirmed = source["dxcc_confirmed"];
|
||||
this.ph_worked = source["ph_worked"];
|
||||
this.ph_confirmed = source["ph_confirmed"];
|
||||
this.cw_worked = source["cw_worked"];
|
||||
this.cw_confirmed = source["cw_confirmed"];
|
||||
this.dig_worked = source["dig_worked"];
|
||||
this.dig_confirmed = source["dig_confirmed"];
|
||||
}
|
||||
}
|
||||
export class Stats {
|
||||
total: number;
|
||||
unique_calls: number;
|
||||
|
||||
+60
-1
@@ -318,7 +318,7 @@ func Migrate(defs []Def) ([]Def, bool) {
|
||||
func Fields() []string {
|
||||
return []string{
|
||||
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||
"state", "cont", "country", "grid", "grid4",
|
||||
"state", "us_county", "cont", "country", "grid", "grid4",
|
||||
"iota", "sota_ref", "pota_ref", "wwff",
|
||||
"name", "qth", "address", "comment", "note",
|
||||
}
|
||||
@@ -643,6 +643,63 @@ func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
|
||||
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||
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 ""
|
||||
}
|
||||
// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
|
||||
// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
|
||||
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
|
||||
// this internal match key uses "/".
|
||||
return st + "/" + co
|
||||
}
|
||||
|
||||
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||
// name resolver as a fallback).
|
||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||
@@ -1145,6 +1202,8 @@ func fieldRaw(field string, q *qso.QSO) string {
|
||||
return q.Callsign
|
||||
case "state":
|
||||
return q.State
|
||||
case "us_county":
|
||||
return USCountyKey(q.State, q.County)
|
||||
case "cont":
|
||||
return q.Continent
|
||||
case "country":
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"def": {
|
||||
"code": "USA-CA",
|
||||
"name": "USA-CA (US Counties)",
|
||||
"description": "CQ United States of America Counties Award. Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes. Independent cities of Virginia/Nevada and DC are excluded per the award rules.",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"type": "QSOFIELDS",
|
||||
"field": "us_county",
|
||||
"match_by": "code",
|
||||
"exact_match": true,
|
||||
"pattern": "",
|
||||
"ref_display": "name",
|
||||
"url": "https://cq-amateur-radio.com/cq_awards/cq_usa_ca_awards/cq_usa_ca_awards.html",
|
||||
"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", …)
|
||||
// - WAS → ADIF STATE code ("AL", …)
|
||||
// - DDFM → "D06" (the award pattern captures the leading D)
|
||||
// - USA-CA → canonical "STATE/COUNTY" key (see award.USCountyKey)
|
||||
func BuiltinRefs(code string) ([]Ref, bool) {
|
||||
switch code {
|
||||
case "DXCC":
|
||||
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
|
||||
return usStates().Refs, true
|
||||
case "DDFM":
|
||||
return frenchDepartments(), true
|
||||
case "USA-CA":
|
||||
return usCounties(), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -534,8 +534,14 @@ func (s *session) emitLine(text string, sent bool) {
|
||||
// ---------- parsing ----------
|
||||
|
||||
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
||||
//
|
||||
// The spotter→freq separator is (?::\s*|\s+): a colon followed by ANY number of
|
||||
// spaces (including ZERO), or one-or-more spaces with no colon. Some RBN skimmer
|
||||
// nodes glue the frequency straight onto the colon — "DX de DL1HWS-3-#:14024.0 …"
|
||||
// — which the old ":?\s+" (colon then a REQUIRED space) rejected, dropping every
|
||||
// spot from those nodes.
|
||||
var spotRE = regexp.MustCompile(
|
||||
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||
`^\s*DX\s+de\s+([A-Z0-9/#\-]+)(?::\s*|\s+)(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||
)
|
||||
|
||||
// Pacing for the per-server init commands.
|
||||
|
||||
+197
-6
@@ -1605,7 +1605,7 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
|
||||
// column to this list AND populate it in scanAwardQSO below, or that award will
|
||||
// silently see an empty value during stats/computation.
|
||||
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
|
||||
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
|
||||
`grid, vucc_grids, country, state, cnty, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
|
||||
`name, qth, address, comment, notes, ` +
|
||||
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
|
||||
|
||||
@@ -1640,6 +1640,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
||||
qsoDateStr string
|
||||
freqHz sql.NullInt64
|
||||
grid, vucc, country, state sql.NullString
|
||||
cnty sql.NullString
|
||||
cont, iotaRef, sota, pota sql.NullString
|
||||
dxcc, cqz, ituz sql.NullInt64
|
||||
name, qth, address sql.NullString
|
||||
@@ -1649,7 +1650,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
||||
)
|
||||
if err := s.Scan(
|
||||
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
|
||||
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
|
||||
&grid, &vucc, &country, &state, &cnty, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
|
||||
&name, &qth, &address, &comment, ¬es,
|
||||
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
||||
); err != nil {
|
||||
@@ -1664,6 +1665,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
||||
q.VUCCGrids = vucc.String
|
||||
q.Country = country.String
|
||||
q.State = state.String
|
||||
q.County = cnty.String
|
||||
q.Continent = cont.String
|
||||
if cqz.Valid {
|
||||
v := int(cqz.Int64)
|
||||
@@ -1782,6 +1784,78 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedCountyKeys returns the set of counties already worked, keyed by the
|
||||
// caller-supplied normaliser (award.USCountyKey — passed in to avoid importing
|
||||
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
|
||||
// are considered. Empty keys (unresolvable state/county) are skipped.
|
||||
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
|
||||
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 1024)
|
||||
for rows.Next() {
|
||||
var state, cnty string
|
||||
if err := rows.Scan(&state, &cnty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if k := keyFn(state, cnty); k != "" {
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
|
||||
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
|
||||
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
|
||||
// an FT8 skimmer firehose (and hammers a remote MySQL).
|
||||
func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT upper(callsign), upper(band), upper(mode) FROM qso WHERE callsign != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 4096)
|
||||
for rows.Next() {
|
||||
var c, b, m string
|
||||
if err := rows.Scan(&c, &b, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[c+"|"+b+"|"+m] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedPOTARefs returns the set of POTA park references already worked
|
||||
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
||||
// (an n-fer); each is added separately.
|
||||
func (r *Repo) WorkedPOTARefs(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT pota_ref FROM qso WHERE pota_ref IS NOT NULL AND pota_ref != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 256)
|
||||
for rows.Next() {
|
||||
var ref string
|
||||
if err := rows.Scan(&ref); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range strings.Split(ref, ",") {
|
||||
if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
|
||||
out[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Count returns the total number of QSOs in the database.
|
||||
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
@@ -1789,6 +1863,40 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecentRate counts QSOs whose start time falls within each trailing window from
|
||||
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
|
||||
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
|
||||
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
|
||||
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
|
||||
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
|
||||
counts := make([]int, len(windows))
|
||||
// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
|
||||
// QSO inside the trailing windows is among the most recently inserted.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now = now.UTC()
|
||||
for rows.Next() {
|
||||
var dateStr sql.NullString
|
||||
if err := rows.Scan(&dateStr); err != nil {
|
||||
return counts, err
|
||||
}
|
||||
t := parseTimeLoose(dateStr.String).UTC()
|
||||
if t.IsZero() || t.After(now) {
|
||||
continue
|
||||
}
|
||||
age := now.Sub(t)
|
||||
for i, w := range windows {
|
||||
if age <= w {
|
||||
counts[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
||||
// used by the ADIF importer to skip records that would re-create the
|
||||
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
|
||||
@@ -1958,18 +2066,100 @@ func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, b
|
||||
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
||||
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
||||
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
||||
// ConfirmedSets captures confirmed combinations for the QSL Manager's NEW flags.
|
||||
// Modes are grouped into CLASSES (Phone/CW/Digital) — a digital confirmation is a
|
||||
// "new mode/slot" only if no digital mode was confirmed there before (RTTY and FT8
|
||||
// are the same DIGI class). Raw-mode granularity lives only in the cluster/matrix.
|
||||
type ConfirmedSets struct {
|
||||
DXCC map[int]bool // dxcc entity confirmed
|
||||
Band map[string]bool // "dxcc|band"
|
||||
Slot map[string]bool // "dxcc|band|mode"
|
||||
Mode map[string]bool // "dxcc|class"
|
||||
Slot map[string]bool // "dxcc|band|class"
|
||||
}
|
||||
|
||||
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
|
||||
// Key builders for ConfirmedSets. Band is mode-agnostic; Mode/Slot use the class.
|
||||
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
|
||||
func ModeClassKey(dxcc int, mode string) string {
|
||||
return fmt.Sprintf("%d|%s", dxcc, modeClass(mode))
|
||||
}
|
||||
func SlotClassKey(dxcc int, band, mode string) string {
|
||||
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), modeClass(mode))
|
||||
}
|
||||
|
||||
// SlotKey is the RAW-mode slot key, kept for the cluster/matrix new-slot flag.
|
||||
func SlotKey(dxcc int, band, mode string) string {
|
||||
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
|
||||
}
|
||||
|
||||
// SlotStats is the worked/confirmed tally for the QSL Manager, counting slots by
|
||||
// mode CLASS (Phone / CW / Digital) — NOT raw mode. Raw-mode granularity (RTTY ≠
|
||||
// FT8) is kept only for the cluster/matrix "new slot" flag; the totals here match
|
||||
// how slots are conventionally counted (a band in a class, not in each digital
|
||||
// sub-mode).
|
||||
type SlotStats struct {
|
||||
SlotsWorked int `json:"slots_worked"` // distinct DXCC × band × class, all QSOs
|
||||
SlotsConfirmed int `json:"slots_confirmed"` // distinct DXCC × band × class, confirmed
|
||||
DXCCWorked int `json:"dxcc_worked"` // distinct DXCC entities worked
|
||||
DXCCConfirmed int `json:"dxcc_confirmed"` // distinct DXCC entities confirmed
|
||||
// Per-class slot breakdown (Phone / CW / Digital) so the numbers are checkable.
|
||||
PHWorked int `json:"ph_worked"`
|
||||
PHConfirmed int `json:"ph_confirmed"`
|
||||
CWWorked int `json:"cw_worked"`
|
||||
CWConfirmed int `json:"cw_confirmed"`
|
||||
DIGWorked int `json:"dig_worked"`
|
||||
DIGConfirmed int `json:"dig_confirmed"`
|
||||
}
|
||||
|
||||
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
|
||||
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
||||
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')),
|
||||
CASE WHEN lotw_rcvd='Y' OR qsl_rcvd='Y' THEN 1 ELSE 0 END
|
||||
FROM qso`)
|
||||
if err != nil {
|
||||
return SlotStats{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
dxccW, slotW := map[int]bool{}, map[string]bool{}
|
||||
dxccC, slotC := map[int]bool{}, map[string]bool{}
|
||||
// Per-class distinct slots (worked "w" / confirmed "c").
|
||||
clsW := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
|
||||
clsC := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
|
||||
for rows.Next() {
|
||||
var dxcc, conf int
|
||||
var band, mode string
|
||||
if err := rows.Scan(&dxcc, &band, &mode, &conf); err != nil {
|
||||
return SlotStats{}, err
|
||||
}
|
||||
if dxcc == 0 {
|
||||
continue
|
||||
}
|
||||
dxccW[dxcc] = true
|
||||
if conf == 1 {
|
||||
dxccC[dxcc] = true
|
||||
}
|
||||
if band == "" {
|
||||
continue // no band → counts for DXCC but not for a slot
|
||||
}
|
||||
key := SlotClassKey(dxcc, band, mode)
|
||||
cl := modeClass(mode)
|
||||
slotW[key] = true
|
||||
clsW[cl][key] = true
|
||||
if conf == 1 {
|
||||
slotC[key] = true
|
||||
clsC[cl][key] = true
|
||||
}
|
||||
}
|
||||
return SlotStats{
|
||||
SlotsWorked: len(slotW), SlotsConfirmed: len(slotC),
|
||||
DXCCWorked: len(dxccW), DXCCConfirmed: len(dxccC),
|
||||
PHWorked: len(clsW["PH"]), PHConfirmed: len(clsC["PH"]),
|
||||
CWWorked: len(clsW["CW"]), CWConfirmed: len(clsC["CW"]),
|
||||
DIGWorked: len(clsW["DIG"]), DIGConfirmed: len(clsC["DIG"]),
|
||||
}, rows.Err()
|
||||
}
|
||||
|
||||
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||
// OR together (guards the dynamic SQL).
|
||||
var confirmedCols = map[string]bool{
|
||||
@@ -1985,7 +2175,7 @@ var confirmedCols = map[string]bool{
|
||||
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
||||
// {qrzcom_qso_download_status}.
|
||||
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
|
||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Mode: map[string]bool{}, Slot: map[string]bool{}}
|
||||
var conds []string
|
||||
for _, c := range cols {
|
||||
if confirmedCols[c] {
|
||||
@@ -2014,7 +2204,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
|
||||
}
|
||||
sets.DXCC[dxcc] = true
|
||||
sets.Band[BandKey(dxcc, band)] = true
|
||||
sets.Slot[SlotKey(dxcc, band, mode)] = true
|
||||
sets.Mode[ModeClassKey(dxcc, mode)] = true
|
||||
sets.Slot[SlotClassKey(dxcc, band, mode)] = true
|
||||
}
|
||||
return sets, rows.Err()
|
||||
}
|
||||
|
||||
+53
-7
@@ -210,9 +210,49 @@ func yes(s string) bool {
|
||||
// it is set and no explicit window is given, the window becomes the contest's own
|
||||
// span — so rate, best-hour and off-air figures are computed over the contest
|
||||
// itself without the operator having to look its dates up.
|
||||
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
|
||||
// Operators returns the distinct operators in the log (upper-cased), with "—"
|
||||
// for QSOs the station owner logged himself (empty OPERATOR). Sorted, with "—"
|
||||
// last so the picker reads real callsigns first.
|
||||
func (r *Repo) Operators(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT COALESCE(operator,'') FROM qso`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var op string
|
||||
if err := rows.Scan(&op); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op = strings.ToUpper(strings.TrimSpace(op))
|
||||
if op == "" {
|
||||
op = "—"
|
||||
}
|
||||
seen[op] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
hasOwner := false
|
||||
for op := range seen {
|
||||
if op == "—" {
|
||||
hasOwner = true
|
||||
continue
|
||||
}
|
||||
out = append(out, op)
|
||||
}
|
||||
sort.Strings(out)
|
||||
if hasOwner {
|
||||
out = append(out, "—")
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int, operator string) (Stats, error) {
|
||||
var s Stats
|
||||
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
||||
// Operator filter: "" = all operators; "—" = QSOs the station owner logged
|
||||
// himself (empty OPERATOR); any other value = that operator's QSOs only.
|
||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
||||
@@ -257,6 +297,17 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
continue
|
||||
}
|
||||
|
||||
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
||||
// it as "—". Computed here so the operator filter can also drop QSOs that
|
||||
// aren't this operator's before they reach ANY bucket.
|
||||
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
||||
if op == "" {
|
||||
op = "—"
|
||||
}
|
||||
if opFilter != "" && op != opFilter {
|
||||
continue
|
||||
}
|
||||
|
||||
// Window first: a QSO outside the period must not reach ANY bucket. Doing
|
||||
// this after the counting (the obvious mistake) would leave the mode/band/
|
||||
// operator charts showing the whole log while only the trend was filtered.
|
||||
@@ -288,12 +339,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||
bandC[b]++
|
||||
}
|
||||
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
||||
// it explicitly rather than dropping the QSO from the operator chart.
|
||||
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
||||
if op == "" {
|
||||
op = "—"
|
||||
}
|
||||
// op was resolved above (with the operator filter applied).
|
||||
opC[op]++
|
||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||
stationC[st]++
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// errFCCMaintenance is raised when the FCC ULS download host bounces us to its
|
||||
// maintenance page instead of serving the file (a frequent, FCC-side event).
|
||||
var errFCCMaintenance = errors.New("fcc uls under maintenance")
|
||||
|
||||
// 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
|
||||
}
|
||||
// Catch the FCC maintenance bounce BEFORE following it: data.fcc.gov redirects
|
||||
// to www.fcc.gov/system-maintenance during maintenance windows, and that page
|
||||
// then HTTP/2-stream-errors — which surfaced as a cryptic "INTERNAL_ERROR"
|
||||
// instead of a plain "try again later".
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
||||
if strings.Contains(r.URL.String(), "system-maintenance") {
|
||||
return errFCCMaintenance
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, errFCCMaintenance) || strings.Contains(err.Error(), "system-maintenance") {
|
||||
return fmt.Errorf("the FCC ULS download service is under maintenance (fcc.gov redirected to its maintenance page) — please try again later")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,27 @@ func (c *Client) queryProgress() ([]int, error) {
|
||||
return []int{total, current}, nil
|
||||
}
|
||||
|
||||
// ReadElements reads the current per-element lengths for the active band
|
||||
// (CMD_READ_BANDS). The controller is write-only for ModifyElement, so this is
|
||||
// the only way to see the current lengths — needed so the operator isn't
|
||||
// adjusting blind. The reply payload layout is not documented in the code, so we
|
||||
// LOG it verbatim (once) and parse a best guess: element lengths as 16-bit
|
||||
// little-endian values, matching how ModifyElement WRITES a length. Confirm the
|
||||
// format from the logged bytes on real hardware, then tighten the parse.
|
||||
func (c *Client) ReadElements() ([]int, error) {
|
||||
payload, err := c.sendCommand(CMD_READ_BANDS, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("Ultrabeam: READ_BANDS payload (% X) — %d bytes", payload, len(payload))
|
||||
// Best-guess parse: consecutive 16-bit LE values = element lengths in mm.
|
||||
out := make([]int, 0, len(payload)/2)
|
||||
for i := 0; i+1 < len(payload); i += 2 {
|
||||
out = append(out, int(payload[i])|int(payload[i+1])<<8)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetFrequency changes frequency and optional direction (command 3)
|
||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
// Trace WHO asked for the change — the caller's function + line — so an
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// Relay auto-control: drives the Station Control relay boards automatically from
|
||||
// the rig's current frequency / band — the equivalent of PstRotator's "Automatic
|
||||
// Control". Each relay carries at most one rule:
|
||||
// - "freq": ON while the frequency is inside [lo,hi] kHz, OFF otherwise;
|
||||
// - "band": ON while the current band is one of the listed bands, OFF otherwise;
|
||||
// - "off"/empty: not managed (left to manual control).
|
||||
//
|
||||
// Evaluated on every CAT frequency/band change. A relay is only switched when its
|
||||
// desired state actually changed since the last apply, so a slow relay board isn't
|
||||
// hammered while you tune within the same range.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const keyRelayAuto = "relayauto.config"
|
||||
|
||||
// RelayAutoRule is one relay's automatic-control rule.
|
||||
type RelayAutoRule struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Relay int `json:"relay"` // 1-based
|
||||
Mode string `json:"mode"` // "off" | "freq" | "band"
|
||||
FreqLoKHz float64 `json:"freq_lo_khz"`
|
||||
FreqHiKHz float64 `json:"freq_hi_khz"`
|
||||
Bands []string `json:"bands"`
|
||||
}
|
||||
|
||||
// RelayAutoConfig is the whole auto-control setup: a master switch + the rules.
|
||||
type RelayAutoConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Rules []RelayAutoRule `json:"rules"`
|
||||
}
|
||||
|
||||
// GetRelayAuto returns the relay auto-control configuration for the settings UI.
|
||||
func (a *App) GetRelayAuto() RelayAutoConfig {
|
||||
var cfg RelayAutoConfig
|
||||
if a.settings == nil {
|
||||
return cfg
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyRelayAuto)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SaveRelayAuto persists the configuration and applies it immediately from the
|
||||
// current rig state, so toggling a rule takes effect without waiting for the next
|
||||
// frequency change.
|
||||
func (a *App) SaveRelayAuto(cfg RelayAutoConfig) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.SetGlobal(a.ctx, keyRelayAuto, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.relayAutoOn.Store(cfg.Enabled) // keep the CAT hot-path flag in sync
|
||||
// Re-apply from the live frequency so a just-changed rule takes hold now. Also
|
||||
// forget the last-applied cache so a rule the user just switched to "off" and
|
||||
// back gets re-sent even if the value is unchanged.
|
||||
a.relayAutoMu.Lock()
|
||||
a.relayAutoLast = map[string]bool{}
|
||||
a.relayAutoMu.Unlock()
|
||||
if a.cat != nil {
|
||||
st := a.cat.State()
|
||||
go a.applyRelayAuto(st.FreqHz, st.Band)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func relayAutoKey(dev string, relay int) string { return dev + "|" + strconv.Itoa(relay) }
|
||||
|
||||
func bandInList(bands []string, band string) bool {
|
||||
band = strings.ToLower(strings.TrimSpace(band))
|
||||
if band == "" {
|
||||
return false
|
||||
}
|
||||
for _, b := range bands {
|
||||
if strings.ToLower(strings.TrimSpace(b)) == band {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyRelayAuto evaluates every rule against the current frequency/band and
|
||||
// switches only the relays whose desired state changed since the last apply.
|
||||
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
a.relayAutoMu.Lock()
|
||||
defer a.relayAutoMu.Unlock()
|
||||
|
||||
cfg := a.GetRelayAuto()
|
||||
if !cfg.Enabled || len(cfg.Rules) == 0 {
|
||||
return
|
||||
}
|
||||
if a.relayAutoLast == nil {
|
||||
a.relayAutoLast = map[string]bool{}
|
||||
}
|
||||
khz := float64(freqHz) / 1000.0
|
||||
|
||||
changed := false
|
||||
for _, r := range cfg.Rules {
|
||||
if r.Relay < 1 {
|
||||
continue
|
||||
}
|
||||
var want bool
|
||||
switch r.Mode {
|
||||
case "freq":
|
||||
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||
continue // unconfigured range → leave the relay alone
|
||||
}
|
||||
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||
if hi < lo {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
want = khz >= lo && khz <= hi
|
||||
case "band":
|
||||
if len(r.Bands) == 0 {
|
||||
continue
|
||||
}
|
||||
want = bandInList(r.Bands, band)
|
||||
default:
|
||||
continue // "off"/empty → not managed
|
||||
}
|
||||
|
||||
key := relayAutoKey(r.DeviceID, r.Relay)
|
||||
if last, ok := a.relayAutoLast[key]; ok && last == want {
|
||||
continue // no change → don't hammer the board
|
||||
}
|
||||
if err := a.StationSetRelay(r.DeviceID, r.Relay, want); err != nil {
|
||||
applog.Printf("relay auto: set %s relay %d = %v failed: %v", r.DeviceID, r.Relay, want, err)
|
||||
continue // don't cache a failed write — retry next change
|
||||
}
|
||||
a.relayAutoLast[key] = want
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed && a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.19.8"
|
||||
appVersion = "0.20.0"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user