Compare commits

...
20 Commits
Author SHA1 Message Date
rouggy f43d41892e docs: changelog — the QRZ date window covers both cases 2026-07-28 15:56:37 +02:00
rouggy 1f2e86c04d feat: a typed QRZ start date fetches that period, not what QRZ edited in it
"Last download" and an operator-chosen date are different questions, and they
now ask QRZ different ones. The automatic run wants everything TOUCHED since it
last ran (MODSINCE) — that is what returns a 2015 QSO confirmed yesterday. A date
typed by the operator names a period of OPERATING, so it maps to BETWEEN date and
today, on the QSO date.

The client-side date filter is skipped only under MODSINCE, where it would throw
away the older-but-newly-confirmed records the query exists to fetch. Under
BETWEEN it agrees with the server and stays on as a backstop.
2026-07-28 15:48:00 +02:00
rouggy 305da583e1 fix: QRZ download used an option QRZ does not accept
The date window was sent as OPTION=AFTER:<date>. That was a guess, and QRZ
rejects the request outright — RESULT=FAIL with no reason — so the confirmation
download failed completely for everyone, whatever the account.

The documented option is MODSINCE, and it is the better window anyway: it selects
records MODIFIED since the date, so a QSO from years ago that was confirmed
yesterday comes back. A QSO-date window structurally cannot return those, and
confirmations are the whole point of this download — which is why the client-side
date filter is now skipped when the server did the windowing, instead of throwing
those same records away on arrival.

A refusal now falls back to ALL rather than leaving the operator with nothing.
2026-07-28 15:43:47 +02:00
rouggy 71bde30e24 chore: log the window geometry that is saved and restored
Reported as a regression — reopens on the wrong screen — but the window code is
untouched since it was verified working: the only change to main.go since is the
background colour. So the fault is in the DATA, and there was no way to see it.

Both ends now log their coordinates, plus where the window actually landed after
the un-maximise / move / re-maximise dance. That separates the cases, which need
opposite fixes: nothing was captured, something wrong was captured, or the right
corner was captured and Windows moved the window anyway.
2026-07-28 15:35:57 +02:00
rouggy 31b13cfbc0 Revert "fix: no right-click Cut/Copy/Paste in release builds"
Reverts 30d8827. Ctrl+C / Ctrl+V already work in the app, so the WebView's
default menu only added Refresh / Save as on the neutral areas — noise in an
application window, for nothing gained. Changelog entry dropped with it.
2026-07-28 15:33:30 +02:00
rouggy 30d88276f3 fix: no right-click Cut/Copy/Paste in release builds
Wails disables the WebView context menu in production, so an operator could not
paste a cluster address, an API key or a callsign anywhere in the app. Ctrl+V
did work — nothing intercepts it — but the menu is where people look, and a key
copied off a web page is exactly what you paste rather than retype.

EnableDefaultContextMenu turns it back on.
2026-07-28 15:28:51 +02:00
rouggy b18db7acfc fix: a port field could not be cleared
Every port box was parseInt(e.target.value) || <default>. Delete the contents
and parseInt gives NaN, so the default is written straight back: the digits
reappear under the cursor and the only way to enter a different port is to
overwrite it character by character. Reported on the cluster editor; the same
line existed in eight other places.

One PortInput component now holds the raw TEXT as its state and only tells the
parent about a value that is actually a port. An empty box stays empty while you
type; on blur an invalid one falls back to the default, so nothing is ever saved
as 0 or NaN. It adopts a value arriving from the backend only while the operator
has not started typing — re-syncing mid-edit is the behaviour being fixed.

This is the controlled-input trap the project notes already warn about: keep the
raw text in local state and derive the stored value from it.
2026-07-28 15:25:21 +02:00
rouggy d303dee768 chore: microwave-band entry belongs to 0.21.8, not the published 0.21.7
Same slip as before the 0.21.6 release: I appended to a block that had already
shipped. 0.21.7 is back to its 12 published entries; the microwave-band fix
opens 0.21.8.
2026-07-28 15:15:37 +02:00
rouggy ad02583ef7 fix: recognise the microwave bands — 10 GHz resolved to no band at all
Reported on 3 cm. cat.BandFromHz stopped at 23 cm, so a 10 GHz frequency came
back EMPTY — and an empty band is not cosmetic: the QSO is logged without one,
counts in no award slot, and exports with no BAND field. The low end was short
too (2190m / 630m / 560m).

Four band tables had to be extended because four exist: the CAT one, the award
plan in app.go, the cluster spot classifier, and the frontend's. Plus the places
that LIST bands — the QSO editor and award-definition pickers (you could not set
3 cm by hand either), the statistics axis, and the award band ORDER, where a
missing band sorts to the end instead of in frequency order.

Ranges and names are ADIF 3.1.7, which is what an export has to carry.

A test now pins one frequency per band across the Go tables and asserts they
agree — that is what was missing, four hand-maintained copies with nothing
checking them. It also pins that an out-of-band frequency stays empty rather
than snapping to the nearest band, which would file a QSO under a band the
operator never used.
2026-07-28 14:50:17 +02:00
rouggy add4b175fc chore: release v0.21.7 2026-07-28 14:43:26 +02:00
rouggy 11940ae843 chore: changelog entry for the two diagnostic-log commits
Left out as "internal", but the log is something operators read and send —
and the Flex meter removal, which is the same nature, was listed. Consistent
now.
2026-07-28 14:42:34 +02:00
rouggy 91f046444a chore: log a DELAYED frequency readback after an OmniRig set
An FTDX10 operator reports that changing band from OpsLog moves the rig but the
displayed frequency stays on the old band until they nudge the VFO. The existing
readback is taken immediately after the write, so it always shows the old value
and proves nothing: OmniRig queues the CAT command and sends it over serial.

A second readback is now logged ~1.5 s later, from ReadState (the goroutine that
owns the COM apartment), together with what OpsLog concluded. That separates the
two candidates, which look identical to the operator: the rig ignored the write,
or the rig moved and its rig file never re-reads the frequency.
2026-07-28 14:39:04 +02:00
rouggy 386fb7f030 feat: ATU controls in the FlexRadio panel
The backend already parsed the atu object and exposed ATUStart / ATUBypass /
SetATUMemories through the Flex controller and the App bindings — only the UI
was missing, so the radio's own tuner was the one thing on the panel you still
had to reach SmartSDR for.

Placed under TUNE, which is the order of operations: the tuner needs a carrier
to measure against.

The status is decoded into words rather than shown raw. That is the point of the
block: TUNE_FAIL and "never tuned" leave the radio looking identical on its own
front panel, and the operator transmits into a bad match either way. Failed is
red, tuning amber, tuned green.
2026-07-28 14:35:56 +02:00
rouggy 5a6db2b656 feat: the header callsign is the station switcher; clock moves to the status bar
Switching station is the frequent act and editing a profile the rare one, but
the callsign opened the settings — so changing station took four clicks and a
scroll. It is now a dropdown of every profile: pick one and it activates. The
active one is ticked and disabled, and "Manage profiles…" stays at the bottom
for the rare case.

The list reloads on every profile change, so a profile added or renamed in the
settings appears without a restart.

The UTC clock leaves the header for the status bar, next to the database: in the
header it sat beside the frequency in the same weight and competed with it for
the eye, while being something you consult rather than watch.
2026-07-28 14:25:06 +02:00
rouggy c3958be0b2 chore: move the post-release entries out of 0.21.6 into 0.21.7
0.21.6 was published before these landed, so its notes must stay exactly as they
shipped — a released block is a record of what an operator actually got, not a
place to keep appending. Restored it to the 11 entries in the release commit and
opened 0.21.7 for the eight that followed.

The bulk-edit line was the one subtlety: it was published in 0.21.6 and then
REWRITTEN in place to cover the filter builder. 0.21.6 keeps its published
wording; the filter half is new and gets its own 0.21.7 entry.
2026-07-28 11:15:50 +02:00
rouggy a42a3d5713 chore: name the database in every migration log line
An operator reported migrations being very slow and sent a log with three
interleaved runs — and no way to tell one database migrated three times from
three databases migrated once. Each line now carries its target: the SQLite file
name, mysql:<database>, or baseline:memory for the in-memory pass that derives
the schema for a FRESH MySQL database (that one is fast and is not a real
database — in the report it sat between two slow runs and looked like a third).

A pass also announces how many migrations it will apply, so a run that applies
nothing is visibly distinct from one that does.
2026-07-28 10:40:04 +02:00
rouggy 0a1569dbbd fix: QRZ confirmation download read a third of a large logbook, then skipped the rest for good
Three faults compounding, all visible in one operator's report (117k QSOs):

1. We asked for OPTION=ALL every time, so QRZ serialised the entire logbook —
   56 MB — and cut it short. The parser stopped at record 27832 with "unexpected
   EOF" and everything past that was never read. The window is now sent to the
   server (OPTION=AFTER:date); the client-side date filter stays as a backstop.

2. The last-download date was stored RIGHT AFTER the fetch, before knowing
   whether the records had been processed. So the truncated run still advanced
   the window past data it had never read — and every later run skipped it. It
   is now written at the end, and only when the ADIF parsed cleanly. A window
   that moves past unseen data is worse than one that re-reads.

3. Which is why the run in the report ended "Confirmed 0, added 0 (of 0
   returned)" despite parsing 27832 records: an earlier truncated run had
   already pushed the window to yesterday, so every record was filtered out
   client-side. The operator could not recover without clearing the key by hand.

