38 Commits
Author SHA1 Message Date
rouggy 0a9a09bec2 chore: release v0.20.1 2026-07-19 03:12:35 +02:00
rouggy 34ec91684e fix: restrict RST fields to valid report values
The RST comboboxes allowed free text, so a report typed by hand that isn't in
the mode's list (e.g. "600") was committed. Drop allowFreeText on the four RST
fields (QSO entry + edit modal) and make commit-on-type push a value only when
it's actually in the list; a leftover invalid entry reverts to the last valid
value on blur. Programmatically filled values (FT8 SNR over UDP, presets) are
unaffected — only manual typing is constrained.
2026-07-19 03:12:12 +02:00
rouggy 11f1e332f7 feat: modernise the Statistics panel with colour
Headline KPI tiles are now tinted cards: a soft colour wash, a corner glow and
a left accent bar, each tile its own hue (QSOs blue, calls teal, DXCC violet,
continents orange, confirmed green) — the number itself stays in foreground ink
for full contrast on every theme. Chart cards gain a soft shadow and a coloured
dot in the header matching the section.

Charts are no longer all-blue: BY MODE colours each bar by the categorical
palette (modes are real categories, like the continents donut), and the other
horizontal-bar charts take their card's accent (top entities green, operators
violet, stations orange) instead of a single blue. Single-hue-per-series
discipline is kept; only genuine categories get per-bar colour.
2026-07-19 03:12:00 +02:00
rouggy bd9e091e65 fix: serialise auto-uploads so a burst doesn't 403 and leave QSOs at R
A QSO logged via UDP / ADIF-import fired `go m.upload` per QSO, so a pileup or
an ADIF-import burst hit a service with dozens of concurrent requests at once.
Club Log's nginx answers that burst with 403; the upload counts as failed and
the QSO stays at "R" while a lucky few go through — the real cause behind the
"it's uploaded but shows R" report (not the earlier display race).

Route all immediate/delayed auto-uploads through a single serialised worker
(one at a time, 250ms apart) so no burst ever trips a per-IP rate limiter.
upload() now reports whether a failure is transient (HTTP/rate-limit) vs
permanent (not eligible, wrong station callsign, no record); transient ones
are retried with exponential back-off (up to 4 attempts, 1s/2s/4s), so a QSO
that hit a momentary 403 ends up marked instead of stuck at R. A full queue
falls back to a goroutine rather than dropping an upload.
2026-07-19 03:05:02 +02:00
rouggy d38c783dcc chore: release v0.20.0 2026-07-19 02:34:23 +02:00
rouggy c825caa7a8 feat: relay auto-control by frequency / band (PstRotator-style)
Automatically switches the Station Control relay boards from the rig's
current frequency / band. Each relay carries one rule: off (manual), a
frequency window (ON inside [lo,hi] kHz, OFF outside), or a set of bands
(ON on those bands, OFF elsewhere). Evaluated on every CAT frequency/band
change; a relay is only switched when its desired state actually changed,
so tuning within a range doesn't hammer the board.

A cached atomic flag keeps the CAT hot path a no-op when the feature is off
(important during FT8 slice churn). Saving re-applies from the live
frequency so a changed rule takes effect immediately.

New Settings → Hardware → Relay auto-control section: master enable plus a
per-relay mode (Off / Frequency / Band) with kHz range inputs or band
chips, per configured relay board. i18n EN + FR. Azimuth/Time modes (the
other two PstRotator tabs) are left for later.
2026-07-19 01:57:56 +02:00
rouggy 215652570c fix: blank ADIF monitor settings panel (hooks in a called-as-function panel)
PANELS[selected]() invokes each settings panel as a plain function, so a panel
must not call hooks. ADIFMonitorPanel uses useState/useEffect/useI18n, which
broke the Rules of Hooks and blanked the section. Render it through JSX
(() => <ADIFMonitorPanel />) so it mounts as a real component with its own
hook context — same pattern as the Flex panel.
2026-07-19 01:51:21 +02:00
rouggy 79552bfae1 fix: Club Log status R->Y->R flicker on UDP auto-log
A UDP auto-logged QSO shows its Club Log (and other) upload status flick
R->Y->R even though the upload succeeds. Two refreshes race: the immediate
one after the UDP log reads the QSO at "R", the debounced one after
extsvc:uploaded reads it at "Y"; variable MySQL latency lets the older (R)
result resolve last and clobber the newer (Y) data.

