Compare commits

44 Commits
Author SHA1 Message Date
rouggy 3cef885934 feat: Flex CWX type-ahead backspace (cwx erase) — phase 2
Completes the CWX type-ahead loop: with send-on-type the keyer-panel CW text
already streams each typed char to the radio's CWX buffer (which keys in order,
so you can keep typing while it sends); this adds the matching un-send. Route
wkBackspace to FlexBackspaceCW -> cwx erase N, so backspacing a mistyped char in
send-on-type removes it from the buffer before the radio keys it.
2026-07-20 16:12:24 +02:00
rouggy b8db653981 fix: ESC now stops the active CW engine (Icom/Flex), not just WinKeyer
The ESC abort keyed WinkeyerStop unconditionally, so it never aborted the Icom
CI-V or Flex CWX keyer. Route the stop through cwSource like the panel Stop
button does.
2026-07-20 15:51:32 +02:00
rouggy 9729ef62ba feat: FlexRadio CWX CW keyer (send/stop/speed) — no WinKeyer/SmartCAT needed
Adds a third CW engine alongside WinKeyer and Icom CI-V: FlexRadio's CWX keyer
over the existing SmartSDR CAT connection.

- cat.FlexController: SendCW (cwx send), StopCW (cwx clear); flex.go implements
  them, escaping the quoted text form. Speed reuses SetCWSpeed (cw wpm).
- App bindings FlexSendCW/FlexStopCW/FlexSetKeySpeed via FlexDo.
- Frontend: cwSource gains 'flex'; macros/auto-call/<LOGQSO>/send-on-type route to
  Flex when the CAT backend is a Flex (estimate-based timing like Icom, no busy
  echo). Keyer panel shows 'Flex CWX' + ready/offline; Settings adds the engine
  option with a CAT-backend guard.

Core send/stop/speed first; type-ahead buffer + mid-send backspace (cwx delete)
to follow.
2026-07-20 15:13:24 +02:00
rouggy cc6411a618 fix: Antenna Genius buttons ignored clicks while the CW decoder ran
PortBtn was defined inside AntGeniusPanel, so every render produced a new
component type and React unmounted/remounted every port button. Normally
re-renders are rare so it's invisible, but the CW decoder floods the app with
cw:text/cw:status events (re-render many times a second), tearing the buttons
down between mousedown and mouseup — the click never completed and the antenna
never switched. Move PortBtn to module scope (stable identity) and pass
onActivate/t as props.
2026-07-20 14:45:29 +02:00
rouggy 991831bdec chore: DDFM catalog file updated from Publish-to-catalog (address+QTH postal OR-rules, full department reference list) 2026-07-20 14:39:15 +02:00
rouggy 1b2da95ad4 ui: widen the Award management modal (max-w-6xl, 95vw) so the fuller footer fits 2026-07-20 14:35:18 +02:00
rouggy 10d86db50a feat: rename the database from Settings, keeping all config
Adds a Rename button to Settings -> Database: renames the current SQLite database
(e.g. opslog.db -> F4BPO.db) while preserving every setting/profile, unlike New
database which starts empty. Implemented as a VACUUM INTO copy + pointer switch;
the old file (open and locked now) is scheduled via config.json delete_pending
and removed, with its -wal/-shm sidecars, on the next launch.
2026-07-20 14:30:45 +02:00
rouggy 8538f48259 feat: Publish-to-catalog export for awards (edit in UI, version, ship to team)
Adds ExportAwardForCatalog(code, version): writes the selected award as a
catalog-format JSON ({"def":…,"references":…}) stamped with a version and with
user_edited cleared, ready to paste over internal/award/catalog/<code>.json. A
new release then propagates the change to the whole team via mergeCatalog —
unedited copies auto-upgrade, edited ones are offered the update.

UI: a version input + "Publish to catalog…" button in the award editor footer,
defaulting to one past the award's current version.
2026-07-20 14:20:30 +02:00
rouggy 0fa91c3d5f feat: ship DDFM v2 (postal-code OR-rule) via the award catalog so it reaches the team
DDFM now derives the French department from the address postal code, not only
from a note typed by hand — the catalog def gains the address OR-rule (\d{2}\d{3}
-> Dxx), France+Corsica scope, and version 2. On next launch every operator whose
DDFM is unedited auto-upgrades to it (user-edited copies are offered it); the
materialised award_refs then recompute so ~500+ French QSOs gain their department.

Also: a catalog definition update now clears the award_refs backfill flag, so any
future catalog bump self-heals the stored columns without a manual flag bump.
2026-07-20 13:57:50 +02:00
rouggy c86d331bd9 fix: re-materialize award_refs so new matching rules reach existing QSOs
The one-shot award_refs backfill never revisited rows after it ran, so a QSO
materialized before an award gained a rule kept stale values — e.g. DDFM's
postal-code OR-rule matched nothing on the 500+ French QSOs whose department
comes from the address, only the handful with it typed in notes. Two fixes:
bump the backfill flag (awards.materialized.v2) so every operator re-materializes
once on next launch with their current definitions, and make RescanAwards (the
Awards tab Rescan button) also recompute award_refs for an on-demand refresh.
2026-07-20 13:41:14 +02:00
rouggy 77a752efe3 chore: release v0.20.4 2026-07-20 12:06:32 +02:00
rouggy 781fbfaa30 fix: wire up File - Exit (was disabled and had no handler)
The File menu's Exit item was hard-disabled and its action had no case in the
menu dispatcher, so it did nothing. Enable it and route file.exit to QuitApp,
which runs the same graceful beforeClose shutdown as the window close button.
2026-07-20 12:05:12 +02:00
rouggy 61c11c0fe3 fix: clear entry-form award refs when the callsign is wiped
Live award detection merges pickable refs into award_refs per call, but nothing
dropped them when the call was cleared, so a previous call's ref lingered onto
the next (IT9AOT's ref still showing after typing F4BPO, both listed in the F3
Awards tab). Clear award_refs the moment the callsign field is emptied.
2026-07-20 11:57:04 +02:00
rouggy 64b746f007 feat: materialize award references into the QSO row (award_refs column)
Award refs are now computed once and stored on the QSO as a JSON column
(award_refs, e.g. {"DDFM":"74","WAJA":"12"}) instead of recomputing the whole
award engine on every grid page load. Written on log/edit/UDP-import and
bulk-recomputed when an award definition or reference list changes; a one-time
per-logbook backfill materializes pre-existing QSOs. The grid reads the column
directly like any other field, so it's fast and available everywhere (including
the shared MySQL logbook).

Also fix per-profile grid column layout: the DB copy was already per-profile,
but the localStorage cache used one global namespace and shadowed it, so a
profile switch kept the previous profile's columns/widths. gridPrefs now scopes
the cache by active profile id and the grids remount on profile:changed.
2026-07-20 11:51:49 +02:00
rouggy 9cc72c7575 fix: OmniRig reads VFO A first (FreqA→Freq fallback), fixing IC-7610 CAT
The 7610-only FreqA gate broke CAT on the 7610 when FreqA was momentarily 0.
Match DXHunter/WSJT-X: read FreqA first for all rigs, fall back to the generic
Freq (IC-9100 etc.), then FreqB. OmniRig's generic Freq maps to VFO B on the
7610, which is why keying off FreqA is correct.
2026-07-20 10:11:10 +02:00
rouggy 9e4f43f648 fix: offline operators removed from live_status + award-column visibility
Live status: when an operator goes offline (no QSO in the window) OpsLog now
DELETEs its live_status row instead of just flipping online=0, so a status page
that lists present/recent rows shows them as gone without having to read the
online column — the whole point of the feature. The row reappears on the next
QSO.