A truncated download now says so in plain words, and says the window was not
moved.
2026-07-28 09:52:20 +02:00
rouggy 843dccf0c4 fix: bulk edit refused the fields it offered (#3)
My mistake yesterday: the new confirmation columns went into uploadStatusCols —
the QSL Manager's FILTER whitelist — instead of bulkEditableCols. The dialog
listed QRZ.com received status and the ten dates, and the repository refused
them at Apply, after the operator had selected 54 QSOs.

Moved to the right map. And two fields have been broken this way since long
before: State and County were offered under "Contacted station" (whose IOTA /
POTA / SOTA / SIG siblings are all allowed) but were missing from the whitelist,
so choosing one always failed. Added — they are exactly what an import loses and
what a whole run shares.

The real fix is the test. Two lists in two languages had to agree by hand and
nothing checked it, so the failure only ever surfaced as an error message at the
last click. The new tests read the ACTUAL field ids out of BulkEditModal.tsx and
FilterBuilder.tsx and assert the repository accepts every one — a field added to
the UI alone now fails the build instead of the operator.
2026-07-28 09:45:22 +02:00
rouggy d49126d45c chore: stop logging FlexRadio meter values
They arrive many times a second and were dumped every 5 s, drowning the rest of
the log. They only ever existed to confirm the meter NAMES during development,
which the subscription line already does — and that one is now a single summary
instead of one line per meter, a Flex announcing dozens of them at each connect.
2026-07-27 22:20:47 +02:00
rouggy cc1ad06a9d fix: keep the precise locator on digital QSOs; make the manual lookup real
Grid on the UDP path — WSJT-X and MSHV can only send a FOUR-character grid,
that is all the FT8 protocol carries. The enrichment rule there is "fill only
what is empty", so the coarse JN05 always won and QRZ's JN05JG was thrown away:
roughly 100 km of accuracy discarded on every digital QSO. The lookup grid is
now taken when it EXTENDS the received square. A lookup that disagrees outright
(JN18 against JN05) is not a refinement — the station may be portable and what
came over the air is then the better record. Both rules are pinned by tests.

Manual fetch in the QSO editor — it read the CACHE, valid for 30 days. An
operator who upgraded their QRZ subscription went on getting the thin
free-account record and clearing the cached row by hand was the only way out.
The button now forces a lookup that bypasses the cache, reaches the provider and
REFRESHES the stored row, with a 15 s budget instead of 2 s: a deliberate click
must reach the network, where giving up early would fall back to cty.dat and
look like nothing happened. The automatic type-ahead lookup keeps the cache,
which is where it earns its keep.

Same fetch: it used `??`, which only guards against null. Go marshals an unset
string as "", so a QRZ record with no grid BLANKED the grid already in the QSO.
The lookup still wins — that is the point of asking for it — but an empty
result no longer erases a good value.
2026-07-27 22:10:00 +02:00
29 changed files with 1023 additions and 243 deletions
+145 -22
View File
@@ -98,6 +98,8 @@ const (
keyCATEnabled = "cat.enabled" keyCATEnabled = "cat.enabled"
keyCATBackend = "cat.backend" // "omnirig" | "flex" keyCATBackend = "cat.backend" // "omnirig" | "flex"
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
@@ -330,6 +332,11 @@ type CATSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci" Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot) OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
// OmniRigVFO overrides which VFO OpsLog reads: "" follows what the rig file
// reports, "A"/"B" force one. Needed because that report is only as good as
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
// operator worked on the main VFO, so OpsLog wrote to A and read B.
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
@@ -1646,6 +1653,11 @@ func (a *App) saveWindowState() {
x, y := wruntime.WindowGetPosition(a.ctx) x, y := wruntime.WindowGetPosition(a.ctx)
ws.X, ws.Y = x, y ws.X, ws.Y = x, y
} }
// Logged because this is the only evidence of WHAT was stored: an operator
// reporting "it reopens on the wrong screen" cannot tell a position that was
// never captured from one that was captured wrong, and the two need opposite
// fixes.
applog.Printf("window: saving %d,%d %dx%d maximised=%v compact=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised, a.compact)
writeWindowState(a.dataDir, ws) writeWindowState(a.dataDir, ws)
} }
@@ -1660,15 +1672,20 @@ func (a *App) restoreWindowPosition() {
} }
ws, ok := readWindowState(a.dataDir) ws, ok := readWindowState(a.dataDir)
if !ok { if !ok {
applog.Printf("window: no saved geometry — opening where Windows puts it")
return return
} }
applog.Printf("window: restoring %d,%d %dx%d maximised=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised)
// Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise, // Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise,
// so a two-screen operator lost OpsLog to the wrong monitor at every launch. // so a two-screen operator lost OpsLog to the wrong monitor at every launch.
// Un-maximise, move to the saved corner (which names the monitor), maximise // Un-maximise, move to the saved corner (which names the monitor), maximise
// again — all while the window is still hidden, so nothing flickers. // again — all while the window is still hidden, so nothing flickers.
if ws.Maximised { if ws.Maximised {
if ws.X == 0 && ws.Y == 0 { if ws.X == 0 && ws.Y == 0 {
return // never recorded (or genuinely the primary corner) nothing to do // Either never recorded, or genuinely the primary monitor's corner —
// indistinguishable, and both mean "leave it to Windows".
applog.Printf("window: maximised with corner 0,0 — nothing to restore")
return
} }
if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) { if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y) applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
@@ -1677,6 +1694,8 @@ func (a *App) restoreWindowPosition() {
wruntime.WindowUnmaximise(a.ctx) wruntime.WindowUnmaximise(a.ctx)
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y) wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
wruntime.WindowMaximise(a.ctx) wruntime.WindowMaximise(a.ctx)
gx, gy := wruntime.WindowGetPosition(a.ctx)
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
return return
} }
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH { if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
@@ -3839,7 +3858,9 @@ type AwardStatsResult struct {
Rows []AwardStatRow `json:"rows"` Rows []AwardStatRow `json:"rows"`
} }
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"} var statsBands = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm", "9cm", "6cm", "3cm", "1.25cm",
"6mm", "4mm", "2.5mm", "2mm", "1mm"}
// awardSnapshot returns the logbook as a light-scanned, award-enriched slice, // awardSnapshot returns the logbook as a light-scanned, award-enriched slice,
// reused across award computations. Pulling the whole logbook is the dominant // reused across award computations. Pulling the whole logbook is the dominant
@@ -4169,7 +4190,11 @@ var awardBandPlan = []struct {
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000}, {"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000}, {"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000}, {"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
{"13cm", 2300000000, 2450000000}, {"13cm", 2300000000, 2450000000}, {"9cm", 3300000000, 3500000000},
{"6cm", 5650000000, 5925000000}, {"3cm", 10000000000, 10500000000},
{"1.25cm", 24000000000, 24250000000}, {"6mm", 47000000000, 47200000000},
{"4mm", 75500000000, 81000000000}, {"2.5mm", 119980000000, 123000000000},
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
} }
func bandForHz(hz int64) string { func bandForHz(hz int64) string {
@@ -6266,6 +6291,22 @@ func (a *App) SaveCabrilloFile() (string, error) {
// Errors are returned as-is to the frontend; ErrNotFound surfaces as // Errors are returned as-is to the frontend; ErrNotFound surfaces as
// "callsign not found". // "callsign not found".
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) { func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
return a.lookupCallsign(callsign, false)
}
// LookupCallsignFresh is the same, but SKIPS the cache and refreshes it.
//
// For a lookup the operator asked for by clicking, in the QSO editor. The cache
// is right for the automatic lookup that fires while typing, but it also freezes
// a wrong answer for its whole 30-day life: an operator who upgraded their QRZ
// subscription went on getting the thin free-account record, and deleting the
// cached row by hand was the only way out. A deliberate click must reach the
// provider and overwrite what was stored.
func (a *App) LookupCallsignFresh(callsign string) (lookup.Result, error) {
return a.lookupCallsign(callsign, true)
}
func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error) {
if a.lookup == nil { if a.lookup == nil {
return lookup.Result{}, fmt.Errorf("lookup not initialized") return lookup.Result{}, fmt.Errorf("lookup not initialized")
} }
@@ -6287,6 +6328,16 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
} }
ctx, cancel := context.WithTimeout(a.ctx, budget) ctx, cancel := context.WithTimeout(a.ctx, budget)
defer cancel() defer cancel()
if force {
// A forced lookup has to reach the network, so it gets a longer leash than
// the type-ahead one: giving up at 2 s would just fall back to cty.dat and
// look like the click did nothing.
cancel()
ctx, cancel = context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
ctx = lookup.WithForce(ctx)
applog.Printf("lookup: FORCED (cache bypassed) for %s", strings.ToUpper(strings.TrimSpace(callsign)))
}
r, err := a.lookup.Lookup(ctx, callsign) r, err := a.lookup.Lookup(ctx, callsign)
if errors.Is(err, lookup.ErrNotFound) { if errors.Is(err, lookup.ErrNotFound) {
return lookup.Result{}, fmt.Errorf("callsign not found") return lookup.Result{}, fmt.Errorf("callsign not found")
@@ -6444,7 +6495,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -6492,6 +6543,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if out.DigitalDefault == "" { if out.DigitalDefault == "" {
out.DigitalDefault = "FT8" out.DigitalDefault = "FT8"
} }
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
out.OmniRigVFO = v
}
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 { if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
out.OmniRigNum = n out.OmniRigNum = n
} }
@@ -6560,6 +6614,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATEnabled: enabled, keyCATEnabled: enabled,
keyCATBackend: s.Backend, keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
keyCATFlexHost: strings.TrimSpace(s.FlexHost), keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort), keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots, keyCATFlexSpots: flexSpots,
@@ -9520,12 +9575,52 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// QSO date. sinceDate is "YYYY-MM-DD". // QSO date. sinceDate is "YYYY-MM-DD".
sinceDate := resolveSince(keyExtQRZLastDownload) sinceDate := resolveSince(keyExtQRZLastDownload)
emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload)) emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload))
if sinceDate != "" { // An automatic run ("last") and an operator-chosen date do not mean the
emit("Fetching QRZ.com logbook (will skip QSOs before " + sinceDate + ")…") // same thing, so they ask QRZ two different questions:
} else { // • "last" → MODSINCE: everything TOUCHED since the last run,
// which is what catches a 2015 QSO confirmed yesterday.
// • a typed date → BETWEEN: the QSOs MADE from that date to today. The
// operator asked for a period of operating, not for
// whatever QRZ happened to edit in that period.
autoWindow := strings.EqualFold(strings.TrimSpace(since), "last")
// Ask QRZ for the WINDOW rather than the whole logbook. "ALL" made the
// server serialise every QSO — 56 MB for a 117k-record log — which it
// truncates mid-record, so the parse died two thirds of the way through
// and everything past that point was never seen. AFTER: narrows it at the
// source; the client-side date filter below stays as the backstop.
//
// The option is MODSINCE (QRZ FETCH documentation) — "AFTER:" was a guess
// and QRZ rejected the whole fetch, which broke the download outright.
// MODSINCE is also the RIGHT window for this job: it selects records
// MODIFIED since the date, so a ten-year-old QSO confirmed yesterday comes
// back — a QSO-date window would never return it, and confirmations are
// exactly what this download is for.
//
// It stays an optimisation, never a requirement: if QRZ refuses it, fall
// back to ALL rather than leaving the operator with no download at all.
//
// serverWindowed is set only for MODSINCE, because it alone returns QSOs
// OLDER than the date — the client-side date filter would drop exactly
// those. Under BETWEEN the filter agrees with the server and stays on as a
// backstop.
fetchOpt, serverWindowed := "ALL", false
switch {
case sinceDate == "":
emit("Fetching QRZ.com logbook (full — no since date)…") emit("Fetching QRZ.com logbook (full — no since date)…")
case autoWindow:
fetchOpt, serverWindowed = "MODSINCE:"+sinceDate, true
emit("Fetching QRZ.com logbook (records modified since " + sinceDate + ")…")
default:
today := time.Now().UTC().Format("2006-01-02")
fetchOpt = "BETWEEN:" + sinceDate + "+" + today
emit("Fetching QRZ.com logbook (QSOs from " + sinceDate + " to " + today + ")…")
}
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
if err != nil && fetchOpt != "ALL" {
emit("QRZ.com refused " + fetchOpt + " (" + err.Error() + ") — fetching the full logbook instead…")
fetchOpt, serverWindowed = "ALL", false
fr, err = extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
} }
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
if err != nil { if err != nil {
emit("Fetch failed: " + err.Error()) emit("Fetch failed: " + err.Error())
done(matched, total) done(matched, total)
@@ -9533,14 +9628,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
} }
adifText := fr.ADIF adifText := fr.ADIF
emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText))) emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
// Persist the last-download date NOW (right after a successful fetch), // The last-download date is stored at the END, and ONLY if the whole ADIF
// not at the end: the QRZ logbook can be huge (tens of thousands of // parsed. It used to be written here, right after the fetch: when the
// records) and the user may close the panel mid-processing — storing it // response was truncated (see above) the window advanced over records that
// late meant the date was never saved, so "since last download" kept // were never processed, and every later run skipped them for good. A
// resolving to empty and re-pulled everything. // window that moves past unseen data is worse than one that re-reads.
if a.settings != nil {
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
}
if snip := strings.TrimSpace(adifText); snip != "" { if snip := strings.TrimSpace(adifText); snip != "" {
if len(snip) > 300 { if len(snip) > 300 {
snip = snip[:300] snip = snip[:300]
@@ -9588,7 +9680,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
return nil return nil
} }
// Date window (client-side): skip QSOs older than the requested date. // Date window (client-side): skip QSOs older than the requested date.
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate { // ONLY when the server did not window the fetch itself — MODSINCE
// returns old QSOs whose CONFIRMATION is recent, and filtering those
// out by QSO date would throw away the very records asked for.
if !serverWindowed && sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
return nil return nil
} }
// Skip a QSO logged under a DIFFERENT one of the operator's callsigns. // Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
@@ -9672,10 +9767,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
sort.Strings(keys) sort.Strings(keys)
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", "))) emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total)) emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
if perr != nil {
emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.")
emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).")
} else if a.settings != nil {
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
}
if qrzSkippedOtherCall > 0 { if qrzSkippedOtherCall > 0 {
emit(fmt.Sprintf("Skipped %d QSO(s) logged under another of your callsigns (scoped to %s).", qrzSkippedOtherCall, qrzOwner)) emit(fmt.Sprintf("Skipped %d QSO(s) logged under another of your callsigns (scoped to %s).", qrzSkippedOtherCall, qrzOwner))
} }
// (last-download date already stored right after the fetch above)
case extsvc.ServiceEQSL: case extsvc.ServiceEQSL:
sinceDate := resolveSince(keyExtEQSLLastDownload) sinceDate := resolveSince(keyExtEQSLLastDownload)
@@ -10290,6 +10390,31 @@ func (a *App) ReloadUDPIntegrations() []string {
return a.udp.Reload(a.ctx) return a.udp.Reload(a.ctx)
} }
// refineGrid picks between the locator a QSO arrived with and the one the
// callsign lookup returned.
//
// WSJT-X and MSHV always send a FOUR-character grid — that is all the FT8
// protocol carries — so a QSO logged from them landed with JN05 while QRZ knew
// JN05JG. The enrichment rule everywhere else on this path is "fill only what is
// empty", which meant the coarse grid always won and the precise one was thrown
// away, silently costing the operator ~100 km of accuracy on every digital QSO.
//
// The upgrade is only taken when the lookup grid EXTENDS the received one (same
// first four characters). A lookup that disagrees outright — JN18 against JN05 —
// is not a refinement: the station may be portable, and what came over the air
// is then the better record. Case-insensitive, since ADIF grids arrive in both.
func refineGrid(have, found string) string {
h := strings.ToUpper(strings.TrimSpace(have))
f := strings.ToUpper(strings.TrimSpace(found))
if h == "" {
return f
}
if len(f) > len(h) && strings.HasPrefix(f, h) {
return f
}
return h
}
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the // LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
// first record into the local logbook. Returns the ID of the inserted // first record into the local logbook. Returns the ID of the inserted
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert / // row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
@@ -10357,9 +10482,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if q.Country == "" { if q.Country == "" {
q.Country = lr.Country q.Country = lr.Country
} }
if q.Grid == "" { q.Grid = refineGrid(q.Grid, lr.Grid)
q.Grid = lr.Grid
}
if q.Continent == "" { if q.Continent == "" {
q.Continent = lr.Continent q.Continent = lr.Continent
} }
@@ -11858,7 +11981,7 @@ func (a *App) reloadCAT() {
// Spawning OmniRig.exe ourselves (even with /Embedding) on every // Spawning OmniRig.exe ourselves (even with /Embedding) on every
// reloadCAT raised the existing instance's window to the front, // reloadCAT raised the existing instance's window to the front,
// which is what Log4OM avoids by relying entirely on COM activation. // which is what Log4OM avoids by relying entirely on COM activation.
a.cat.Start(cat.NewOmniRig(s.OmniRigNum)) a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO))
case "flex": case "flex":
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed. // Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots) fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"testing"
"hamlog/internal/cat"
)
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
// logged, not counted in any award slot, and exports without a BAND field.
//
// One frequency per band, taken inside the range, plus the edges that used to be
// missing entirely.
func TestBandPlansAgree(t *testing.T) {
cases := []struct {
hz int64
want string
}{
{137000, "2190m"},
{475000, "630m"},
{1840000, "160m"},
{3650000, "80m"},
{7100000, "40m"},
{10120000, "30m"},
{14200000, "20m"},
{18100000, "17m"},
{21200000, "15m"},
{24940000, "12m"},
{28400000, "10m"},
{50150000, "6m"},
{70200000, "4m"},
{144300000, "2m"},
{223500000, "1.25m"},
{432000000, "70cm"},
{1296000000, "23cm"},
// The microwave bands that were missing. 3 cm is the one an operator
// reported: 10 GHz is worked and spotted, and resolved to nothing.
{2320000000, "13cm"},
{3400000000, "9cm"},
{5760000000, "6cm"},
{10368000000, "3cm"},
{24048000000, "1.25cm"},
{47088000000, "6mm"},
{76032000000, "4mm"},
{122250000000, "2.5mm"},
{134928000000, "2mm"},
{241920000000, "1mm"},
}
for _, c := range cases {
if got := cat.BandFromHz(c.hz); got != c.want {
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
}
if got := bandForHz(c.hz); got != c.want {
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
}
}
}
// A frequency in no amateur band must resolve to "" everywhere, not to the
// nearest band: guessing here would put a QSO in a band the operator never used.
func TestBandPlanRejectsOutOfBand(t *testing.T) {
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
if got := cat.BandFromHz(hz); got != "" {
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
}
if got := bandForHz(hz); got != "" {
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
"hamlog/internal/qso"
)
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
//
// These two lists live in different packages — a TypeScript field table on one
// side, a Go whitelist on the other — and they drifted apart the day the
// confirmation dates were added: the new columns went into the QSL Manager's
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
// could not write and the operator got "field … is not bulk-editable" only after
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
//
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
// field added to the UI alone fails here immediately.
func TestBulkEditFieldsAreWritable(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
if len(ids) < 10 {
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
}
for _, m := range ids {
id := m[1]
// Handled before the column map: freq takes a numeric path (freq_hz +
// band together) and the extras live in extras_json.
if id == "freq" || qso.IsBulkEditableExtra(id) {
continue
}
col, ok := bulkFieldColumns[id]
if !ok {
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
continue
}
// Extras live outside the qso table (owner_callsign and friends): they are
// written into extras_json, not a column, so the column whitelist does not
// apply to them.
if strings.HasPrefix(col, "extras.") {
continue
}
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
}
}
}
// Same contract for the filter builder: every field it offers must be one the
// query layer will accept.
func TestFilterFieldsAreQueryable(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
// not, and without it every operator (eq, contains, …) was read as a field.
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
if len(ids) < 10 {
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
}
for _, m := range ids {
col := m[1]
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
t.Errorf("filter builder offers %q, which the query layer refuses", col)
}
}
}
+46
View File
@@ -1,4 +1,50 @@
[ [
{
"version": "0.21.8",
"date": "2026-07-28",
"en": [
"Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow.",
"QRZ.com confirmation download works again: the date window used an option QRZ rejects, so every fetch failed. An automatic run now asks for everything modified since the last one — so an old QSO confirmed recently comes back — while a date you type fetches the QSOs made between that date and today.",
"Port fields can be cleared. Deleting the contents put the default straight back, so entering a different port meant overwriting it digit by digit. Fixed on the cluster editor, where it was reported, and in the eight other places with the same fault (CAT, amplifier, antenna, rotator, database, SMTP)."
],
"fr": [
"Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent.",
"Le téléchargement des confirmations QRZ.com refonctionne : la fenêtre de date utilisait une option refusée par QRZ, donc chaque récupération échouait. Un téléchargement automatique demande désormais tout ce qui a été modifié depuis le précédent — un QSO ancien confirmé récemment revient donc — tandis qu'une date saisie récupère les QSO faits entre cette date et aujourd'hui.",
"Les champs de port peuvent être vidés. Effacer le contenu réinscrivait aussitôt la valeur par défaut, si bien qu'entrer un autre port obligeait à l'écraser chiffre par chiffre. Corrigé dans l'éditeur de cluster, où c'était signalé, et dans les huit autres endroits présentant le même défaut (CAT, amplificateur, antenne, rotator, base de données, SMTP)."
]
},
{
"version": "0.21.7",
"date": "2026-07-28",
"en": [
"The filter builder uses the same confirmation field names as bulk edit, gains the missing QRZ.com received status, and can filter on the sent and received dates (before / after).",
"A fresh install now starts on the dark theme. Existing installs keep the theme they are on.",
"A QSO logged from WSJT-X or MSHV now keeps the precise 6-character locator from QRZ/HamQTH instead of the 4-character one the digital protocol carries — about 100 km of accuracy that was being discarded on every digital QSO. A lookup that disagrees with the received square is not applied.",
"Edit QSO: the QRZ/HamQTH fetch button now bypasses the cache, so it really re-reads the callsign — upgrading a QRZ subscription used to change nothing until the cached entry expired a month later. It also no longer blanks an existing value when the lookup comes back with that field empty.",
"The diagnostic log no longer carries the FlexRadio meter readings, which drowned everything else.",
"OmniRig: a new \"VFO to read\" setting. OmniRig reports the active VFO from the rig file, and some files get it wrong — an IC-7610 file declared VFO B while the operator was on the main VFO, so OpsLog wrote to one VFO and read the other, and the frequency looked frozen. Force VFO A or B when that happens.",
"Bulk edit: fixed \"field is not bulk-editable\" on the QRZ.com received status and the confirmation dates added in 0.21.6 — they were declared in the wrong place and refused at Apply. Bulk-editing State and County, offered since the start, was refused the same way and now works.",
"QRZ.com confirmation download: the whole logbook was requested every time, which QRZ cuts short on a large log — the read stopped two thirds of the way through and the rest was never seen. Only the window is requested now. And the last-download date is no longer moved when the download was incomplete: it used to advance over records that had never been read, which skipped them for good.",
"The callsign in the top bar is now the station switcher: click it and pick a profile to activate it. Managing profiles is still there, at the bottom of the list.",
"The UTC clock moved from the top bar to the status bar at the bottom, beside the database.",
"FlexRadio panel: the built-in ATU is now controllable — Tune, Bypass, memories, and the tuner state in words (tuning, tuned, TUNE FAILED…), because a failed tune and one that never ran look identical otherwise.",
"Diagnostic log: each database migration now names the database it runs on, and a frequency set through OmniRig is checked again 1.5 s later — both to make a reported problem readable from the log instead of guessed at."
],
"fr": [
"Le constructeur de filtres reprend les mêmes noms de champs de confirmation que l'édition en lot, gagne le statut de réception QRZ.com manquant, et permet de filtrer sur les dates d'envoi et de réception (avant / après).",
"Une nouvelle installation démarre désormais sur le thème sombre. Les installations existantes conservent le leur.",
"Un QSO enregistré depuis WSJT-X ou MSHV conserve désormais le locator précis à 6 caractères de QRZ/HamQTH au lieu de celui à 4 caractères transporté par le protocole numérique — une centaine de kilomètres de précision perdus jusqu'ici à chaque QSO numérique. Une réponse qui contredit le carré reçu n'est pas appliquée.",
"Édition QSO : le bouton de récupération QRZ/HamQTH contourne désormais le cache et relit donc réellement l'indicatif — passer à un abonnement QRZ payant ne changeait rien tant que l'entrée en cache n'avait pas expiré un mois plus tard. Il n'efface plus non plus une valeur existante lorsque la recherche revient sans ce champ.",
"Le journal de diagnostic ne contient plus les mesures des instruments FlexRadio, qui noyaient tout le reste.",
"OmniRig : nouveau réglage « VFO à lire ». OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — un fichier IC-7610 déclarait le VFO B alors que l'opérateur était sur le VFO principal, si bien qu'OpsLog écrivait dans l'un et lisait l'autre, et la fréquence semblait figée. Forcez le VFO A ou B le cas échéant.",
"Édition en lot : correction du refus « field is not bulk-editable » sur le statut de réception QRZ.com et les dates de confirmation ajoutées en 0.21.6 — ils étaient déclarés au mauvais endroit et rejetés au moment d'appliquer. L'édition en lot de State et County, proposée depuis toujours, était refusée de la même façon et fonctionne désormais.",
"Téléchargement des confirmations QRZ.com : le carnet entier était demandé à chaque fois, ce que QRZ tronque sur un grand log — la lecture s'arrêtait aux deux tiers et le reste n'était jamais vu. Seule la fenêtre est désormais demandée. Et la date du dernier téléchargement n'avance plus lorsque le téléchargement est incomplet : elle passait par-dessus des enregistrements jamais lus, qui étaient alors ignorés définitivement.",
"L'indicatif en haut de la fenêtre est désormais le sélecteur de station : un clic, on choisit un profil et il devient actif. La gestion des profils reste accessible, en bas de la liste.",
"L'horloge UTC est passée de la barre du haut à la barre d'état en bas, à côté de la base de données.",
"Panneau FlexRadio : le coupleur intégré est désormais pilotable — Accord, Bypass, mémoires, et l'état du coupleur en clair (accord en cours, accordé, ÉCHEC ACCORD…), car sans ça un accord raté et un accord jamais lancé se ressemblent.",
"Journal de diagnostic : chaque migration de base indique désormais sur quelle base elle tourne, et une fréquence envoyée via OmniRig est relue 1,5 s plus tard — de quoi lire un problème signalé dans le journal au lieu de le deviner."
]
},
{ {
"version": "0.21.6", "version": "0.21.6",
"date": "2026-07-27", "date": "2026-07-27",
+86 -36
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock, Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
@@ -45,7 +45,7 @@ import {
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs, GetAwardDefs,
GetUIPref, GetActiveProfile, QuitApp, GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec, ReportLiveActivity, LiveLastQSOAgeSec,
GetAmpStatuses, AmpOperate, GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate, GetFlexState, FlexAmpOperate,
@@ -57,6 +57,9 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import { Menubar, type Menu } from '@/components/Menubar'; import { Menubar, type Menu } from '@/components/Menubar';
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { APP_VERSION, APP_AUTHOR } from '@/version'; import { APP_VERSION, APP_AUTHOR } from '@/version';
import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal'; import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
@@ -269,7 +272,11 @@ function bandForMHz(mhz: number): string {
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'], [1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'], [10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'], [24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'], [144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
]; ];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b; for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return ''; return '';
@@ -2367,7 +2374,7 @@ export default function App() {
if (n <= 0) return; if (n <= 0) return;
await refresh(); await refresh();
const file = String(p?.file ?? '').replace(/^.*[\\/]/, ''); const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`); showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
}); });
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); }; return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -2398,6 +2405,18 @@ export default function App() {
// every profile switch; the grids take it as their React key so they remount // every profile switch; the grids take it as their React key so they remount
// and re-read the now-correct per-profile layout. // and re-read the now-correct per-profile layout.
const [activeProfileId, setActiveProfileId] = useState<number | null>(null); const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
// The station switcher in the header needs the whole list, not just the active
// one. Reloaded on every profile change so a profile renamed or added in the
// settings shows up without a restart.
const [profileList, setProfileList] = useState<{ id: number; callsign: string; name: string }[]>([]);
const loadProfileList = useCallback(() => {
ListProfiles().then((ps: any) => {
setProfileList((Array.isArray(ps) ? ps : []).map((p: any) => ({
id: p.id, callsign: p.callsign ?? '', name: p.name ?? '',
})));
}).catch(() => {});
}, []);
useEffect(() => { loadProfileList(); }, [loadProfileList]);
useEffect(() => { useEffect(() => {
GetActiveProfile().then((p: any) => { GetActiveProfile().then((p: any) => {
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); } if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
@@ -2420,7 +2439,7 @@ export default function App() {
// Re-scope the grid column layout BEFORE the grids remount (key change). // Re-scope the grid column layout BEFORE the grids remount (key change).
setGridPrefsProfile(id ?? null); setGridPrefsProfile(id ?? null);
setActiveProfileId(typeof id === 'number' ? id : null); setActiveProfileId(typeof id === 'number' ? id : null);
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadProfileList();
// The chat is per shared logbook — clear the previous profile's messages // 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). // and reload for the new logbook (or hide if it isn't a MySQL log).
setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0); setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
@@ -2428,7 +2447,7 @@ export default function App() {
setChatEpoch((e) => e + 1); setChatEpoch((e) => e + 1);
}); });
return () => { off(); }; return () => { off(); };
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]); }, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes, loadProfileList]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await reloadWk(); await reloadWk();
@@ -4650,24 +4669,48 @@ export default function App() {
) : <span />} ) : <span />}
</div> </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" />
{utcNow}<span className="text-[10px]">UTC</span>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{station.callsign ? ( {station.callsign ? (
// Switching station is the frequent act; editing a profile is the
// rare one. The callsign used to open the settings, so changing
// station took four clicks and a scroll — it now IS the switcher,
// with the settings kept at the bottom of the list.
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button <button
onClick={() => { setSettingsSection('profiles'); setShowSettings(true); }}
className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors" className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
title="Click to switch / edit profiles" title={t('prof.switchTitle')}
> >
<Antenna className="size-3" /> <Antenna className="size-3" />
{station.callsign} {station.callsign}
{station.my_grid && ( {station.my_grid && (
<span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span> <span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
)} )}
<ChevronDown className="size-3 opacity-70" />
</button> </button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
{profileList.map((p) => (
<DropdownMenuItem
key={p.id}
disabled={p.id === activeProfileId}
onSelect={() => { if (p.id !== activeProfileId) ActivateProfile(p.id).catch((e: any) => setError(String(e?.message ?? e))); }}
>
<span className="flex items-center gap-2 min-w-0">
{p.id === activeProfileId
? <Check className="size-3.5 shrink-0 text-primary" />
: <span className="size-3.5 shrink-0" />}
<span className="font-mono font-semibold">{p.callsign || '—'}</span>
{p.name && <span className="text-muted-foreground truncate">{p.name}</span>}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => { setSettingsSection('profiles'); setShowSettings(true); }}>
{t('prof.manage')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : ( ) : (
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)}> <Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
<Settings className="size-3.5" /> Set station <Settings className="size-3.5" /> Set station
@@ -5368,27 +5411,27 @@ export default function App() {
: 'bg-success-muted border-success-border text-success-muted-foreground', : 'bg-success-muted border-success-border text-success-muted-foreground',
)}> )}>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<strong>Import complete.</strong> <strong>{t('imp.complete')}</strong>
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{t('imp.imported', { n: importResult.imported })}</Badge>
{importResult.updated > 0 && ( {importResult.updated > 0 && (
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.updated} updated</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.updated', { n: importResult.updated })}</Badge>
)} )}
{importResult.duplicates > 0 && ( {importResult.duplicates > 0 && (
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.duplicates} duplicates</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.duplicates', { n: importResult.duplicates })}</Badge>
)} )}
<Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{importResult.skipped} skipped</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{t('imp.skipped', { n: importResult.skipped })}</Badge>
<Badge variant="outline" className="bg-card/60 font-mono">{importResult.total} total</Badge> <Badge variant="outline" className="bg-card/60 font-mono">{t('imp.total', { n: importResult.total })}</Badge>
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && ( {importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}> <button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
{importDupsOpen ? 'Hide' : 'Show'} duplicates {importDupsOpen ? t('imp.hideDups') : t('imp.showDups')}
{importResult.duplicates > importResult.duplicate_samples.length {importResult.duplicates > importResult.duplicate_samples.length
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})` ? t('imp.dupsFirst', { shown: importResult.duplicate_samples.length, total: importResult.duplicates })
: ''} : ''}
</button> </button>
)} )}
{importResult.errors && importResult.errors.length > 0 && ( {importResult.errors && importResult.errors.length > 0 && (
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}> <button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''} {importErrorsOpen ? t('imp.hideErrors', { n: importResult.errors.length }) : t('imp.showErrors', { n: importResult.errors.length })}
</button> </button>
)} )}
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button> <button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
@@ -5930,6 +5973,13 @@ export default function App() {
</> </>
)} )}
</div> </div>
{/* UTC clock moved out of the header, where it competed with the
frequency for the eye. It belongs with the other passive
indicators. */}
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-muted-foreground shrink-0" title="UTC">
<Clock className="size-3" />
{utcNow}<span className="text-[9px]">Z</span>
</span>
{dbConn && ( {dbConn && (
<button <button
type="button" type="button"
@@ -6113,19 +6163,19 @@ export default function App() {
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}> <Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
<DialogContent className="max-w-lg px-6"> <DialogContent className="max-w-lg px-6">
<DialogHeader className="px-2"> <DialogHeader className="px-2">
<DialogTitle>Import ADIF</DialogTitle> <DialogTitle>{t('imp.dialogTitle')}</DialogTitle>
<DialogDescription className="text-xs break-all"> <DialogDescription className="text-xs break-all">
{pendingImportPath} {pendingImportPath}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-2 px-2 space-y-2"> <div className="py-2 px-2 space-y-2">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Duplicate = same <span className="font-medium text-foreground">callsign + UTC minute + band + mode</span> as a QSO already in the log. {t('imp.dupHintPre')}<span className="font-medium text-foreground">{t('imp.dupHintKey')}</span>{t('imp.dupHintPost')}
</div> </div>
{([ {([
{ id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' }, { id: 'skip', title: t('imp.skipTitle'), desc: t('imp.skipDesc') },
{ id: 'update', title: 'Update duplicates', desc: 'Refresh existing QSOs with this file — merges its non-empty fields (QSL/LoTW/eQSL/QRZ statuses & dates, etc.) onto the matching QSO. Use this to re-sync from Log4OM or LoTW. Fields the file omits are kept.' }, { id: 'update', title: t('imp.updateTitle'), desc: t('imp.updateDesc') },
{ id: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' }, { id: 'all', title: t('imp.allTitle'), desc: t('imp.allDesc') },
] as const).map((o) => ( ] as const).map((o) => (
<button <button
key={o.id} key={o.id}
@@ -6153,9 +6203,9 @@ export default function App() {
className="mt-0.5" className="mt-0.5"
/> />
<span> <span>
Fix country &amp; zones (cty.dat + ClubLog) {t('imp.ctyTitle')}
<span className="block text-xs text-muted-foreground mt-0.5"> <span className="block text-xs text-muted-foreground mt-0.5">
Recompute Country, DXCC &amp; CQ/ITU zones from cty.dat, overriding the file corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF Reunion, TO2A 2012 French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use <strong>Update duplicates</strong> to re-fix QSOs already in your log. {t('imp.ctyDesc')}
</span> </span>
</span> </span>
</label> </label>
@@ -6166,16 +6216,16 @@ export default function App() {
className="mt-0.5" className="mt-0.5"
/> />
<span> <span>
Fill my station fields from my profile {t('imp.stationTitle')}
<span className="block text-xs text-muted-foreground mt-0.5"> <span className="block text-xs text-muted-foreground mt-0.5">
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log. {t('imp.stationDesc')}
</span> </span>
</span> </span>
</label> </label>
</div> </div>
<DialogFooter className="px-2 bg-transparent border-t-0"> <DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button> <Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
<Button onClick={runImport}>Import</Button> <Button onClick={runImport}>{t('imp.import')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -6185,7 +6235,7 @@ export default function App() {
<Dialog open> <Dialog open>
<DialogContent className="max-w-sm px-6" hideClose> <DialogContent className="max-w-sm px-6" hideClose>
<DialogHeader className="px-2"> <DialogHeader className="px-2">
<DialogTitle>Importing ADIF</DialogTitle> <DialogTitle>{t('imp.progressTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="px-2 pb-4 space-y-2"> <div className="px-2 pb-4 space-y-2">
{(() => { {(() => {
@@ -6202,8 +6252,8 @@ export default function App() {
</div> </div>
<div className="text-xs text-muted-foreground text-center font-mono"> <div className="text-xs text-muted-foreground text-center font-mono">
{tot > 0 {tot > 0
? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%` ? t('imp.progressCount', { done: done.toLocaleString(), tot: tot.toLocaleString(), pct })
: `${done.toLocaleString()} records…`} : t('imp.progressCountOnly', { done: done.toLocaleString() })}
</div> </div>
</> </>
); );
+1 -1
View File
@@ -63,7 +63,7 @@ const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }, { id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' }, { id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
]; ];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm']; const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE']; const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL']; const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
+32 -7
View File
@@ -26,7 +26,9 @@ export interface QueryFilter {
// Curated field catalog. `value` MUST match a column in the backend whitelist // Curated field catalog. `value` MUST match a column in the backend whitelist
// (qso.FilterableFields); `type` only drives which operators/value input we show. // (qso.FilterableFields); `type` only drives which operators/value input we show.
type FieldType = 'text' | 'number' | 'date'; // 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
const FIELDS: { value: string; label: string; type: FieldType }[] = [ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' }, { value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' }, { value: 'qso_date', label: 'fltb.fDate', type: 'date' },
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' }, { value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
{ value: 'rig', label: 'fltb.fRig', type: 'text' }, { value: 'rig', label: 'fltb.fRig', type: 'text' },
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' }, { value: 'ant', label: 'fltb.fAntenna', type: 'text' },
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
// of them — a paper QSL is not uploaded.
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' }, { value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' }, { value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' }, { value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' }, { value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' }, { value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' }, { value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' }, { value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' }, { value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' }, { value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' }, { value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' }, { value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' }, { value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' }, { value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
function opsFor(field: string): { value: FilterOp; label: string }[] { function opsFor(field: string): { value: FilterOp; label: string }[] {
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text'; const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
// comparison operators (before/after), not the text ones.
const allow = t === 'text' ? TEXT_OPS : NUM_OPS; const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
return OPS.filter((o) => allow.includes(o.value)); return OPS.filter((o) => allow.includes(o.value));
} }
@@ -253,12 +271,19 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
<Input <Input
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'} // An ADIF date is picked with a calendar but STORED as the
// 8-digit form the column holds, so the comparison stays a
// plain string one on both sides.
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
className="h-8 flex-1 text-xs" className="h-8 flex-1 text-xs"
disabled={!needsValue} disabled={!needsValue}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'} placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
value={c.value} value={fieldType === 'adifdate' && /^d{8}$/.test(c.value)
onChange={(e) => setCond(i, { value: e.target.value })} ? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
: c.value}
onChange={(e) => setCond(i, {
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
})}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }} onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
/> />
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}> <button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
+62 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react'; import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
import { import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
@@ -125,8 +125,8 @@ function Segmented({ value, options, onChange, disabled }: {
} }
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…). // Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: { function Chip({ on, onClick, label, disabled, accent = 'emerald', title }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; title?: string;
}) { }) {
const onCls = { const onCls = {
emerald: 'bg-success border-success text-success-foreground', emerald: 'bg-success border-success text-success-foreground',
@@ -135,7 +135,7 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
amber: 'bg-warning border-warning text-warning-foreground', amber: 'bg-warning border-warning text-warning-foreground',
}[accent]; }[accent];
return ( return (
<button type="button" onClick={onClick} disabled={disabled} <button type="button" onClick={onClick} disabled={disabled} title={title}
className={cn( className={cn(
// min width (not fixed) so a longer label like STONE keeps symmetric // min width (not fixed) so a longer label like STONE keeps symmetric
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned. // padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
@@ -146,6 +146,37 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
); );
} }
// SmartSDR reports the tuner as an enum. It is worth decoding rather than
// showing raw: TUNE_FAIL and "never tuned" both leave the radio looking normal,
// and the operator transmits into a bad match either way.
function atuLabel(status: string | undefined, t: (k: string) => string): string {
switch ((status || '').toUpperCase()) {
case 'TUNE_IN_PROGRESS': return t('flxp.atuTuning');
case 'TUNE_SUCCESSFUL':
case 'TUNE_OK': return t('flxp.atuOk');
case 'TUNE_FAIL':
case 'TUNE_FAIL_BYPASS': return t('flxp.atuFail');
case 'TUNE_BYPASS':
case 'TUNE_MANUAL_BYPASS': return t('flxp.atuBypassed');
case 'TUNE_ABORTED': return t('flxp.atuAborted');
case 'TUNE_NOT_STARTED':
case 'NONE':
case '': return t('flxp.atuIdle');
default: return status || '';
}
}
function atuTone(status: string | undefined): string {
switch ((status || '').toUpperCase()) {
case 'TUNE_SUCCESSFUL':
case 'TUNE_OK': return 'text-success';
case 'TUNE_FAIL':
case 'TUNE_FAIL_BYPASS': return 'text-danger';
case 'TUNE_IN_PROGRESS': return 'text-warning';
default: return 'text-muted-foreground';
}
}
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: { function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void; label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
@@ -624,6 +655,33 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
)} )}
</div> </div>
{/* ATU — the radio's built-in tuner. Sits under TUNE because that is
the order of operations: the tuner needs a carrier to measure
against, so TUNE first, then start a tuning cycle. */}
<div className="flex items-center gap-2 border-t border-border/60 pt-3">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">ATU</span>
<button type="button" disabled={off}
title={t('flxp.atuTuneHint')}
onClick={() => FlexATUStart().catch(() => {})}
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
{t('flxp.atuTune')}
</button>
<button type="button" disabled={off}
title={t('flxp.atuBypassHint')}
onClick={() => FlexATUBypass().catch(() => {})}
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
{t('flxp.atuBypass')}
</button>
<Chip on={st.atu_memories} disabled={off} label={t('flxp.atuMem')} accent="cyan"
title={t('flxp.atuMemHint')}
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
{/* The status is the whole point: a tune that FAILED and one that
never ran look identical on the radio's own front panel. */}
<span className={cn('ml-auto text-[11px] font-mono font-semibold whitespace-nowrap', atuTone(st.atu_status))}>
{atuLabel(st.atu_status, t)}
</span>
</div>
{!isCW ? ( {!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
+22 -13
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react'; import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App'; import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst'; import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -37,7 +37,7 @@ function pfxOf(call: string): string {
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base; return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
} }
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm']; const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9']; const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
// label holds an i18n key (resolved with t() at render time). // label holds an i18n key (resolved with t() at render time).
const QSL_STATUSES = [ const QSL_STATUSES = [
@@ -330,19 +330,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
setLooking(true); setLooking(true);
setLocalErr(''); setLocalErr('');
try { try {
const r: any = await LookupCallsign(call); // FRESH: this fetch is a deliberate click, so it bypasses the cache and
// refreshes it. A cached answer from a thinner QRZ subscription (or any
// stale row) otherwise stayed for its whole 30-day life and the button
// appeared to do nothing.
const r: any = await LookupCallsignFresh(call);
// The lookup WINS over what is in the record — that is the point of asking
// for it. But an EMPTY result must never blank a good value: `??` only
// guards against null, and Go marshals an unset string as "", so a QRZ
// record with no grid used to wipe the grid that was already there.
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
setDraft((d) => ({ setDraft((d) => ({
...d, ...d,
name: r.name ?? d.name, name: keep(r.name, d.name),
qth: r.qth ?? d.qth, qth: keep(r.qth, d.qth),
address: r.address ?? (d as any).address, address: keep(r.address, (d as any).address),
email: r.email ?? (d as any).email, email: keep(r.email, (d as any).email),
country: r.country ?? d.country, country: keep(r.country, d.country),
grid: r.grid ?? d.grid, grid: keep(r.grid, d.grid),
state: r.state ?? d.state, state: keep(r.state, d.state),
cnty: r.cnty ?? d.cnty, cnty: keep(r.cnty, d.cnty),
cont: r.cont ?? d.cont, cont: keep(r.cont, d.cont),
qsl_via: r.qsl_via ?? d.qsl_via, qsl_via: keep(r.qsl_via, d.qsl_via),
dxcc: r.dxcc || d.dxcc, dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz, cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz, ituz: r.ituz || d.ituz,
+77 -17
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
@@ -1052,7 +1052,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState(''); const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', digital_default: 'FT8',
@@ -2334,6 +2334,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select> </Select>
</div> </div>
)} )}
{catCfg.backend === 'omnirig' && (
<div className="space-y-1">
<Label>{t('cat.omnirigVfo')}</Label>
<Select value={catCfg.omnirig_vfo || 'auto'}
onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_vfo: v === 'auto' ? '' : v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('cat.omnirigVfoAuto')}</SelectItem>
<SelectItem value="A">{t('cat.omnirigVfoA')}</SelectItem>
<SelectItem value="B">{t('cat.omnirigVfoB')}</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
</div>
)}
{catCfg.backend === 'flex' && ( {catCfg.backend === 'flex' && (
<> <>
<div className="space-y-1"> <div className="space-y-1">
@@ -2343,8 +2358,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.port')}</Label> <Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.flex_port || 4992} <PortInput value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} /> onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} /> <FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
@@ -2467,8 +2482,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.port')}</Label> <Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.tci_port || 40001} <PortInput value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} /> onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
</div> </div>
<p className="col-span-2 text-xs text-muted-foreground"> <p className="col-span-2 text-xs text-muted-foreground">
{t('cat.tciHint')} {t('cat.tciHint')}
@@ -2639,10 +2654,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>TCP port</Label> <Label>TCP port</Label>
<Input <PortInput
type="number" min={1} max={65535}
value={ultrabeam.port} value={ultrabeam.port}
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))} onChange={(n) => setUltrabeam((s) => ({ ...s, port: n }))} fallback={23}
className="font-mono" className="font-mono"
/> />
</div> </div>
@@ -2928,8 +2942,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>TCP port</Label> <Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={amp.port} <PortInput value={amp.port}
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" /> onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
</div> </div>
</div> </div>
)} )}
@@ -3099,10 +3113,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label> <Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input <PortInput
type="number" min={1} max={65535}
value={rotator.port} value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))} onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
className="font-mono" className="font-mono"
/> />
</div> </div>
@@ -4663,7 +4676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('db.host')}</Label> <Label className="text-sm">{t('db.host')}</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} /> <Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
<Label className="text-sm">{t('db.port')}</Label> <Label className="text-sm">{t('db.port')}</Label>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} /> <PortInput className="h-8 w-28 font-mono" value={mysqlCfg.port} fallback={3306} onChange={(n) => setMysqlField({ port: n })} />
<Label className="text-sm">{t('db.database')}</Label> <Label className="text-sm">{t('db.database')}</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} /> <Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">{t('db.user')}</Label> <Label className="text-sm">{t('db.user')}</Label>
@@ -5139,7 +5152,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} /> <Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
<Label className="text-sm">Port / encryption</Label> <Label className="text-sm">Port / encryption</Label>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} /> <PortInput className="h-8 w-24 font-mono" value={emailCfg.smtp_port} fallback={587} onChange={(n) => setEmailField({ smtp_port: n })} />
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}> <Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -5343,6 +5356,53 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
); );
} }
// PortInput — a TCP port field you can actually clear.
//
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
// the contents and parseInt gives NaN, so the default is written straight back:
// the digits reappear under the cursor and the only way to enter a different
// port is to overwrite character by character. Reported on the cluster editor;
// the same line existed in eight other places.
//
// The raw TEXT is the state here, and the parent is only told about a value that
// is actually a port. An empty box stays empty while you type; on blur it falls
// back to the last good value, so nothing is ever saved as 0 or NaN.
function PortInput({ value, onChange, fallback, className, min = 1, max = 65535 }: {
value: number; onChange: (n: number) => void; fallback: number; className?: string; min?: number; max?: number;
}) {
const [text, setText] = useState(value ? String(value) : '');
const typed = useRef(false);
// Only adopt the incoming value while the operator has NOT started typing —
// settings arrive from the backend after mount, and re-syncing mid-edit is
// precisely the behaviour being fixed.
useEffect(() => {
if (!typed.current) setText(value ? String(value) : '');
}, [value]);
return (
<Input
type="text"
inputMode="numeric"
className={className}
value={text}
onChange={(e) => {
typed.current = true;
const digits = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
setText(digits);
const n = parseInt(digits, 10);
if (n >= min && n <= max) onChange(n);
}}
onBlur={() => {
typed.current = false;
const n = parseInt(text, 10);
if (!(n >= min && n <= max)) {
setText(String(fallback));
onChange(fallback);
}
}}
/>
);
}
// ClusterServerEditor edits one row of cluster_servers. Init commands are // ClusterServerEditor edits one row of cluster_servers. Init commands are
// free-form (one per line); the backend strips blanks and "//" comments. // free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps { interface ClusterEditorProps {
@@ -5374,7 +5434,7 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Port</Label> <Label>Port</Label>
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} /> <PortInput className="font-mono" value={s.port} fallback={7300} onChange={(n) => update({ port: n })} />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Login callsign (optional)</Label> <Label>Login callsign (optional)</Label>
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -14,7 +14,11 @@ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
]; ];
export const LS_KEY = 'opslog.theme'; export const LS_KEY = 'opslog.theme';
const DEFAULT: ThemeChoice = 'light-warm'; // A fresh install starts DARK. A shack is usually a dim room and the screen is
// looked at for hours; every other logger defaults the same way. Graphite
// specifically, because that is what 'auto' already resolves to for a dark
// system — so the two paths agree instead of landing on different darks.
const DEFAULT: ThemeChoice = 'dark-graphite';
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES]; const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
function systemDark(): boolean { function systemDark(): boolean {
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.21.6'; export const APP_VERSION = '0.21.7';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -637,6 +637,8 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>; export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
export function MotorReadElements():Promise<Array<number>>; export function MotorReadElements():Promise<Array<number>>;
export function MotorSetElement(arg1:number,arg2:number):Promise<void>; export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
+4
View File
@@ -1222,6 +1222,10 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1); return window['go']['main']['App']['LookupCallsign'](arg1);
} }
export function LookupCallsignFresh(arg1) {
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
}
export function MotorReadElements() { export function MotorReadElements() {
return window['go']['main']['App']['MotorReadElements'](); return window['go']['main']['App']['MotorReadElements']();
} }
+2
View File
@@ -1829,6 +1829,7 @@ export namespace main {
enabled: boolean; enabled: boolean;
backend: string; backend: string;
omnirig_rig: number; omnirig_rig: number;
omnirig_vfo: string;
flex_host: string; flex_host: string;
flex_port: number; flex_port: number;
flex_spots: boolean; flex_spots: boolean;
@@ -1857,6 +1858,7 @@ export namespace main {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.backend = source["backend"]; this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"]; this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"];
this.flex_host = source["flex_host"]; this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"]; this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"]; this.flex_spots = source["flex_spots"];
+6 -1
View File
@@ -6,6 +6,7 @@
// QSO may yield several references (e.g. a Note holding "D74 D73"). // QSO may yield several references (e.g. a Note holding "D74 D73").
// //
// Examples: // Examples:
//
// DXCC : field "dxcc" (no pattern) → entity number // DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state // WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes // DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
@@ -1326,7 +1327,11 @@ func setToSorted(m map[string]struct{}) []string {
return out return out
} }
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"} var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
// Microwave. A band missing from this order sorts to the end of an award's
// band columns rather than in frequency order — and 3 cm was missing.
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
func sortedBands(m map[string]int) []string { func sortedBands(m map[string]int) []string {
idx := map[string]int{} idx := map[string]int{}
+32
View File
@@ -776,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
func BandFromHz(hz int64) string { func BandFromHz(hz int64) string {
mhz := float64(hz) / 1_000_000 mhz := float64(hz) / 1_000_000
switch { switch {
// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
// which then cost them the band on the QSO and in every award count.
case mhz >= 0.1357 && mhz <= 0.1378:
return "2190m"
case mhz >= 0.472 && mhz <= 0.479:
return "630m"
case mhz >= 0.501 && mhz <= 0.504:
return "560m"
case mhz >= 1.8 && mhz <= 2.0: case mhz >= 1.8 && mhz <= 2.0:
return "160m" return "160m"
case mhz >= 3.5 && mhz <= 4.0: case mhz >= 3.5 && mhz <= 4.0:
@@ -810,6 +818,30 @@ func BandFromHz(hz int64) string {
return "33cm" return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0: case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm" return "23cm"
// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
// actually work, and spot — resolved to an empty band: no band on the QSO, no
// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
// which is what an export has to carry.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
} }
return "" return ""
} }
+7 -11
View File
@@ -50,7 +50,6 @@ type Flex struct {
meterMeta map[int]meterInfo meterMeta map[int]meterInfo
meterVal map[int]float64 meterVal map[int]float64
meterSub map[int]bool // ids we've already sent "sub meter <id>" for meterSub map[int]bool // ids we've already sent "sub meter <id>" for
meterLogAt time.Time // throttle for value logging
vitaSeen int // count of UDP datagrams (first few logged for diag) vitaSeen int // count of UDP datagrams (first few logged for diag)
meterRawLogged bool // log the first raw meter-definition status once meterRawLogged bool // log the first raw meter-definition status once
txRawLogged bool // log the first raw transmit status once (field-name audit) txRawLogged bool // log the first raw transmit status once (field-name audit)
@@ -768,11 +767,17 @@ func (f *Flex) handleStatus(payload string) {
f.meterMeta[num] = old f.meterMeta[num] = old
} }
f.mu.Unlock() f.mu.Unlock()
// One line for the whole batch, not one per meter: a Flex announces
// dozens at connect and that was a wall of text every time.
var names []string
for _, id := range newIDs { for _, id := range newIDs {
mi := f.meterMeta[id] mi := f.meterMeta[id]
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi) names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
f.subscribeMeter(id) f.subscribeMeter(id)
} }
if len(names) > 0 {
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
}
} }
// Spot status: "spot <index> …". Track the index so we can clear the // Spot status: "spot <index> …". Track the index so we can clear the
// panadapter, and log it verbatim — a click on a panadapter spot pushes a // panadapter, and log it verbatim — a click on a panadapter spot pushes a
@@ -2154,15 +2159,6 @@ func (f *Flex) parseVita(p []byte, seen int) {
raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4])) raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit) f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
} }
if time.Since(f.meterLogAt) > 5*time.Second { // throttled dump to validate names
f.meterLogAt = time.Now()
var b strings.Builder
for id, v := range f.meterVal {
mi := f.meterMeta[id]
fmt.Fprintf(&b, "%s=%.1f%s ", nonEmpty(mi.name, strconv.Itoa(id)), v, mi.unit)
}
debugLog.Printf("Flex: meters %s", strings.TrimSpace(b.String()))
}
f.mu.Unlock() f.mu.Unlock()
} }
+50 -8
View File
@@ -29,6 +29,14 @@ const (
type OmniRig struct { type OmniRig struct {
RigNum int // 1 (Rig1) or 2 (Rig2) RigNum int // 1 (Rig1) or 2 (Rig2)
// ForceVFO overrides which VFO to read: "" trusts the rig file, "A"/"B" do
// not. OmniRig's VFO report is only as good as the .ini behind it, and a
// wrong one is invisible: an IC-7610 file was seen declaring VFO B while the
// operator worked on the main VFO, so OpsLog wrote to A (SetSimplexMode acts
// on the main VFO) and read B — the frequency "never followed the knob",
// while it was following the other one all along.
ForceVFO string
omnirig *ole.IDispatch omnirig *ole.IDispatch
rig *ole.IDispatch rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change lastSig string // last logged Split/VFO signature — only log on change
@@ -59,14 +67,24 @@ type OmniRig struct {
splitFlaky bool splitFlaky bool
splitFlips int splitFlips int
splitFlipWindow time.Time splitFlipWindow time.Time
// pendingReadback schedules one delayed frequency log after a Set — see
// SetFrequency. Zero when nothing is pending.
pendingReadback time.Time
} }
// NewOmniRig creates a non-connected backend. Call Connect before use. // NewOmniRig creates a non-connected backend. Call Connect before use.
func NewOmniRig(rigNum int) *OmniRig { // NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
// reports, or "A"/"B" to override it — see the ForceVFO field.
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
if rigNum < 1 || rigNum > 2 { if rigNum < 1 || rigNum > 2 {
rigNum = 1 rigNum = 1
} }
return &OmniRig{RigNum: rigNum} v := strings.ToUpper(strings.TrimSpace(forceVFO))
if v != "A" && v != "B" {
v = ""
}
return &OmniRig{RigNum: rigNum, ForceVFO: v}
} }
func (o *OmniRig) Name() string { return "omnirig" } func (o *OmniRig) Name() string { return "omnirig" }
@@ -306,7 +324,21 @@ func (o *OmniRig) ReadState() (RigState, error) {
} }
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, s.Vfo, splitRaw, splitRecentOn) // A forced VFO replaces whatever the rig file reported, BEFORE anything is
// derived from it. Nothing downstream then has to know about the override.
vfo := s.Vfo
if o.ForceVFO != "" {
vfo = o.ForceVFO
}
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, vfo, splitRaw, splitRecentOn)
// Delayed readback after a Set (see SetFrequency): logged from HERE because
// this runs on the COM-owning goroutine. One line, once per set.
if !o.pendingReadback.IsZero() && time.Now().After(o.pendingReadback) {
o.pendingReadback = time.Time{}
debugLog.Printf("OmniRig.SetFrequency: readback +1.5s FreqA=%d FreqB=%d Freq=%d → shown %d",
freqA, freqB, freqMain, s.FreqHz)
}
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal // Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
// operation stays quiet but toggling split or SUB VFO on the radio is // operation stays quiet but toggling split or SUB VFO on the radio is
@@ -497,10 +529,17 @@ func (o *OmniRig) SetFrequency(hz int64) error {
} }
} }
// Read back all three immediately. OmniRig is async (the CAT command is // Read back all three, then AGAIN a moment later. OmniRig is async the CAT
// queued + sent over serial), so these may still show the OLD value for // command is queued and sent over serial so an immediate read always shows
// one poll cycle — but if they NEVER change in the next poll, the rig // the old value and proves nothing. The delayed one is the useful half: it
// isn't honouring the write (wrong .ini WRITE command for this model). // separates "the rig ignored the write" from "the rig moved but its .ini
// never re-reads the frequency", which look identical to an operator whose
// display stays on the old band until they nudge the VFO.
//
// Off the CAT goroutine so the poll loop is not held for a second. COM is
// thread-affine, so the delayed read goes through the manager's own command
// channel rather than touching o.rig from here.
logFreqs := func(when string) {
fa, fb, fg := int64(-1), int64(-1), int64(-1) fa, fb, fg := int64(-1), int64(-1), int64(-1)
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil { if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
fa = v.Val fa = v.Val
@@ -511,7 +550,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil { if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
fg = v.Val fg = v.Val
} }
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz) debugLog.Printf("OmniRig.SetFrequency: readback %s FreqA=%d FreqB=%d Freq=%d (target %d)", when, fa, fb, fg, hz)
}
logFreqs("now")
o.pendingReadback = time.Now().Add(1500 * time.Millisecond)
return nil return nil
} }
+8
View File
@@ -47,6 +47,14 @@ func TestResolveOmniRigVFOs(t *testing.T) {
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false}, {"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false}, {"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false},
// F4?? IC-7610 report, 2026-07-27: the operator's custom rig file declares
// VFO B permanently while they work on the MAIN VFO. OpsLog then wrote to A
// (SetSimplexMode acts on main) and read B — "the frequency never follows
// the knob". Honouring the enum is right in general, which is why the cure
// is an explicit override in the settings, applied before this function.
{"IC-7610 file wrongly says B — enum honoured, hence the override", "IC-7610", 0, 7150000, 14295000, "B", pmSplitOff, false, 14295000, 0, false},
{"same rig with the override forcing A", "IC-7610", 0, 7150000, 14295000, "A", pmSplitOff, false, 7150000, 0, false},
// Non-regression: a rig that does not report the VFO enum keeps the old // Non-regression: a rig that does not report the VFO enum keeps the old
// order — freqA, then the generic Freq, then freqB. // order — freqA, then the generic Freq, then freqB.
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false}, {"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
+22
View File
@@ -682,6 +682,28 @@ func bandFromHz(hz int64) string {
return "33cm" return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0: case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm" return "23cm"
// Microwave — a 10 GHz spot used to land with no band at all, so it could
// not be filtered, matched against the log, or shown on a band map.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
} }
return "" return ""
} }
+21 -6
View File
@@ -6,6 +6,7 @@ import (
"embed" "embed"
"fmt" "fmt"
"os" "os"
"path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -161,7 +162,7 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("ping sqlite: %w", err) return nil, fmt.Errorf("ping sqlite: %w", err)
} }
Dialect = "sqlite" Dialect = "sqlite"
if err := migrate(conn, nil, path); err != nil { if err := migrate(conn, nil, path, filepath.Base(path)); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, err return nil, err
} }
@@ -175,8 +176,8 @@ var LogSink func(format string, args ...any)
// logMigration records a migration that has just been applied, and how long it // logMigration records a migration that has just been applied, and how long it
// took — the only trace an operator has that a data-rewriting migration ran. // took — the only trace an operator has that a data-rewriting migration ran.
func logMigration(name string, start time.Time) { func logMigration(label, name string, start time.Time) {
logf("db: migration %s applied in %s", name, time.Since(start).Round(time.Millisecond)) logf("db[%s]: migration %s applied in %s", label, name, time.Since(start).Round(time.Millisecond))
} }
func logf(format string, args ...any) { func logf(format string, args ...any) {
@@ -231,7 +232,11 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
// skipping those already applied. Intentionally minimal in-house system // skipping those already applied. Intentionally minimal in-house system
// (no external dependency). translate, when non-nil, rewrites each statement // (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is. // for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string, dbPath string) error { // label names the database being migrated, for the log. Without it three
// interleaved migration runs in one log were indistinguishable — an operator
// reported "migrations are very slow" and the lines gave no way to tell one
// database migrated three times from three databases migrated once.
func migrate(conn *sql.DB, translate func(string) string, dbPath, label string) error {
// A non-nil translator means this is the MySQL connection (use the // A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is // per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the // determined by the caller's argument, NOT the global Dialect, so the
@@ -274,6 +279,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
return fmt.Errorf("read applied migrations: %w", err) return fmt.Errorf("read applied migrations: %w", err)
} }
pending := 0
for _, name := range names {
if !applied[name] {
pending++
}
}
if pending > 0 {
logf("db[%s]: %d migration(s) to apply", label, pending)
}
for _, name := range names { for _, name := range names {
if applied[name] { if applied[name] {
continue // already applied continue // already applied
@@ -307,7 +322,7 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil { if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err) return fmt.Errorf("record migration %s: %w", name, err)
} }
logMigration(name, start) logMigration(label, name, start)
continue continue
} }
@@ -327,7 +342,7 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err) return fmt.Errorf("commit migration %s: %w", name, err)
} }
logMigration(name, start) logMigration(label, name, start)
} }
return nil return nil
} }
+7 -2
View File
@@ -139,6 +139,7 @@ func translateTextColumns(s string) string {
// app-lifetime ctx with no per-query timeout) hold a connection for the whole // app-lifetime ctx with no per-query timeout) hold a connection for the whole
// chain of remote-MySQL latency, so a handful of concurrent UI bursts drained // chain of remote-MySQL latency, so a handful of concurrent UI bursts drained
// the pool and "after a while nothing updated" — grid, chat, live_status froze. // the pool and "after a while nothing updated" — grid, chat, live_status froze.
//
// 40 fits the actual deployment (max 5 ops on a server with max_connections=300 → // 40 fits the actual deployment (max 5 ops on a server with max_connections=300 →
// 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE // 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE
// pool, so keep max_connections >= pool*ops + headroom. // pool, so keep max_connections >= pool*ops + headroom.
@@ -200,7 +201,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
err = applyMySQLBaseline(conn) err = applyMySQLBaseline(conn)
} else { } else {
// Existing database: apply only the migrations it's missing. // Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL, "") err = migrate(conn, mysqlDDL, "", "mysql:"+name)
} }
if err != nil { if err != nil {
_ = conn.Close() _ = conn.Close()
@@ -287,7 +288,11 @@ func applyMySQLBaseline(conn *sql.DB) error {
return fmt.Errorf("open baseline sqlite: %w", err) return fmt.Errorf("open baseline sqlite: %w", err)
} }
defer mem.Close() defer mem.Close()
if err := migrate(mem, nil, ""); err != nil { // In-memory SQLite, used only to derive the final schema for a FRESH MySQL
// database. Labelled so its (fast) migration lines are not mistaken for a
// real database being migrated — in one operator's log this pass sat between
// two slow MySQL runs and looked like a third database.
if err := migrate(mem, nil, "", "baseline:memory"); err != nil {
return fmt.Errorf("build baseline schema: %w", err) return fmt.Errorf("build baseline schema: %w", err)
} }
+24
View File
@@ -86,6 +86,22 @@ func (m *Manager) SetProviders(p ...Provider) {
// Lookup returns a Result for the callsign. Falls back through providers // Lookup returns a Result for the callsign. Falls back through providers
// when one returns ErrNotFound or fails. // when one returns ErrNotFound or fails.
// forceKey marks a context as a FORCED (operator-requested) lookup, which
// bypasses the cache on the way in and refreshes it on the way out. Carried on
// the context rather than as a parameter so every existing caller — and the
// Provider interface — stays untouched.
type forceKey struct{}
// WithForce returns a context that makes Lookup skip the cache.
func WithForce(ctx context.Context) context.Context {
return context.WithValue(ctx, forceKey{}, true)
}
func isForced(ctx context.Context) bool {
v, _ := ctx.Value(forceKey{}).(bool)
return v
}
func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
call := strings.ToUpper(strings.TrimSpace(callsign)) call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" { if call == "" {
@@ -97,6 +113,13 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
dxcc := m.dxcc dxcc := m.dxcc
m.mu.RUnlock() m.mu.RUnlock()
// A FORCED lookup skips the cache. The cache is right for the automatic
// lookup that fires as you type, but it also freezes a wrong answer for the
// whole TTL: an operator who upgraded their QRZ subscription kept getting the
// thin free-account record for a month, and clearing the cache by hand was
// the only way out. A lookup the operator asked for by clicking is a
// deliberate act and must reach the provider.
if !isForced(ctx) {
if r, ok := m.cache.Get(ctx, call); ok { if r, ok := m.cache.Get(ctx, call); ok {
r.Source = "cache" r.Source = "cache"
// Re-assert the authoritative DXCC fields (country/zones/continent) // Re-assert the authoritative DXCC fields (country/zones/continent)
@@ -107,6 +130,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
normalizeNames(&r) normalizeNames(&r)
return r, nil return r, nil
} }
}
var lastErr error var lastErr error
// An operational suffix (/M, /P, …) is never registered as such: skip the // An operational suffix (/M, /P, …) is never registered as such: skip the
+46 -13
View File
@@ -539,20 +539,8 @@ var uploadStatusCols = map[string]bool{
"lotw_sent": true, "lotw_sent": true,
"eqsl_sent": true, "eqsl_sent": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
// Confirmation dates (ADIF YYYYMMDD strings, so plain TEXT like the rest).
"qsl_sent_date": true,
"qsl_rcvd_date": true,
"lotw_sent_date": true,
"lotw_rcvd_date": true,
"eqsl_sent_date": true,
"eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true,
"qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true,
"hrdlog_qso_upload_date": true,
} }
// ListForUpload returns QSOs whose per-service sent-status column equals // ListForUpload returns QSOs whose per-service sent-status column equals
@@ -763,8 +751,20 @@ var bulkEditableCols = map[string]bool{
"qsl_rcvd": true, "qsl_rcvd": true,
"qsl_via": true, "qsl_via": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
"qsl_sent_date": true,
"qsl_rcvd_date": true,
"lotw_sent_date": true,
"lotw_rcvd_date": true,
"eqsl_sent_date": true,
"eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true,
"qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true,
"hrdlog_qso_upload_date": true,
// My station / operator // My station / operator
"station_callsign": true, "station_callsign": true,
"operator": true, "operator": true,
@@ -787,6 +787,14 @@ var bulkEditableCols = map[string]bool{
"my_arrl_sect": true, "my_arrl_sect": true,
"my_darc_dok": true, "my_darc_dok": true,
"my_vucc_grids": true, "my_vucc_grids": true,
// Contacted station: the LOCATION fields only. State and county are the ones
// an import loses and a whole run shares (a park activation, a county line
// operation), and the dialog has always offered them — but they were missing
// here, so choosing one failed at Apply. The rest of the contacted station
// (callsign, name, RST…) stays deliberately out: bulk-setting those would
// corrupt the log.
"state": true,
"cnty": true,
// Contest — the exchange/label fields that are constant across a run. // Contest — the exchange/label fields that are constant across a run.
// (srx/stx serial numbers stay excluded: they are per-QSO.) // (srx/stx serial numbers stay excluded: they are per-QSO.)
"contest_id": true, "contest_id": true,
@@ -1185,7 +1193,15 @@ var filterableColumns = map[string]bool{
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true, "iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true, "lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true, "qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
// also chronological — "before 20240101" works with no date parsing.
"qsl_sent_date": true, "qsl_rcvd_date": true,
"lotw_sent_date": true, "lotw_rcvd_date": true,
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true, "hrdlog_qso_upload_date": true,
"contest_id": true, "srx": true, "stx": true, "contest_id": true, "srx": true, "stx": true,
"prop_mode": true, "sat_name": true, "prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true, "station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
@@ -2812,3 +2828,20 @@ func parseTimeLoose(s string) time.Time {
} }
return time.Time{} return time.Time{}
} }
// IsBulkEditable reports whether a column may be written by a bulk edit. Used by
// the app layer's own test to prove that every field the UI offers is actually
// accepted here — the two lists live in different packages and drifted apart
// once already: the new confirmation columns were added to the QSL Manager's
// filter whitelist instead of this one, so the UI offered fields that the
// repository then refused with "field … is not bulk-editable".
func IsBulkEditable(column string) bool { return bulkEditableCols[column] }
// IsFilterable reports whether a column may appear in a query filter. Same
// reason as IsBulkEditable.
func IsFilterable(column string) bool { return filterableColumns[column] }
// IsBulkEditableExtra / IsFilterableExtra cover the ADIF fields that have no
// column of their own and live in extras_json.
func IsBulkEditableExtra(field string) bool { _, ok := bulkEditableExtras[field]; return ok }
func IsFilterableExtra(field string) bool { _, ok := filterableExtras[field]; return ok }
+4 -1
View File
@@ -125,7 +125,10 @@ func main() {
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1}, // The colour the window is painted before the WebView draws. Matches the
// graphite theme's background (#16181d), which is the default on a fresh
// install — otherwise every launch flashes white first.
BackgroundColour: &options.RGBA{R: 0x16, G: 0x18, B: 0x1d, A: 1},
OnStartup: app.startup, OnStartup: app.startup,
OnDomReady: app.domReady, OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose, OnBeforeClose: app.beforeClose,
+28
View File
@@ -0,0 +1,28 @@
package main
import "testing"
// The case that started this: WSJT-X / MSHV can only send a 4-character grid, so
// a digital QSO arrived with JN05 while QRZ knew JN05JG — and the fill-if-empty
// enrichment rule kept the coarse one. Roughly 100 km of accuracy, silently lost
// on every digital QSO.
func TestRefineGrid(t *testing.T) {
cases := []struct{ have, found, want, why string }{
{"JN05", "JN05JG", "JN05JG", "the lookup EXTENDS the received square — take the precise one"},
{"jn05", "JN05JG", "JN05JG", "grids arrive in both cases"},
{"", "JN05JG", "JN05JG", "nothing received — take whatever was found"},
{"JN05JG", "", "JN05JG", "nothing found — keep what was received"},
{"JN05JG", "JN05", "JN05JG", "never trade a precise grid for a coarse one"},
{"JN05JG", "JN05JG", "JN05JG", "same grid"},
// A DISAGREEMENT is not a refinement: the station may be portable, and
// what came over the air is then the better record.
{"JN05", "JN18AA", "JN05", "different square — keep what was actually received"},
{"JN18AA", "JN05", "JN18AA", "different square, coarse lookup — keep the received one"},
{"", "", "", "nothing either way"},
}
for _, c := range cases {
if got := refineGrid(c.have, c.found); got != c.want {
t.Errorf("refineGrid(%q, %q) = %q, want %q — %s", c.have, c.found, got, c.want, c.why)
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.21.6" appVersion = "0.21.7"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.