Guard refresh() with a monotonic sequence number so only the most recently
issued refresh may apply its result — an older in-flight refresh is dropped.
Fixes the same class of stale-clobber for every confirmation column.
2026-07-19 01:47:39 +02:00
rouggy 8fc04563e1 feat: ADIF monitor — auto-import QSOs from watched external ADIF files
Watches a configurable list of external ADIF files (fldigi RTTY logbook,
N1MM, VarAC…) and imports newly appended QSOs automatically. Each record
goes through the existing UDP-log path, so it gets full enrichment, ±2-minute
dedup (shared udpLogMu, can't race the UDP auto-log) and automatic upload to
the configured external services — no per-file toggles like Log4OM.

A newly added file starts at its current size (offset -1 sentinel → size on
first scan), so the QSOs already in it are NOT bulk-imported; only contacts
logged after it was added come in. Reads only up to the last complete <eor>
so a half-written record waits. Handles truncation/rotation (re-reads from 0,
dedup protects) and persists per-file offsets without clobbering a concurrent
UI edit of the file list.

New Settings → ADIF monitor section: master enable + add/remove/toggle files.
Backend emits adifmon:imported → the grid refreshes and a toast reports the
count. i18n EN + FR.
2026-07-19 01:35:00 +02:00
rouggy 19993bafc1 feat: header rate/propagation colours + CW macro word space
Header: colour the propagation indices semantically (SFI/SSN green when
strong; A/K green quiet → yellow unsettled → red storm) and glow the QSO-rate
numbers the brand accent when active, dim to muted when idle.

WinKeyer/Icom CW: append a trailing word space to each macro send so two
macros fired back-to-back don't run together in the keyer buffer ("CQ"+"TEST"
→ "CQTEST"). The keyer keys the space at the current speed, so it scales with
WPM. Only the macro path is affected — send-on-type stays per-character.
2026-07-19 00:59:05 +02:00
rouggy da1793a902 fix: clearer Club Log / FCC ULS upload-download diagnostics
Club Log: on a failed batch, log the callsign#id of every QSO in it so a
per-record rejection (e.g. a field value nginx's WAF blocks with a 403) can
actually be located instead of hiding behind "batch FAILED".

FCC ULS: catch the 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. Detect the redirect and return "try again later".
2026-07-19 00:58:55 +02:00
rouggy 14c87f7fa9 chore: regenerate Wails bindings for GetQSORate / QSORate 2026-07-18 21:53:44 +02:00
rouggy 9d4ccb9254 feat: QSO rate meter (10/60 min) in the header
Opt-in via Settings→General (portable pref opslog.showQsoRate). Shows the
contest-style QSO rate in QSOs/hour, projected from the trailing 10-minute
(count ×6) and 60-minute windows, between the widget icons and propagation.

Backend: qso.RecentRate counts QSOs whose start time falls in each trailing
window, scanning only the last 400 rows (cheap on a large log); App.GetQSORate
exposes the 10/60-min counts. Frontend refreshes on qso:logged and a 30s tick.

The meter shares the propagation grid cell — the header is a fixed 6-column
grid, so adding it as its own child pushed profile/band-map/compact onto a
second row. i18n EN + FR.
2026-07-18 21:51:51 +02:00
rouggy d30b305ff2 chore: release v0.19.9 2026-07-18 19:32:17 +02:00
rouggyandClaude Opus 4.8 5abe4bd0c3 fix: restore theme on restart + consistent spot pills + red DVK TX
theme: self-heal the persisted theme after mount. The synchronous boot
read only looks at localStorage, which is empty when the WebView cleared
its storage or when syncPortablePrefs ran before the backend wired its
settings store (GetUIPref returned "" with no error) — so a restart could
silently land on the default light theme. ThemeProvider now re-reads the
portable pref from the DB once the backend is up (retrying briefly to ride
out a slow startup) and applies it, without clobbering a manual pick.

ClusterGrid: the Call cell now uses a danger pill for NEW DXCC, consistent
with the NEW BAND / NEW MODE pills, instead of a full-cell red fill. cellChip
inherits the column's font size (no fixed 9px / height) so a pill around a
callsign stays the same size as the plain callsigns beside it.

DvkPanel: the voice-keyer TX indicator (LED + label) is red while
transmitting, not orange.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 19:28:55 +02:00
rouggyandClaude Opus 4.8 3ed9f29d9a feat: never drop cluster spots + theme-aware spot badges + FR settings
Backend: replace the bounded cluster-event channel (which dropped spots
under RBN bursts once enrichment/UI fell behind) with an unbounded
clusterQueue (slice + sync.Cond). The socket-read goroutine still never
blocks, but a burst now grows the backlog and drains instead of losing
spots. Head compaction keeps a sustained burst from growing the backing
array without bound.

ClusterGrid: drop all hard-coded light-theme hex colours (Time, Country,
Spotter, Comment, POTA, band/mode fills) for semantic CSS-var tokens so
they read correctly on every theme. Replace the heavy full-cell band/mode
fills with small rounded pills (cellChip). Only NEW MODE pills the mode
cell — NEW SLOT (band and mode each worked, just not together) no longer
highlights the mode cell, which wrongly implied the mode was new.

App: stage incoming spots ~50ms and resolve their slot status before
inserting, so a row paints with its badge already on instead of flashing
plain text then flipping to a pill. Self-spot toast still fires per spot.

i18n: translate the General settings that were still English — telemetry
and live-status toggles, the Main view pane pickers and their options.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 18:20:06 +02:00
rouggyandClaude Opus 4.8 04eaa91bd8 chore: drop the station-owner note under the BY OPERATOR chart
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 17:12:59 +02:00
rouggyandClaude Opus 4.8 443698b507 feat: per-operator filter in the statistics dashboard
New Operator picker next to the contest picker, populated with the operators
actually present in the log. Selecting one recomputes every statistic for that
operator — QSO/DXCC totals, top countries, continent split, by band/mode, and
the rate/best-60. "— Station owner —" buckets QSOs logged with no OPERATOR set.
Shown only when the log has more than one operator.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 17:11:52 +02:00
rouggyandClaude Opus 4.8 db2908b3ef fix: RIT Ctrl+arrow tuning also works while the QSO entry field has focus
The radio-style Ctrl+left/right RIT shortcut skipped any focused input/textarea
(to preserve word navigation), but that's exactly where the operator's focus is
mid-QSO. Now only the RIT offset row itself is skipped; text-field word nav is
overridden only while RIT is engaged.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 14:33:55 +02:00
rouggyandClaude Opus 4.8 0838c2ec89 fix: Update from QRZ now fills the full address (and email/QSL-via/lat-lon), matching the edit-window Fetch
The right-click "Update from QRZ" only mapped QTH (city), name, state, county,
grid and zones — the street address and a few other fields QRZ returns were
dropped, so it looked half-updated next to the edit window's Fetch button.
Aligned it to fill the same set: address, email, qsl_via and lat/lon too.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 12:26:23 +02:00
rouggy 80c5fdc095 feat: Optimization of spots not to miss any 2026-07-18 12:17:22 +02:00
rouggy a1be0dfe68 feat: Added color per band in statistics as well as number of qso 2026-07-18 04:48:26 +02:00
rouggy ac4039393d feat: RIT back to 0 once a qso is logged 2026-07-18 04:06:43 +02:00
rouggy bb53085c21 fix: wider space for country names in statistics 2026-07-18 03:52:14 +02:00
rouggy 031dfa8f46 feat: Adding show password for SMTP server 2026-07-17 20:50:05 +02:00
rouggy 6322a425d8 feat: Added columns selection in the Net Control 2026-07-17 20:21:29 +02:00
rouggy 9f08df1c39 fix: fix a bug adjusting the length of Ultrabeam 2026-07-17 20:19:54 +02:00
rouggy aeeb658269 fix: Improved status badges in cluster 2026-07-17 19:35:30 +02:00
rouggy 8e088576c7 fix: Better matching of the counties for the USA-CA 2026-07-17 16:28:32 +02:00
rouggy eb2ff8ed59 feat: New badges in cluster view for new Counties and new POTA 2026-07-17 15:28:36 +02:00
rouggy dd3b51a2ae feat: Added support for US Counties in OpsLog / Extra feature with DXHunter 2026-07-17 13:23:35 +02:00
rouggy 1a155e3627 fix: bugs 2026-07-17 11:55:40 +02:00
rouggy cd13921322 fix: QSL Manager properly calculating new slot (Digi modes are grouped together) 2026-07-16 23:26:04 +02:00
rouggy 46e3619a38 fix: relocated Lotw and Alerts 2026-07-16 22:56:21 +02:00
rouggy dcf006905c fix: QTH field is wider to see the full city names 2026-07-16 22:47:24 +02:00
rouggy 7cf2dfeaf9 feat: For steppir & Ultrabeam,possibility to change each element length 2026-07-16 22:32:29 +02:00
rouggy 69229964f4 fix: bug 2026-07-16 22:13:21 +02:00
rouggy 09848adddc feat: Added Ultrabeam/Steppir to Station Control with function to retract elements. 2026-07-16 22:10:36 +02:00
38 changed files with 6536 additions and 334 deletions
+251
View File
@@ -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
}
+570 -50
View File
@@ -54,6 +54,7 @@ import (
"hamlog/internal/settings"
"hamlog/internal/solar"
"hamlog/internal/steppir"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer"
@@ -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
}
+100
View File
@@ -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])
}
+181 -31
View File
@@ -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;
@@ -2766,12 +2879,12 @@ export default function App() {
);
const rstTxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div>
);
// DX country flag, shown large next to RST (moved here from the Country field).
@@ -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">&nbsp;</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>
</>
+3 -1
View File
@@ -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 },
+91 -50
View File
@@ -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',
+2 -2
View File
@@ -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')}
+35 -1
View File
@@ -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' }];
+18 -54
View File
@@ -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>
+32 -4
View File
@@ -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>
+11 -3
View File
@@ -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)} 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)} 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')}
+27 -11
View File
@@ -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
+309 -22
View File
@@ -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,
+187 -11
View File
@@ -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 && (
+70 -40
View File
@@ -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';
@@ -52,11 +52,14 @@ const nf = (n: number) => n.toLocaleString('en-US');
// ── Shell ────────────────────────────────────────────────────────────────────
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
return (
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
<header className="mb-3 shrink-0">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
{title}
</h3>
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
</header>
{children}
@@ -64,14 +67,22 @@ function Card({ title, sub, children, className }: { title: string; sub?: string
);
}
// A headline number IS the chart — a one-bar bar chart would be noise.
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
// carries an accent (a categorical chart hue or a semantic token): a soft tint
// wash + a left accent bar give it a colourful identity, while the number itself
// stays in high-contrast foreground ink so it reads on every theme.
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
<div className="relative">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
</div>
</div>
);
}
@@ -80,7 +91,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', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: 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.
@@ -90,13 +101,13 @@ function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empt
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
return (
<div className="flex flex-col gap-1.5 min-w-0">
{top.map((d) => (
{top.map((d, i) => (
<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"
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
/>
</div>
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
@@ -115,7 +126,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 +140,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 +502,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 +517,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 +538,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 +585,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')],
@@ -610,12 +640,12 @@ export function StatsPanel() {
{/* Headline figures: stat tiles, not a grouped bar chart. */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
</div>
{/* Period / contest block ONLY when a window is selected. "12 QSO/h" across
@@ -704,43 +734,43 @@ export function StatsPanel() {
</div>
) : (
<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} />
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')} accent="var(--chart-3)">
<VBars data={stats.by_band} empty={empty} showValues colorful />
</Card>
<Card title={t('stats.byMode')}>
<HBars data={stats.by_mode} max={8} empty={empty} />
<Card title={t('stats.byMode')} accent="var(--chart-2)">
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
</Card>
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<AreaTrend data={stats.by_month} empty={empty} />
</Card>
{/* 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')} accent="var(--chart-5)">
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_operator} empty={empty} share />
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
</div>
</Card>
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
<Donut data={stats.by_continent} empty={empty} />
</Card>
<Card title={t('stats.topEntities')}>
<HBars data={stats.top_entities} max={12} empty={empty} />
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
</Card>
<div className="flex flex-col gap-2.5 min-w-0">
<Card title={t('stats.confirmations')} className="flex-1">
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
<div className="flex flex-col gap-3 justify-center flex-1">
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
</div>
</Card>
<Card title={t('stats.byStation')}>
<Card title={t('stats.byStation')} accent="var(--chart-8)">
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_station} empty={empty} share />
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
</div>
</Card>
</div>
+11 -1
View File
@@ -64,7 +64,17 @@ export function Combobox({
// Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
onChange={(e) => {
const v = e.target.value;
setQuery(v);
setOpen(true);
// Commit-on-type pushes the value live to the parent (so a CW macro sent
// without leaving the field uses what was just typed). With free text that's
// any input; a restricted field (allowFreeText=false) commits ONLY a value
// that's actually in the list, so a half-typed or invalid report never
// becomes the committed value — blur then reverts the leftover text.
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
}}
onBlur={onBlur}
onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
File diff suppressed because one or more lines are too long
+22
View File
@@ -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'];
}
+33 -1
View File
@@ -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;
+1
View File
@@ -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 -1
View File
@@ -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.1';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+27 -1
View File
@@ -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>;
+54 -2
View File
@@ -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']();
}
+188
View File
@@ -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
View File
@@ -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":
+30
View File
@@ -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
}
}
+27
View File
@@ -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)
}
}
}
+3
View File
@@ -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
+7 -1
View File
@@ -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.
+85 -20
View File
@@ -87,17 +87,76 @@ type Manager struct {
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
// uploadCh serialises immediate auto-uploads through a single worker. Firing a
// goroutine per QSO meant a pileup / ADIF-import burst hit a service with dozens
// of concurrent requests at once — Club Log's nginx answers that with 403, the
// upload is counted as failed and the QSO stays at "R" despite the others going
// through. One-at-a-time with a small gap keeps every upload under the limit.
uploadCh chan uploadJob
}
// uploadJob is one queued auto-upload.
type uploadJob struct {
svc Service
id int64
cfg ServiceConfig
attempt int // 0 on first try; incremented on each retry
}
// uploadGap spaces serialized uploads so a burst never trips a service's per-IP
// rate limiter. maxUploadAttempts bounds retries of a transient failure.
const (
uploadGap = 250 * time.Millisecond
maxUploadAttempts = 4
)
func NewManager(deps Deps) *Manager {
if deps.Client == nil {
deps.Client = &http.Client{Timeout: 20 * time.Second}
}
return &Manager{
m := &Manager{
deps: deps,
// Seeded from the clock; the delay only needs to be unpredictable
// enough to spread bursts, not cryptographically random.
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
uploadCh: make(chan uploadJob, 4096),
}
go m.uploadWorker()
return m
}
// uploadWorker drains the queue one upload at a time, spacing them so a burst of
// freshly-logged QSOs can't hammer (and get 403'd by) a service. A transient
// failure is re-queued with an exponential back-off, so a QSO that hit a
// momentary rate-limit still ends up marked instead of stuck at "R".
func (m *Manager) uploadWorker() {
for job := range m.uploadCh {
ok, retryable := m.upload(job.svc, job.id, job.cfg)
if !ok && retryable && job.attempt+1 < maxUploadAttempts {
next := job
next.attempt++
backoff := time.Duration(1<<uint(job.attempt)) * time.Second // 1s, 2s, 4s…
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", job.svc, job.id, next.attempt+1, backoff)
time.AfterFunc(backoff, func() { m.enqueueJob(next) })
}
time.Sleep(uploadGap)
}
}
// enqueueUpload queues a first-attempt upload without blocking the logging path.
func (m *Manager) enqueueUpload(svc Service, id int64, cfg ServiceConfig) {
m.enqueueJob(uploadJob{svc: svc, id: id, cfg: cfg})
}
// enqueueJob queues a job (possibly a retry). If the queue is somehow full (an
// enormous burst), it falls back to a goroutine rather than dropping the upload —
// a dropped upload would leave the QSO stuck at "R".
func (m *Manager) enqueueJob(job uploadJob) {
select {
case m.uploadCh <- job:
default:
go m.upload(job.svc, job.id, job.cfg)
}
}
@@ -175,15 +234,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
m.scheduleUpload(svc, id, cfg)
}
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
// scheduleUpload either queues the upload now (immediate) or arms a timer that
// queues it later (delayed). Both go through the serialised worker so uploads are
// never fired concurrently in a burst.
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeDelayed {
d := m.delaySeconds()
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
time.AfterFunc(d, func() { m.enqueueUpload(svc, id, cfg) })
return
}
go m.upload(svc, id, cfg)
m.enqueueUpload(svc, id, cfg)
}
// onCloseServices returns the services configured for on-close auto-upload,
@@ -243,25 +304,25 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
case ServiceQRZ:
for _, id := range ids {
if m.upload(svc, id, cfg.QRZ) {
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
uploaded++
}
}
case ServiceClublog:
for _, id := range ids {
if m.upload(svc, id, cfg.Clublog) {
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
uploaded++
}
}
case ServiceHRDLog:
for _, id := range ids {
if m.upload(svc, id, cfg.HRDLog) {
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
uploaded++
}
}
case ServiceEQSL:
for _, id := range ids {
if m.upload(svc, id, cfg.EQSL) {
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
uploaded++
}
}
@@ -312,12 +373,16 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
// upload performs the actual push and returns true on success. It builds a
// fresh, lifecycle-independent context so a delayed upload still completes
// even if it fires close to shutdown.
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
// upload performs one upload. It returns ok=true when the QSO was uploaded (and
// marked), and retryable=true when it failed in a way worth retrying later (an
// HTTP/service error such as a rate-limit 403) as opposed to a permanent skip
// (not eligible, wrong station callsign, no record).
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, retryable bool) {
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
// match the configured Upload flag).
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
return false
return false, false
}
// Station-callsign guard. Each logbook belongs to one callsign:
@@ -345,7 +410,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return false
return false, false
}
}
@@ -360,7 +425,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
case ServiceClublog:
@@ -369,7 +434,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
case ServiceLoTW:
@@ -378,7 +443,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadLoTW(ctx, cfg, "", record)
case ServiceHRDLog:
@@ -387,7 +452,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
case ServiceEQSL:
@@ -396,11 +461,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
return false, false
}
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
default:
return false
return false, false
}
if err != nil || !res.OK {
@@ -411,12 +476,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return false
return false, true // transient (rate-limit / network) → worth a retry
}
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID)
}
return true
return true, false
}
+197 -6
View File
@@ -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, &notes,
&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
View File
@@ -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]++
+426
View File
@@ -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),
})
}
+42
View File
@@ -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)
}
}
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.