Compare commits

...
39 Commits
Author SHA1 Message Date
rouggy 5f044b959e chore: release v0.20.3 2026-07-20 01:25:28 +02:00
rouggy 68a49be8c1 feat: rate meter OP/Team display, on-air-only station widget, column-filter count
Rate meter: on a shared MySQL logbook it now shows two lines — OP (the active
operator, accent) and Team (all operators, foreground) — each with the 10/60-min
QSOs/hour; single-op keeps the one-line display.

Live-stations widget: only stations currently on air are listed (offline ones
are hidden rather than greyed).

Recent QSOs footer: 'Showing X of Y' now reflects the AG-Grid COLUMN filters
(the funnel icons) — the grid reports its displayed row count, so filtering a
column shows how many QSOs remain instead of always the full total.
2026-07-20 01:08:17 +02:00
rouggy 8eb82d6cdb feat: QSO rate meter — per-operator AND team totals
GetQSORate now returns both the active operator's rate (their own performance)
and the whole station's rate (all operators combined). RecentRateBreakdown
computes both in one scan of the recent rows (per-operator + all-operators),
replacing RecentRate.
2026-07-20 01:08:17 +02:00
rouggy d327db3f57 fix: auto-update relaunch — clear Mark-of-the-Web + wait for exit
The new build downloaded and OpsLog quit, but never came back. Two Windows
causes: the freshly written exe carried the internet Zone.Identifier mark, so
SmartScreen wanted to prompt "are you sure you want to open this?" — invisibly,
since we launch it programmatically — and silently blocked the launch; and
starting the new exe while the old one was still exiting raced the single-
instance mutex.

Now the swapped exe's Zone.Identifier stream is removed, and the relaunch is
done by a detached, hidden PowerShell that Wait-Process's on our PID (so we're
fully gone and the mutex is free) before Start-Process'ing the new exe.
2026-07-19 19:53:19 +02:00
rouggy 59e6570f17 chore: release v0.20.2 2026-07-19 19:11:06 +02:00
rouggy 82a2c6cb7f fix: relay auto-control no longer toggles on every launch/close
Closing OpsLog dropped the CAT, whose frequency went to 0; the auto-control
read that as out of range and switched the relay OFF, then ON again at the next
launch. Now a rule is skipped entirely when the frequency/band is unknown (CAT
off), so relays keep their state across a close. And on the first evaluation
after launch/save it reads the boards' LIVE state and only switches a relay
that isn't already in the wanted position — no more clunk when it's already
correct.
2026-07-19 18:35:18 +02:00
rouggy 24eaf597fd fix: ON AIR badge correct at launch + faster to appear
At launch nothing is in memory yet, so the badge showed offline even with a
recent QSO. liveLastQSOTime is now authoritative: it takes the most recent of
the in-memory stamp AND the DB (this operator's last QSO — covers a contact
from the shared logbook or one logged before launch), used by both the
published status and the badge. The badge polls the backend every 5 s (down
from 15) and on each qso:logged, so it shows on air within a few seconds and
flips online instantly on a new contact.
2026-07-19 18:35:18 +02:00
rouggy 14a22ddb66 fix: SetExtra used the wrong column name (extras -> extras_json)
The targeted extras write used SET extras, but the column is extras_json, so
sending an OpsLog QSL card (and the recording stamps) failed with an unknown-
column error. Use the real column name.
2026-07-19 18:35:18 +02:00
rouggy 9156acea5f feat: ON AIR status-bar badge + auto-updater UI (10-min check, progress)
ON AIR badge (bottom status bar, shown when live-status publishing is on): a
blinking red light + "On air" when a QSO was logged in the last 5 min, dim
"Offline" otherwise. Seeded at launch from LiveLastQSOAgeSec so it's right
immediately, flips online on qso:logged, expires via a 10s tick.

Auto-updater UI: the update check now also runs every 10 minutes, and the
update notification downloads and installs in-app — a live progress bar
(update:progress) then OpsLog restarts on the new build. Falls back to opening
the release page when there's no auto-download asset or on error (retry).
Regenerated bindings for DownloadAndApplyUpdate / LiveLastQSOAgeSec.
2026-07-19 18:15:07 +02:00
rouggy 5d0906f00e feat: in-app auto-updater — download, swap and relaunch
CheckForUpdate now also resolves the release's downloadable asset (portable
.exe, or a .zip to unpack). DownloadAndApplyUpdate streams it with progress
events (update:progress 0-100), swaps it in for the running exe (rename the
current one to .old — allowed for a running image on Windows — move the new one
into place, roll back on failure), then relaunches with --post-update and quits.

main: a --post-update relaunch retries the single-instance mutex for ~20 s so
the fresh process waits for the old one to exit and free it, then clears the
.old exe left behind. Falls back to opening the release page when a release has
no auto-download asset.
2026-07-19 18:14:56 +02:00
rouggy 901e967b53 feat: live operator status goes offline after 5 min idle, online on log
The multi-op live status heartbeated every 15 s, so an operator who left the
log open but stopped working still showed on air. Now it publishes an explicit
online flag: online when a new contact was logged within the last 5 minutes,
offline otherwise. live_status gains `online` + `last_qso_at` columns (added to
existing tables via ALTER). Logging a QSO (manual, UDP, ADIF monitor) stamps the
time and republishes at once, so they flip back online instantly.

At launch the last-QSO time is seeded from the DB (Repo.LastQSOTime), so an
operator who worked someone just before starting OpsLog is shown online right
away rather than offline until their next contact. LiveLastQSOAgeSec exposes it
to the UI badge.
2026-07-19 18:14:43 +02:00
rouggy 4ab4f70349 fix: no duplicate logs on repeat Enter + ESC stops the callsign lookup
Duplicate logs: `saving` is React state (async), so a burst of Enter presses /
clicks fired before the re-render each ran a full AddQSO and logged the same
QSO several times when a slow lookup made logging take a moment. A synchronous
savingRef guard now ignores the repeat immediately.

ESC: clearing the entry (ESC) left the "looking up" spinner turning and let a
still in-flight lookup re-populate the field afterwards. resetEntry now cancels
the pending lookup debounce, hides the spinner at once, and bumps a generation
counter so a late lookup result is discarded instead of applied.
2026-07-19 17:42:27 +02:00
rouggy 64e80986ea fix: QSL/recording no longer revert a QSO's upload status + faster logging
Status clobber: sending an OpsLog QSL card (or stamping a recording) read the
QSO, did slow work (e-mail send), then wrote the WHOLE row back from that now-
stale copy — silently reverting clublog/qrz_qso_upload_status from Y to R that a
concurrent auto-upload had just set. That's why QSOs "showed R again, but only
with QSL sending on", intermittently. New Repo.SetExtra does a targeted
`UPDATE qso SET extras = ?` that touches only the extras column, so an app-field
stamp can never clobber another column. Used by SendEQSL, markRecordingSent and
the recording-path stamp.

Logging speed: the log path no longer runs a callsign lookup at all — the e-mail
already arrives on the QSO from the entry lookup, so the second one was
redundant and stalled logging on a slow/not-found QRZ. And the entry lookup
(LookupCallsign) is now bounded to ~2 s: providers get 2 s, then it falls
through to cty.dat instead of spinning 10 s+ on a call that isn't in QRZ.
2026-07-19 17:42:14 +02:00
rouggy 816c6ffcf1 fix: revert upload serialisation — it stranded QSOs at R (regression)
The single-worker upload queue introduced head-of-line blocking: one slow
upload (LoTW signing via TQSL, or a service on a 30 s timeout) stalled the
whole queue, so every following QSO's Club Log upload waited and the rows sat
at "R". Operators on the older, concurrent build had no such problem — the
tell that this was a fresh regression.