Recent QSOs columns: toggling an award column (e.g. DDFM) rebuilt columnDefs and
made AG Grid re-apply every colDef hide default, so hidden non-award columns
(QTH/Grid) came back and restoringRef stayed stuck true (saving off). The
restore-saved-state effect now also runs on awardShown changes, so the other
columns keep the user's visibility.
2026-07-20 09:55:14 +02:00
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
33 changed files with 3590 additions and 311 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
}
+448 -41
View File
@@ -483,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)
@@ -501,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)
@@ -694,6 +699,20 @@ func (a *App) startup(ctx context.Context) {
usingDefault = false
}
}
// A rename in a previous session left the OLD file to delete now that it's no
// longer open. Only ever delete a file that ISN'T the one we're about to use.
if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" {
old := strings.TrimSpace(boot.DeletePending)
if old != a.dbPath {
for _, p := range []string{old, old + "-wal", old + "-shm"} {
if err := os.Remove(p); err == nil {
fmt.Printf("OpsLog: removed old database file %s\n", p)
}
}
}
boot.DeletePending = ""
_ = writeBootstrap(dataDir, boot)
}
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
@@ -807,7 +826,10 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn)
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
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
@@ -893,6 +915,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()
@@ -1325,6 +1354,11 @@ func copyFileData(src, dst string) error {
type dbPointer struct {
DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"`
// DeletePending is the previous database file to remove on the NEXT launch —
// set by a rename, which can't delete the still-open old file in-process. The
// startup path deletes it (with its -wal/-shm sidecars) once the new DB is the
// one in use, then clears this.
DeletePending string `json:"delete_pending,omitempty"`
}
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
@@ -1639,6 +1673,7 @@ func (a *App) PickSaveDatabase() (string, error) {
}
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Save the OpsLog database to…",
DefaultDirectory: filepath.Dir(a.dbPath),
DefaultFilename: "opslog.db",
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
})
@@ -1682,6 +1717,44 @@ func (a *App) MoveDatabase(dest string) error {
return writeDBPointer(a.dataDir, dest)
}
// RenameDatabase renames the current database file to dest — keeping ALL config
// (it is the same database under a new name), unlike "New database" which starts
// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the
// ORIGINAL file is scheduled for deletion on the next launch (it is open now and
// can't be removed in-process on Windows). dest must not already exist.
func (a *App) RenameDatabase(dest string) error {
dest = strings.TrimSpace(dest)
if dest == "" {
return fmt.Errorf("no destination given")
}
if a.db == nil {
return fmt.Errorf("database not open")
}
old := a.dbPath
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
return fmt.Errorf("that is already the current database name")
}
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
safe := strings.ReplaceAll(dest, "'", "''")
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
return fmt.Errorf("copy database: %w", err)
}
boot := readBootstrap(a.dataDir)
boot.DBPath = dest
// Only schedule the old file for deletion when it's a real, on-disk file (a
// custom path or the default opslog.db) — never something we somehow share
// with the destination.
if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) {
boot.DeletePending = old
}
return writeBootstrap(a.dataDir, boot)
}
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
// points OpsLog at it for the next launch. dest must not already exist.
func (a *App) CreateDatabase(dest string) error {
@@ -1837,13 +1910,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
@@ -1859,6 +1931,8 @@ 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"
a.materializeAwardRefs(q) // stamp award_refs so the grid columns show at once
// 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)
@@ -2436,6 +2510,14 @@ func (a *App) migrateAwardDefs() {
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
a.reseedRefsFromCatalog(code)
}
// A catalog definition changed (e.g. DDFM gained the postal-code OR-rule), so
// the materialised award_refs on existing QSOs are now stale. Clear the
// one-shot backfill flag; backfillAwardRefsOnce runs later in startup and will
// re-materialise every row against the updated definitions. This makes ANY
// future catalog bump self-heal the stored columns without a manual flag bump.
if len(updated) > 0 {
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "")
}
// Version-gated correction of the built-in awards' Validate sources, which
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
@@ -2501,6 +2583,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
return err
}
go a.mirrorAwardsToFolder(defs)
a.recomputeAwardRefsAsync() // definitions changed → refresh every row's award_refs
return nil
}
@@ -3398,6 +3481,10 @@ func (a *App) invalidateAwardStats() {
// flips qsl_rcvd flags on existing rows).
func (a *App) RescanAwards() error {
a.invalidateAwardStats()
// Also refresh the materialised award_refs on every QSO (the grid columns read
// from there). Manual trigger for when a definition/reference change should be
// re-applied to existing rows on demand rather than waiting for the next event.
a.recomputeAwardRefsAsync()
return nil
}
@@ -3625,24 +3712,31 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
return out, nil
}
// AwardRefsForQSOs returns, per QSO id, a map of award code → the reference(s)
// that QSO contributes to (joined when several). Powers the per-award columns in
// the Recent QSOs / Worked-before grids. The reference metadata is computed ONCE
// for the whole batch so a page of QSOs stays cheap.
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
out := map[int64]map[string]string{}
if a.qso == nil || len(ids) == 0 {
return out, nil
}
// awardMatCtx bundles the precompiled award state needed to derive a QSO's
// materialised references. Built ONCE (newAwardMatCtx) and reused across a batch
// or a full recompute so award.Compute's per-pattern compilation isn't repeated.
type awardMatCtx struct {
defs []award.Def
metas map[string][]award.RefMeta
fieldByCode map[string]string
dispByCode map[string]string
nameOf award.NameResolver
}
func (a *App) newAwardMatCtx() awardMatCtx {
defs := a.awardDefs()
metas := a.awardRefMetas(defs)
fieldByCode := map[string]string{}
dispByCode := map[string]string{}
for _, d := range defs {
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
}
nameOf := func(field, ref string) string {
return awardMatCtx{
defs: defs,
metas: a.awardRefMetas(defs),
fieldByCode: fieldByCode,
dispByCode: dispByCode,
nameOf: func(field, ref string) string {
switch field {
case "dxcc":
if n, err := strconv.Atoi(ref); err == nil {
@@ -3652,24 +3746,28 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
return continentName(ref)
}
return ""
},
}
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
}
// awardRefLabels derives, for one QSO, the map of award code → the reference(s)
// that QSO contributes to (joined when several), formatted per the award's
// RefDisplay choice (ref / name / both; DXCC shows the country name by default).
func (a *App) awardRefLabels(ac awardMatCtx, q qso.QSO) map[string]string {
a.enrichQSOForAwards(&q)
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf)
results := award.Compute(ac.defs, []qso.QSO{q}, ac.metas, ac.nameOf)
m := map[string]string{}
for i := range results {
r := &results[i]
code := strings.ToUpper(r.Code)
dxccField := fieldByCode[code] == "dxcc"
dxccField := ac.fieldByCode[code] == "dxcc"
var refs []string
for _, rf := range r.Refs {
if !rf.Worked {
continue
}
// Per-award display choice: ref (default), name (description), or
// both. DXCC keeps showing the country name under the default.
label := rf.Ref
switch dispByCode[code] {
switch ac.dispByCode[code] {
case "name":
if rf.Name != "" {
label = rf.Name
@@ -3689,7 +3787,154 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
m[code] = strings.Join(refs, ", ")
}
}
if len(m) > 0 {
return m
}
// awardRefsJSONFor returns awardRefLabels marshalled to a compact JSON object
// keyed by award code, or "" when the QSO contributes to no award (blank column).
func (a *App) awardRefsJSONFor(ac awardMatCtx, q qso.QSO) string {
m := a.awardRefLabels(ac, q)
if len(m) == 0 {
return ""
}
b, err := json.Marshal(m)
if err != nil {
return ""
}
return string(b)
}
// materializeAwardRefs computes ONE QSO's award references and stores them on the
// row (the award_refs column) so the grid columns read them straight from the DB.
// Cheap: an in-memory award.Compute plus one targeted UPDATE. Called on every
// log / edit / UDP-import. Never fatal — a failure just leaves the column stale
// until the next recompute.
func (a *App) materializeAwardRefs(q qso.QSO) {
if a.qso == nil || q.ID == 0 {
return
}
ac := a.newAwardMatCtx()
if err := a.qso.SetAwardRefs(a.ctx, q.ID, a.awardRefsJSONFor(ac, q)); err != nil {
applog.Printf("award_refs: store for qso %d failed: %v", q.ID, err)
}
}
// materializeAwardRefsForIDs recomputes award_refs for a specific set of QSOs
// (e.g. after a bulk cty/QRZ/Club Log update or a bulk edit changed fields that
// feed awards). Writes only the changed rows, in one transaction.
func (a *App) materializeAwardRefsForIDs(ids []int64) {
if a.qso == nil || len(ids) == 0 {
return
}
ac := a.newAwardMatCtx()
changes := map[int64]string{}
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
changes[q.ID] = js
}
return nil
})
if err != nil {
applog.Printf("award_refs: recompute for ids failed: %v", err)
return
}
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
applog.Printf("award_refs: batch write for ids failed: %v", err)
}
}
// RecomputeAllAwardRefs rebuilds every QSO's materialised award references. Run
// when an award definition or reference list changes (stored labels would
// otherwise go stale) and once after upgrade to backfill existing rows. Rows
// whose result is unchanged are skipped; the changed ones are written in a single
// transaction (SetAwardRefsBatch) so even a large logbook on a remote MySQL is
// one round-trip's worth of work rather than N. Returns how many rows changed.
func (a *App) RecomputeAllAwardRefs() (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
ac := a.newAwardMatCtx()
// Collect updates during the scan; DON'T write mid-iteration (a nested query on
// the same connection can deadlock SQLite / trip MySQL "commands out of sync").
changes := map[int64]string{}
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
changes[q.ID] = js
}
return nil
})
if err != nil {
return 0, err
}
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
return 0, err
}
return len(changes), nil
}
// recomputeAwardRefsAsync runs a full recompute off the UI goroutine and, when
// done, tells the frontend to reload so the refreshed award columns show. Used
// wherever the set of matches could shift for MANY rows at once: an award
// definition / reference-list change, or a bulk ADIF import.
func (a *App) recomputeAwardRefsAsync() {
go func() {
n, err := a.RecomputeAllAwardRefs()
if err != nil {
applog.Printf("award_refs: bulk recompute failed: %v", err)
return
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
}
}()
}
// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs
// backfill has run, so it doesn't re-scan the whole logbook on every launch.
// keyAwardsMaterialized is versioned: bump the suffix to force every operator to
// re-materialise award_refs once on next launch. Needed when the stored values
// could be stale against the CURRENT award definitions — e.g. rows materialised
// before an award gained a new matching rule (DDFM's postal-code OR-rule), which
// the one-shot backfill would otherwise never revisit.
const keyAwardsMaterialized = "awards.materialized.v2"
// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature
// (or were logged by an older client that didn't materialise them). It runs at
// most once per logbook — guarded by a per-profile flag — in the background so it
// never delays startup, even on a large remote MySQL logbook.
func (a *App) backfillAwardRefsOnce() {
if a.qso == nil || a.settings == nil {
return
}
if done, _ := a.settings.Get(a.ctx, keyAwardsMaterialized); done == "1" {
return
}
go func() {
n, err := a.RecomputeAllAwardRefs()
if err != nil {
applog.Printf("award_refs: initial backfill failed: %v", err)
return
}
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "1")
applog.Printf("award_refs: backfilled %d QSO(s)", n)
if a.ctx != nil && n > 0 {
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
}
}()
}
// AwardRefsForQSOs returns, per QSO id, the award code → reference(s) map. It is
// the live (non-materialised) path, kept for callers that want fresh values
// without touching the DB. The grid now reads the stored award_refs column
// instead, but this stays available and is the single source of the label logic.
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
out := map[int64]map[string]string{}
if a.qso == nil || len(ids) == 0 {
return out, nil
}
ac := a.newAwardMatCtx()
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
if m := a.awardRefLabels(ac, q); len(m) > 0 {
out[q.ID] = m
}
return nil
@@ -3735,6 +3980,17 @@ func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
// UpdateAwardReferenceList downloads the latest reference list for an award and
// replaces the stored set. Returns the new reference count.
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
meta, err := a.updateAwardReferenceList(code)
if err == nil {
a.recomputeAwardRefsAsync() // new reference list → labels change → refresh rows
}
return meta, err
}
// updateAwardReferenceList is the recompute-free core, so DownloadAllReferenceLists
// can update several lists in a loop and trigger ONE bulk recompute at the end
// rather than one per list.
func (a *App) updateAwardReferenceList(code string) (AwardRefMeta, error) {
if a.awardRefs == nil {
return AwardRefMeta{}, fmt.Errorf("db not initialized")
}
@@ -3771,7 +4027,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
if !awardref.CanUpdate(code) {
continue
}
meta, err := a.UpdateAwardReferenceList(code)
meta, err := a.updateAwardReferenceList(code)
if err != nil {
parts = append(parts, fmt.Sprintf("%s ✗", code))
if firstErr == nil {
@@ -3781,6 +4037,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
}
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
}
a.recomputeAwardRefsAsync() // one bulk recompute after all lists updated
return strings.Join(parts, " · "), firstErr
}
@@ -3824,6 +4081,7 @@ func (a *App) DeleteAwardReference(code, refCode string) error {
}
a.markAwardEdited(code)
a.mirrorAwards()
a.recomputeAwardRefsAsync()
return nil
}
@@ -3867,6 +4125,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err
}
a.markAwardEdited(code)
a.mirrorAwards()
a.recomputeAwardRefsAsync() // reference list replaced → refresh every row's award_refs
return n, nil
}
@@ -4002,6 +4261,65 @@ func (a *App) ExportAward(code string) (string, error) {
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
}
// ExportAwardForCatalog writes ONE award as a ready-to-ship CATALOG file — the
// exact shape internal/award/catalog/*.json use ({"def":{…},"references":[…]}).
//
// The workflow it enables: edit an award in the UI, call this with the NEXT
// version number, and paste the file over that award's catalog JSON. A new OpsLog
// release then carries the change to the whole team: every operator whose copy is
// unedited auto-upgrades to it (mergeCatalog), and those who edited it keep theirs
// but are offered the update.
//
// Two things a plain export/mirror can't do and this does, both essential for a
// catalog file: it STAMPS the award's Version (the UI save deliberately never
// bumps it) and it CLEARS user_edited, so a fresh install seeded from this file is
// NOT pre-flagged as the operator's own work — which would otherwise freeze it out
// of every future catalog update.
func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
if a.awardRefs == nil || a.ctx == nil {
return "", fmt.Errorf("db not initialized")
}
code = strings.ToUpper(strings.TrimSpace(code))
var def award.Def
found := false
for _, d := range a.awardDefs() {
if strings.EqualFold(strings.TrimSpace(d.Code), code) {
def, found = d, true
break
}
}
if !found {
return "", fmt.Errorf("unknown award %q", code)
}
def.Version = version
def.UserEdited = false // a catalog seed is not "the operator's edit"
def.Builtin = true
refs, err := a.awardRefs.List(a.ctx, code)
if err != nil {
return "", fmt.Errorf("load references: %w", err)
}
entry := struct {
Def award.Def `json:"def"`
References []awardref.Ref `json:"references,omitempty"`
}{Def: def, References: refs}
b, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return "", err
}
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
DefaultFilename: strings.ToLower(code) + ".json",
Title: "Export " + code + " for the catalog",
Filters: []wruntime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}},
})
if err != nil || strings.TrimSpace(path) == "" {
return "", err
}
if err := os.WriteFile(path, b, 0o644); err != nil {
return "", err
}
return path, nil
}
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
if a.awardRefs == nil {
@@ -4471,6 +4789,7 @@ func (a *App) UpdateQSO(q qso.QSO) error {
err := a.qso.Update(a.ctx, q)
if err == nil {
a.invalidateAwardStats()
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
}
return err
}
@@ -4501,6 +4820,36 @@ func (a *App) GetOperators() ([]string, error) {
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
// the Statistics picker only ever offers contests you really entered.
func (a *App) GetContestRuns() ([]qso.ContestRun, error) {
@@ -4768,6 +5117,7 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
}
if n > 0 {
a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids) // the edited field may feed an award → refresh
}
return n, nil
}
@@ -4917,7 +5267,11 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
im.OnProgress = func(processed, total int) {
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
}
return im.ImportFile(a.ctx, path)
res, err := im.ImportFile(a.ctx, path)
if err == nil && (res.Imported > 0 || res.Updated > 0) {
a.recomputeAwardRefsAsync() // materialise award_refs for the imported rows
}
return res, err
}
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
@@ -5258,7 +5612,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")
}
@@ -5788,8 +6149,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)
}
}
@@ -6661,17 +7024,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)
}
}
@@ -7825,7 +8181,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 {
@@ -8706,6 +9070,7 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
}
if changed > 0 {
a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids) // entity fields changed → refresh award_refs
}
return changed, nil
}
@@ -8781,6 +9146,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
}
if changed > 0 {
a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids) // entity/geo fields changed → refresh award_refs
}
return changed, nil
}
@@ -9235,6 +9601,8 @@ 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.materializeAwardRefs(q)
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
@@ -9991,6 +10359,45 @@ func (a *App) IcomSendCW(text string) error {
return err
}
// FlexSendCW keys a CW message through the FlexRadio CWX keyer (SmartSDR), so a
// Flex needs no WinKeyer / SmartCAT. Text is already variable-resolved by the UI.
func (a *App) FlexSendCW(text string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SendCW(text) })
if err != nil {
applog.Printf("flex cw: FlexSendCW(%q) failed: %v", text, err)
}
return err
}
// FlexStopCW clears the CWX buffer, aborting whatever is being keyed.
func (a *App) FlexStopCW() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.StopCW() })
}
// FlexSetKeySpeed sets the CW keyer speed in WPM (the CWX keyer uses the radio's
// CW speed).
func (a *App) FlexSetKeySpeed(wpm int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
}
// FlexBackspaceCW removes the last n not-yet-keyed characters from the CWX buffer
// (type-ahead correction). n<1 deletes one.
func (a *App) FlexBackspaceCW(n int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.BackspaceCW(n) })
}
// IcomStopCW aborts the CW message currently being sent.
func (a *App) IcomStopCW() error {
if a.cat == nil {
+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)
+417 -76
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,
@@ -35,14 +36,14 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs,
GetUIPref,
ReportLiveActivity,
AwardRefsForQSOs,
GetUIPref, GetActiveProfile, QuitApp,
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -86,6 +87,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref';
import { setGridPrefsProfile } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
import { Button } from '@/components/ui/button';
@@ -134,6 +136,22 @@ const emptyDetails: DetailsState = {
award_refs: '',
};
// parseAwardRefs turns the QSO row's materialised award_refs JSON string
// ({"DDFM":"74","WAJA":"12"}) into the code→ref object the grid columns read.
// Tolerant of empty / malformed values (returns {}), and passes an already-parsed
// object straight through.
function parseAwardRefs(s: any): Record<string, string> {
if (!s) return {};
if (typeof s === 'object') return s as Record<string, string>;
if (typeof s !== 'string') return {};
try {
const o = JSON.parse(s);
return o && typeof o === 'object' ? o : {};
} catch {
return {};
}
}
function fmtDateUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
@@ -209,6 +227,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 +437,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 +644,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
@@ -730,7 +776,7 @@ export default function App() {
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
// auto-call and <LOGQSO> are shared; only the transport differs.
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
const cwSourceRef = useRef(cwSource);
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
@@ -759,7 +805,9 @@ export default function App() {
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
useEffect(() => {
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected;
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
: wkStatus.connected;
wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
@@ -1085,21 +1133,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);
@@ -1138,6 +1242,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);
@@ -1145,38 +1253,28 @@ export default function App() {
// list once, then compute each shown QSO's reference per award and attach it
// to the rows (the grids render one hideable column per award).
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
// Bumped whenever award definitions are saved so the grid columns AND the
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
// changing it updates ALL contacts (old included) with no restart.
// Bumped when award definitions change (or the backend finishes a bulk
// award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs.
// The per-QSO values themselves ride on the row (award_refs) and refresh with
// the grid reload triggered alongside this bump.
const [awardsVersion, setAwardsVersion] = useState(0);
useEffect(() => {
GetAwardDefs().then((defs: any[]) =>
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
).catch(() => {});
}, [awardsVersion]);
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; }
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [qsos, awardCols.length, awardsVersion]);
// Award references are now MATERIALISED on the QSO row (the award_refs JSON
// column, written by the backend on log/edit and bulk-recomputed when awards
// change). The grid reads them straight from the row — no per-page backend
// recompute — so here we just parse the stored JSON string into the code→ref
// object the award columns expect (keys are already upper-case).
const qsosWithAwards = useMemo(
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
[qsos, qsoAwardRefs],
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
[qsos],
);
const [wbAwardRefs, setWbAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean);
if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; }
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [wb, awardCols.length, awardsVersion]);
const wbWithAwards = useMemo(
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
[wb, wbAwardRefs],
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null),
[wb],
);
// Always-current copy of the entry callsign, so the UDP event handlers
// (which live in a []-deps effect with a stale `callsign` closure) can
@@ -1299,20 +1397,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]);
@@ -1329,6 +1435,15 @@ export default function App() {
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
}, [refresh]);
// The backend bulk-recomputed the materialised award_refs (an award definition
// or reference list changed, or the one-time backfill ran). Reload the rows so
// the award columns show the new values, and bump awardsVersion so the set of
// award COLUMNS refreshes too (a new award may have appeared).
useEffect(() => {
const off = EventsOn('awards:recomputed', () => { refresh(); setAwardsVersion((v) => v + 1); });
return () => { off(); };
}, [refresh]);
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
useEffect(() => {
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
@@ -1898,7 +2013,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
}, []);
@@ -1915,15 +2039,29 @@ export default function App() {
setWkMacros((s.macros ?? []) as WKMacro[]);
setWkEscClears(s.esc_clears_call !== false);
setWkSendOnType(!!s.send_on_type);
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
} catch { /* keyer not configured */ }
}, []);
// Active profile id — scopes the grids' column layout (visibility / width /
// order) so each profile keeps its own. Seeded once on mount and updated on
// every profile switch; the grids take it as their React key so they remount
// and re-read the now-correct per-profile layout.
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
useEffect(() => {
GetActiveProfile().then((p: any) => {
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
}).catch(() => {});
}, []);
// Every setting is per-profile, so when the active profile changes the whole
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
// side reloads its managers; this keeps the React state in sync.
useEffect(() => {
const off = EventsOn('profile:changed', () => {
const off = EventsOn('profile:changed', (id: any) => {
// Re-scope the grid column layout BEFORE the grids remount (key change).
setGridPrefsProfile(id ?? null);
setActiveProfileId(typeof id === 'number' ? id : null);
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
// The chat is per shared logbook — clear the previous profile's messages
// and reload for the new logbook (or hide if it isn't a MySQL log).
@@ -1993,17 +2131,23 @@ 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.
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, 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)));
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
await sendFn(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,
@@ -2035,7 +2179,7 @@ export default function App() {
// the wait at the ESTIMATED send time (not the busy flag alone): over a
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
// true for tens of seconds, which made the next CQ fire ~120s late.
if (cwSourceRef.current === 'icom') {
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
} else {
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
@@ -2073,8 +2217,14 @@ export default function App() {
writeUiPref('opslog.wkAutoCallSecs', String(v));
}
// send-on-type: key the typed chars verbatim (no variable substitution).
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
function wkSendRaw(chars: string) {
if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
WinkeyerSend(chars).catch(() => {});
}
function wkBackspace() {
if (cwSourceRef.current === 'flex') { FlexBackspaceCW(1).catch(() => {}); return; }
WinkeyerBackspace().catch(() => {});
}
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
// Resolve slot status for any spot we haven't seen yet — debounced so we
@@ -2119,7 +2269,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;
@@ -2189,13 +2341,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 = "";
@@ -2400,14 +2560,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
@@ -2460,9 +2621,15 @@ export default function App() {
QSOAudioBegin().then(setRecording).catch(() => {});
}
} catch (e: any) {
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
} finally { setLookupBusy(false); }
}
} 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('');
@@ -2516,7 +2683,14 @@ export default function App() {
// keeps the pre-roll from before this); clearing it discards the take.
// Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; }
if (v.trim() === '') {
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
// Callsign wiped → drop this contact's award references. They are auto-added
// per call (live detection merges pickable refs into award_refs), so without
// this they'd carry over to the NEXT call — e.g. IT9AOT's ref lingering when
// you then type F4BPO, showing both in the F3 Awards tab.
updateDetails({ award_refs: '' });
}
const isEmpty = v.trim() === '';
if (!isEmpty && !locks.start) {
// Restart the start time on every callsign change (each keystroke, a
@@ -2599,7 +2773,7 @@ export default function App() {
{ type: 'separator' },
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' },
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q' },
]},
{ name: 'edit', label: t('menu.edit'), items: [
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
@@ -2644,6 +2818,7 @@ export default function App() {
case 'file.export': exportAdif(); break;
case 'file.exportCabrillo': exportCabrillo(); break;
case 'file.deleteall': setShowDeleteAll(true); break;
case 'file.exit': QuitApp(); break;
case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
@@ -2716,7 +2891,14 @@ export default function App() {
const keyerLive = wkActiveRef.current;
// ESC aborts the current CW transmission AND the auto-call loop, so it
// won't resend after the gap — you must click a CQ macro to restart it.
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
// Route the abort to whichever engine is active (was WinKeyer-only, so
// ESC didn't stop the Icom or Flex keyer).
if (keyerLive) {
stopAutoCall();
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
}
if (!keyerLive || wkEscClearsRef.current) {
resetEntry();
callsignRef.current?.focus();
@@ -2843,12 +3025,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).
@@ -3483,6 +3665,7 @@ export default function App() {
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<RecentQSOsGrid
key={`rqg-${activeProfileId ?? 'x'}`}
rows={qsosWithAwards as any}
total={total}
awardCols={awardCols}
@@ -3736,8 +3919,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
@@ -3749,6 +3991,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>
@@ -3756,15 +4006,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" />
@@ -3846,22 +4097,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>
<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={() => { 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 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>
{!updating && (
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
<X className="size-4" />
</button>
)}
</div>
</div>
)}
@@ -4052,8 +4323,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
@@ -4106,8 +4424,8 @@ export default function App() {
{wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel
status={cwSource === 'icom'
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
status={cwSource === 'icom' || cwSource === 'flex'
? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
: wkStatus}
ports={wkPorts}
port={wkPort}
@@ -4124,11 +4442,15 @@ export default function App() {
onSetSpeed={(w) => {
setWkWpm(w); saveWk({ wpm: w });
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {});
}}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
onStop={() => { stopAutoCall();
if (cwSource === 'icom') IcomStopCW().catch(() => {});
else if (cwSource === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {}); }}
onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType}
@@ -4374,9 +4696,11 @@ export default function App() {
)}
<RecentQSOsGrid
key={`rqg2-${activeProfileId ?? 'x'}`}
rows={qsosWithAwards as any}
total={total}
awardCols={awardCols}
onFilteredCountChange={setGridFilteredCount}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
@@ -4412,11 +4736,18 @@ export default function App() {
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button>
) : null}
{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 && (
@@ -4786,6 +5117,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
+33 -23
View File
@@ -33,6 +33,37 @@ function pretty(name: string): string {
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
}
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
// would be a new component *type* on every render, so React would unmount and
// remount every port button each time the panel re-renders — harmless when that's
// rare, but with the CW decoder running the parent re-renders many times a second
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
// different element instances), so antenna changes silently did nothing.
function PortBtn({ port, index, active, tx, onActivate, t }: {
port: 1 | 2; index: number; active: boolean; tx: boolean;
onActivate: (port: number, antenna: number) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
}) {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
}
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
// match the app's light theme with soft gradients + glows. Each antenna row has
// a port-A button (left) and port-B button (right). Colours: green = selected on
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
if (filtered.length > 0) list = filtered;
}
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
};
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
return (
<div key={a.index} className="flex items-center gap-1.5">
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
{pretty(a.name)}
</div>
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
</div>
);
})}
+32 -2
View File
@@ -19,6 +19,7 @@ import {
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
ExportAwardForCatalog,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App';
@@ -37,7 +38,7 @@ export type AwardDef = {
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
total: number; builtin?: boolean;
total: number; builtin?: boolean; version?: number;
};
type AwardOrRule = {
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [search, setSearch] = useState('');
const [updating, setUpdating] = useState<string | null>(null);
const [err, setErr] = useState('');
// Version to stamp into a "publish for catalog" export — defaults to one past
// the selected award's current version whenever the selection changes.
const [catVer, setCatVer] = useState('1');
// The err banner doubles as a success/notice area (export path, import counts,
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
@@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
// ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string };
@@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
// paste over internal/award/catalog/<code>.json. A new release then ships it to
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
async function exportForCatalog() {
setErr('');
if (!cur) return;
const v = Math.trunc(Number(catVer));
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
try {
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Import: LOOK FIRST, then ask.
//
// This used to merge by code with "imported wins", silently — import a WAPC
@@ -352,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-5 py-3 border-b">
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader>
@@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button>
{/* Publish the selected award to the catalog: stamp a version and write a
file to paste over internal/award/catalog/<code>.json, so a release
ships your change to the whole team. */}
{cur && (
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
<Button variant="outline" onClick={exportForCatalog}>
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
</Button>
</div>
)}
<div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
+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')}
+22 -7
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,17 +364,26 @@ 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();
if (state) saveState(colStateKey, stripAwardCols(state));
}, []);
// The award columns load asynchronously; when they arrive (or change) the
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
// default — wiping the user's saved visibility (award columns reappear,
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
// every rebuild so the user's choices win. No-op before the grid is ready.
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
// award column (both change the memo restoringRef flips true at line 316). Each
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
// an award reset the other columns AND left restoringRef stuck true (saving off).
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(colStateKey);
@@ -378,7 +391,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
}, [awardCols, awardShown]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
@@ -467,6 +480,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}
+221 -2
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -28,7 +28,7 @@ import {
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
@@ -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',
};
@@ -576,6 +586,172 @@ function LiveStatusToggle() {
);
}
// 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,
@@ -909,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 });
@@ -2630,6 +2807,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent>
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
</SelectContent>
</Select>
@@ -2660,6 +2838,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
</>
) : wk.engine === 'flex' ? (
<>
<p className="text-xs text-muted-foreground -mt-2">
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection no WinKeyer or SmartCAT needed. It reuses the connection set in Settings CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
</p>
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
<span aria-hidden>⚠</span>
<span>
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
</span>
</p>
)}
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label>Speed (WPM)</Label>
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div>
</>
) : (
<>
<div className="grid grid-cols-4 gap-3">
@@ -3801,6 +3999,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Rename the CURRENT database (keeps all config), unlike New database which
// starts empty. The old file is removed on the next launch.
async function renameDb() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await RenameDatabase(p);
await refreshDb();
setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function resetDefault() {
try {
await ResetDatabaseToDefault();
@@ -3879,6 +4088,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</div>
@@ -4194,6 +4404,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>
@@ -4446,6 +4660,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,
+37 -26
View File
@@ -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,15 +67,23 @@ 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">
<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, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
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, labelWidth = 'w-20' }: { data: Bucket[
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={`${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>
@@ -629,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
@@ -723,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')}>
<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')}>
<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} labelWidth="w-40" />
<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>
+7 -5
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void;
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{source === 'icom' ? (
{source === 'icom' || source === 'flex' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
{source === 'flex'
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
</span>
) : !connected ? (
<>
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
<div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
{t('wkp.cwText')}
{source === 'winkeyer' && (
{(source === 'winkeyer' || source === 'flex') && (
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title={t('wkp.sendOnTypeHint')}>
<input type="checkbox" className="accent-primary" checked={sendOnType}
+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); }
+26 -4
View File
@@ -6,11 +6,32 @@
// back to the DB copy and re-seed the cache.
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
// the active profile id). The localStorage cache, however, is one namespace for
// the whole WebView, so without scoping it too a profile switch would keep
// serving the previous profile's cached layout and the correct per-profile DB
// value would never win. lsScope makes the cache per-profile as well; it's set
// once the active profile is known and updated on every profile switch.
let lsScope = '';
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
// the grids read their state (at startup) and again whenever the active profile
// changes so each profile keeps its own column layout / widths.
export function setGridPrefsProfile(id: number | string | null | undefined): void {
lsScope = id == null || id === '' ? '' : `p${id}.`;
}
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
function lsKey(key: string): string {
return lsScope + key;
}
// loadLocal reads the cached column state synchronously (used in onGridReady
// to apply instantly, before the async DB round-trip).
export function loadLocal(key: string): any[] | null {
try {
const raw = localStorage.getItem(key);
const raw = localStorage.getItem(lsKey(key));
const v = raw ? JSON.parse(raw) : null;
return Array.isArray(v) ? v : null;
} catch {
@@ -29,15 +50,16 @@ export async function loadRemote(key: string): Promise<any[] | null> {
}
}
// saveState write-throughs to both the cache and the DB (fire-and-forget).
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
// the cache key is profile-scoped; the DB key is scoped by the backend.
export function saveState(key: string, state: any[]) {
const json = JSON.stringify(state);
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
}
// seedLocal writes a value into the cache without touching the DB (used after
// hydrating the cache from the DB on a fresh machine).
export function seedLocal(key: string, state: any[]) {
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
}
+57 -4
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…',
@@ -101,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.',
@@ -117,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',
@@ -219,7 +244,7 @@ const en: Dict = {
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.rename': 'Rename…', 'db.renameTip': 'Rename this database (keeps all your config). The old file is removed on the next launch.', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
@@ -250,7 +275,7 @@ const en: Dict = {
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rigs own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rigs own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)', 'wkp.cwxReady': 'Flex CWX ready', 'wkp.cwxOffline': 'Flex not connected (Settings → CAT)',
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
@@ -282,6 +307,10 @@ const en: Dict = {
'awed.builtin': 'Built-in',
'awed.awardsFolder': 'Awards folder',
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
'awed.catalogPublish': 'Publish to catalog…',
'awed.catalogPublishTip': 'Export this award as a catalog file (with the version on the left), to paste over internal/award/catalog/<code>.json. A new release then ships it to the whole team — copies nobody edited auto-upgrade, edited ones are offered it.',
'awed.catalogExportedTo': 'Catalog file written to:\n{path}\n\nPaste it over internal/award/catalog/<code>.json, then build and release.',
'awed.catalogBadVersion': 'Enter a version number (1 or more).',
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
'awed.protectedFlag': 'Protected',
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
@@ -308,6 +337,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…',
@@ -390,6 +424,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.",
@@ -406,10 +447,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',
@@ -495,7 +544,7 @@ const fr: Dict = {
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.rename': 'Renommer…', 'db.renameTip': 'Renomme cette base (garde toute ta config). Lancien fichier est supprimé au prochain lancement.', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
@@ -523,7 +572,7 @@ const fr: Dict = {
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de lIcom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de lIcom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwxReady': 'Flex CWX prêt', 'wkp.cwxOffline': 'Flex non connecté (Réglages → CAT)',
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
@@ -553,6 +602,10 @@ const fr: Dict = {
'awed.builtin': 'Intégré',
'awed.awardsFolder': 'Dossier awards',
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
'awed.catalogPublish': 'Publier au catalogue…',
'awed.catalogPublishTip': "Exporte ce diplôme en fichier catalogue (avec la version à gauche), à coller par-dessus internal/award/catalog/<code>.json. Une nouvelle release le diffuse à toute l'équipe — les copies non modifiées s'upgradent seules, les modifiées se le voient proposer.",
'awed.catalogExportedTo': 'Fichier catalogue écrit dans :\n{path}\n\nColle-le par-dessus internal/award/catalog/<code>.json, puis build et release.',
'awed.catalogBadVersion': 'Entre un numéro de version (1 ou plus).',
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez lutilisateur — alors que tu las livré.',
'awed.protectedFlag': 'Protégé',
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
+5 -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)
@@ -31,7 +32,10 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
// through this global path would fight that per-profile scoping.
];
// syncPortablePrefs reconciles the DB with the local cache at startup:
+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.9';
export const APP_VERSION = '0.20.4';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+32
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>;
@@ -166,6 +168,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
export function ExportAward(arg1:string):Promise<string>;
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
@@ -188,8 +192,12 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexBackspaceCW(arg1:number):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSendCW(arg1:string):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>;
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
@@ -220,6 +228,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetKeySpeed(arg1:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>;
@@ -276,8 +286,12 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
export function FlexSetXITFreq(arg1:number):Promise<void>;
export function FlexStopCW():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,6 +364,8 @@ 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>;
@@ -382,6 +398,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>;
@@ -540,6 +560,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>;
@@ -598,6 +620,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>;
@@ -652,6 +676,8 @@ export function QSOAudioRestart():Promise<boolean>;
export function QuitApp():Promise<void>;
export function RecomputeAllAwardRefs():Promise<number>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshSolar():Promise<void>;
@@ -660,6 +686,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
@@ -688,6 +716,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>;
@@ -732,6 +762,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>;
+64
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']();
}
@@ -290,6 +294,10 @@ export function ExportAward(arg1) {
return window['go']['main']['App']['ExportAward'](arg1);
}
export function ExportAwardForCatalog(arg1, arg2) {
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
}
export function ExportAwards() {
return window['go']['main']['App']['ExportAwards']();
}
@@ -334,10 +342,18 @@ export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
}
export function FlexBackspaceCW(arg1) {
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
}
export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1);
}
export function FlexSendCW(arg1) {
return window['go']['main']['App']['FlexSendCW'](arg1);
}
export function FlexSetAGCMode(arg1) {
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
}
@@ -398,6 +414,10 @@ export function FlexSetFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
}
export function FlexSetKeySpeed(arg1) {
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
}
export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1);
}
@@ -510,10 +530,18 @@ export function FlexSetXITFreq(arg1) {
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
}
export function FlexStopCW() {
return window['go']['main']['App']['FlexStopCW']();
}
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 +686,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']();
}
@@ -722,6 +754,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']();
}
@@ -1038,6 +1078,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);
}
@@ -1154,6 +1198,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']();
}
@@ -1262,6 +1310,10 @@ export function QuitApp() {
return window['go']['main']['App']['QuitApp']();
}
export function RecomputeAllAwardRefs() {
return window['go']['main']['App']['RecomputeAllAwardRefs']();
}
export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat']();
}
@@ -1278,6 +1330,10 @@ export function RemovePassphrase(arg1) {
return window['go']['main']['App']['RemovePassphrase'](arg1);
}
export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1);
}
export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
}
@@ -1334,6 +1390,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);
}
@@ -1422,6 +1482,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);
}
+152
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 {
@@ -3498,6 +3648,7 @@ export namespace qso {
my_arrl_sect?: string;
my_vucc_grids?: string;
extras?: Record<string, string>;
award_refs?: string;
// Go type: time
created_at: any;
// Go type: time
@@ -3635,6 +3786,7 @@ export namespace qso {
this.my_arrl_sect = source["my_arrl_sect"];
this.my_vucc_grids = source["my_vucc_grids"];
this.extras = source["extras"];
this.award_refs = source["award_refs"];
this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null);
}
+789 -2
View File
@@ -4,11 +4,27 @@
"name": "Départements Français Métropolitains",
"valid": true,
"protected": true,
"ref_display": "name",
"type": "QSOFIELDS",
"field": "note",
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
"or_rules": [
{
"field": "address",
"match_by": "code",
"pattern": "\\b(\\d{2})\\d{3}\\b",
"prefix": "D"
},
{
"field": "qth",
"match_by": "code",
"pattern": "\\b(\\d{2})\\d{3}\\b",
"prefix": "D"
}
],
"dxcc_filter": [
227
227,
214
],
"confirm": [
"lotw",
@@ -18,6 +34,777 @@
"lotw"
],
"total": 96,
"builtin": true
"builtin": true,
"version": 2
},
"references": [
{
"code": "D01",
"name": "Ain",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D02",
"name": "Aisne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D03",
"name": "Allier",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D04",
"name": "Alpes-de-Haute-Provence",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D05",
"name": "Hautes-Alpes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D06",
"name": "Alpes-Maritimes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D07",
"name": "Ardèche",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D08",
"name": "Ardennes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D09",
"name": "Ariège",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D10",
"name": "Aube",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D11",
"name": "Aude",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D12",
"name": "Aveyron",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D13",
"name": "Bouches-du-Rhône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D14",
"name": "Calvados",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D15",
"name": "Cantal",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D16",
"name": "Charente",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D17",
"name": "Charente-Maritime",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D18",
"name": "Cher",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D19",
"name": "Corrèze",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D21",
"name": "Côte-d'Or",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D22",
"name": "Côtes-d'Armor",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D23",
"name": "Creuse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D24",
"name": "Dordogne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D25",
"name": "Doubs",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D26",
"name": "Drôme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D27",
"name": "Eure",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D28",
"name": "Eure-et-Loir",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D29",
"name": "Finistère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D2A",
"name": "Corse-du-Sud",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D2B",
"name": "Haute-Corse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D30",
"name": "Gard",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D31",
"name": "Haute-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D32",
"name": "Gers",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D33",
"name": "Gironde",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D34",
"name": "Hérault",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D35",
"name": "Ille-et-Vilaine",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D36",
"name": "Indre",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D37",
"name": "Indre-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D38",
"name": "Isère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D39",
"name": "Jura",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D40",
"name": "Landes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D41",
"name": "Loir-et-Cher",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D42",
"name": "Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D43",
"name": "Haute-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D44",
"name": "Loire-Atlantique",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D45",
"name": "Loiret",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D46",
"name": "Lot",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D47",
"name": "Lot-et-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D48",
"name": "Lozère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D49",
"name": "Maine-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D50",
"name": "Manche",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D51",
"name": "Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D52",
"name": "Haute-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D53",
"name": "Mayenne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D54",
"name": "Meurthe-et-Moselle",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D55",
"name": "Meuse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D56",
"name": "Morbihan",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D57",
"name": "Moselle",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D58",
"name": "Nièvre",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D59",
"name": "Nord",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D60",
"name": "Oise",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D61",
"name": "Orne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D62",
"name": "Pas-de-Calais",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D63",
"name": "Puy-de-Dôme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D64",
"name": "Pyrénées-Atlantiques",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D65",
"name": "Hautes-Pyrénées",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D66",
"name": "Pyrénées-Orientales",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D67",
"name": "Bas-Rhin",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D68",
"name": "Haut-Rhin",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D69",
"name": "Rhône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D70",
"name": "Haute-Saône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D71",
"name": "Saône-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D72",
"name": "Sarthe",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D73",
"name": "Savoie",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D74",
"name": "Haute-Savoie",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D75",
"name": "Paris",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D76",
"name": "Seine-Maritime",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D77",
"name": "Seine-et-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D78",
"name": "Yvelines",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D79",
"name": "Deux-Sèvres",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D80",
"name": "Somme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D81",
"name": "Tarn",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D82",
"name": "Tarn-et-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D83",
"name": "Var",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D84",
"name": "Vaucluse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D85",
"name": "Vendée",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D86",
"name": "Vienne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D87",
"name": "Haute-Vienne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D88",
"name": "Vosges",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D89",
"name": "Yonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D90",
"name": "Territoire de Belfort",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D91",
"name": "Essonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D92",
"name": "Hauts-de-Seine",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D93",
"name": "Seine-Saint-Denis",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D94",
"name": "Val-de-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D95",
"name": "Val-d'Oise",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
}
]
}
+8
View File
@@ -394,6 +394,14 @@ type FlexController interface {
SetCWSpeed(int) error
SetCWPitch(int) error
SetCWBreakInDelay(int) error
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
// StopCW clears the buffer, aborting the send. BackspaceCW removes the last n
// not-yet-keyed characters from the buffer (un-send while sending — for
// type-ahead corrections).
SendCW(string) error
StopCW() error
BackspaceCW(int) error
SetCWSidetone(bool) error
SetSidetoneLevel(int) error
SetCWFilter(int) error
+31
View File
@@ -1581,6 +1581,37 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
return nil
}
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
// characters fed while it's already sending append and key in order (the basis
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
func (f *Flex) SendCW(text string) error {
text = strings.TrimRight(text, "\r\n")
if strings.TrimSpace(text) == "" {
return nil
}
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
f.send(`cwx send "` + esc + `"`)
return nil
}
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
func (f *Flex) StopCW() error {
f.send("cwx clear")
return nil
}
// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
// corrections (backspacing a mistyped call before the radio has keyed it).
func (f *Flex) BackspaceCW(n int) error {
if n < 1 {
n = 1
}
f.send(fmt.Sprintf("cwx erase %d", n))
return nil
}
func (f *Flex) SetCWSidetone(on bool) error {
f.mu.Lock()
f.tx.cwSidetone = on
+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
+14 -9
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,7 +81,8 @@ 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
}
@@ -194,17 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
s.FreqHz, s.RxFreqHz = freqB, freqA
}
} else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
// stock ini), so keying off FreqA gives the operator the VFO they expect.
s.Split = false
s.RxFreqHz = 0
s.FreqHz = freqMain
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB
} else {
switch {
case freqA != 0:
s.FreqHz = freqA
}
case freqMain != 0:
s.FreqHz = freqMain
default:
s.FreqHz = freqB
}
}
return s, nil
@@ -0,0 +1,10 @@
-- Materialised award references per QSO. As soon as a QSO matches an award
-- (via the operator's award definitions) or a reference is set by hand, the
-- resolved reference(s) are stored here as a compact JSON object keyed by award
-- code, e.g. {"DDFM":"74","WAJA":"12"}. The grid's per-award columns then read
-- straight from the row like any other column instead of recomputing the whole
-- award engine on every page load — much faster, and easy to display anywhere
-- (including the shared MySQL logbook). Kept in step by the app: written on log
-- / edit / UDP-import and bulk-recomputed when an award definition or reference
-- list changes. SQLite ADD COLUMN is metadata-only, fast even on large logbooks.
ALTER TABLE qso ADD COLUMN award_refs TEXT;
+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
}
+157 -2
View File
@@ -197,6 +197,14 @@ type QSO struct {
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
Extras map[string]string `json:"extras,omitempty"`
// AwardRefs is the materialised award-reference JSON for this QSO — a compact
// object keyed by award code, e.g. {"DDFM":"74","WAJA":"12"}. Derived (the app
// computes it on log/edit and bulk-recomputes it when awards change) and stored
// so the grid's award columns read straight from the row. Written ONLY via
// SetAwardRefs, never through the normal insert/update column list, so an edit
// that doesn't know about it can't clobber it.
AwardRefs string `json:"award_refs,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -250,7 +258,10 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
extras_json`
const selectCols = `id, ` + columnList + `, created_at, updated_at`
// award_refs is read here but is NOT part of columnList (the insert/update
// write path) — it is a derived cache written only via SetAwardRefs, so a
// normal QSO write can never clobber it.
const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at`
// columnCount is derived from columnList at init so they can never drift.
var columnCount = countColumns(columnList)
@@ -781,6 +792,81 @@ 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
}
// SetAwardRefs stores the materialised award-reference JSON for one QSO. Like
// SetExtra it is a targeted single-column UPDATE — never a full-row write — so it
// cannot clobber a field another action changed meanwhile. updated_at is left
// UNTOUCHED on purpose: award_refs is a derived cache, and bumping updated_at
// would masquerade as a real edit (re-triggering uploads / sync that watch it).
func (r *Repo) SetAwardRefs(ctx context.Context, id int64, jsonStr string) error {
if id == 0 {
return fmt.Errorf("missing id")
}
if _, err := r.db.ExecContext(ctx,
`UPDATE qso SET award_refs = ? WHERE id = ?`, jsonStr, id); err != nil {
return fmt.Errorf("set award_refs: %w", err)
}
return nil
}
// SetAwardRefsBatch stores materialised award refs for many QSOs in a single
// transaction — used by the bulk recompute so a large logbook on a remote MySQL
// is one round-trip's worth of work, not N. Like SetAwardRefs it touches only the
// award_refs column and leaves updated_at alone (derived cache). A nil/empty map
// is a no-op.
func (r *Repo) SetAwardRefsBatch(ctx context.Context, byID map[int64]string) error {
if len(byID) == 0 {
return nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `UPDATE qso SET award_refs = ? WHERE id = ?`)
if err != nil {
return fmt.Errorf("prepare: %w", err)
}
defer stmt.Close()
for id, js := range byID {
if _, err := stmt.ExecContext(ctx, js, id); err != nil {
return fmt.Errorf("set award_refs %d: %w", id, err)
}
}
return tx.Commit()
}
// 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 {
@@ -1863,6 +1949,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
@@ -2275,6 +2428,7 @@ func scanQSO(s scanner) (QSO, error) {
creditGranted, creditSubmitted sql.NullString
myARRLSect, myVUCCGrids sql.NullString
extrasJSON sql.NullString
awardRefs sql.NullString
createdStr, updatedStr string
)
if err := s.Scan(
@@ -2302,7 +2456,7 @@ func scanQSO(s scanner) (QSO, error) {
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
&darcDOK, &myDarcDOK, &region, &silentKey, &swl, &qsoComplete, &qsoRandom,
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
&extrasJSON, &createdStr, &updatedStr,
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
); err != nil {
return QSO{}, fmt.Errorf("scan qso: %w", err)
}
@@ -2499,6 +2653,7 @@ func scanQSO(s scanner) (QSO, error) {
q.MyARRLSect = myARRLSect.String
q.MyVUCCGrids = myVUCCGrids.String
q.Extras = decodeExtras(extrasJSON.String)
q.AwardRefs = awardRefs.String
return q, nil
}
+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()
+163 -7
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,121 @@ func (a *App) publishLiveStatus() {
mode = a.liveMode
}
a.liveActMu.Unlock()
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
// On air = a new contact was logged within the window. An operator who leaves
// the log open but stops working goes offline after `liveOnlineWindow`; the next
// QSO puts them back on. never-logged (zero time) → offline.
online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow
if err := a.ensureLiveStatusTable(); err != nil {
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
return
}
// Offline → REMOVE the row entirely, not just flip a flag: a status page that
// lists the present rows (the common case, keyed on updated_at) then shows the
// operator as gone without having to read the online column. The row reappears
// on the next QSO. This is the whole point — no one shows on air when they're not.
if !online {
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
applog.Printf("livestatus: offline DELETE failed: %v", err)
}
return
}
lastQSOArg := lastQSO.UTC()
_, 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, 1, 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 ON AIR", op, station, freqHz, band, mode)
}
// 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)")
"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.9"
appVersion = "0.20.4"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
+204 -3
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
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)