Back to one goroutine per upload (never serialised), so a slow upload never
holds up the rest, while keeping the transient-failure retry with back-off
(concurrent, so it can't block anything).
2026-07-19 17:41:48 +02:00
rouggy 2166d1aa4b fix: IC-7610 VFO A over OmniRig + faster Icom LAN reconnect
OmniRig: on the IC-7610 the generic Freq property reports the wrong VFO (its
Main/Sub model confuses the stock ini), so OpsLog showed VFO B. Detect the rig
by RigType and, in simplex, read VFO A explicitly — matching Log4OM. Only the
7610 is affected; other rigs keep using the generic Freq.

Icom network: when the rig tears the session down (control/CI-V 0x05) OpsLog
only logged it and kept the half-dead link until the 6 s liveness timeout
expired. Mark the link dead on 0x05 so Alive() fails on the next poll and the
manager reconnects cleanly right away.
2026-07-19 17:00:11 +02:00
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
31 changed files with 2508 additions and 264 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
}
+253 -53
View File
@@ -435,8 +435,13 @@ 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
@@ -478,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)
@@ -496,6 +505,7 @@ type App struct {
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
liveBand string
liveMode string
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
@@ -802,6 +812,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
@@ -887,6 +900,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()
@@ -900,10 +920,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(
@@ -1830,13 +1851,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
// Fill the contacted operator's e-mail from the (cached) lookup so the
// recording can be auto-sent. Cheap: the entry already looked the call up.
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
q.Email = lr.Email
}
}
// NOTE: the contacted operator's e-mail already rides in on the QSO — the entry
// lookup (when you typed the call) fetched it along with name/QTH/grid and the
// form carries it here. There is deliberately NO second lookup at log time: it
// was redundant with the entry lookup and only slowed logging (a call not yet in
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
id, err = a.qso.Add(a.ctx, q)
if err != nil && db.IsConnLost(err) {
// The database is UNREACHABLE (not a data error) — park the QSO in the
@@ -1851,6 +1871,10 @@ 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
a.noteLiveQSO() // multi-op: flip this operator back "online"
// 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)
@@ -3375,6 +3399,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
@@ -4468,13 +4496,53 @@ 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"` // active operator, last 10 min
Last60 int `json:"last60"` // active operator, last 60 min
TeamLast10 int `json:"team_last10"` // ALL operators (the whole station), last 10 min
TeamLast60 int `json:"team_last60"` // ALL operators, last 60 min
}
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
// for the active operator (their own performance) AND for all operators combined
// (the team/station rate). Cheap (one scan of 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{}
}
operator := ""
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
operator = p.Operator
}
}
op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
if err != nil || len(op) < 2 || len(all) < 2 {
return QSORate{}
}
return QSORate{Last10: op[0], Last60: op[1], TeamLast10: all[0], TeamLast60: all[1]}
}
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
@@ -5234,7 +5302,14 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
if a.lookup == nil {
return lookup.Result{}, fmt.Errorf("lookup not initialized")
}
r, err := a.lookup.Lookup(a.ctx, callsign)
// Bound the whole lookup: give the providers a couple of seconds, then let
// Lookup fall through to cty.dat (country/zones). Without this a call that isn't
// in QRZ.com — or a slow/unresponsive provider — left the "looking up" spinner
// turning for 10 s+ before the cty.dat fallback showed. The providers respect
// the context, so they're cancelled at the deadline and cty.dat answers instantly.
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Second)
defer cancel()
r, err := a.lookup.Lookup(ctx, callsign)
if errors.Is(err, lookup.ErrNotFound) {
return lookup.Result{}, fmt.Errorf("callsign not found")
}
@@ -5764,8 +5839,10 @@ func (a *App) saveQSORecording(q *qso.QSO) {
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING"] = name
if err := a.qso.Update(a.ctx, *q); err != nil {
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine
// Persist ONLY this extras key (targeted) — a full-row Update from this
// in-memory copy could revert a column a concurrent post-log action changed.
if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil {
applog.Printf("qso-rec: store recording path: %v", err)
}
}
@@ -5879,20 +5956,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
@@ -5900,7 +6042,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)
@@ -5997,21 +6143,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
@@ -6529,17 +6714,10 @@ func (a *App) markRecordingSent(id int64) {
if a.qso == nil || id == 0 {
return
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
return
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
// Targeted extras write — never a full-row Update, which (from a stale copy)
// could revert a clublog/qrz upload-status another action just stamped.
if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil {
applog.Printf("qso-rec: mark sent %d: %v", id, err)
}
}
@@ -7693,7 +7871,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 {
@@ -8630,6 +8816,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++
}
@@ -9090,6 +9289,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
return 0, fmt.Errorf("insert qso: %w", err)
}
q.ID = id
a.noteLiveQSO() // multi-op: flip this operator back "online"
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
+10 -11
View File
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
return err
}
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
// independent. q came straight from GetByID, so a full Update rewrites the
// row unchanged apart from this field.
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("eQSL sent but status not saved: %w", err)
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
//
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
// read up top is now stale after the slow e-mail send, and rewriting the whole
// row would revert any column an auto-upload changed meanwhile — that's how
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("QSL card sent but status not saved: %w", err)
}
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
+406 -51
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
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,
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
@@ -13,7 +13,7 @@ import {
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate,
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations,
WorkedBefore,
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
@@ -28,6 +28,7 @@ import {
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings,
GetSolarData,
GetQSORate,
LoTWUserInfo,
OperatingDefaultForBand,
LogUDPLoggedADIF,
@@ -41,7 +42,7 @@ import {
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs,
GetUIPref,
ReportLiveActivity,
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
AwardRefsForQSOs,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
@@ -209,6 +210,16 @@ function bandForMHz(mhz: number): string {
return '';
}
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
// CW gold, phone green, digital blue, unknown muted.
function modeAccent(mode?: string): string {
const m = (mode || '').toUpperCase();
if (/CW/.test(m)) return 'var(--chart-3)';
if (/SSB|USB|LSB|AM|FM|PHONE|DV/.test(m)) return 'var(--chart-2)';
if (/FT8|FT4|RTTY|PSK|JT|JS8|Q65|MSK|FST|MFSK|OLIVIA|DIG|DATA|WSPR/.test(m)) return 'var(--chart-1)';
return 'var(--muted-foreground)';
}
// rstCategory buckets a mode into the report family used for its RST list.
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
function rstCategory(mode: string): keyof RSTLists {
@@ -409,6 +420,18 @@ export default function App() {
// click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// Multi-op "who's on air" widget: every operator's live status from the shared
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
useEffect(() => {
if (dbConn?.backend !== 'mysql') { setLiveStations([]); return; }
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
load();
const id = window.setInterval(load, 15 * 1000);
return () => window.clearInterval(id);
}, [dbConn]);
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface.
@@ -604,11 +627,17 @@ export default function App() {
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
};
const [saving, setSaving] = useState(false);
// Synchronous re-entrancy guard: `saving` is React state (updates async), so it
// can't stop a burst of Enter presses / clicks fired before the re-render — each
// would run a full AddQSO and log the SAME contact several times when a slow QRZ
// lookup made the log take seconds. This ref blocks the repeat immediately.
const savingRef = useRef(false);
const [filterCallsign, setFilterCallsign] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns).
const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
const [matchCount, setMatchCount] = useState<number | null>(null);
const [gridFilteredCount, setGridFilteredCount] = useState<number | null>(null); // rows after AG-Grid column filters, or null if none
// The selected tab is remembered across restarts. Only the always-present tabs
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
// a feature or CAT backend that isn't known this early, and restoring one that
@@ -1064,6 +1093,15 @@ export default function App() {
// 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; 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,21 +1114,77 @@ 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]);
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
// offline. Only shown when live-status publishing is enabled (Settings→General).
const [liveStatusOn, setLiveStatusOn] = useState(false);
const [onAir, setOnAir] = useState(false);
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
useEffect(() => {
// Read the ON-AIR state straight from the backend (single source of truth:
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
const refresh = () => LiveLastQSOAgeSec()
.then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
.catch(() => {});
refresh();
const off = EventsOn('qso:logged', refresh);
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
return () => { off(); window.clearInterval(id); };
}, [liveStatusOn]);
// 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; team10: number; team60: number }>({ last10: 0, last60: 0, team10: 0, team60: 0 });
useEffect(() => {
if (!showQsoRate) return;
const load = () => { GetQSORate().then((r: any) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0, team10: r?.team_last10 ?? 0, team60: r?.team_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);
const [showDeleteAll, setShowDeleteAll] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
// Check GitHub for a newer release once at startup (unless disabled in
// General); surface a toast if one exists. Best effort — silent on failure.
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
const [updating, setUpdating] = useState(false);
const [updateProgress, setUpdateProgress] = useState(0);
const [updateError, setUpdateError] = useState('');
// Check GitHub for a newer release at startup AND every 10 minutes (unless
// disabled in General). Best effort — silent on failure.
useEffect(() => {
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
CheckForUpdate().then((u: any) => {
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? '') });
const check = () => CheckForUpdate().then((u: any) => {
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
}).catch(() => {});
check();
const id = window.setInterval(check, 10 * 60 * 1000);
return () => window.clearInterval(id);
}, []);
// Live download progress for the in-app updater.
useEffect(() => {
const off = EventsOn('update:progress', (p: any) => setUpdateProgress(Math.max(0, Math.min(100, Number(p) || 0))));
return () => { off(); };
}, []);
// startUpdate downloads the new build in-app and (on success) swaps + relaunches.
// Falls back to opening the release page when the release has no auto-download asset.
const startUpdate = useCallback(async () => {
if (!updateInfo) return;
if (!updateInfo.downloadUrl) { if (updateInfo.url) BrowserOpenURL(updateInfo.url); return; }
setUpdating(true); setUpdateProgress(0); setUpdateError('');
try {
await DownloadAndApplyUpdate(updateInfo.downloadUrl); // app quits + relaunches on success
} catch (e: any) {
setUpdateError(String(e?.message ?? e));
setUpdating(false);
}
}, [updateInfo]);
const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false);
const [refsDownloading, setRefsDownloading] = useState(false);
@@ -1129,6 +1223,10 @@ export default function App() {
const [lookupError, setLookupError] = useState('');
const lookupTimerRef = useRef<number | null>(null);
const wbTimerRef = useRef<number | null>(null);
// Bumped whenever the entry is cleared (ESC) or a new call starts, so a still
// in-flight lookup discards its result when it finally returns instead of
// re-populating a field the operator just cleared.
const lookupGenRef = useRef(0);
const [wb, setWb] = useState<WB | null>(null);
const [wbBusy, setWbBusy] = useState(false);
@@ -1290,20 +1388,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 +1846,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 +1923,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 +1995,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 +2099,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,
@@ -2044,7 +2229,9 @@ export default function App() {
}, [spots]);
async function save() {
if (savingRef.current) return; // a log is already in flight — ignore the repeat
if (!callsign.trim()) { setError('Callsign required'); return; }
savingRef.current = true;
setSaving(true); setError('');
try {
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
@@ -2114,13 +2301,21 @@ export default function App() {
await refresh();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally { setSaving(false); }
} finally { setSaving(false); savingRef.current = false; }
}
// resetEntry clears the form for the next QSO. Triggered after a
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
// are preserved so backdated batches stay productive.
function resetEntry() {
// Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking
// up" spinner immediately, and invalidate any request already in flight so its
// late result can't re-fill the field we're about to clear (the "ESC clears the
// QSO but the QRZ lookup keeps going" bug).
if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; }
if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; }
lookupGenRef.current++;
setLookupBusy(false);
// Discard any in-progress QSO recording (no-op if it was already saved on
// log, or if the recorder is off).
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
@@ -2325,14 +2520,15 @@ export default function App() {
}
async function runLookup(call: string) {
if (call !== lastLookedUpRef.current) resetAutoFill();
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
setLookupBusy(true);
try {
const r = await LookupCallsign(call);
// Discard a STALE result: the operator already moved to another call
// (clicked a new spot / typed) while this lookup was in flight. Applying it
// would clobber the current call's fields and zoom the map to the wrong
// station — the bug where replacing a call didn't re-zoom the map.
if (call !== callsignValRef.current.trim().toUpperCase()) return;
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
// was in flight. Applying it would clobber the current fields and zoom the
// map to the wrong station.
if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return;
lastLookedUpRef.current = call;
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
@@ -2385,9 +2581,15 @@ export default function App() {
QSOAudioBegin().then(setRecording).catch(() => {});
}
} catch (e: any) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
} finally { setLookupBusy(false); }
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
}
} finally {
// Only clear the spinner if we're still the current lookup — a newer one
// (or an ESC that already reset it) owns the busy state otherwise.
if (gen === lookupGenRef.current) setLookupBusy(false);
}
}
function scheduleLookup(value: string, force?: boolean) {
setLookupError('');
@@ -2768,12 +2970,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).
@@ -3661,8 +3863,67 @@ export default function App() {
)}
</button>
)}
{/* Multi-op "who's on air": a dockable widget (toggle), not a popover. */}
{dbConn?.backend === 'mysql' && (
<button
type="button"
onClick={() => { const v = !showLiveStations; setShowLiveStations(v); writeUiPref('opslog.showLiveStations', v ? '1' : '0'); }}
title={showLiveStations ? `${t('live.stationsTitle')} — shown · click to hide` : `${t('live.stationsTitle')} · click to show`}
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showLiveStations ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
: 'border-border text-muted-foreground hover:bg-muted')}
>
<Radio className="size-4" />
{liveStations.filter((s) => s.online).length > 0 && (
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
{(() => { const n = liveStations.filter((s) => s.online).length; return n > 9 ? '9+' : n; })()}
</span>
)}
</button>
)}
</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 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
title={t('rate.title')}>
{/* Contest-style rate: QSOs/hour projected from each window (10-min
count ×6; the 60-min count is already per hour). On a shared MySQL
logbook it shows both OP (the active operator, accent) and TEAM (all
operators, muted); single-op shows one line. */}
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
{dbConn?.backend === 'mysql' ? (
<div className="flex flex-col gap-0.5 leading-none">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span><span className="text-muted-foreground text-[7px]">10</span></span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span><span className="text-muted-foreground text-[7px]">60</span></span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team10 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}</span><span className="text-muted-foreground text-[7px]">10</span></span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team60 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}</span><span className="text-muted-foreground text-[7px]">60</span></span>
</div>
</div>
) : (
<>
<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 self-center">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
@@ -3674,6 +3935,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>
@@ -3681,15 +3950,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" />
@@ -3771,22 +4041,42 @@ export default function App() {
<div className="flex items-start gap-2">
<div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">OpsLog v{updateInfo.latest} available</p>
<p className="text-xs text-muted-foreground">You're on v{APP_VERSION}.</p>
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => { if (updateInfo.url) BrowserOpenURL(updateInfo.url); setUpdateInfo(null); }}
className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
Download
</button>
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
Later
</button>
</div>
<p className="text-sm font-semibold">{t('upd.available', { v: updateInfo.latest })}</p>
<p className="text-xs text-muted-foreground">{t('upd.current', { v: APP_VERSION })}</p>
{updating ? (
<div className="mt-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
<span>{updateProgress >= 100 ? t('upd.installing') : t('upd.downloading')}</span>
<span className="tabular-nums">{updateProgress}%</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div className="h-full bg-primary transition-[width] duration-150" style={{ width: `${updateProgress}%` }} />
</div>
<p className="mt-1 text-[10px] text-muted-foreground">{t('upd.restartNote')}</p>
</div>
) : updateError ? (
<div className="mt-2">
<p className="text-[11px] text-destructive break-words">{updateError}</p>
<div className="mt-1.5 flex items-center gap-2">
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">{t('upd.retry')}</button>
{updateInfo.url && <button onClick={() => BrowserOpenURL(updateInfo.url)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.browser')}</button>}
</div>
</div>
) : (
<div className="mt-2 flex items-center gap-2">
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
</button>
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
</div>
)}
</div>
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
<X className="size-4" />
</button>
{!updating && (
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
<X className="size-4" />
</button>
)}
</div>
</div>
)}
@@ -3977,8 +4267,55 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && (
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
their freq/mode (colour-coded) and OpsLog version. */}
{showLiveStations && dbConn?.backend === 'mysql' && (
<div className="w-[248px] shrink-0 min-h-0 relative">
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
<Radio className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}</span>
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
{liveStations.filter((s) => s.online).length === 0 ? (
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
) : liveStations.filter((s) => s.online).map((s, i) => {
const mc = modeAccent(s.mode);
return (
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
title={s.online ? t('live.onAir') : t('live.offline')} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="text-xs font-bold font-mono truncate">{s.operator}</span>
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
</div>
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
</span>
{s.mode && (
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
)}
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{chatShown && (
// relative + absolute inner: the chat takes the row height (set by the
// entry strip) WITHOUT its message list growing the row, like the
@@ -4302,6 +4639,7 @@ export default function App() {
rows={qsosWithAwards as any}
total={total}
awardCols={awardCols}
onFilteredCountChange={setGridFilteredCount}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
@@ -4337,11 +4675,18 @@ export default function App() {
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button>
) : null}
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span>
{gridFilteredCount != null ? (
<span>
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
</span>
) : (
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && (
@@ -4711,6 +5056,16 @@ export default function App() {
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
{liveStatusOn && (
<div
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
>
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
{onAir ? t('live.onAir') : t('live.offline')}
</div>
)}
{/* Toasts / errors: the status bar's free space is far wider than the
header band they used to sit in. Still one line (the bar is 28px),
but CLICK opens the full text wrapped long messages (a TQSL or
+44 -31
View File
@@ -113,6 +113,26 @@ function tok(name: string, text: string): Badge {
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 tok('danger', t('clg2.newDxcc'));
@@ -129,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: 'var(--danger-muted)', color: 'var(--danger-muted-foreground)', fontWeight: 700 };
if (s?.worked_call) return { color: 'var(--info)', 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);
@@ -204,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),
},
{
@@ -219,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),
},
{
@@ -231,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');
@@ -252,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',
@@ -289,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',
@@ -298,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' }];
+2 -2
View File
@@ -412,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>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></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>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></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')}
+14 -1
View File
@@ -49,6 +49,10 @@ type Props = {
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => void;
// Reports how many rows the grid shows after its COLUMN filters (the funnel
// icons), or null when no column filter is active — so the parent's "Showing X
// of Y" can reflect them. Fired on filter change and when the data updates.
onFilteredCountChange?: (count: number | null) => void;
// One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[];
@@ -245,7 +249,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
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) {
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -360,6 +364,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
}
});
}
// Report the post-column-filter row count (funnel filters) to the parent, or
// null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api;
if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]);
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
@@ -467,6 +478,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={onGridReady}
onFilterChanged={reportFilteredCount}
onModelUpdated={reportFilteredCount}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState}
+203 -20
View File
@@ -42,6 +42,8 @@ import {
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';
@@ -169,6 +171,7 @@ type SectionId =
| 'confirmations'
| 'external-services'
| 'udp'
| 'adifmon'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
@@ -185,6 +188,7 @@ type SectionId =
| 'antgenius'
| 'pgxl'
| 'flex'
| 'relayauto'
| 'audio';
type TreeNode =
@@ -202,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 [
@@ -225,6 +230,7 @@ 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' },
@@ -241,9 +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).
@@ -261,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',
@@ -269,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',
};
@@ -540,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(() => {
@@ -549,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>
);
}
@@ -560,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(() => {
@@ -569,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); });
}, []);
@@ -609,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>
@@ -622,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>
@@ -912,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 });
@@ -4197,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>
@@ -4449,6 +4627,11 @@ 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,
+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); }
+69 -2
View File
@@ -14,6 +14,16 @@ type Dict = Record<string, string>;
const en: Dict = {
// Menu bar
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
'live.onAir': 'On air', 'live.offline': 'Offline',
'live.onAirTip': 'On air — a QSO was logged in the last 5 minutes (published to the live status)',
'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes',
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'No station reporting yet.', 'live.stationsHide': 'Hide',
'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.",
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
'upd.restartNote': 'OpsLog will restart on the new version.',
'upd.retry': 'Retry', 'upd.browser': 'Open page',
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
@@ -67,7 +77,7 @@ const en: Dict = {
'stats.best60': 'Best 60 min', 'stats.best60Tip': 'Best ROLLING 60 minutes (not the best clock hour) — the figure contesters quote.',
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
'stats.noGaps': 'No break of 30 min or more.', 'stats.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', 'stats.noContest': '— No contest —',
'stats.noGaps': 'No break of 30 min or more.', 'stats.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', 'stats.noContest': '— No contest —', 'stats.allOperators': '— All operators —', 'stats.stationOwner': '— Station owner —', 'stats.operatorTip': 'Narrow every statistic to one operator from the log.',
'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console',
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
'msg.expand': 'Click to read the full message',
@@ -80,6 +90,17 @@ const en: Dict = {
'offline.synced': '{n} QSO(s) added to the logbook',
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
'settings.telemetry': 'Send anonymous usage statistics',
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
'settings.liveStatus': 'Publish live operator status',
'settings.liveStatusHint': 'multi-op on shared MySQL — feeds a QRZ live page',
'settings.mainView': 'Main view',
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control',
'settings.pane.flex': 'FlexRadio controls', 'settings.pane.icom': 'Icom console',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
@@ -90,6 +111,13 @@ const en: Dict = {
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
'sec.adifmon': 'ADIF monitor',
'adifmon.hint': 'Watch external ADIF files and import new QSOs automatically — e.g. fldigi logging RTTY, or N1MM/VarAC. Imported QSOs are enriched, de-duplicated and uploaded to your external services just like a QSO logged here.',
'adifmon.enable': 'Enable ADIF monitor',
'adifmon.empty': 'No file watched yet. Add an ADIF file below.',
'adifmon.add': 'Add ADIF file…',
'adifmon.remove': 'Stop watching this file',
'adifmon.note': 'A newly added file starts from its current end — QSOs already in it are NOT imported, only contacts logged after you add it.',
'uscty.title': 'US Counties (USA-CA)',
'uscty.intro': 'Resolve a US callsign to its county and grid offline, from the FCC ULS licence database. This powers the US Counties award and county hunting — including on CW/SSB, where a spot carries only a callsign.',
'uscty.needDownload': 'County resolution requires downloading the FCC database first (about 150 MB, stored locally). Nothing is resolved until you download it.',
@@ -106,11 +134,19 @@ const en: Dict = {
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.relayauto': 'Relay auto-control',
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
'relayauto.enable': 'Enable relay auto-control',
'relayauto.noDevices': 'No relay board configured. Add one in the Station Control panel first.',
'relayauto.relay': 'Relay',
'relayauto.modeOff': 'Off (manual)', 'relayauto.modeFreq': 'Frequency', 'relayauto.modeBand': 'Band',
'relayauto.from': 'from', 'relayauto.to': 'to',
// General panel
'gen.hint': 'App behaviour (saved instantly).',
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
'gen.showBeam': 'Show the antenna beam heading on the Main map',
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)',
'gen.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
'email.title': 'E-mail',
@@ -297,6 +333,11 @@ const en: Dict = {
const fr: Dict = {
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
'live.onAir': 'On air', 'live.offline': 'Hors ligne',
'live.onAirTip': "On air — un QSO a été loggé dans les 5 dernières minutes (publié dans le statut live)",
'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes',
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer',
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
@@ -346,7 +387,7 @@ const fr: Dict = {
'stats.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.',
'stats.activeHours': 'Temps en lair', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en lair + hors antenne = la fenêtre, par construction.',
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.rateSheet': 'Feuille de cadence', 'stats.rateSheetSub': 'Heure par heure — qui a fait les QSO (heures muettes omises)', 'stats.noContest': '— Aucun contest —',
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.rateSheet': 'Feuille de cadence', 'stats.rateSheetSub': 'Heure par heure — qui a fait les QSO (heures muettes omises)', 'stats.noContest': '— Aucun contest —', 'stats.allOperators': '— Tous les opérateurs —', 'stats.stationOwner': '— Propriétaire station —', 'stats.operatorTip': "Restreindre toutes les statistiques à un opérateur du log.",
'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster',
'cluster.consoleEmpty': 'Le trafic brut du cluster saffiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
'msg.expand': 'Cliquer pour lire le message en entier',
@@ -359,6 +400,17 @@ const fr: Dict = {
'offline.synced': '{n} QSO ajoutés au journal',
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
'settings.liveStatus': 'Publier le statut opérateur en direct',
'settings.liveStatusHint': 'multi-op sur MySQL partagé — alimente une page live QRZ',
'settings.mainView': 'Vue principale',
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés',
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net',
'settings.pane.flex': 'Contrôles FlexRadio', 'settings.pane.icom': 'Console Icom',
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
@@ -368,6 +420,13 @@ const fr: Dict = {
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
'sec.adifmon': 'Moniteur ADIF',
'adifmon.hint': "Surveille des fichiers ADIF externes et importe les nouveaux QSO automatiquement — ex. fldigi en RTTY, ou N1MM/VarAC. Les QSO importés sont enrichis, dédoublonnés et envoyés à tes services externes comme un QSO loggé ici.",
'adifmon.enable': 'Activer le moniteur ADIF',
'adifmon.empty': 'Aucun fichier surveillé. Ajoute un fichier ADIF ci-dessous.',
'adifmon.add': 'Ajouter un fichier ADIF…',
'adifmon.remove': 'Ne plus surveiller ce fichier',
'adifmon.note': "Un fichier ajouté démarre à sa fin actuelle — les QSO déjà présents ne sont PAS importés, seulement les contacts loggés après l'ajout.",
'uscty.title': 'Comtés US (USA-CA)',
'uscty.intro': "Résout un indicatif US en comté et locator, hors-ligne, depuis la base de licences FCC ULS. Ça alimente le diplôme Comtés US et la chasse aux comtés — même en CW/SSB, où le spot ne porte qu'un indicatif.",
'uscty.needDownload': "La résolution des comtés nécessite d'abord de télécharger la base FCC (environ 150 Mo, stockée en local). Rien n'est résolu tant que tu ne l'as pas téléchargée.",
@@ -384,10 +443,18 @@ const fr: Dict = {
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.relayauto': 'Relais automatiques',
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
'relayauto.enable': 'Activer les relais automatiques',
'relayauto.noDevices': "Aucune carte relais configurée. Ajoutes-en une dans le panneau Station Control d'abord.",
'relayauto.relay': 'Relais',
'relayauto.modeOff': 'Off (manuel)', 'relayauto.modeFreq': 'Fréquence', 'relayauto.modeBand': 'Bande',
'relayauto.from': 'de', 'relayauto.to': 'à',
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)',
'gen.checkUpdates': 'Vérifier les mises à jour au démarrage', 'gen.checkUpdatesHint': '(prévient quand une version plus récente est publiée)',
'email.title': 'E-mail',
+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.3';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+21 -1
View File
@@ -146,6 +146,8 @@ export function DismissAwardUpdate(arg1:string):Promise<void>;
export function DownloadAllReferenceLists():Promise<string>;
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
@@ -278,6 +280,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>;
@@ -350,13 +354,15 @@ export function GetIcomState():Promise<cat.IcomTXState>;
export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLiveStatusEnabled():Promise<boolean>;
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>;
@@ -368,6 +374,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>;
@@ -380,6 +388,10 @@ 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>;
@@ -538,6 +550,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
export function LiveLastQSOAgeSec():Promise<number>;
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
@@ -596,6 +610,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>;
@@ -686,6 +702,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>;
@@ -730,6 +748,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>;
+42 -2
View File
@@ -250,6 +250,10 @@ export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists']();
}
export function DownloadAndApplyUpdate(arg1) {
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
}
export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty']();
}
@@ -514,6 +518,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']();
}
@@ -658,6 +666,10 @@ export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings']();
}
export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations']();
}
export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled']();
}
@@ -670,8 +682,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() {
@@ -694,6 +706,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']();
}
@@ -718,6 +734,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']();
}
@@ -1034,6 +1058,10 @@ export function ListUDPIntegrations() {
return window['go']['main']['App']['ListUDPIntegrations']();
}
export function LiveLastQSOAgeSec() {
return window['go']['main']['App']['LiveLastQSOAgeSec']();
}
export function LoTWUserInfo(arg1) {
return window['go']['main']['App']['LoTWUserInfo'](arg1);
}
@@ -1150,6 +1178,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']();
}
@@ -1330,6 +1362,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);
}
@@ -1418,6 +1454,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);
}
+150
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;
@@ -2005,6 +2054,32 @@ export namespace main {
return a;
}
}
export class LiveStation {
operator: string;
station: string;
freq_hz: number;
band: string;
mode: string;
online: boolean;
version: string;
age_sec: number;
static createFrom(source: any = {}) {
return new LiveStation(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.operator = source["operator"];
this.station = source["station"];
this.freq_hz = source["freq_hz"];
this.band = source["band"];
this.mode = source["mode"];
this.online = source["online"];
this.version = source["version"];
this.age_sec = source["age_sec"];
}
}
export class LoTWUsersStatus {
count: number;
updated?: string;
@@ -2329,6 +2404,79 @@ export namespace main {
this.pickable = source["pickable"];
}
}
export class QSORate {
last10: number;
last60: number;
team_last10: number;
team_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"];
this.team_last10 = source["team_last10"];
this.team_last60 = source["team_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;
@@ -2645,6 +2793,7 @@ export namespace main {
latest: string;
available: boolean;
url: string;
download_url: string;
static createFrom(source: any = {}) {
return new UpdateInfo(source);
@@ -2656,6 +2805,7 @@ export namespace main {
this.latest = source["latest"];
this.available = source["available"];
this.url = source["url"];
this.download_url = source["download_url"];
}
}
export class WKMacro {
+10
View File
@@ -136,6 +136,11 @@ type icomNet struct {
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
lastRx atomic.Int64
// dead is set when the rig explicitly tears the session down (control 0x05):
// Alive() then returns false immediately so ReadState fails on the next poll and
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
dead atomic.Bool
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
// Torn down alongside the CI-V/control streams in Close.
audio *icomAudio
@@ -178,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
func (n *icomNet) Alive() bool {
if n.dead.Load() {
return false // rig sent an explicit disconnect — reconnect now, don't wait
}
last := n.lastRx.Load()
if last == 0 {
return true // just connected, nothing received yet — give it a chance
@@ -292,6 +300,7 @@ func (n *icomNet) ctrlPump() {
n.ctrlResend(icnLE.Uint16(buf[6:]))
}
case 0x05: // rig-initiated disconnect — it dropped US
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
default:
// Anything else on the control stream is (almost always) the rig's
@@ -367,6 +376,7 @@ func (n *icomNet) civPump() {
n.resend(icnLE.Uint16(buf[6:]))
}
case typ == 0x05: // rig-initiated disconnect — it dropped US
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
+17 -1
View File
@@ -32,6 +32,7 @@ type OmniRig struct {
omnirig *ole.IDispatch
rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
@@ -80,11 +81,20 @@ func (o *OmniRig) Connect() error {
o.rig = rigVar.ToIDispatch()
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
o.rigType = rt.ToString()
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
}
return nil
}
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
// shows.
func (o *OmniRig) isIC7610() bool {
return strings.Contains(strings.ToUpper(o.rigType), "7610")
}
func (o *OmniRig) Disconnect() {
if o.rig != nil {
o.rig.Release()
@@ -199,6 +209,12 @@ func (o *OmniRig) ReadState() (RigState, error) {
s.Split = false
s.RxFreqHz = 0
s.FreqHz = freqMain
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
// explicitly for the 7610 — what the operator actually wants to see.
if o.isIC7610() && freqA != 0 {
s.FreqHz = freqA
}
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB
+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.
+45 -18
View File
@@ -89,6 +89,9 @@ type Manager struct {
rnd *rand.Rand
}
// maxUploadAttempts bounds retries of a transient upload failure.
const maxUploadAttempts = 4
func NewManager(deps Deps) *Manager {
if deps.Client == nil {
deps.Client = &http.Client{Timeout: 20 * time.Second}
@@ -101,6 +104,24 @@ func NewManager(deps Deps) *Manager {
}
}
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
// independent — never serialised through a shared worker, because a single slow
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
// every following QSO's upload and strand them all at "R" (the regression that hit
// the operator on the newest build while everyone on the old concurrent path was
// fine).
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
go func() {
ok, retryable := m.upload(svc, id, cfg)
if !ok && retryable && attempt+1 < maxUploadAttempts {
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
}
}()
}
func (m *Manager) logf(format string, args ...any) {
if m.deps.Logf != nil {
m.deps.Logf(format, args...)
@@ -175,15 +196,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 uploads now (immediate) or after a random fuse (delayed). Each
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
// one never holds up the rest.
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.attemptUpload(svc, id, cfg, 0) })
return
}
go m.upload(svc, id, cfg)
m.attemptUpload(svc, id, cfg, 0)
}
// onCloseServices returns the services configured for on-close auto-upload,
@@ -243,25 +266,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 +335,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 +372,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 +387,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 +396,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 +405,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 +414,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 +423,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 +438,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
}
+121
View File
@@ -781,6 +781,38 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
return n, nil
}
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
// QSL card, saving a recording) must not do a full-row Update: the row it read may
// be seconds stale, and writing it all back silently reverts any column another
// action changed meanwhile — e.g. an auto-upload flipping clublog_qso_upload_status
// from R to Y. Touching only `extras` makes that impossible. Empty value deletes
// the key.
func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error {
if id == 0 || strings.TrimSpace(key) == "" {
return fmt.Errorf("missing id or key")
}
var extrasJSON sql.NullString
if err := r.db.QueryRowContext(ctx, `SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
return fmt.Errorf("load extras: %w", err)
}
m := decodeExtras(extrasJSON.String)
if m == nil {
m = map[string]string{}
}
if value == "" {
delete(m, key)
} else {
m[key] = value
}
if _, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras_json = ?, updated_at = ? WHERE id = ?`,
encodeExtras(m), db.NowISO(), id); err != nil {
return fmt.Errorf("set extra %s: %w", key, err)
}
return nil
}
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
func (r *Repo) Update(ctx context.Context, q QSO) error {
if q.ID == 0 {
@@ -1809,6 +1841,28 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
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.
@@ -1841,6 +1895,73 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
return n, err
}
// LastQSOTime returns the start time of the most recently LOGGED QSO for an
// operator (highest id wins) — used to seed the live "on air" state at launch so an
// operator who just worked someone before (re)starting OpsLog shows online right
// away instead of waiting for their next QSO. Empty operator matches every QSO.
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
if err != nil {
return time.Time{}, false
}
defer rows.Close()
opFilter := strings.ToUpper(strings.TrimSpace(operator))
for rows.Next() {
var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil {
return time.Time{}, false
}
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
continue
}
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
return t, true
}
}
return time.Time{}, false
}
// RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
// start time falls within each trailing window from `now` — for a specific operator
// (their own rate, `op`) AND for ALL operators combined (the team/station rate,
// `all`). The header rate meter shows both. It scans only recently inserted rows
// (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
// it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
// format agnostic).
func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
op = make([]int, len(windows))
all = make([]int, len(windows))
// 2000 rows covers a full hour even in a busy multi-op run.
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
if err != nil {
return op, all, err
}
defer rows.Close()
now = now.UTC()
opFilter := strings.ToUpper(strings.TrimSpace(operator))
for rows.Next() {
var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil {
return op, all, err
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) {
continue
}
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
age := now.Sub(t)
for i, w := range windows {
if age <= w {
all[i]++
if mine {
op[i]++
}
}
}
}
return op, all, 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
+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]++
+24 -1
View File
@@ -24,6 +24,7 @@ import (
"bufio"
"context"
"database/sql"
"errors"
"fmt"
"io"
"math"
@@ -37,6 +38,10 @@ import (
_ "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"
@@ -325,8 +330,26 @@ func download(ctx context.Context, url, dest string, prog func(pct int)) error {
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
// 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()
+160 -8
View File
@@ -1,6 +1,7 @@
package main
import (
"database/sql"
"fmt"
"strings"
"time"
@@ -19,6 +20,23 @@ import (
const keyLiveStatusEnabled = "livestatus.enabled"
// liveOnlineWindow is how long after the last logged contact an operator still
// counts as "on air". Leaving the log open without working anyone flips them
// offline once this elapses; logging a new QSO flips them back online.
const liveOnlineWindow = 5 * time.Minute
// noteLiveQSO records that this operator just logged a new contact and pushes the
// live status right away, so they flip back to online the instant they work
// someone. Called from the logging paths (manual entry, UDP auto-log).
func (a *App) noteLiveQSO() {
a.liveActMu.Lock()
a.liveLastQSOAt = time.Now()
a.liveActMu.Unlock()
if a.liveStatusActive() {
go a.publishLiveStatus()
}
}
// GetLiveStatusEnabled reports whether this operator publishes live status.
func (a *App) GetLiveStatusEnabled() bool {
if a.settings == nil {
@@ -50,11 +68,61 @@ func (a *App) SetLiveStatusEnabled(on bool) error {
return nil
}
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
// instead of offline until their next QSO.
func (a *App) seedLiveLastQSO() {
if a.qso == nil {
return
}
op, _ := a.liveStatusOperator()
if op == "" {
return
}
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok {
a.liveActMu.Lock()
if a.liveLastQSOAt.IsZero() {
a.liveLastQSOAt = t
}
a.liveActMu.Unlock()
}
}
// liveLastQSOTime is the authoritative "last contact" instant for this operator:
// the most recent of the in-memory stamp (this session's local logs, updated
// instantly) AND the DB (a contact that arrived via the SHARED logbook from another
// station, or one logged before launch). Used by both the published status and the
// UI badge so on-air/offline is right in every multi-op case.
func (a *App) liveLastQSOTime() time.Time {
a.liveActMu.Lock()
last := a.liveLastQSOAt
a.liveActMu.Unlock()
if a.qso != nil {
if op, _ := a.liveStatusOperator(); op != "" {
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) {
last = t
}
}
}
return last
}
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
// none is known — the UI polls it for the "on air" badge.
func (a *App) LiveLastQSOAgeSec() int {
last := a.liveLastQSOTime()
if last.IsZero() {
return -1
}
return int(time.Since(last).Seconds())
}
// liveStatusLoop heartbeats the current activity while enabled. Started once at
// startup; cheap no-op when disabled or not on MySQL.
func (a *App) liveStatusLoop() {
defer func() { _ = recover() }() // never crash the app from here
applog.Printf("livestatus: loop started")
a.seedLiveLastQSO() // so online/offline is right at launch, not only after the next QSO
a.publishLiveStatus() // attempt immediately, don't wait the first tick
t := time.NewTicker(15 * time.Second)
defer t.Stop()
@@ -133,33 +201,117 @@ func (a *App) publishLiveStatus() {
mode = a.liveMode
}
a.liveActMu.Unlock()
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
// Online = a new contact was logged within the window. An operator who leaves
// the log open but stops working shows offline after `liveOnlineWindow`; the
// next QSO flips them back on. never-logged (zero time) → offline.
online := 0
var lastQSOArg any
if !lastQSO.IsZero() {
lastQSOArg = lastQSO.UTC()
if time.Since(lastQSO) < liveOnlineWindow {
online = 1
}
}
if err := a.ensureLiveStatusTable(); err != nil {
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
return
}
_, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
op, station, freqHz, band, mode)
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
op, station, freqHz, band, mode, online, appVersion, lastQSOArg)
if err != nil {
applog.Printf("livestatus: INSERT failed: %v", err)
return
}
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode)
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online)
}
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
type LiveStation struct {
Operator string `json:"operator"`
Station string `json:"station"`
FreqHz int64 `json:"freq_hz"`
Band string `json:"band"`
Mode string `json:"mode"`
Online bool `json:"online"` // logged a QSO in the last 5 min
Version string `json:"version"` // that operator's OpsLog version
AgeSec int `json:"age_sec"` // seconds since their last heartbeat (stale = OpsLog closed)
}
// GetLiveStations returns every operator's live status from the shared MySQL
// logbook (empty on a local SQLite logbook). Rows whose heartbeat is very stale
// (OpsLog closed without clearing its row) are dropped. Online stations first.
func (a *App) GetLiveStations() []LiveStation {
if a.logDb == nil || a.dbBackend != "mysql" {
return nil
}
if err := a.ensureLiveStatusTable(); err != nil {
return nil
}
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT operator, COALESCE(station,''), COALESCE(freq_hz,0), COALESCE(band,''), "+
"COALESCE(mode,''), COALESCE(online,0), COALESCE(version,''), "+
"TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) "+
"FROM live_status ORDER BY online DESC, operator")
if err != nil {
applog.Printf("livestatus: list failed: %v", err)
return nil
}
defer rows.Close()
out := []LiveStation{}
for rows.Next() {
var s LiveStation
var online int
var age sql.NullInt64
if err := rows.Scan(&s.Operator, &s.Station, &s.FreqHz, &s.Band, &s.Mode, &online, &s.Version, &age); err != nil {
continue
}
// Drop rows from an OpsLog that hasn't heartbeated in a while (closed): the
// heartbeat is every 15 s, so > 3 min means it's gone.
if age.Valid && age.Int64 > 180 {
continue
}
s.Online = online == 1
if age.Valid {
s.AgeSec = int(age.Int64)
}
out = append(out, s)
}
return out
}
func (a *App) ensureLiveStatusTable() error {
_, err := a.logDb.ExecContext(a.ctx,
if _, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS live_status ("+
"operator VARCHAR(32) PRIMARY KEY, "+
"station VARCHAR(32), "+
"freq_hz BIGINT, "+
"band VARCHAR(16), "+
"mode VARCHAR(16), "+
"updated_at DATETIME)")
return err
"online TINYINT DEFAULT 0, "+
"version VARCHAR(32), "+
"last_qso_at DATETIME NULL, "+
"updated_at DATETIME)"); err != nil {
return err
}
// Add newer columns to a table created by an older build. MySQL has no portable
// "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and ignore the duplicate-
// column error when they already exist.
for _, ddl := range []string{
"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0",
"ALTER TABLE live_status ADD COLUMN version VARCHAR(32)",
"ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL",
} {
if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
applog.Printf("livestatus: %q: %v", ddl, err)
}
}
return nil
}
// clearLiveStatus removes this operator's row (on disable / shutdown).
+40 -1
View File
@@ -4,6 +4,7 @@ import (
"embed"
"os"
"strings"
"time"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -35,15 +36,53 @@ func profileArg(args []string) string {
return ""
}
// hasFlag reports whether flag is present in args.
func hasFlag(args []string, flag string) bool {
for _, a := range args {
if a == flag {
return true
}
}
return false
}
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the
// previous instance may still be shutting down and holding the mutex, so retry for
// a few seconds until it frees.
func acquireInstance(postUpdate bool) bool {
if acquireSingleInstance() {
return true
}
if !postUpdate {
return false
}
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(300 * time.Millisecond)
if acquireSingleInstance() {
return true
}
}
return false
}
func main() {
// Single-instance guard: if OpsLog is already running, focus that window and
// exit instead of spawning a duplicate. A second process would open its own
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
// its own" when a windowless zombie instance was left running.
if !acquireSingleInstance() {
// A --post-update relaunch (from the auto-updater) may start while the previous
// instance is still exiting and holding the single-instance mutex — wait for it
// to free instead of bailing out. Then clear the old exe it left behind.
postUpdate := hasFlag(os.Args[1:], "--post-update")
if !acquireInstance(postUpdate) {
return
}
if postUpdate {
cleanupOldUpdateBinary()
}
// Create an instance of the app structure
app := NewApp()
+205
View File
@@ -0,0 +1,205 @@
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
}
// relayAction is one relay's computed desired state for this evaluation.
type relayAction struct {
dev string
relay int
want bool
}
// applyRelayAuto evaluates every rule against the current frequency/band and
// switches only the relays that are NOT already in the wanted position. Two things
// it deliberately does NOT do, which used to make the relay clunk on every
// launch/close:
// - Never acts on an UNKNOWN frequency/band. When the CAT disconnects (app close)
// the frequency drops to 0; reading that as "out of range" and switching the
// relay off — then back on at the next launch — was the whole bug.
// - Never commands a relay already in the right position: on the first evaluation
// after launch/save it reads the boards' LIVE state, so a relay that's already
// correct is left untouched instead of being re-sent.
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
band = strings.TrimSpace(band)
// Compute desired states, skipping rules whose input is unknown right now.
var acts []relayAction
needLive := false
for _, r := range cfg.Rules {
if r.Relay < 1 {
continue
}
var want bool
switch r.Mode {
case "freq":
if freqHz <= 0 {
continue // no known frequency (CAT off/closing) → leave the relay as-is
}
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
continue // unconfigured range
}
lo, hi := r.FreqLoKHz, r.FreqHiKHz
if hi < lo {
lo, hi = hi, lo
}
want = khz >= lo && khz <= hi
case "band":
if band == "" {
continue // no known band → leave the relay as-is
}
if len(r.Bands) == 0 {
continue
}
want = bandInList(r.Bands, band)
default:
continue // "off"/empty → not managed
}
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
needLive = true
}
}
if len(acts) == 0 {
return
}
// First evaluation after launch/save: read the boards' LIVE relay states once
// so we don't re-command a relay that's already in the wanted position.
var live map[string]bool
if needLive {
live = map[string]bool{}
for _, ds := range a.GetStationStatus() {
for _, rl := range ds.Relays {
live[relayAutoKey(ds.ID, rl.Number)] = rl.On
}
}
}
changed := false
for _, ac := range acts {
key := relayAutoKey(ac.dev, ac.relay)
cur, known := a.relayAutoLast[key]
if !known && live != nil {
cur, known = live[key]
}
if known && cur == ac.want {
a.relayAutoLast[key] = ac.want // already in position — record it, don't switch
continue
}
if err := a.StationSetRelay(ac.dev, ac.relay, ac.want); err != nil {
applog.Printf("relay auto: set %s relay %d = %v failed: %v", ac.dev, ac.relay, ac.want, err)
continue // don't cache a failed write — retry next change
}
a.relayAutoLast[key] = ac.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.3"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
+207 -6
View File
@@ -1,12 +1,21 @@
package main
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
)
@@ -14,12 +23,13 @@ import (
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
// UpdateInfo is the result of the startup version check.
// UpdateInfo is the result of the version check.
type UpdateInfo struct {
Current string `json:"current"` // this build's version (appVersion)
Latest string `json:"latest"` // newest published release, "" if unknown
Available bool `json:"available"` // Latest > Current
URL string `json:"url"` // release page to open
Current string `json:"current"` // this build's version (appVersion)
Latest string `json:"latest"` // newest published release, "" if unknown
Available bool `json:"available"` // Latest > Current
URL string `json:"url"` // release page to open (manual fallback)
DownloadURL string `json:"download_url"` // the .exe/.zip asset to auto-download, "" if none
}
// CheckForUpdate asks GitHub for the latest release and compares it to this
@@ -45,6 +55,10 @@ func (a *App) CheckForUpdate() UpdateInfo {
var r struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return out
@@ -52,8 +66,25 @@ func (a *App) CheckForUpdate() UpdateInfo {
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
out.URL = r.HTMLURL
out.Available = versionLess(appVersion, out.Latest)
// Pick the auto-download asset: a bare Windows .exe (portable build) first,
// else a .zip we can unpack. The frontend hands this straight to
// DownloadAndApplyUpdate for a one-click in-app update.
for _, as := range r.Assets {
if strings.HasSuffix(strings.ToLower(as.Name), ".exe") {
out.DownloadURL = as.URL
break
}
}
if out.DownloadURL == "" {
for _, as := range r.Assets {
if strings.HasSuffix(strings.ToLower(as.Name), ".zip") {
out.DownloadURL = as.URL
break
}
}
}
if out.Available {
applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest)
applog.Printf("update: newer version available — current=%s latest=%s asset=%q", appVersion, out.Latest, out.DownloadURL)
}
return out
}
@@ -82,6 +113,176 @@ func versionLess(a, b string) bool {
return false
}
// DownloadAndApplyUpdate downloads the new build, swaps it in for the running exe
// and relaunches — the fully in-app update. Progress is emitted on "update:progress"
// (0-100) so the UI can show a bar. On success it never returns normally: it starts
// the new process and quits this one.
func (a *App) DownloadAndApplyUpdate(url string) error {
if strings.TrimSpace(url) == "" {
return fmt.Errorf("no download URL")
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate executable: %w", err)
}
dir := filepath.Dir(exe)
// Download to a temp file next to the exe (same volume, so the rename-swap is
// atomic and can't fail across drives).
tmp := filepath.Join(dir, ".opslog-update.download")
_ = os.Remove(tmp)
if err := a.downloadWithProgress(url, tmp); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("download: %w", err)
}
// The asset is either the bare exe or a zip holding it. Resolve to the new exe.
newExe := tmp
if strings.HasSuffix(strings.ToLower(url), ".zip") {
extracted, xerr := extractExeFromZip(tmp, dir)
_ = os.Remove(tmp)
if xerr != nil {
return fmt.Errorf("unpack: %w", xerr)
}
newExe = extracted
}
// Swap: rename the running exe out of the way (Windows allows renaming a
// running image), move the new one into its place, then relaunch. Roll back if
// the second rename fails so we never end up with no exe.
oldExe := exe + ".old"
_ = os.Remove(oldExe)
if err := os.Rename(exe, oldExe); err != nil {
_ = os.Remove(newExe)
return fmt.Errorf("stage current exe: %w", err)
}
if err := os.Rename(newExe, exe); err != nil {
_ = os.Rename(oldExe, exe) // roll back
return fmt.Errorf("install new exe: %w", err)
}
// Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
// this?" — but since we launch the exe programmatically that prompt never shows,
// and the launch is silently blocked. This is exactly why the relaunch failed.
_ = os.Remove(exe + ":Zone.Identifier")
applog.Printf("update: installed new exe, scheduling relaunch")
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
// the new exe directly while we're still alive raced the mutex and often left
// nothing running; waiting for our own exit first makes the restart reliable,
// and the launcher outlives us.
quoted := strings.ReplaceAll(exe, "'", "''")
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
os.Getpid(), quoted)
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err)
}
if a.ctx != nil {
wruntime.Quit(a.ctx)
} else {
os.Exit(0)
}
return nil
}
// downloadWithProgress streams url into dest, emitting "update:progress" (0-100).
func (a *App) downloadWithProgress(url, dest string) error {
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
total := resp.ContentLength
var read int64
last := -1
buf := make([]byte, 64*1024)
emit := func(pct int) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "update:progress", pct)
}
}
emit(0)
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := f.Write(buf[:n]); werr != nil {
return werr
}
read += int64(n)
if total > 0 {
if pct := int(read * 100 / total); pct != last {
last = pct
emit(pct)
}
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
return rerr
}
}
emit(100)
return nil
}
// extractExeFromZip unpacks the first *.exe found in the zip into dir and returns
// its path.
func extractExeFromZip(zipPath, dir string) (string, error) {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return "", err
}
defer zr.Close()
for _, zf := range zr.File {
if !strings.HasSuffix(strings.ToLower(zf.Name), ".exe") {
continue
}
rc, err := zf.Open()
if err != nil {
return "", err
}
out := filepath.Join(dir, ".opslog-update.exe")
f, err := os.Create(out)
if err != nil {
rc.Close()
return "", err
}
_, cerr := io.Copy(f, rc)
rc.Close()
f.Close()
if cerr != nil {
return "", cerr
}
return out, nil
}
return "", fmt.Errorf("no .exe inside the archive")
}
// cleanupOldUpdateBinary removes the previous exe left behind by a self-update
// (exe + ".old"). Called at startup after a --post-update relaunch. Best-effort:
// the file may still be briefly locked, in which case the next launch gets it.
func cleanupOldUpdateBinary() {
if exe, err := os.Executable(); err == nil {
_ = os.Remove(exe + ".old")
}
}
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
func leadingInt(s string) int {
s = strings.TrimSpace(s)