Compare commits

...
32 Commits
Author SHA1 Message Date
rouggy c9d1fc1cc0 chore: release v0.21.8 2026-07-28 19:10:14 +02:00
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
rouggy 4dd15b09e9 chore: release v0.21.6 2026-07-27 21:26:31 +02:00
rouggy 4eec272c0b fix: bulk edit — name the confirmation fields properly, add the missing ones (#3)
"Upload" was wrong for half of them: a paper QSL is not uploaded. Every entry
now says whether it sets a STATUS or a DATE, and in which direction — "QRZ.com
sent status", "LoTW received date" — which is also how the ADIF fields read.

Added: the QRZ.com received status (the column existed, it was simply never
offered) and the ten confirmation DATES — QSL / LoTW / eQSL / QRZ.com in both
directions, Club Log and HRDLog outbound only, those two having no return
channel. Dates use the same picker as the QSL Info tab, with a Today (UTC)
button; clearing the field writes an empty value, which is how a wrong date is
removed from a batch.

Fields are grouped by CHANNEL now, each status followed by its date, in the same
order as the QSL Info tab — a longer list, but nothing to hunt for.

The repository whitelist is extended to match: without it the new columns would
have been refused at write time. The old ids (qrz_upload, clublog_upload,
hrdlog_upload) still resolve, in case anything still holds one.
2026-07-27 21:21:09 +02:00
rouggy 816a727e88 feat: stats timeline, band-map colours, frameless window, and four fixes
Statistics — the activity chart ignored the period selector: picking a year up
top left it showing the last 7 days, two controls contradicting each other. The
buttons were four fixed rolling windows anchored on the newest QSO, not
granularities. They now choose the BUCKET SIZE over the selected period, with an
Auto default and a step-up when a bucket would be too fine (the backend drops a
series past 750 buckets rather than ship 6000 unreadable bars). The hour-of-day
histogram covered a single day, too little to read anything from; it moves to
its own card with a weekday view, over the whole period.

Band map — the CW/data/phone sub-bands were shaded with the STATUS tokens, the
same amber that means "new band" on the pills drawn on top of them. A sub-band
is an identity, not a state: categorical hues now, validated in both themes
(violet failed on the dark steps — 1.9 ΔE from blue for a protan reader) and
added to the legend, since identity must never be colour-alone.

Frameless window — the OS title bar was a dead 32px band above a window that
already has its own. Minimise/maximise/close move into the app header, which
carries the drag region and double-click-to-maximise. The drag opt-out is by
ROLE in CSS, so a future button in that bar keeps working without anyone
remembering the rule.

Fixes:
- Saving settings froze the window while a device was slow: restarting a link
  waits for its poll goroutine, which can sit 45 s inside an uninterruptible COM
  Connect when another program holds the rig. The restart is now off the UI
  path; a slow one is logged.
- Multi-screen: a MAXIMISED window was never repositioned, so it came back on
  the primary screen every launch. The corner is now recorded even when
  maximised (it names the monitor) and re-applied while still hidden.
- The Callsign field is marked out at rest — operators were typing the call into
  Name, which sat beside it and looked identical.
- QSL dates get a real picker (native, for the OS calendar and locale order); a
  malformed old value stays in a text box rather than vanishing behind an empty
  one.

Also: a Support OpsLog entry in Help, and a WinKeyer protocol trace. The WK2
report (one element then a ten-second pause, sidetone that will not switch off)
is NOT fixed here: the WK1/WK2/WK3 command sets differ and a guessed fix would
break the operators it works for today. The trace logs every byte with the
command name and spells out the firmware family, which is what will settle it.
2026-07-27 20:50:44 +02:00
rouggy 2b5c195ab4 chore: release v0.21.5 2026-07-27 13:49:19 +02:00
rouggy 139b4675e3 fix: portable folder — stop storing absolute database paths
Moving the folder (C:\OpsLog → D:\OpsLog, or onto a stick) broke everything:
config.json and each profile's logbook path were absolute, so on the new
machine the pointer named a drive that no longer applied.

The logbook case was the dangerous one. db.Open CREATES what is missing, so if
the stale C: path happened to be creatable, the operator silently got a NEW
EMPTY logbook instead of an error — with their QSOs sitting untouched in the
folder they had just copied.

A path inside the application folder is now stored relative to it and resolved
against the current location at read time. A path OUTSIDE it (a synced folder,
a chosen drive) stays absolute and untouched — that is a deliberate choice; it
is only re-rooted when it has gone missing AND a file of the same name exists in
this install's data folder, i.e. the copied-folder case. With no such twin the
path is left alone so the failure is reported rather than papered over with an
unrelated database. Both rules are pinned by tests.
2026-07-27 13:48:11 +02:00
rouggy 2ad72b19fb feat: separate column layout for Recent QSOs in the Main tab
The Main-tab pane and the full Recent QSOs tab are the same component sharing
one storage key, but the pane is about half as wide — so it wants fewer and
narrower columns. Whichever was opened last rewrote the other's widths.

The pane now stores under "mainpane", which also scopes its award-column
visibility and widths. The full tab keeps the historical key, so its layout is
untouched; the pane starts from defaults once.
2026-07-27 11:58:24 +02:00
rouggy 678c8821c2 fix: column layouts would not stick — five separate faults
1. A language change rebuilt the column defs, which sets the anti-clobber
   guard, but the effect that clears it listened to only two of the rebuild's
   causes. Guard stuck on → every column change was silently discarded for the
   rest of the session. Now keyed on the rebuilt defs themselves, so any future
   cause is covered.
2. The cluster grid had no guard at all: the same rebuild wrote its defaults
   over the saved layout, in the cache AND the database. Unrecoverable.
3. Worked-before and cluster read their state before the active profile was
   known, so they looked up the unscoped cache key (always a miss) and then
   saved under the scoped one. They now wait for the scope and remount on a
   profile switch, like the main log.
4. GetUIPref returns an ERROR, not "", while the settings store is not yet
   scoped — deliberately distinct from "unset". The frontend read it as "no
   preference", rendered defaults, and the first column event overwrote the
   good copy. It now retries instead of giving up.
5. The QSL manager had no storageKey, so it shared the main log's layout and
   each rewrote the other.

Also: the DB write is debounced (a resize drag fired dozens of writes) with a
flush on close, and a rejected write is retried rather than dropped.
2026-07-27 11:56:43 +02:00
rouggy 5394b55bb7 feat: amplifier band-follow — answer the amp's frequency polls
On its CAT/AUX connector an ACOM is the MASTER: it polls a transceiver and
changes band from the reply, so nothing can be pushed to it. internal/catemu
answers those polls on a second serial port, in ACOM command set 5 (Kenwood /
Elecraft): FA;, FB;, IF; and ID;, and nothing else — a wrong-length reply is
worse than none, it desynchronises the amp's parser for the following poll.

Also offered for SPE. Some amps do not poll at all but read the radio↔PC CAT
line in parallel; those never hear a responder, so an optional unprompted send
(500/1000 ms) covers them.

The TX frequency is what is sent: in split the amp must be tuned where we
transmit. Frame lengths are pinned by tests — the failure they guard against is
silent and only shows up as an amp that mistunes.

Untested on hardware.
2026-07-27 11:56:30 +02:00
rouggy 05d64024ef fix: version string was 021.4, not 0.21.4
The release prompt was answered with a missing dot and the script propagated
it verbatim into both constants, so the shipped exe reported "021.4".

That is not cosmetic: versionLess splits on dots, so "021.4" parses as [21,4]
and compares GREATER than "0.21.5" — a client on that build would be told it
is up to date forever and never offered another update. It also broke the
What's new dialog on a fresh install, where the changelog entry ("0.21.4") is
matched against appVersion by string equality.

Same code as the release otherwise; only the version string changes.
2026-07-27 10:03:57 +02:00
rouggy cb27aa5ebf chore: release v021.4 2026-07-27 00:16:17 +02:00
rouggy aefb984974 feat: follow the SUB VFO over OmniRig; distance column in the QSO grids
OmniRig reports the VFO pair (AA/AB/BA/BB) on the whole Yaesu range and never
the single-letter form. Only the latter was honoured, so every rig in that
family stayed pinned to VFO A: pressing SUB moved the radio but not OpsLog,
and a QSO worked on SUB was logged on the main VFO's frequency. The first
letter of the pair is the VFO being listened on — Log4OM reads it and gets the
right frequency on the same rigs, which is what showed the data was there.
The enum now wins over the Yaesu Freq==FreqB inference, which is only a
fallback for a rig file that names no VFO at all (the stock FTDX10 one answers
neither VS; nor FR; usably — verified on the air).

Split: the ON flag is still latched to survive a rig file that flips it on its
own, but the latch is now ARMED only after 8 flips in 30 s. A first cut at
3-in-15s was armed by the operator toggling split while testing, imposing the
6 s clearing delay on a radio that did not need it; a misreading file flips a
dozen times in that window untouched, so the two cases separate cleanly.

Distance (km) column added to Recent QSOs and Worked before (shared catalog).
Computed from the QSO's OWN my_grid/my_lat/lon first, falling back to the
current profile's locator: a log spans years and portable outings, so the
station a QSO was made from is not necessarily today's.

Locator: a precise QRZ/HamQTH grid is no longer overwritten by the cty.dat
entity centroid. The lookup runs several times per QSO and the provider gets
2 s; a slow second answer fell back to cty.dat and downgraded JN05JG to JN16
while name and QTH survived (they are only written when non-empty).

The OmniRig diagnostic line now logs what OpsLog concluded, not just what
OmniRig reported. icomnet.go: gofmt alignment only.
2026-07-26 23:36:56 +02:00
49 changed files with 3332 additions and 443 deletions
+437 -31
View File
@@ -30,6 +30,7 @@ import (
"hamlog/internal/backup"
"hamlog/internal/cabrillo"
"hamlog/internal/cat"
"hamlog/internal/catemu"
"hamlog/internal/clublog"
"hamlog/internal/cluster"
"hamlog/internal/contest"
@@ -97,6 +98,8 @@ const (
keyCATEnabled = "cat.enabled"
keyCATBackend = "cat.backend" // "omnirig" | "flex"
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)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
@@ -279,6 +282,14 @@ const (
keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"
// Cloudlog and its fork Wavelog are self-hosted, so the instance URL is part
// of the configuration (there is no fixed endpoint).
keyExtCloudlogURL = "extsvc.cloudlog.url"
keyExtCloudlogAPIKey = "extsvc.cloudlog.api_key"
keyExtCloudlogStationID = "extsvc.cloudlog.station_id"
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
@@ -321,6 +332,11 @@ type CATSettings struct {
Enabled bool `json:"enabled"`
Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
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)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
@@ -909,6 +925,10 @@ func (a *App) startup(ctx context.Context) {
wruntime.EventsEmit(a.ctx, "cat:state", s)
}
a.emitRadioUDP(s)
// Feed the frequency to any amplifier we are pretending to be a radio
// for. Just two atomic stores per amp — the reply itself is built when
// the amp polls, so a fast-tuning VFO costs nothing here.
a.feedAmpBandFollow(s)
// Drive station relays by the current frequency/band (PstRotator-style
// automatic control). Cheap cached-flag check keeps this a no-op when the
// feature is off; when on, run off this callback so a slow relay board never
@@ -1504,6 +1524,74 @@ type dbPointer struct {
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
// ── Portable paths ─────────────────────────────────────────────────────
//
// OpsLog is meant to be carried on a stick or copied between machines: the exe,
// its data folder and the databases travel together. Storing "C:\OpsLog\data\
// logbook.db" broke that — dropped into D:\OpsLog on another PC, the pointer
// still named C:, and the app either lost the database or (worse) silently
// created a NEW empty one at a C: path that happened to be creatable.
//
// So a path INSIDE the application folder is stored relative to it, and any
// stored path is resolved against the CURRENT location at read time. Absolute
// paths outside the folder (a deliberate choice — a synced folder, another
// drive) keep working exactly as before: they are only re-rooted if they have
// gone missing AND the same file exists in this install's data folder.
// appDir is the folder the running exe lives in — the anchor for relative paths.
func appDir() string {
exe, err := os.Executable()
if err != nil {
return ""
}
return filepath.Dir(exe)
}
// portablePath prepares a path for STORAGE: relative to the app folder when it
// sits inside it, unchanged otherwise. Forward slashes so the value survives a
// round trip through a folder copied between machines.
func portablePath(p string) string {
p = strings.TrimSpace(p)
base := appDir()
if p == "" || base == "" || !filepath.IsAbs(p) {
return p
}
rel, err := filepath.Rel(base, p)
if err != nil || strings.HasPrefix(rel, "..") {
return p // outside the app folder — the user meant that exact place
}
return filepath.ToSlash(rel)
}
// resolvePath turns a stored path back into an absolute one for THIS install.
// dataDir is where the app's own data lives, used for the re-rooting rescue.
func resolvePath(dataDir, p string) string {
p = strings.TrimSpace(p)
if p == "" {
return ""
}
if !filepath.IsAbs(p) {
if base := appDir(); base != "" {
return filepath.Join(base, filepath.FromSlash(p))
}
return filepath.FromSlash(p)
}
if fileExists(p) {
return p
}
// An absolute path from another machine. Rescue it ONLY if a file of the same
// name is sitting in this install's data folder — that is the copied-folder
// case. Anything else is left alone so a genuinely-missing database is
// reported rather than silently replaced by an unrelated file.
if dataDir != "" {
if cand := filepath.Join(dataDir, filepath.Base(p)); fileExists(cand) {
applog.Printf("path: %q not found — using %q from this install (folder moved?)", p, cand)
return cand
}
}
return p
}
// ── Window geometry (window.json) ──────────────────────────────────────
//
// Remembered across restarts so the window reopens where and how you left it.
@@ -1556,7 +1644,20 @@ func (a *App) saveWindowState() {
if w > 0 && h > 0 {
ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y
}
} else if max {
// The POSITION is recorded even when maximised — it is what identifies the
// MONITOR. Without it a multi-screen operator who always runs maximised got
// no position at all, and Windows reopened OpsLog on the primary screen
// every time. The SIZE is deliberately not touched here: it would be the
// maximised size, which must not become the restored-down size.
x, y := wruntime.WindowGetPosition(a.ctx)
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)
}
@@ -1570,7 +1671,31 @@ func (a *App) restoreWindowPosition() {
return
}
ws, ok := readWindowState(a.dataDir)
if !ok || ws.Maximised {
if !ok {
applog.Printf("window: no saved geometry — opening where Windows puts it")
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,
// 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
// again — all while the window is still hidden, so nothing flickers.
if ws.Maximised {
if ws.X == 0 && ws.Y == 0 {
// 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) {
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
return
}
wruntime.WindowUnmaximise(a.ctx)
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
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
}
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
@@ -1623,12 +1748,16 @@ func readBootstrap(dataDir string) dbPointer {
return c
}
_ = json.Unmarshal(b, &c)
c.DBPath = strings.TrimSpace(c.DBPath)
// Stored relative when it lives inside the app folder, so the pointer follows
// the folder from C:OpsLog to D:OpsLog or to a stick.
c.DBPath = resolvePath(dataDir, c.DBPath)
c.DeletePending = resolvePath(dataDir, c.DeletePending)
return c
}
func writeBootstrap(dataDir string, c dbPointer) error {
c.DBPath = strings.TrimSpace(c.DBPath)
c.DBPath = portablePath(c.DBPath)
c.DeletePending = portablePath(c.DeletePending)
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
}
@@ -1766,6 +1895,15 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
if lp == "" {
return a.db, "sqlite", nil
}
// Resolve against THIS install before opening. Without it, a profile carried
// from C:\OpsLog to D:\OpsLog kept naming the C: path — and since db.Open
// CREATES what is missing, the operator silently got a brand-new empty
// logbook instead of an error. Their QSOs were still on disk, in the folder
// they had just copied.
if r := resolvePath(a.dataDir, lp); r != lp {
applog.Printf("logbook: profile path %q resolved to %q", lp, r)
lp = r
}
c, err := db.Open(lp)
if err != nil {
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
@@ -3720,7 +3858,9 @@ type AwardStatsResult struct {
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,
// reused across award computations. Pulling the whole logbook is the dominant
@@ -4050,7 +4190,11 @@ var awardBandPlan = []struct {
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
{"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 {
@@ -5450,9 +5594,26 @@ var bulkFieldColumns = map[string]string{
"qsl_sent": "qsl_sent",
"qsl_rcvd": "qsl_rcvd",
"qsl_via": "qsl_via",
"qrz_sent": "qrzcom_qso_upload_status",
"qrz_rcvd": "qrzcom_qso_download_status",
"clublog_sent": "clublog_qso_upload_status",
"hrdlog_sent": "hrdlog_qso_upload_status",
// Old ids kept so anything holding one keeps working.
"qrz_upload": "qrzcom_qso_upload_status",
"clublog_upload": "clublog_qso_upload_status",
"hrdlog_upload": "hrdlog_qso_upload_status",
// Confirmation DATES. A status without its date is half the record: an
// operator correcting a batch after an import needs both.
"qsl_sent_date": "qsl_sent_date",
"qsl_rcvd_date": "qsl_rcvd_date",
"lotw_sent_date": "lotw_sent_date",
"lotw_rcvd_date": "lotw_rcvd_date",
"eqsl_sent_date": "eqsl_sent_date",
"eqsl_rcvd_date": "eqsl_rcvd_date",
"qrz_sent_date": "qrzcom_qso_upload_date",
"qrz_rcvd_date": "qrzcom_qso_download_date",
"clublog_sent_date": "clublog_qso_upload_date",
"hrdlog_sent_date": "hrdlog_qso_upload_date",
// My station / operator
"station_callsign": "station_callsign",
"operator": "operator",
@@ -6130,6 +6291,22 @@ func (a *App) SaveCabrilloFile() (string, error) {
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
// "callsign not found".
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 {
return lookup.Result{}, fmt.Errorf("lookup not initialized")
}
@@ -6151,6 +6328,16 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
}
ctx, cancel := context.WithTimeout(a.ctx, budget)
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)
if errors.Is(err, lookup.ErrNotFound) {
return lookup.Result{}, fmt.Errorf("callsign not found")
@@ -6308,7 +6495,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
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 {
return CATSettings{}, err
}
@@ -6356,6 +6543,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if out.DigitalDefault == "" {
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 {
out.OmniRigNum = n
}
@@ -6424,6 +6614,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATEnabled: enabled,
keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots,
@@ -6447,7 +6638,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
return err
}
}
a.reloadCAT()
a.restartAsync("cat", a.reloadCAT)
return nil
}
@@ -8512,7 +8703,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
keyExtLoTWUsername, keyExtLoTWWebPassword,
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode)
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode)
if err != nil {
return out
}
@@ -8582,6 +8775,13 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
out.EQSL.Username = p.Callsign
}
}
out.Cloudlog = extsvc.ServiceConfig{
URL: m[keyExtCloudlogURL],
APIKey: m[keyExtCloudlogAPIKey],
StationID: m[keyExtCloudlogStationID],
AutoUpload: m[keyExtCloudlogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtCloudlogUploadMode]),
}
return out
}
@@ -8639,6 +8839,17 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if cfg.EQSL.AutoUpload {
eqAuto = "1"
}
// Cloudlog has no per-QSO upload-status column in the logbook, so the
// on-close sweep (which selects by that column) cannot apply — force any
// stored on_close back to immediate rather than silently doing nothing.
clgMode := modeOf(cfg.Cloudlog.UploadMode)
if clgMode == string(extsvc.ModeOnClose) {
clgMode = string(extsvc.ModeImmediate)
}
clgAuto := "0"
if cfg.Cloudlog.AutoUpload {
clgAuto = "1"
}
scope := a.profileScope() // write under the active profile's prefix
for k, v := range map[string]string{
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
@@ -8674,6 +8885,12 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname),
keyExtEQSLAutoUpload: eqAuto,
keyExtEQSLUploadMode: eqMode,
keyExtCloudlogURL: strings.TrimSpace(cfg.Cloudlog.URL),
keyExtCloudlogAPIKey: strings.TrimSpace(cfg.Cloudlog.APIKey),
keyExtCloudlogStationID: strings.TrimSpace(cfg.Cloudlog.StationID),
keyExtCloudlogAutoUpload: clgAuto,
keyExtCloudlogUploadMode: clgMode,
} {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
return err
@@ -8712,6 +8929,12 @@ func (a *App) TestEQSLUpload() (string, error) {
return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
}
// TestCloudlogUpload checks the Cloudlog/Wavelog URL, API key and station id
// against the user's own instance, without inserting anything.
func (a *App) TestCloudlogUpload() (string, error) {
return extsvc.TestCloudlog(a.ctx, nil, a.loadExternalServices().Cloudlog)
}
// ── QSL Manager (manual upload) ────────────────────────────────────────
// uploadColumnFor maps a service id to its QSO sent-status column.
@@ -9352,12 +9575,52 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// QSO date. sinceDate is "YYYY-MM-DD".
sinceDate := resolveSince(keyExtQRZLastDownload)
emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload))
if sinceDate != "" {
emit("Fetching QRZ.com logbook (will skip QSOs before " + sinceDate + ")…")
} else {
// An automatic run ("last") and an operator-chosen date do not mean the
// same thing, so they ask QRZ two different questions:
// • "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)…")
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 {
emit("Fetch failed: " + err.Error())
done(matched, total)
@@ -9365,14 +9628,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
}
adifText := fr.ADIF
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),
// not at the end: the QRZ logbook can be huge (tens of thousands of
// records) and the user may close the panel mid-processing — storing it
// late meant the date was never saved, so "since last download" kept
// resolving to empty and re-pulled everything.
if a.settings != nil {
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
}
// The last-download date is stored at the END, and ONLY if the whole ADIF
// parsed. It used to be written here, right after the fetch: when the
// response was truncated (see above) the window advanced over records that
// were never processed, and every later run skipped them for good. A
// window that moves past unseen data is worse than one that re-reads.
if snip := strings.TrimSpace(adifText); snip != "" {
if len(snip) > 300 {
snip = snip[:300]
@@ -9420,7 +9680,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
return nil
}
// 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
}
// Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
@@ -9504,10 +9767,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
sort.Strings(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))
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 {
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:
sinceDate := resolveSince(keyExtEQSLLastDownload)
@@ -9999,6 +10267,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
return false
}
return true
case extsvc.ServiceCloudlog:
// No per-QSO status column for Cloudlog: it dedupes server-side (its API
// checks each incoming record against the log), so re-sending is harmless
// and every QSO is eligible. The cost is that a permanently failed upload
// is not remembered — hence no on-close mode and no manual backlog.
return true
case extsvc.ServiceLoTW:
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
if strings.EqualFold(q.LOTWSent, f) {
@@ -10034,6 +10308,9 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
err = a.qso.MarkHRDLogUploaded(ctx, id, date)
case extsvc.ServiceEQSL:
err = a.qso.MarkEQSLSent(ctx, id, date)
case extsvc.ServiceCloudlog:
// Nothing to stamp — see extShouldUpload. Still logged and announced so
// the upload is visible in the diagnostic log and the UI.
}
if err != nil {
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
@@ -10113,6 +10390,31 @@ func (a *App) ReloadUDPIntegrations() []string {
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
// first record into the local logbook. Returns the ID of the inserted
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
@@ -10180,9 +10482,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if q.Country == "" {
q.Country = lr.Country
}
if q.Grid == "" {
q.Grid = lr.Grid
}
q.Grid = refineGrid(q.Grid, lr.Grid)
if q.Continent == "" {
q.Continent = lr.Continent
}
@@ -11629,12 +11929,34 @@ func (a *App) SwitchCATRig(n int) error {
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
return err
}
a.reloadCAT()
a.restartAsync("cat", a.reloadCAT)
return nil
}
// reloadCAT (re)starts the CAT manager based on the current settings.
// Called at startup and after the user saves new CAT config.
// restartAsync runs a hardware (re)start off the caller's goroutine.
//
// Saving settings must never block the UI on a device. Restarting a link tears
// the old one down FIRST and waits for its poll goroutine to exit — and that
// goroutine can be tens of seconds deep inside a call that cannot be
// interrupted: OmniRig's COM Connect when another program (MSHV, a contest
// logger) is holding the rig takes ~45 s to give up. Save-and-close froze for
// exactly that long, because the Wails binding was waiting on it.
//
// The restart still takes as long; it just no longer holds the window. The
// duration is logged, since "the rig took 45 s to let go" is invisible
// otherwise and looks like OpsLog hanging.
func (a *App) restartAsync(name string, f func()) {
go func() {
t0 := time.Now()
f()
if d := time.Since(t0); d > 2*time.Second {
applog.Printf("%s: restart took %s (device slow to release/connect)", name, d.Round(time.Millisecond))
}
}()
}
func (a *App) reloadCAT() {
if a.cat == nil {
return
@@ -11659,7 +11981,7 @@ func (a *App) reloadCAT() {
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
// reloadCAT raised the existing instance's window to the front,
// 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":
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
@@ -11906,6 +12228,10 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
if a.profiles == nil {
return profile.Profile{}, fmt.Errorf("profiles not initialized")
}
// Store a logbook that lives inside the app folder RELATIVE to it, so the
// profile keeps working when the folder is copied to another machine or
// another drive letter.
p.DB.Path = portablePath(p.DB.Path)
if err := a.profiles.Save(a.ctx, &p); err != nil {
return profile.Profile{}, err
}
@@ -11966,7 +12292,10 @@ func (a *App) reloadAfterProfileSwitch() {
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
a.reloadCAT()
// Off the caller's goroutine for the same reason as the settings save: this
// runs from ActivateProfile, a click, and a rig that is slow to release would
// otherwise freeze the switch.
a.restartAsync("cat", a.reloadCAT)
a.startQSORecorderIfEnabled()
}
@@ -12696,7 +13025,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
return err
}
}
a.startUltrabeam()
a.restartAsync("antenna", a.startUltrabeam)
return nil
}
@@ -13269,7 +13598,7 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
return err
}
}
a.startAmps()
a.restartAsync("amp", a.startAmps)
return nil
}
@@ -13294,6 +13623,18 @@ type AmpConfig struct {
Port int `json:"port"`
ComPort string `json:"com_port"`
Baud int `json:"baud"`
// Band-follow: an ACOM is the MASTER on its CAT/AUX connector — it polls a
// transceiver and changes band from the reply, so following OpsLog means
// answering those polls on a SECOND serial port (independent of the one used
// above for metering; both run at once). See internal/catemu.
FreqOut bool `json:"freq_out"`
FreqComPort string `json:"freq_com_port"`
FreqBaud int `json:"freq_baud"`
// FreqBroadcastMs > 0 also SENDS the frequency unprompted at that interval,
// for an amplifier that listens to a transceiver's CAT stream instead of
// polling it. 0 = answer polls only.
FreqBroadcastMs int `json:"freq_broadcast_ms"`
}
type ampInst struct {
@@ -13301,6 +13642,7 @@ type ampInst struct {
pgxl *powergenius.Client
spe *spe.Client
acom *acom.Client
catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
}
func (i *ampInst) stopAll() {
@@ -13313,6 +13655,9 @@ func (i *ampInst) stopAll() {
if i.acom != nil {
i.acom.Stop()
}
if i.catemu != nil {
i.catemu.Stop()
}
}
// ampTypeLabel is the default display name for an amp type.
@@ -13389,7 +13734,7 @@ func (a *App) SaveAmplifiers(list []AmpConfig) error {
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
return err
}
a.startAmps()
a.restartAsync("amp", a.startAmps)
return nil
}
@@ -13442,12 +13787,61 @@ func (a *App) startAmps() {
a.spe = inst.spe
}
}
// Band-follow on a SECOND serial port, for any amp that takes its band from
// a transceiver CAT link (ACOM and SPE both do). Independent of the control
// transport above, so metering and band-follow run at the same time.
if c.FreqOut && strings.TrimSpace(c.FreqComPort) != "" {
inst.catemu = catemu.New(catemu.Config{
ComPort: c.FreqComPort, Baud: c.FreqBaud,
BroadcastMs: c.FreqBroadcastMs,
}, applog.Printf)
if st := a.cat.State(); st.Connected {
inst.catemu.SetFrequency(st.FreqHz)
inst.catemu.SetMode(st.Mode)
}
inst.catemu.Start()
applog.Printf("amp %s: band-follow on %s at %d baud (Kenwood format, broadcast=%dms)",
c.Name, c.FreqComPort, c.FreqBaud, c.FreqBroadcastMs)
}
a.ampsMu.Lock()
a.ampInsts[c.ID] = inst
a.ampsMu.Unlock()
}
}
// feedAmpBandFollow pushes the rig's frequency/mode to every amplifier we are
// emulating a transceiver for. Called on each CAT state change.
//
// The TX frequency is what the amp must follow: in split it has to be tuned
// for where we transmit, not where we listen.
func (a *App) feedAmpBandFollow(s cat.RigState) {
if !s.Connected || s.FreqHz <= 0 {
return
}
a.ampsMu.Lock()
defer a.ampsMu.Unlock()
for _, inst := range a.ampInsts {
if inst.catemu != nil {
inst.catemu.SetFrequency(s.FreqHz)
inst.catemu.SetMode(s.Mode)
}
}
}
// GetAmpBandFollowStatus returns the band-follow link state per amplifier id,
// so the settings panel can show whether the amp is actually polling us.
func (a *App) GetAmpBandFollowStatus() map[string]catemu.Status {
out := map[string]catemu.Status{}
a.ampsMu.Lock()
defer a.ampsMu.Unlock()
for id, inst := range a.ampInsts {
if inst.catemu != nil {
out[id] = inst.catemu.GetStatus()
}
}
return out
}
// AmpStatus is one amp's live state for the UI poll — exactly one of the
// per-family payloads is set, per the amp's type.
type AmpStatus struct {
@@ -13785,6 +14179,18 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
return nil
}
// SetWinkeyerTrace turns the byte-level WinKeyer protocol trace on or off.
//
// The K1EL command set differs across WK1 / WK2 / WK3 and a mismatch is silent:
// the keyer accepts a byte meant for another command and keys something odd
// (one element then a long pause, a sidetone that will not switch off). Reading
// the actual wire is the only way to tell which command the firmware misread,
// and it beats guessing from a description — a guessed fix breaks the operators
// for whom it currently works.
func (a *App) SetWinkeyerTrace(on bool) {
winkeyer.SetTrace(on)
}
// WinkeyerConnect opens the serial link using the saved config.
func (a *App) WinkeyerConnect() error {
if a.winkeyer == nil {
+1
View File
@@ -26,6 +26,7 @@ var sensitiveSettingKeys = map[string]bool{
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true,
}
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
+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)
}
}
}
+116
View File
@@ -1,4 +1,120 @@
[
{
"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",
"date": "2026-07-27",
"en": [
"Statistics: the activity chart now follows the period you selected, and Day / Week / Month / Year choose the bucket size (QSOs per week over the whole period, for instance). It used to show fixed windows — the last 7 days, the last 30 — regardless of the period.",
"Statistics: new \"When you are on the air\" card — by hour and by day of week, over the selected period.",
"Band map: the CW / data / phone sub-bands are no longer shaded in the status colours — the SSB portion was amber, the same amber that means \"new band\" on the spots above it. They now use distinct, colour-blind-checked hues and appear in the legend.",
"Help menu: a Support OpsLog entry, which opens the donation page in your browser.",
"The Callsign field is now visually marked out, so it is no longer mistaken for Name next to it.",
"Multi-screen: OpsLog reopens on the screen it was closed on, including when it was maximised — it used to come back on the primary screen every time.",
"The Windows title bar is gone: minimise, maximise and close now sit in the OpsLog bar, which you drag to move the window (double-click to maximise). That is 32 pixels of screen back.",
"Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.",
"Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).",
"CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly.",
"Bulk edit: the confirmation fields are renamed \"sent/received status\" instead of \"upload\", the missing QRZ.com received status is added, and every channel now offers its sent and received DATE with a calendar."
],
"fr": [
"Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.",
"Statistiques : nouvelle carte « Quand vous trafiquez » — par heure et par jour de semaine, sur la période choisie.",
"Carte de bande : les portions CW / numérique / phonie ne sont plus teintées avec les couleurs de statut — la partie SSB était ambre, l'ambre qui signifie « nouvelle bande » sur les spots posés dessus. Elles utilisent maintenant des teintes distinctes, vérifiées pour le daltonisme, et figurent dans la légende.",
"Menu Aide : une entrée « Soutenir OpsLog », qui ouvre la page de don dans votre navigateur.",
"Le champ Indicatif est désormais mis en évidence, pour ne plus être confondu avec le champ Nom voisin.",
"Multi-écrans : OpsLog se rouvre sur l'écran où il a été fermé, y compris s'il était maximisé — il revenait jusqu'ici sur l'écran principal à chaque fois.",
"La barre de titre Windows a disparu : réduire, agrandir et fermer sont désormais dans la barre OpsLog, qu'on saisit pour déplacer la fenêtre (double-clic pour agrandir). Autant de pixels d'écran repris.",
"Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.",
"Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).",
"Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal.",
"Édition en lot : les champs de confirmation s'appellent désormais « envoi/réception (statut) » au lieu d'« upload », le statut de réception QRZ.com manquant a été ajouté, et chaque canal propose ses DATES d'envoi et de réception avec un calendrier."
]
},
{
"version": "0.21.5",
"date": "2026-07-27",
"en": [
"Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.",
"Recent QSOs keeps a separate column layout in the Main tab and in its own tab — the pane is half as wide there.",
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier).",
"Portable folder: moving OpsLog to another drive or PC (C:OpsLog → D:OpsLog) no longer loses the databases — paths inside the folder are now stored relative to it."
],
"fr": [
"Les dispositions de colonnes (largeurs, ordre, colonnes masquées) tiennent enfin — cinq défauts les défaisaient.",
"Les QSO récents gardent une disposition de colonnes distincte dans l'onglet Principal et dans leur propre onglet — le volet y est deux fois moins large.",
"Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur).",
"Dossier portable : déplacer OpsLog sur un autre disque ou un autre PC (C:OpsLog → D:OpsLog) ne fait plus perdre les bases — les chemins internes au dossier sont désormais enregistrés relativement à celui-ci."
]
},
{
"version": "0.21.4",
"date": "2026-07-26",
"en": [
"OmniRig on an FTDX101D: the frequency follows the SUB VFO again, and split is detected. The stock rig file never names a single VFO — it reports only the A/B pair and alternates between the two values, split included, from one poll to the next. OpsLog now takes the displayed frequency as the reference on Yaesu and keeps a split reported once for a few seconds, so the contradicting reading no longer cancels it.",
"The SMTP password can no longer be revealed in the settings — the eye button is gone. The settings window is often open while the screen is being shared or shown to someone in the shack.",
"Cloudlog and Wavelog: each QSO can now be uploaded to your own instance as it is logged. Settings → External services → CLOUDLOG: the address of your instance, a read/write API key, the station ID, and a Test connection button. Sending is immediate or delayed by 12 minutes so a mistake can still be corrected.",
"Recent QSOs and Worked before: new Distance (km) column, like the cluster's. It is computed from the QSO's own locator pair, so a contact made portable keeps the distance from where you actually were; QSOs with no locator on either side stay blank.",
"OmniRig: the frequency follows the SUB VFO on radios that report the two VFOs as a pair (AA / AB / BA / BB) — the whole Yaesu range. Only the single-VFO form was recognised, so pressing SUB left OpsLog on the main VFO, and a QSO worked on SUB was logged on the wrong frequency.",
"OmniRig split is held briefly ONLY for a radio whose rig file reports it erratically. On a radio that reports it correctly, split now clears the instant you cancel it on the front panel instead of a few seconds later.",
"The diagnostic log now records what OpsLog concluded from OmniRig (VFO, TX/RX frequencies, split), not just what OmniRig reported.",
"Locator: a precise grid from QRZ/HamQTH is no longer replaced by the country centroid (JN05JG becoming JN16 for a French station). The lookup runs more than once per QSO and the provider only gets two seconds to answer; on a slower connection the second answer fell back to cty.dat and downgraded the locator, while the name and QTH stayed — which made it look like QRZ had returned a wrong grid."
],
"fr": [
"OmniRig sur FTDX101D : la fréquence suit de nouveau le VFO SUB, et le split est détecté. Le fichier radio d'origine ne nomme jamais un VFO seul — il ne rapporte que la paire A/B, et alterne entre les deux valeurs, split compris, d'une interrogation à l'autre. OpsLog prend désormais la fréquence affichée comme référence sur Yaesu et conserve quelques secondes un split annoncé une fois, pour que la lecture contradictoire ne l'annule plus.",
"Le mot de passe SMTP ne peut plus être affiché en clair dans les réglages — le bouton en forme d'œil a été retiré. La fenêtre des réglages est souvent ouverte pendant un partage d'écran ou devant quelqu'un au shack.",
"Cloudlog et Wavelog : chaque QSO peut désormais être envoyé vers votre propre instance au moment où il est enregistré. Réglages → Services externes → CLOUDLOG : l'adresse de votre instance, une clé API lecture/écriture, l'ID de station, et un bouton de test de connexion. L'envoi est immédiat ou différé de 1 à 2 minutes pour laisser le temps de corriger une erreur.",
"QSO récents et Déjà contacté : nouvelle colonne Distance (km), comme celle du cluster. Elle est calculée à partir des locators propres au QSO, si bien qu'un contact fait en portable garde la distance depuis l'endroit où vous étiez réellement ; les QSO sans locator d'un côté ou de l'autre restent vides.",
"OmniRig : la fréquence suit le VFO SUB sur les radios qui rapportent les deux VFO sous forme de paire (AA / AB / BA / BB) — toute la gamme Yaesu. Seule la forme à un seul VFO était reconnue, si bien qu'un passage sur SUB laissait OpsLog sur le VFO principal, et qu'un QSO fait sur SUB était enregistré sur la mauvaise fréquence.",
"Le split OmniRig n'est maintenu brièvement que pour une radio dont le fichier de définition le rapporte de façon erratique. Sur une radio qui le rapporte correctement, le split disparaît désormais dès que vous le coupez en façade, au lieu de quelques secondes plus tard.",
"Le journal de diagnostic enregistre maintenant ce qu'OpsLog a déduit d'OmniRig (VFO, fréquences TX/RX, split), et pas seulement ce qu'OmniRig a rapporté.",
"Locator : un locator précis venu de QRZ/HamQTH n'est plus remplacé par le centre du pays (JN05JG devenant JN16 pour une station française). La recherche est lancée plusieurs fois par QSO et le fournisseur ne dispose que de deux secondes ; sur une connexion plus lente, la seconde réponse retombait sur cty.dat et dégradait le locator, alors que le nom et le QTH restaient — d'où l'impression que QRZ renvoyait un mauvais locator."
]
},
{
"version": "0.21.3",
"date": "2026-07-25",
+199 -47
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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,
} from 'lucide-react';
@@ -45,18 +45,21 @@ import {
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs,
GetUIPref, GetActiveProfile, QuitApp,
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec,
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
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 { QSLManagerPanel } from '@/components/QSLManagerModal';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
@@ -95,7 +98,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref';
import { setGridPrefsProfile } from '@/lib/gridPrefs';
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
import { Button } from '@/components/ui/button';
@@ -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'],
[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'],
[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;
return '';
@@ -331,6 +338,54 @@ function computePrefix(call: string): string {
return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
}
// WindowControls — minimise / maximise / close, since the window is frameless.
//
// Deliberately shaped like the Windows ones (flat, wide, close goes red on
// hover) rather than styled like the app's own buttons: these are OS controls,
// and an operator must recognise them without reading them. Close runs the
// normal shutdown path — the same one the OS button used — so the backup and
// on-close uploads still happen.
function WindowControls() {
const [max, setMax] = useState(false);
useEffect(() => {
WindowIsMaximised().then(setMax).catch(() => {});
}, []);
const btn = 'inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors';
return (
<div className="flex items-center gap-0.5 -mr-2 ml-1" data-no-drag>
<button type="button" className={btn} onClick={() => WindowMinimise()} title="Minimise" aria-label="Minimise">
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden><path d="M0 5h10" stroke="currentColor" strokeWidth="1.2" /></svg>
</button>
<button type="button" className={btn}
// Flip the icon straight away, then confirm from the runtime: reading it
// back immediately returns the state BEFORE the toggle has been applied.
onClick={() => {
WindowToggleMaximise();
setMax((v) => !v);
setTimeout(() => { WindowIsMaximised().then(setMax).catch(() => {}); }, 150);
}}
title={max ? 'Restore' : 'Maximise'} aria-label={max ? 'Restore' : 'Maximise'}>
{max ? (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="0.6" y="2.6" width="6.8" height="6.8" /><path d="M2.6 2.6V0.6h6.8v6.8H7.4" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="0.6" y="0.6" width="8.8" height="8.8" />
</svg>
)}
</button>
<button type="button"
className="inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-danger hover:text-danger-foreground transition-colors"
onClick={() => Quit()} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden stroke="currentColor" strokeWidth="1.2">
<path d="M0.5 0.5l9 9M9.5 0.5l-9 9" />
</svg>
</button>
</div>
);
}
export default function App() {
const { t, lang } = useI18n();
// === Lists from settings (fallback for first paint) ===
@@ -612,6 +667,10 @@ export default function App() {
const userEditedRef = useRef<Set<string>>(new Set());
const lastLookedUpRef = useRef<string>('');
// Callsign for which a PROVIDER (QRZ/HamQTH) grid is currently in the field.
// A later cty.dat-only answer for the same call must not replace that precise
// locator with the entity centroid — see the apply block in doLookup.
const providerGridCallRef = useRef<string>('');
// Tracks the call we last auto-switched to the Worked-before tab for, so we
// don't keep yanking the tab on every wb refresh of the same callsign.
const lastWbFocusRef = useRef<string>('');
@@ -2315,7 +2374,7 @@ export default function App() {
if (n <= 0) return;
await refresh();
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?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -2346,12 +2405,32 @@ export default function App() {
// every profile switch; the grids take it as their React key so they remount
// and re-read the now-correct per-profile layout.
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
// 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(() => {
GetActiveProfile().then((p: any) => {
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
}).catch(() => {});
}, []);
// A column resized in the last seconds before quitting must still reach the
// database: the DB write is debounced, so flush it on the way out.
useEffect(() => {
const flush = () => flushGridPrefs();
window.addEventListener('beforeunload', flush);
return () => { window.removeEventListener('beforeunload', flush); flush(); };
}, []);
// Every setting is per-profile, so when the active profile changes the whole
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
// side reloads its managers; this keeps the React state in sync.
@@ -2360,7 +2439,7 @@ export default function App() {
// Re-scope the grid column layout BEFORE the grids remount (key change).
setGridPrefsProfile(id ?? null);
setActiveProfileId(typeof id === 'number' ? id : null);
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadProfileList();
// 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).
setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
@@ -2368,7 +2447,7 @@ export default function App() {
setChatEpoch((e) => e + 1);
});
return () => { off(); };
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes, loadProfileList]);
useEffect(() => {
(async () => {
await reloadWk();
@@ -2723,6 +2802,7 @@ export default function App() {
function resetAutoFill() {
setName(''); setQth(''); setCountry(''); setGrid('');
providerGridCallRef.current = ''; // the grid field is empty again
// NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms
// pass) and the short-callsign guard in scheduleLookup. Clearing it inside
// runLookup blanked the Worked-before table for the whole (possibly slow,
@@ -3015,12 +3095,24 @@ export default function App() {
if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? '');
if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? '');
if (!ue.has('grid')) {
if ((r.grid ?? '') !== '') setGrid(r.grid ?? '');
if ((r.grid ?? '') !== '') {
setGrid(r.grid ?? '');
providerGridCallRef.current = call;
}
// No provider grid (cty.dat-only / portable): derive a 4-char grid from
// the entity centroid (e.g. Svalbard → JQ88) so the field — and the
// bearing/map — aren't empty. 4 chars signals it's entity-level, not a
// precise QTH (matches how Log4OM shows it).
else if (r.lat || r.lon) setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
//
// Skipped when QRZ already gave a precise grid for THIS call: lookups run
// more than once per QSO (debounced typing, then blur/Enter), and the
// provider only has a 2-second budget — one slow answer fell back to
// cty.dat and downgraded JN05JG to the France centroid JN16, while Name
// and QTH stayed (they are only written when non-empty). Same rule as
// those fields: a cty.dat answer never overwrites a richer one.
else if ((r.lat || r.lon) && providerGridCallRef.current !== call) {
setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
}
}
// Country/zones are exactly what cty.dat IS authoritative for — set them
// (only skipped if empty, so we never blank a known country).
@@ -3267,6 +3359,8 @@ export default function App() {
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
] : []),
{ type: 'separator' },
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
{ type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
@@ -3300,6 +3394,9 @@ export default function App() {
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
case 'help.sendlog': sendLogToDeveloper(); break;
// Opens in the system browser, NOT the app WebView: a payment page must show
// the address bar and padlock the donor knows how to check.
case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
}
}
@@ -3431,7 +3528,7 @@ export default function App() {
const callsignBlock = (
<div className="flex flex-col w-44" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
{t('field.callsign')}
<span className="text-primary font-semibold">{t('field.callsign')}</span>
{lookupBusy && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
<Loader2 className="size-2.5 animate-spin" />
@@ -3485,8 +3582,14 @@ export default function App() {
)}
<Input
ref={callsignRef}
// The call field is marked out at REST, not only on focus: operators were
// typing the callsign into Name, which sits right beside it and looked
// exactly the same. A primary-tinted border plus an inset left bar make
// it the obvious one without adding a colour the theme doesn't own. The
// contest-dupe state still overrides it — that one is a warning.
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground')}
'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
value={callsign}
onChange={(e) => onCallsignInput(e.target.value)}
onBlur={() => {
@@ -4130,7 +4233,7 @@ export default function App() {
</div>
<div className="flex-1 min-h-0 flex">
<div className="flex-1 min-w-0 flex flex-col min-h-0">
<ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
<ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
</div>
{clusterShowFilters && renderClusterFilters()}
</div>
@@ -4139,7 +4242,7 @@ export default function App() {
case 'worked':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
@@ -4175,7 +4278,13 @@ export default function App() {
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<RecentQSOsGrid
key={`rqg-${activeProfileId ?? 'x'}`}
// Its OWN layout, separate from the full-width Recent QSOs tab.
// This pane is roughly half as wide, so it wants fewer columns and
// narrower ones; sharing one key meant whichever was opened last
// rewrote the other's widths.
storageKey="mainpane"
rows={qsosWithAwards as any}
myGrid={station.my_grid}
total={total}
awardCols={awardCols}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
@@ -4204,7 +4313,7 @@ export default function App() {
{compact ? (
// Minimal compact topbar — brand + freq + toggle. Saves vertical space
// so the single-row entry strip fits in a ~140px tall window.
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
<header className="app-dragregion flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
<div className="flex items-center gap-1.5">
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
<span className="font-bold text-xs tracking-tight">OpsLog</span>
@@ -4225,7 +4334,15 @@ export default function App() {
</Button>
</header>
) : (
<header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
<header
className="app-dragregion grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm"
// Double-click anywhere on the bar toggles maximise, as a title bar does.
// Frameless windows get none of that for free.
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest('button,a,input,select,nav,[role="button"]')) return;
WindowToggleMaximise();
}}
>
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<div className="flex items-baseline gap-1.5">
@@ -4552,24 +4669,48 @@ export default function App() {
) : <span />}
</div>
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
<Clock className="size-3" />
{utcNow}<span className="text-[10px]">UTC</span>
</div>
<div className="flex items-center gap-3">
{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
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"
title="Click to switch / edit profiles"
title={t('prof.switchTitle')}
>
<Antenna className="size-3" />
{station.callsign}
{station.my_grid && (
<span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
)}
<ChevronDown className="size-3 opacity-70" />
</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)}>
<Settings className="size-3.5" /> Set station
@@ -4593,6 +4734,8 @@ export default function App() {
>
<Minimize2 className="size-3.5" />
</Button>
{/* Window controls — the OS title bar is gone, so they live here. */}
<WindowControls />
</div>
</header>
)}
@@ -5268,27 +5411,27 @@ export default function App() {
: 'bg-success-muted border-success-border text-success-muted-foreground',
)}>
<div className="flex items-center gap-3 flex-wrap">
<strong>Import complete.</strong>
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge>
<strong>{t('imp.complete')}</strong>
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{t('imp.imported', { n: importResult.imported })}</Badge>
{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 && (
<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">{importResult.total} total</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">{t('imp.total', { n: importResult.total })}</Badge>
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
<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
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
? t('imp.dupsFirst', { shown: importResult.duplicate_samples.length, total: importResult.duplicates })
: ''}
</button>
)}
{importResult.errors && importResult.errors.length > 0 && (
<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 className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
@@ -5309,6 +5452,7 @@ export default function App() {
<RecentQSOsGrid
key={`rqg2-${activeProfileId ?? 'x'}`}
rows={qsosWithAwards as any}
myGrid={station.my_grid}
total={total}
awardCols={awardCols}
onFilteredCountChange={setGridFilteredCount}
@@ -5466,6 +5610,7 @@ export default function App() {
}
return (
<ClusterGrid
key={`clg2-${activeProfileId ?? 'x'}`}
rows={rendered as any}
spotStatus={spotStatus}
onSpotClick={handleSpotClick}
@@ -5550,7 +5695,7 @@ export default function App() {
</TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
@@ -5828,6 +5973,13 @@ export default function App() {
</>
)}
</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 && (
<button
type="button"
@@ -6011,19 +6163,19 @@ export default function App() {
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
<DialogContent className="max-w-lg px-6">
<DialogHeader className="px-2">
<DialogTitle>Import ADIF</DialogTitle>
<DialogTitle>{t('imp.dialogTitle')}</DialogTitle>
<DialogDescription className="text-xs break-all">
{pendingImportPath}
</DialogDescription>
</DialogHeader>
<div className="py-2 px-2 space-y-2">
<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>
{([
{ id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' },
{ 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: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' },
{ id: 'skip', title: t('imp.skipTitle'), desc: t('imp.skipDesc') },
{ id: 'update', title: t('imp.updateTitle'), desc: t('imp.updateDesc') },
{ id: 'all', title: t('imp.allTitle'), desc: t('imp.allDesc') },
] as const).map((o) => (
<button
key={o.id}
@@ -6051,9 +6203,9 @@ export default function App() {
className="mt-0.5"
/>
<span>
Fix country &amp; zones (cty.dat + ClubLog)
{t('imp.ctyTitle')}
<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>
</label>
@@ -6064,16 +6216,16 @@ export default function App() {
className="mt-0.5"
/>
<span>
Fill my station fields from my profile
{t('imp.stationTitle')}
<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>
</label>
</div>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
<Button onClick={runImport}>Import</Button>
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
<Button onClick={runImport}>{t('imp.import')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -6083,7 +6235,7 @@ export default function App() {
<Dialog open>
<DialogContent className="max-w-sm px-6" hideClose>
<DialogHeader className="px-2">
<DialogTitle>Importing ADIF</DialogTitle>
<DialogTitle>{t('imp.progressTitle')}</DialogTitle>
</DialogHeader>
<div className="px-2 pb-4 space-y-2">
{(() => {
@@ -6100,8 +6252,8 @@ export default function App() {
</div>
<div className="text-xs text-muted-foreground text-center font-mono">
{tot > 0
? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%`
: `${done.toLocaleString()} records…`}
? t('imp.progressCount', { done: done.toLocaleString(), tot: tot.toLocaleString(), pct })
: t('imp.progressCountOnly', { done: done.toLocaleString() })}
</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: '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 EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
+46 -16
View File
@@ -64,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
'70cm': [430000, 440000],
};
// Sub-band shading: CW / digital / phone.
//
// These are IDENTITIES (which mode the segment is for), not states, so they take
// categorical hues — never the status tokens. They used to use success / info /
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
// is "new band" in this very component's legend, so the whole SSB portion read as
// a giant "new band" wash. Status colours are reserved for status.
//
// All three are drawn from the COOL end of the categorical order and laid down at
// low opacity, so the band plan stays background context while the warm spot pills
// keep the foreground to themselves.
// Checked with the palette validator in BOTH themes. Violet was the first pick
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
// protan reader there, i.e. the same colour. Magenta clears every check on both
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
const SEG_CW = 'var(--chart-7)'; // magenta
const SEG_DIGI = 'var(--chart-1)'; // blue
const SEG_PHONE = 'var(--chart-2)'; // aqua
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
// are read on top of it.
const SEG_OPACITY = 0.13;
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
'60m': [[5350, 5450, 'fill-warning-muted']],
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
'60m': [[5350, 5450, SEG_PHONE]],
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
};
// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, label }: { cls: string; label: string }) {
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
return (
<span className="inline-flex items-center gap-1">
<span className={cn('size-2 rounded-full', cls)} />
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
{label}
</span>
);
@@ -447,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
height={totalH}
preserveAspectRatio="none"
>
{segments.map(([s, e, cls], i) => {
{segments.map(([s, e, colour], i) => {
const y1 = freqToY(Math.min(e, hi));
const y2 = freqToY(Math.max(s, lo));
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
return (
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
fill={colour} fillOpacity={SEG_OPACITY} />
);
})}
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
</svg>
@@ -559,7 +584,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
<span className="mx-0.5 opacity-40">|</span>
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
</div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
{t('bmp.footerHint')}
+47 -9
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
type FieldKind = 'status' | 'text' | 'freq';
type FieldKind = 'status' | 'text' | 'freq' | 'date';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
@@ -21,16 +21,31 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
// label holds an i18n key (resolved with t() at render time).
const FIELDS: FieldDef[] = [
// QSL / upload status
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
// One channel at a time, each with its status and its date, in the same order
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
// QSL is not uploaded — so every entry now reads "<channel> sent/received
// status" or "… date", which is also what the ADIF field is called.
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
// My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
@@ -117,7 +132,7 @@ type Props = {
// so they become eligible for upload.
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
const { t } = useI18n();
const [field, setField] = useState('hrdlog_upload');
const [field, setField] = useState('qsl_sent');
const [statusValue, setStatusValue] = useState('R');
const [textValue, setTextValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -181,6 +196,29 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
</SelectContent>
</Select>
) : def.kind === 'date' ? (
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
// writes an empty value — which is how a wrong date is removed
// from a batch, the same meaning as leaving a text field blank.
<div className="flex gap-1">
<Input
type="date"
className="h-8 text-xs font-mono"
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
/>
<Button
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
title={t('bulk.todayUtc')}
onClick={() => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
// UTC: the whole log is UTC, and near midnight the local day
// is the wrong one.
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
}}
>{t('bulk.today')}</Button>
</div>
) : (
<Input
className="h-8 text-xs"
+27 -6
View File
@@ -12,7 +12,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => string;
@@ -371,10 +371,28 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
// matching column events. Without this guard those events were persisted, so a
// single language change overwrote the saved cluster layout — in the cache AND
// in the database, with nothing to restore from.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
restoringRef.current = true;
return COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), [COL_CATALOG]);
});
}, [COL_CATALOG]);
// Re-apply the saved state after every rebuild, then re-enable saving.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(tm);
}, [columnDefs]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
@@ -394,17 +412,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
gridRef.current?.api?.refreshCells({ force: true });
}, [spotStatus]);
function onGridReady(e: GridReadyEvent) {
// Restore AFTER the profile scope is known — this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
loadRemote(COL_STATE_KEY).then((remote) => {
const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
+32 -7
View File
@@ -26,7 +26,9 @@ export interface QueryFilter {
// Curated field catalog. `value` MUST match a column in the backend whitelist
// (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 }[] = [
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
{ 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: 'rig', label: 'fltb.fRig', 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_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
{ 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: '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_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
{ 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: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', 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: 'srx', label: 'fltb.fSerialRcvd', 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 }[] {
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;
return OPS.filter((o) => allow.includes(o.value));
}
@@ -253,12 +271,19 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
</SelectContent>
</Select>
<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"
disabled={!needsValue}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
value={c.value}
onChange={(e) => setCond(i, { value: e.target.value })}
value={fieldType === 'adifdate' && /^d{8}$/.test(c.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(); }}
/>
<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 { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
GetFlexState, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
@@ -125,8 +125,8 @@ function Segmented({ value, options, onChange, disabled }: {
}
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
function Chip({ on, onClick, label, disabled, accent = 'emerald', title }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; title?: string;
}) {
const onCls = {
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',
}[accent];
return (
<button type="button" onClick={onClick} disabled={disabled}
<button type="button" onClick={onClick} disabled={disabled} title={title}
className={cn(
// min width (not fixed) so a longer label like STONE keeps symmetric
// 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 }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
@@ -624,6 +655,33 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
)}
</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 ? (
<div className="border-t border-border/60 pt-3 space-y-3">
+5 -1
View File
@@ -6,7 +6,10 @@ import {
import { cn } from '@/lib/utils';
export type MenuItem =
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
// accent highlights an item that is an OFFER rather than a command (Donate).
// Generic on purpose: a hard-coded label test in the renderer would break the
// moment the menu is translated.
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
| { type: 'separator' };
export interface Menu {
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
<DropdownMenuItem
key={i}
disabled={item.disabled}
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
onSelect={() => onAction(item.action)}
>
<span>{item.label}</span>
@@ -427,6 +427,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
// drives which QSOs the apply-form below updates; a search selects all.
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
// Its OWN column layout. Without a storageKey this fell back to
// the main log's key, so every resize or hidden column here
// silently rewrote the Recent QSOs layout, and vice versa.
storageKey="qslmgr.paper"
rows={paperRows as any}
total={paperRows.length}
selectAllSignal={paperSelAllSig}
@@ -532,6 +536,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : (
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
storageKey="qslmgr.upload"
rows={rows as any}
total={rows.length}
selectAllSignal={uploadSelAllSig}
+75 -16
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -37,7 +37,7 @@ function pfxOf(call: string): string {
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'];
// label holds an i18n key (resolved with t() at render time).
const QSL_STATUSES = [
@@ -148,6 +148,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
);
}
// AdifDateInput — a real date picker over an ADIF date.
//
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
// are converted at the edge and the log keeps its ADIF form. The native control
// is used on purpose: it brings the OS calendar, the locale's day/month order
// and keyboard entry for free, which a hand-rolled popover would have to
// reimplement and get wrong.
//
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
// a plain text box instead, so it stays visible and correctable rather than
// silently disappearing behind an empty picker.
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
const raw = (value ?? '').trim();
const valid = /^\d{8}$/.test(raw);
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
const today = () => {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
// UTC: every date in the log is UTC, and near midnight the local day is the
// wrong one.
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
};
if (raw !== '' && !valid) {
return (
<div className="flex gap-1">
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
</div>
);
}
return (
<div className="flex gap-1">
<Input
type="date"
value={iso}
disabled={disabled}
onChange={(e) => {
const v = e.target.value; // "" when cleared
onChange(v ? v.replace(/-/g, '') : '');
}}
className="font-mono"
/>
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
onClick={today} title="Today (UTC)">
<CalendarDays className="size-3.5" />
</Button>
</div>
);
}
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n();
return (
@@ -280,19 +330,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
setLooking(true);
setLocalErr('');
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) => ({
...d,
name: r.name ?? d.name,
qth: r.qth ?? d.qth,
address: r.address ?? (d as any).address,
email: r.email ?? (d as any).email,
country: r.country ?? d.country,
grid: r.grid ?? d.grid,
state: r.state ?? d.state,
cnty: r.cnty ?? d.cnty,
cont: r.cont ?? d.cont,
qsl_via: r.qsl_via ?? d.qsl_via,
name: keep(r.name, d.name),
qth: keep(r.qth, d.qth),
address: keep(r.address, (d as any).address),
email: keep(r.email, (d as any).email),
country: keep(r.country, d.country),
grid: keep(r.grid, d.grid),
state: keep(r.state, d.state),
cnty: keep(r.cnty, d.cnty),
cont: keep(r.cont, d.cont),
qsl_via: keep(r.qsl_via, d.qsl_via),
dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz,
@@ -580,8 +639,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />}
</div>
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && (
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
)}
+38 -4
View File
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
// Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
@@ -27,6 +28,9 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
type Props = {
rows: QSOForm[];
total: number;
// Operator's CURRENT locator — fallback for the distance column on older QSOs
// that carry no my_grid of their own.
myGrid?: string;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
@@ -111,7 +115,26 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
// hooks can't run at module level, so the component calls this with its own t.
type TFn = (key: string, vars?: Record<string, string | number>) => string;
export const makeColCatalog = (t: TFn): ColEntry[] => [
// qsoDistanceKm returns the great-circle distance for one row, in km.
//
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
// portable outings, so the station the QSO was made from is not necessarily the
// one configured today. `myGrid` (the current profile) is only the fallback for
// the many older records that carry no my_* fields at all.
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
if (!d) return undefined;
const here =
(d.my_grid && gridToLatLon(d.my_grid)) ||
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
(myGrid ? gridToLatLon(myGrid) : null);
const there =
(d.grid && gridToLatLon(d.grid)) ||
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
if (!here || !there) return undefined;
return Math.round(pathBetweenLatLon(here, there).distanceShort);
}
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
// ── QSO basics ──
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
@@ -146,6 +169,11 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
// Derived, not stored: computed from the two locations at display time, like
// the cluster grid's own distance column.
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
@@ -263,7 +291,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -275,7 +303,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
// Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection.
@@ -493,7 +521,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols, awardShown]);
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
// causes here (awardCols/awardShown) missed the others — switching the UI
// language rebuilds the memo through `t`, which set restoringRef and left it
// set, so every column change for the rest of the session was silently
// discarded and the layout reverted on reload. Any future dependency of the
// memo is now covered for free.
}, [columnDefs]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
+253 -34
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -35,7 +35,8 @@ import {
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
SetWinkeyerTrace,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
@@ -627,7 +628,10 @@ function ADIFMonitorPanel() {
}
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
// frequency polls, pretending to be a Kenwood-format transceiver.
freq_out?: boolean; freq_com_port?: string; freq_baud?: number; freq_broadcast_ms?: number };
// RelayAutoPanel configures automatic control of the Station Control relay boards
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
@@ -1048,7 +1052,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
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,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
@@ -1090,6 +1094,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
});
const [wkPorts, setWkPorts] = useState<string[]>([]);
// Session-only: the byte trace is a diagnostic, never a saved preference.
const [wkTrace, setWkTrace] = useState(false);
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
// ── Audio (DVK + QSO recorder) ──
@@ -1178,7 +1184,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
});
const [emailMsg, setEmailMsg] = useState('');
const [showSmtpPass, setShowSmtpPass] = useState(false);
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// eQSL card e-mail (subject/body templates + auto-send on log).
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
@@ -1223,20 +1228,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
type ExtServiceCfg = {
api_key: string; email: string; username: string; password: string; callsign: string;
code: string; qth_nickname: string;
url: string; station_id: string; // Cloudlog/Wavelog: own instance + station profile
force_station_callsign: string;
tqsl_path: string; station_location: string; key_password: string;
upload_flags: string[]; write_log: boolean;
auto_upload: boolean; upload_mode: string;
};
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
upload_flags: ['N', 'R'], write_log: false,
auto_upload: false, upload_mode: 'immediate',
});
const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(),
});
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false);
@@ -1299,12 +1305,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
};
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hrdlogTesting, setHrdlogTesting] = useState(false);
const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [cloudlogTesting, setCloudlogTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [eqslTesting, setEqslTesting] = useState(false);
const [stationLocations, setStationLocations] = useState<string[]>([]);
// Active tab in the External Services panel — lifted here because
// PANELS[selected]() is called as a function, so panels can't hold hooks.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState('');
const [potaBusy, setPotaBusy] = useState(false);
@@ -2326,6 +2334,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select>
</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' && (
<>
<div className="space-y-1">
@@ -2335,8 +2358,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
<PortInput value={catCfg.flex_port || 4992}
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
</div>
<div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
@@ -2459,8 +2482,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
<PortInput value={catCfg.tci_port || 40001}
onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
</div>
<p className="col-span-2 text-xs text-muted-foreground">
{t('cat.tciHint')}
@@ -2631,10 +2654,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
<PortInput
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"
/>
</div>
@@ -2809,6 +2831,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
});
const addAmp = () => setAmps((l) => [...l, {
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
freq_out: false, freq_com_port: '', freq_baud: 9600, freq_broadcast_ms: 0,
}]);
return (
<>
@@ -2919,12 +2942,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={amp.port}
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
<PortInput value={amp.port}
onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
</div>
</div>
)}
{/* Band-follow, for any amp that takes its band from a transceiver
CAT link (ACOM and SPE both do) never PowerGenius, which is
driven over its network protocol. A SECOND serial port,
separate from the metering one above. */}
{!isPGXL && (
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!amp.freq_out}
onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
/>
{t('amp.freqOut')}
</label>
{amp.freq_out && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('amp.freqPort')}</Label>
<div className="flex items-center gap-2">
<Select value={amp.freq_com_port || '_'} onValueChange={(v) => patchAmp(i, { freq_com_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Input type="number" min={1200} value={amp.freq_baud ?? 9600}
onChange={(e) => patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('amp.freqBroadcast')}</Label>
<Select value={String(amp.freq_broadcast_ms ?? 0)} onValueChange={(v) => patchAmp(i, { freq_broadcast_ms: parseInt(v) })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('amp.freqPollOnly')}</SelectItem>
<SelectItem value="500">{t('amp.freqEvery', { ms: 500 })}</SelectItem>
<SelectItem value="1000">{t('amp.freqEvery', { ms: 1000 })}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-[10px] text-muted-foreground">{t('amp.freqHint')}</p>
</>
)}
</div>
)}
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
{!isPGXL && !isACOM && (
<p className="text-[10px] text-muted-foreground">
@@ -3034,10 +3113,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input
type="number" min={1} max={65535}
<PortInput
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"
/>
</div>
@@ -3301,6 +3379,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
</label>
</div>
{/* Protocol trace for reporting a keyer that misbehaves. Session
only, deliberately not persisted: it is a diagnostic, and a log
full of hex bytes helps nobody who forgot it was on. */}
<div className="border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wkTrace} onCheckedChange={(c) => { setWkTrace(!!c); SetWinkeyerTrace(!!c); }} />
{t('wk.trace')}
</label>
<p className="text-[10px] text-muted-foreground mt-1">{t('wk.traceHint')}</p>
</div>
</>
)}
@@ -3756,6 +3845,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
{ k: 'eqsl', label: 'EQSL', ready: true },
{ k: 'lotw', label: 'LOTW', ready: true },
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
{ k: 'pota', label: 'POTA', ready: true },
];
@@ -3825,6 +3915,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
}
const cloudlog = extSvc.cloudlog;
const setCloudlog = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } }));
async function testCloudlog() {
setCloudlogTesting(true);
setCloudlogTest(null);
try {
// Save first: the backend test reads the stored config, so it must see
// what was just typed (same as the other services' Test buttons).
await SaveExternalServices(extSvc as any);
const msg = await TestCloudlogUpload();
setCloudlogTest({ ok: true, msg });
} catch (e: any) {
setCloudlogTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setCloudlogTesting(false);
}
}
const eqsl = extSvc.eqsl;
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
@@ -4077,6 +4187,74 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
</div>
) : extSvcTab === 'cloudlog' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">{t('es.cloudlogUrl')}</Label>
<Input
value={cloudlog.url}
onChange={(e) => setCloudlog({ url: e.target.value })}
placeholder={t('es.cloudlogUrlPh')}
className="font-mono text-xs"
/>
<Label className="text-sm">{t('es.apiKey')}</Label>
<Input
type="password"
value={cloudlog.api_key}
onChange={(e) => setCloudlog({ api_key: e.target.value })}
placeholder={t('es.cloudlogKeyPh')}
className="text-xs"
/>
<Label className="text-sm">{t('es.cloudlogStationId')}</Label>
<Input
value={cloudlog.station_id}
onChange={(e) => setCloudlog({ station_id: e.target.value })}
placeholder="1"
className="font-mono text-xs w-24"
/>
</div>
<div className="text-[10px] text-muted-foreground -mt-1">
{t('es.cloudlogHint')}
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={cloudlog.auto_upload}
onCheckedChange={(c) => setCloudlog({ auto_upload: !!c })}
/>
{t('es.autoUpload')}
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">{t('es.uploadTiming')}</Label>
<Select
value={cloudlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
onValueChange={(v) => setCloudlog({ upload_mode: v })}
>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* No "on close" option: unlike the other services, Cloudlog has no
per-QSO status column in the logbook, so there is nothing to
select a backlog from at shutdown. */}
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testCloudlog} disabled={cloudlogTesting}>
<UploadCloud className="size-3.5" /> {cloudlogTesting ? t('es.testing') : t('es.testConn')}
</Button>
{cloudlogTest && (
<span className={cn('text-xs', cloudlogTest.ok ? 'text-success' : 'text-danger')}>
{cloudlogTest.msg}
</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'eqsl' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
@@ -4498,7 +4676,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<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 })} />
<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>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">{t('db.user')}</Label>
@@ -4974,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 })} />
<Label className="text-sm">Port / encryption</Label>
<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 })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -4992,15 +5170,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('em.username')}</Label>
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
<Label className="text-sm">{t('es.password')}</Label>
<div className="relative">
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={!emailCfg.auth}>
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
{/* No reveal button: the settings window is often open while the
screen is shared or shown to someone in the shack. */}
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<Label className="text-sm">{t('em.fromAddr')}</Label>
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
<Label className="text-sm">{t('em.replyTo')}</Label>
@@ -5184,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
// free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps {
@@ -5215,7 +5434,7 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
</div>
<div className="space-y-1">
<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 className="space-y-1">
<Label>Login callsign (optional)</Label>
+63 -6
View File
@@ -33,6 +33,10 @@ type Stats = {
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
// Chronological series across the SELECTED period, one per bucket size; empty
// when that bucket would be too fine for the period (see the Go side).
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
// Period / contest metrics.
window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number;
@@ -509,19 +513,44 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
// own state, module-scoped so a parent re-render doesn't reset the choice. The
// timeline is the chronological month series; day/week/month/year are the cyclical
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
// panel — not a window of its own. It used to be four fixed rolling windows
// anchored on the newest QSO, which ignored the period filter entirely: picking
// a year up top left this chart showing the last 7 days.
const ACT_GRAN = [
{ key: 'timeline', tkey: 'stats.granTimeline' },
{ key: 'day', tkey: 'stats.granDay' },
{ key: 'week', tkey: 'stats.granWeek' },
{ key: 'month', tkey: 'stats.granMonth' },
{ key: 'year', tkey: 'stats.granYear' },
] as const;
type Gran = typeof ACT_GRAN[number]['key'];
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<string>('timeline');
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
const [g, setG] = useState<Gran | 'auto'>('auto');
const seriesFor = (k: Gran): Bucket[] => (
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
);
// Auto picks the finest bucket that stays readable: the backend leaves a
// series empty when it would be too fine for the period, so "the first
// non-empty one" is exactly the right rule and needs no thresholds here.
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
// A bucket the user forces but that is too fine for the period falls back to
// the next coarser one rather than showing an empty chart.
let shown: Gran = g === 'auto' ? auto : g;
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
const series = seriesFor(shown);
const steppedUp = g !== 'auto' && shown !== g;
return (
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<div className="mb-2 flex flex-wrap gap-1">
<div className="mb-2 flex flex-wrap gap-1 items-center">
<button type="button" onClick={() => setG('auto')}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('stats.granAuto')}
</button>
{ACT_GRAN.map((o) => (
<button key={o.key} type="button" onClick={() => setG(o.key)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
@@ -529,14 +558,39 @@ function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: an
{t(o.tkey)}
</button>
))}
{steppedUp && (
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
)}
</div>
{g === 'timeline'
? <AreaTrend data={stats.by_month} empty={empty} />
{/* Many buckets read better as a filled trend than as a forest of bars. */}
{series.length > 60
? <AreaTrend data={series} empty={empty} />
: <VBars data={series} empty={empty} showValues height={160} />}
</Card>
);
}
// RhythmCard — WHEN the operator is on the air over the selected period, which is
// a different question from "how much lately" and so gets its own card. The hour
// histogram used to cover a single day, too little to read anything from.
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<'hour' | 'weekday'>('hour');
return (
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
<div className="mb-2 flex flex-wrap gap-1">
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
<button key={k} type="button" onClick={() => setG(k)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t(tk)}
</button>
))}
</div>
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
</Card>
);
}
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
@@ -603,6 +657,8 @@ export function StatsPanel() {
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats);
@@ -818,6 +874,7 @@ export function StatsPanel() {
</Card>
<ActivityCard stats={stats} t={t} empty={empty} />
<RhythmCard stats={stats} t={t} empty={empty} />
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls
+14 -7
View File
@@ -15,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -25,6 +25,8 @@ const hamlogTheme = hamlogGridTheme;
type WorkedEntry = QSOForm; // entries are now full QSO records
type Props = {
// Operator's CURRENT locator — fallback for the distance column (see catalog).
myGrid?: string;
wb: WorkedBeforeView | null;
busy: boolean;
currentCall: string;
@@ -54,14 +56,14 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog (shared with the Recent QSOs grid).
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
@@ -111,15 +113,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []);
function onGridReady(e: GridReadyEvent) {
// Restore AFTER the profile scope is known: this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint, so it
// used to miss the cache every time and then save under the scoped key.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
loadRemote(COL_STATE_KEY).then((remote) => {
const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild
@@ -135,7 +140,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
// columnDefs itself, not the reasons it was rebuilt — see the same note in
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
}, [columnDefs]);
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
+67 -3
View File
@@ -19,6 +19,26 @@ let lsScope = '';
// changes so each profile keeps its own column layout / widths.
export function setGridPrefsProfile(id: number | string | null | undefined): void {
lsScope = id == null || id === '' ? '' : `p${id}.`;
markReady();
}
// The active profile is only known after an async call, but the grids mount and
// read their state on the first paint. A grid that read before the scope was set
// looked up the UNSCOPED cache key (always a miss) and then saved under the
// scoped one — so its layout could never be restored, only rewritten. Grids now
// wait for this instead of racing it.
//
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
// still come up with whatever the unscoped cache holds rather than hang with no
// columns restored.
let markReady: () => void = () => {};
const gridPrefsReady = new Promise<void>((resolve) => {
markReady = resolve;
setTimeout(resolve, 5000);
});
export function whenGridPrefsReady(): Promise<void> {
return gridPrefsReady;
}
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
@@ -40,22 +60,66 @@ export function loadLocal(key: string): any[] | null {
}
// loadRemote pulls the portable copy from the DB (null if none / unset).
export async function loadRemote(key: string): Promise<any[] | null> {
//
// GetUIPref returns an ERROR — not "" — while the settings store is still
// coming up and not yet scoped to the active profile. Treating that as "no
// preference" was the silent data-loss path: the grid rendered defaults and the
// first column event then wrote those defaults over the good saved copy. So
// retry for a few seconds instead, and only give up on a real absence.
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
for (let i = 0; i < attempts; i++) {
try {
const v = await GetUIPref(key);
const parsed = v ? JSON.parse(v) : null;
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
// Not ready yet (or a parse failure on a corrupt value — one more read
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
await new Promise((r) => setTimeout(r, 300));
}
}
return null;
}
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
// the cache key is profile-scoped; the DB key is scoped by the backend.
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
// frame, so dragging one border used to fire dozens of settings writes; the
// cache write stays synchronous so nothing is lost if the window closes.
const pendingDB = new Map<string, string>();
const dbTimers = new Map<string, number>();
export function saveState(key: string, state: any[]) {
const json = JSON.stringify(state);
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
pendingDB.set(key, json);
const prev = dbTimers.get(key);
if (prev) clearTimeout(prev);
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
}
function flushOne(key: string) {
const json = pendingDB.get(key);
dbTimers.delete(key);
if (json == null) return;
pendingDB.delete(key);
SetUIPref(key, json).catch(() => {
// The store may not be scoped yet at startup. Keep the value and try once
// more shortly — dropping it here is how a fresh profile ended up with no
// portable copy at all.
pendingDB.set(key, json);
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
});
}
// flushGridPrefs writes any debounced state immediately. Call it when the app is
// about to close so a resize made in the last moments still reaches the DB.
export function flushGridPrefs() {
for (const key of Array.from(pendingDB.keys())) {
const t = dbTimers.get(key);
if (t) clearTimeout(t);
flushOne(key);
}
}
// seedLocal writes a value into the cache without touching the DB (used after
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';
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];
function systemDark(): boolean {
+34
View File
@@ -652,3 +652,37 @@
border: 2px solid var(--background);
}
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
/* Frameless window: drag region
The OS title bar is gone (main.go, Frameless), so the app header IS the title
bar and must be draggable. Everything interactive inside it opts OUT: a drag
region swallows clicks, and without this the menus and toolbar buttons would
move the window instead of doing their job. Opting out by ROLE rather than
listing each child keeps a future button working without anyone remembering
this rule. */
.app-dragregion {
--wails-draggable: drag;
}
.app-dragregion button,
.app-dragregion a,
.app-dragregion input,
.app-dragregion select,
.app-dragregion textarea,
.app-dragregion nav,
.app-dragregion [role='button'],
.app-dragregion [role='menu'],
.app-dragregion [data-no-drag] {
--wails-draggable: no-drag;
}
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
disappears on the dark themes. Invert it there the control itself is worth
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
help. */
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.6);
opacity: 0.75;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.21.3';
export const APP_VERSION = '0.21.8';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+9
View File
@@ -6,6 +6,7 @@ import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {acom} from '../models';
import {catemu} from '../models';
import {antgenius} from '../models';
import {award} from '../models';
import {awardref} from '../models';
@@ -344,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>;
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
@@ -634,6 +637,8 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
export function MotorReadElements():Promise<Array<number>>;
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
@@ -904,6 +909,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
@@ -914,6 +921,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>;
export function TestEQSLUpload():Promise<string>;
+16
View File
@@ -638,6 +638,10 @@ export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo']();
}
export function GetAmpBandFollowStatus() {
return window['go']['main']['App']['GetAmpBandFollowStatus']();
}
export function GetAmpStatuses() {
return window['go']['main']['App']['GetAmpStatuses']();
}
@@ -1218,6 +1222,10 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1);
}
export function LookupCallsignFresh(arg1) {
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
}
export function MotorReadElements() {
return window['go']['main']['App']['MotorReadElements']();
}
@@ -1758,6 +1766,10 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function SetWinkeyerTrace(arg1) {
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
}
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
@@ -1778,6 +1790,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
}
export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload']();
}
export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload']();
}
+28
View File
@@ -1184,6 +1184,8 @@ export namespace extsvc {
export class ServiceConfig {
api_key: string;
url: string;
station_id: string;
email: string;
username: string;
password: string;
@@ -1206,6 +1208,8 @@ export namespace extsvc {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.api_key = source["api_key"];
this.url = source["url"];
this.station_id = source["station_id"];
this.email = source["email"];
this.username = source["username"];
this.password = source["password"];
@@ -1228,6 +1232,7 @@ export namespace extsvc {
lotw: ServiceConfig;
hrdlog: ServiceConfig;
eqsl: ServiceConfig;
cloudlog: ServiceConfig;
static createFrom(source: any = {}) {
return new ExternalServices(source);
@@ -1240,6 +1245,7 @@ export namespace extsvc {
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -1436,6 +1442,10 @@ export namespace main {
port: number;
com_port: string;
baud: number;
freq_out: boolean;
freq_com_port: string;
freq_baud: number;
freq_broadcast_ms: number;
static createFrom(source: any = {}) {
return new AmpConfig(source);
@@ -1452,6 +1462,10 @@ export namespace main {
this.port = source["port"];
this.com_port = source["com_port"];
this.baud = source["baud"];
this.freq_out = source["freq_out"];
this.freq_com_port = source["freq_com_port"];
this.freq_baud = source["freq_baud"];
this.freq_broadcast_ms = source["freq_broadcast_ms"];
}
}
export class AmpStatus {
@@ -1815,6 +1829,7 @@ export namespace main {
enabled: boolean;
backend: string;
omnirig_rig: number;
omnirig_vfo: string;
flex_host: string;
flex_port: number;
flex_spots: boolean;
@@ -1843,6 +1858,7 @@ export namespace main {
this.enabled = source["enabled"];
this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"];
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
@@ -4190,6 +4206,12 @@ export namespace qso {
by_day7: Bucket[];
by_day30: Bucket[];
by_month12: Bucket[];
by_day_win: Bucket[];
by_week_win: Bucket[];
by_month_win: Bucket[];
by_year_win: Bucket[];
by_hour_of_day: Bucket[];
by_weekday: Bucket[];
window_start: string;
window_end: string;
window_hours: number;
@@ -4234,6 +4256,12 @@ export namespace qso {
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
this.by_day30 = this.convertValues(source["by_day30"], Bucket);
this.by_month12 = this.convertValues(source["by_month12"], Bucket);
this.by_day_win = this.convertValues(source["by_day_win"], Bucket);
this.by_week_win = this.convertValues(source["by_week_win"], Bucket);
this.by_month_win = this.convertValues(source["by_month_win"], Bucket);
this.by_year_win = this.convertValues(source["by_year_win"], Bucket);
this.by_hour_of_day = this.convertValues(source["by_hour_of_day"], Bucket);
this.by_weekday = this.convertValues(source["by_weekday"], Bucket);
this.window_start = source["window_start"];
this.window_end = source["window_end"];
this.window_hours = source["window_hours"];
+6 -1
View File
@@ -6,6 +6,7 @@
// QSO may yield several references (e.g. a Note holding "D74 D73").
//
// Examples:
//
// DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state
// 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
}
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 {
idx := map[string]int{}
+32
View File
@@ -776,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
func BandFromHz(hz int64) string {
mhz := float64(hz) / 1_000_000
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:
return "160m"
case mhz >= 3.5 && mhz <= 4.0:
@@ -810,6 +818,30 @@ func BandFromHz(hz int64) string {
return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0:
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 ""
}
+7 -11
View File
@@ -50,7 +50,6 @@ type Flex struct {
meterMeta map[int]meterInfo
meterVal map[int]float64
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)
meterRawLogged bool // log the first raw meter-definition status once
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.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 {
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)
}
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
// 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]))
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()
}
+149 -21
View File
@@ -29,6 +29,14 @@ const (
type OmniRig struct {
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
rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change
@@ -46,14 +54,37 @@ type OmniRig struct {
// the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64
lastSetFreqAt time.Time
// lastSplitOnAt is when OmniRig last reported PM_SPLITON cleanly. See the
// FTDX101D note in ReadState — some .ini files alternate between ON and OFF
// on consecutive polls, so the flag has to be latched to be usable.
lastSplitOnAt time.Time
// splitFlaky records that THIS rig's .ini flips the split flag on its own,
// which is what arms the latch. lastSplitFlag / splitFlips / splitFlipWindow
// count the flips inside a rolling window to detect it.
lastSplitFlag bool
splitFlaky bool
splitFlips int
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.
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 {
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" }
@@ -251,21 +282,75 @@ func (o *OmniRig) ReadState() (RigState, error) {
splitRaw = v.Val
}
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
// normal operation stays quiet but toggling split on the radio is captured —
// needed to pin down this rig's PM_SPLITON value.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
return v.Val
// FTDX101D field capture: OmniRig alternates between two contradictory
// readings on consecutive polls — "Vfo=AB Split=0x10000(OFF)" then
// "Vfo=BA Split=0x8000(ON)", ~1.5 s apart, with the rig untouched. The stock
// .ini evidently has two status commands that each write these params. A
// sample-by-sample test therefore reports split for half the polls and no
// split for the other half, which the UI shows as no split at all. Latch the
// ON flag briefly so one truthful sample survives the contradicting one; the
// latch expires on its own once the rig stops reporting ON, so cancelling
// split on the radio still clears within a few seconds.
//
// The latch is ARMED ONLY for a rig that actually oscillates, because it costs
// ~6 s before split clears on screen. A correct .ini (FTDX10 with VS; and FT;
// read as separate frames, confirmed on the air 2026-07-26) never flips
// unprompted, and there the latch would be a pure delay on a reading that was
// already right.
//
// 8 flips in 30 s: a first cut at 3-in-15s was armed by the OPERATOR toggling
// split three times while testing, which then imposed the 6 s delay on a rig
// that did not need it. A misreading .ini flips every 1.53 s without being
// touched — a dozen in the same window — so the gap is wide. The arming also
// expires after 30 s without a flip, so a rig that behaves is never stuck with
// the delay because of one burst.
const flipWindow, flipsToArm = 30 * time.Second, 8
now := time.Now()
flagOn := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
if flagOn {
o.lastSplitOnAt = now
}
return -1
}())
if flagOn != o.lastSplitFlag {
o.lastSplitFlag = flagOn
if now.Sub(o.splitFlipWindow) > flipWindow {
o.splitFlipWindow, o.splitFlips, o.splitFlaky = now, 0, false
}
o.splitFlips++
if o.splitFlips >= flipsToArm {
o.splitFlaky = true
}
} else if o.splitFlaky && now.Sub(o.splitFlipWindow) > flipWindow {
o.splitFlaky, o.splitFlips = false, 0 // stopped oscillating — drop the delay
}
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
// 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)
}
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(freqMain, freqA, freqB, s.Vfo, splitRaw)
// 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
// captured. It logs the RESOLVED tx/rx/split too: with the raw values alone a
// user's log showed what OmniRig said but not what OpsLog concluded, which is
// the half that was wrong.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: rig=%q Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X sticky=%v → tx=%d rx=%d split=%v",
o.RigNum, o.rigType, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, splitRecentOn,
s.FreqHz, s.RxFreqHz, s.Split)
}
return s, nil
}
@@ -276,14 +361,14 @@ func (o *OmniRig) ReadState() (RigState, error) {
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
// to change it safely is with every known rig's behaviour pinned in a test. COM
// cannot be exercised from a test; this can.
func resolveOmniRigVFOs(freqMain, freqA, freqB int64, vfo string, splitRaw int64) (txHz, rxHz int64, split bool) {
func resolveOmniRigVFOs(rigType string, freqMain, freqA, freqB int64, vfo string, splitRaw int64, splitRecentOn bool) (txHz, rxHz int64, split bool) {
// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
// nothing else, and silently reports "no split" for any rig reporting the bit
// alongside another. Requiring ON set and OFF clear keeps the two states apart
// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
// tolerating extra bits.
splitFlagged := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
splitFlagged := (splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0) || splitRecentOn
// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
// band test kills the common false positive where VFO B merely holds a
@@ -316,6 +401,32 @@ func resolveOmniRigVFOs(freqMain, freqA, freqB int64, vfo string, splitRaw int64
// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
// the generic Freq.
// The PAIR enums name BOTH VFOs at once — first letter = the one being
// listened on, second = the one that transmits. Only the single-letter forms
// were honoured here, so a rig that reports nothing but pairs (the whole Yaesu
// family) always fell through to freqA and never followed the operator to SUB.
// Log4OM reads that first letter and logs the right frequency on the same
// rigs, which is what showed this was readable data and not a dead end.
//
// Checked BEFORE the Yaesu fallback below: an enum that names a VFO is the
// rig speaking, the fallback is only an inference.
switch {
case (vfo == "BA" || vfo == "BB") && freqB != 0:
return freqB, 0, false
case (vfo == "AA" || vfo == "AB") && freqA != 0:
return freqA, 0, false
}
// Yaesu fallback, for a rig file that names no VFO at all (the stock FTDX10
// one: it answers neither VS; nor FR; usably, verified on the air 2026-07-26).
// There the generic Freq is the last clue — matching FreqB and not FreqA means
// the operator is on SUB. Limited to Yaesu: the IC-7610's stock ini reports
// the generic Freq as VFO B permanently, where this would name the wrong VFO —
// that case is pinned in the test table.
if isYaesuRig(rigType) && freqMain != 0 && freqMain == freqB && freqB != freqA {
return freqB, 0, false
}
switch {
case (vfo == "B" || vfo == "BB") && freqB != 0:
return freqB, 0, false
@@ -418,10 +529,17 @@ func (o *OmniRig) SetFrequency(hz int64) error {
}
}
// Read back all three immediately. OmniRig is async (the CAT command is
// queued + sent over serial), so these may still show the OLD value for
// one poll cycle — but if they NEVER change in the next poll, the rig
// isn't honouring the write (wrong .ini WRITE command for this model).
// Read back all three, then AGAIN a moment later. OmniRig is async the CAT
// command is queued and sent over serial so an immediate read always shows
// the old value and proves nothing. The delayed one is the useful half: it
// 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)
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
fa = v.Val
@@ -432,7 +550,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
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
}
@@ -601,6 +722,13 @@ func omniRigMode(m int64) string {
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
// isYaesuRig recognises a Yaesu from OmniRig's RigType (the .ini title, e.g.
// "FTDX101D", "FT-891"). Only used to gate rules that are true for Yaesu and
// false for Icom, so a mis-titled ini simply keeps the generic behaviour.
func isYaesuRig(rigType string) bool {
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "FT")
}
func omniRigVfo(v int64) string {
switch {
case v&0x40 != 0: // PM_VFOAA
+42 -13
View File
@@ -14,40 +14,69 @@ func TestResolveOmniRigVFOs(t *testing.T) {
cases := []struct {
name string
rig string
main, fa, fb int64
vfo string
split int64
sticky bool
wantTX, wantRX int64
wantSplit bool
}{
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
// freqA meant the display never followed the operator to B.
{"FTDX101D on SUB VFO", b14205, a14200, b14205, "B", pmSplitOff, b14205, 0, false},
{"FTDX101D on MAIN VFO", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
{"FTDX101D on SUB VFO", "FTDX101D", b14205, a14200, b14205, "B", pmSplitOff, false, b14205, 0, false},
{"FTDX101D on MAIN VFO", "FTDX101D", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
// Yaesu fallback for a rig file that names NO VFO at all (the stock FTDX10
// one, confirmed 2026-07-26): the generic Freq matching FreqB is then the
// only sign that the operator is on SUB. It applies ONLY when the enum is
// silent — when the enum names a VFO, the enum wins (pair cases below).
{"FTDX10, no enum, generic Freq is B", "FTDX10", b14205, a14200, b14205, "", pmSplitOff, false, b14205, 0, false},
// Same alternating ini: one poll says OFF while the rig IS in split. The
// latched ON flag has to survive the contradicting sample.
{"FTDX101D split, contradicting OFF sample", "FTDX101D", a14200, a14200, b14205, "AB", pmSplitOff, true, b14205, a14200, true},
// The latch must not manufacture a split out of a stale cross-band VFO B.
{"FTDX101D latch, VFOs on different bands", "FTDX101D", a14200, a14200, b21000, "AB", pmSplitOff, true, a14200, 0, false},
// Pair enums: first letter is the VFO being listened on. Reported by the
// whole Yaesu family; ignoring them pinned the display to VFO A (F4NBZ,
// 2026-07-26 — Log4OM follows SUB on the same rig through OmniRig).
{"pair enum BA, simplex → listening on B", "", b14205, a14200, b14205, "BA", pmSplitOff, false, b14205, 0, false},
{"pair enum BB, simplex → listening on B", "", b14205, a14200, b14205, "BB", pmSplitOff, false, b14205, 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},
// 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
// order — freqA, then the generic Freq, then freqB.
{"no VFO enum, freqA populated (Yaesu/Kenwood)", a14200, a14200, 0, "", pmSplitOff, a14200, 0, false},
{"no VFO enum, only generic Freq (IC-9100)", a14200, 0, 0, "", pmSplitOff, a14200, 0, false},
{"IC-7610: generic Freq reports B, enum says A", b14205, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
{"no VFO enum, only generic Freq (IC-9100)", "IC-9100", a14200, 0, 0, "", pmSplitOff, false, a14200, 0, false},
{"IC-7610: generic Freq reports B, enum says A", "IC-7610", b14205, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
// split" for any rig that sets the flag alongside another bit.
{"split, ON flag alone", a14200, a14200, b14205, "AB", pmSplitOn, b14205, a14200, true},
{"split, ON flag with extra bits set", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, b14205, a14200, true},
{"listening on B → TX on A", b14205, a14200, b14205, "BA", pmSplitOn, a14200, b14205, true},
{"split, ON flag alone", "", a14200, a14200, b14205, "AB", pmSplitOn, false, b14205, a14200, true},
{"split, ON flag with extra bits set", "", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, false, b14205, a14200, true},
{"listening on B → TX on A", "", b14205, a14200, b14205, "BA", pmSplitOn, false, a14200, b14205, true},
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
// left on another band (the FT-710 / TS-570 false positive).
{"OFF flag, two distinct VFOs", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
{"ON flag but VFOs on different bands", a14200, a14200, b21000, "AB", pmSplitOn, a14200, 0, false},
{"ON flag but both VFOs identical", a14200, a14200, a14200, "AB", pmSplitOn, a14200, 0, false},
{"ON and OFF both set — ambiguous, treat as no split", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, a14200, 0, false},
{"OFF flag, two distinct VFOs", "", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
{"ON flag but VFOs on different bands", "", a14200, a14200, b21000, "AB", pmSplitOn, false, a14200, 0, false},
{"ON flag but both VFOs identical", "", a14200, a14200, a14200, "AB", pmSplitOn, false, a14200, 0, false},
{"ON and OFF both set — ambiguous, treat as no split", "", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, false, a14200, 0, false},
}
for _, c := range cases {
tx, rx, split := resolveOmniRigVFOs(c.main, c.fa, c.fb, c.vfo, c.split)
tx, rx, split := resolveOmniRigVFOs(c.rig, c.main, c.fa, c.fb, c.vfo, c.split, c.sticky)
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
+376
View File
@@ -0,0 +1,376 @@
// Package catemu emulates a transceiver on a serial port so a device that
// POLLS a radio for its frequency can follow OpsLog instead.
//
// This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is
// the master — it polls the transceiver every few hundred milliseconds and
// changes band only when it gets a valid reply. There is no way to push a
// frequency to it, so following OpsLog means answering its polls.
//
// The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what
// Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is
// the simplest of the five sets by a wide margin, which is why the SDC utility
// uses it to steer an ACOM with no physical radio attached.
//
// Only the handful of commands an amp actually asks for are implemented:
//
// FA; → FA00014025000; TX frequency, 11 digits, Hz
// FB; → same (sub VFO — amps poll it on some firmware)
// IF; → the 38-character TS-2000 status frame
// ID; → ID019; (TS-2000 — a known model keeps the amp from timing out)
//
// Anything else is ignored rather than answered: a wrong-length reply is worse
// than none, because it desynchronises the amp's parser for the next poll.
package catemu
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
)
// Config is the serial port the amplifier's CAT/AUX cable is wired to. This is
// a SECOND port, independent of the one used for the amp's own remote/metering
// protocol — both run at the same time on an ACOM.
type Config struct {
ComPort string
Baud int
// BroadcastMs > 0 also sends the frequency UNPROMPTED every that many
// milliseconds. Some amplifiers do not poll at all: they sit in parallel on
// the radio↔PC CAT line and read whatever goes past. Such an amp would never
// hear us, since answering polls means speaking only when spoken to.
// 0 = answer polls only.
BroadcastMs int
}
// Status is what the settings panel shows about the link.
type Status struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
Port string `json:"port"`
Polls int64 `json:"polls"` // replies sent since start
LastCmd string `json:"last_cmd"` // last command received, e.g. "FA;"
LastAt string `json:"last_at"` // RFC3339 of the last poll, "" if none
FreqHz int64 `json:"freq_hz"` // what we are currently answering
Error string `json:"error"` // last open/IO failure
}
// Server answers a polling amplifier on one serial port.
type Server struct {
cfg Config
mu sync.Mutex
port serial.Port
status Status
freqHz atomic.Int64
mode atomic.Value // string, ADIF-ish ("CW", "USB"…)
stop chan struct{}
done chan struct{}
logf func(string, ...any)
}
// New builds a server. Nothing is opened until Start.
func New(cfg Config, logf func(string, ...any)) *Server {
if cfg.Baud <= 0 {
cfg.Baud = 9600
}
s := &Server{cfg: cfg, logf: logf}
s.mode.Store("")
s.status.Port = cfg.ComPort
return s
}
func (s *Server) log(format string, args ...any) {
if s.logf != nil {
s.logf(format, args...)
}
}
// SetFrequency updates the frequency reported to the amplifier. Called from the
// CAT state callback; safe from any goroutine and never blocks — the serve loop
// reads the value when a poll arrives, so a fast-tuning VFO costs nothing.
func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) }
// SetMode updates the mode digit in the IF frame. Optional: the amp only cares
// about the frequency, but a coherent frame avoids odd firmware behaviour.
func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) }
// Start opens the port and serves polls until Stop. It returns immediately;
// a port that is missing or busy is retried every 5 s, because the amplifier is
// often powered on after the software.
func (s *Server) Start() {
s.stop = make(chan struct{})
s.done = make(chan struct{})
go s.run()
}
// Stop closes the port and waits for the loop to end.
func (s *Server) Stop() {
if s.stop == nil {
return
}
close(s.stop)
s.mu.Lock()
if s.port != nil {
_ = s.port.Close()
s.port = nil
}
s.mu.Unlock()
<-s.done
s.stop = nil
}
// GetStatus returns a snapshot for the UI.
func (s *Server) GetStatus() Status {
s.mu.Lock()
defer s.mu.Unlock()
st := s.status
st.Enabled = true
st.FreqHz = s.freqHz.Load()
return st
}
func (s *Server) setErr(msg string) {
s.mu.Lock()
s.status.Error = msg
s.status.Connected = false
s.mu.Unlock()
}
func (s *Server) run() {
defer close(s.done)
for {
select {
case <-s.stop:
return
default:
}
if err := s.open(); err != nil {
s.setErr(err.Error())
s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err)
select {
case <-s.stop:
return
case <-time.After(5 * time.Second):
}
continue
}
s.serve()
}
}
func (s *Server) open() error {
if strings.TrimSpace(s.cfg.ComPort) == "" {
return fmt.Errorf("no COM port configured")
}
p, err := serial.Open(s.cfg.ComPort, &serial.Mode{
BaudRate: s.cfg.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
if err != nil {
return err
}
// A short read timeout keeps the loop responsive to Stop while idle: the amp
// may poll only every few hundred ms, and a blocking read would hold the
// port open past shutdown.
_ = p.SetReadTimeout(200 * time.Millisecond)
s.mu.Lock()
s.port = p
s.status.Connected = true
s.status.Error = ""
s.mu.Unlock()
s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud)
return nil
}
// broadcast sends an unsolicited FA frame at the configured interval, for an
// amplifier that listens to the CAT line rather than polling it. It stops when
// the port is closed or Stop is called.
func (s *Server) broadcast(stopServe <-chan struct{}) {
if s.cfg.BroadcastMs <= 0 {
return
}
// Below ~100 ms this is pure noise on the wire; the band only ever changes
// at human speed.
every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond
if every < 100*time.Millisecond {
every = 100 * time.Millisecond
}
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-s.stop:
return
case <-stopServe:
return
case <-t.C:
hz := s.freqHz.Load()
if hz <= 0 {
continue // nothing known yet — say nothing rather than "0 Hz"
}
s.mu.Lock()
p := s.port
s.mu.Unlock()
if p == nil {
return
}
if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil {
s.setErr(err.Error())
return
}
}
}
}
// serve reads commands until the port fails or Stop is called.
func (s *Server) serve() {
// The broadcaster shares this port and must die with it, or it would write
// into a closed handle after a reopen.
stopServe := make(chan struct{})
defer close(stopServe)
go s.broadcast(stopServe)
buf := make([]byte, 64)
var acc []byte
for {
select {
case <-s.stop:
return
default:
}
s.mu.Lock()
p := s.port
s.mu.Unlock()
if p == nil {
return
}
n, err := p.Read(buf)
if err != nil {
s.setErr(err.Error())
s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err)
s.mu.Lock()
if s.port != nil {
_ = s.port.Close()
s.port = nil
}
s.mu.Unlock()
return
}
if n == 0 {
continue
}
acc = append(acc, buf[:n]...)
// Commands are ';'-terminated; handle every complete one in the buffer.
for {
i := indexByte(acc, ';')
if i < 0 {
break
}
cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i])))
acc = acc[i+1:]
s.handle(p, cmd)
}
// A runaway buffer means we are seeing something that is not this
// protocol (wrong baud, or the amp's other port); drop it rather than
// grow without bound.
if len(acc) > 512 {
acc = acc[:0]
}
}
}
func indexByte(b []byte, c byte) int {
for i := range b {
if b[i] == c {
return i
}
}
return -1
}
// handle answers one command. cmd has no trailing ';'.
func (s *Server) handle(p serial.Port, cmd string) {
hz := s.freqHz.Load()
var reply string
switch {
case cmd == "FA" || cmd == "FB":
reply = fmt.Sprintf("%s%011d;", cmd, hz)
case cmd == "IF":
reply = s.ifFrame(hz)
case cmd == "ID":
reply = "ID019;" // TS-2000
default:
// Unknown or a SET command (FA00014025000;) — silently ignored: an amp
// never sets our frequency, and answering the wrong length would break
// its parser for the following poll.
return
}
if _, err := p.Write([]byte(reply)); err != nil {
s.setErr(err.Error())
return
}
s.mu.Lock()
s.status.Polls++
s.status.LastCmd = cmd + ";"
s.status.LastAt = time.Now().Format(time.RFC3339)
s.mu.Unlock()
}
// modeDigit maps our mode name to the Kenwood mode digit used in IF.
func (s *Server) modeDigit() byte {
m, _ := s.mode.Load().(string)
switch {
case strings.HasPrefix(m, "CW"):
return '3'
case strings.HasPrefix(m, "LSB"):
return '1'
case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"):
return '2'
case strings.HasPrefix(m, "FM"):
return '4'
case strings.HasPrefix(m, "AM"):
return '5'
case m == "RTTY", strings.HasPrefix(m, "FSK"):
return '6'
case m == "":
return '2'
default:
// Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares.
return '2'
}
}
// ifFrame builds the 38-character TS-2000 IF status frame:
//
// IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode |
// FR | scan | split | tone | tone#(2) | shift | ;
//
// Only the frequency and mode carry meaning here; the rest is a valid, inert
// state (no RIT, receiving, simplex) so the amp's parser is satisfied.
func (s *Server) ifFrame(hz int64) string {
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;",
hz, // P1 frequency, Hz
0, // P2 frequency step
0, // P3 RIT/XIT offset, signed 5 digits
0, // P4-P6 RIT off, XIT off, channel-bank
0, // P7 memory channel
0, // P8 0 = RX
s.modeDigit(), // P9 mode
0, // P10 VFO A
0, // P11 scan off
0, // P12 split off
0, // P13 tone off
0, // P14 tone number
0, // P15 shift
)
}
+45
View File
@@ -0,0 +1,45 @@
package catemu
import "testing"
// The reply LENGTHS are the contract: an amplifier parses these frames by
// fixed offsets, so a frame one character short desynchronises its parser for
// every following poll. Pin them.
func TestReplyShapes(t *testing.T) {
s := New(Config{ComPort: "COM99", Baud: 9600}, nil)
s.SetFrequency(14025000)
s.SetMode("CW")
if got := s.ifFrame(14025000); len(got) != 38 {
t.Errorf("IF frame is %d chars, want 38: %q", len(got), got)
}
// TS-2000 IF: "IF" then the 11-digit frequency in Hz.
if got := s.ifFrame(14025000); got[:13] != "IF00014025000" {
t.Errorf("IF frame frequency field = %q", got[:13])
}
if got := s.ifFrame(14025000); got[len(got)-1] != ';' {
t.Errorf("IF frame not terminated by ';': %q", got)
}
// Mode digit sits at P9, right after freq(11)+step(4)+rit(6)+3+2+1 — index 29 in the frame.
if got := s.ifFrame(14025000)[29]; got != '3' {
t.Errorf("CW mode digit = %q, want '3'", got)
}
s.SetMode("FT8") // data rides on SSB as far as an amp cares
if got := s.ifFrame(14074000)[29]; got != '2' {
t.Errorf("FT8 mode digit = %q, want '2'", got)
}
}
func TestModeDigit(t *testing.T) {
cases := map[string]byte{
"CW": '3', "CW-R": '3', "LSB": '1', "USB": '2', "SSB": '2',
"FM": '4', "AM": '5', "RTTY": '6', "": '2', "FT8": '2',
}
for mode, want := range cases {
s := New(Config{}, nil)
s.SetMode(mode)
if got := s.modeDigit(); got != want {
t.Errorf("mode %q → %q, want %q", mode, got, want)
}
}
}
+22
View File
@@ -682,6 +682,28 @@ func bandFromHz(hz int64) string {
return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0:
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 ""
}
+21 -6
View File
@@ -6,6 +6,7 @@ import (
"embed"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -161,7 +162,7 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("ping sqlite: %w", err)
}
Dialect = "sqlite"
if err := migrate(conn, nil, path); err != nil {
if err := migrate(conn, nil, path, filepath.Base(path)); err != nil {
_ = conn.Close()
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
// took — the only trace an operator has that a data-rewriting migration ran.
func logMigration(name string, start time.Time) {
logf("db: migration %s applied in %s", name, time.Since(start).Round(time.Millisecond))
func logMigration(label, name string, start time.Time) {
logf("db[%s]: migration %s applied in %s", label, name, time.Since(start).Round(time.Millisecond))
}
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
// (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.
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
// per-statement, FK-aware path); nil means a SQLite connection. This is
// 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)
}
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 {
if applied[name] {
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 {
return fmt.Errorf("record migration %s: %w", name, err)
}
logMigration(name, start)
logMigration(label, name, start)
continue
}
@@ -327,7 +342,7 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
logMigration(name, start)
logMigration(label, name, start)
}
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
// 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.
//
// 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
// pool, so keep max_connections >= pool*ops + headroom.
@@ -200,7 +201,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL, "")
err = migrate(conn, mysqlDDL, "", "mysql:"+name)
}
if err != nil {
_ = conn.Close()
@@ -287,7 +288,11 @@ func applyMySQLBaseline(conn *sql.DB) error {
return fmt.Errorf("open baseline sqlite: %w", err)
}
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)
}
+195
View File
@@ -0,0 +1,195 @@
package extsvc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Cloudlog (and its fork Wavelog) are self-hosted logbooks, so there is no
// fixed endpoint: the user gives the base URL of their own instance and we
// append the API path. Both expose the SAME contract — an ADIF record wrapped
// in JSON — which is why one uploader serves both.
//
// POST <base>/index.php/api/qso
// {"key":"…","station_profile_id":"1","type":"adif","string":"<call:4>… <eor>"}
//
// The station profile id is NOT optional: Cloudlog files the QSO under one of
// the account's station locations, and a wrong id silently lands the contact in
// someone else's log slot.
const cloudlogAPIPath = "index.php/api/qso"
// cloudlogEndpoint builds the API URL from whatever the user pasted. People
// paste the dashboard URL, the API URL, with or without a trailing slash or
// index.php, so normalise all of it rather than make them guess the exact form.
func cloudlogEndpoint(base string) (string, error) {
u := strings.TrimSpace(base)
if u == "" {
return "", fmt.Errorf("cloudlog: URL not set")
}
// A bare host or IP is almost always meant as http:// on a LAN instance;
// requiring the scheme just produces a confusing "unsupported protocol".
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
u = "http://" + u
}
u = strings.TrimRight(u, "/")
// Trim anything the user copied past the site root, so both
// "https://log.f4bpo.fr" and "https://log.f4bpo.fr/index.php/api/qso" work.
for _, suffix := range []string{"/index.php/api/qso", "/api/qso", "/index.php"} {
if strings.HasSuffix(strings.ToLower(u), suffix) {
u = u[:len(u)-len(suffix)]
u = strings.TrimRight(u, "/")
}
}
return u + "/" + cloudlogAPIPath, nil
}
// cloudlogRequest is the JSON body both Cloudlog and Wavelog expect.
type cloudlogRequest struct {
Key string `json:"key"`
StationID string `json:"station_profile_id"`
Type string `json:"type"`
String string `json:"string"`
}
// cloudlogReply covers the documented failure shape
// ({"status":"failed","reason":"missing api key"}); success replies vary
// between versions, so success is judged on the HTTP status plus the ABSENCE
// of a failure marker rather than on a field that may not be there.
type cloudlogReply struct {
Status string `json:"status"`
Reason string `json:"reason"`
Type string `json:"type"`
String string `json:"string"`
}
// cloudlogPost sends one JSON body and returns the trimmed response.
func cloudlogPost(ctx context.Context, client *http.Client, endpoint string, body cloudlogRequest) (string, int, error) {
buf, err := json.Marshal(body)
if err != nil {
return "", 0, fmt.Errorf("cloudlog: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
if err != nil {
return "", 0, fmt.Errorf("cloudlog: build request: %w", err)
}
// Content-Type is what most "wrong JSON" reports come down to: without it
// Cloudlog falls back to form-decoding and never sees the fields.
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("cloudlog: request failed: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
return strings.TrimSpace(string(raw)), resp.StatusCode, nil
}
// cloudlogReason turns a reply into a human-readable failure reason, or ""
// when the reply looks like a success.
func cloudlogReason(body string, status int) string {
var r cloudlogReply
if json.Unmarshal([]byte(body), &r) == nil && strings.EqualFold(r.Status, "failed") {
if r.Reason != "" {
return r.Reason
}
return "rejected"
}
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
// The documented 401 body is {"status":"failed","reason":"missing api key"},
// but a reverse proxy in front of the instance can swallow it.
return "API key refused"
case status == http.StatusNotFound:
return "API not found at this URL — check the address of your Cloudlog/Wavelog instance"
case status >= 400:
msg := body
if len(msg) > 200 {
msg = msg[:200]
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", status)
}
return msg
}
return ""
}
// UploadCloudlog pushes one ADIF record to a Cloudlog or Wavelog instance.
//
// Duplicates are handled by the server (Cloudlog dedupes on the fly), so a
// retry of an already-accepted QSO is harmless — the upload stays idempotent
// without OpsLog having to track it.
func UploadCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
endpoint, err := cloudlogEndpoint(cfg.URL)
if err != nil {
return UploadResult{}, err
}
key := strings.TrimSpace(cfg.APIKey)
station := strings.TrimSpace(cfg.StationID)
if key == "" {
return UploadResult{}, fmt.Errorf("cloudlog: API key not set")
}
if station == "" {
return UploadResult{}, fmt.Errorf("cloudlog: station ID not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("cloudlog: empty adif record")
}
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
Key: key, StationID: station, Type: "adif", String: adifRecord,
})
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
// The endpoint is echoed in both outcomes: a self-hosted instance means the
// URL itself is a prime suspect, and a log line naming what was actually
// called settles it without the operator having to guess how we normalised
// what they typed.
if reason := cloudlogReason(body, status); reason != "" {
return UploadResult{OK: false, Message: reason},
fmt.Errorf("cloudlog: POST %s → HTTP %d: %s", endpoint, status, reason)
}
return UploadResult{OK: true, Message: fmt.Sprintf("uploaded to %s (HTTP %d)", endpoint, status)}, nil
}
// TestCloudlog validates URL, API key and station ID with a REAL request.
//
// It posts a well-formed body with an EMPTY ADIF string: the credentials are
// checked by the server before the record is parsed, so a bad key or id fails
// exactly as it would for a real QSO, while nothing is inserted.
func TestCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
endpoint, err := cloudlogEndpoint(cfg.URL)
if err != nil {
return "", err
}
key := strings.TrimSpace(cfg.APIKey)
station := strings.TrimSpace(cfg.StationID)
if key == "" {
return "", fmt.Errorf("cloudlog: API key not set")
}
if station == "" {
return "", fmt.Errorf("cloudlog: station ID not set")
}
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
Key: key, StationID: station, Type: "adif", String: "",
})
if err != nil {
return "", err
}
if reason := cloudlogReason(body, status); reason != "" {
return "", fmt.Errorf("cloudlog: %s", reason)
}
return fmt.Sprintf("Connected — station profile %s", station), nil
}
+8
View File
@@ -33,6 +33,9 @@ const (
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
// only the instance URL differs, so one service handles both.
ServiceCloudlog Service = "cloudlog"
)
// UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -63,6 +66,8 @@ const (
// user can run e.g. Club Log immediate and QRZ delayed).
type ServiceConfig struct {
APIKey string `json:"api_key"`
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
Email string `json:"email"` // Club Log account email
Username string `json:"username"` // LoTW website login (for confirmation download)
Password string `json:"password"` // Club Log account / LoTW website password
@@ -83,6 +88,8 @@ type ServiceConfig struct {
// mode (defaults to immediate).
func (c ServiceConfig) normalised() ServiceConfig {
c.APIKey = strings.TrimSpace(c.APIKey)
c.URL = strings.TrimSpace(c.URL)
c.StationID = strings.TrimSpace(c.StationID)
c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.Code = strings.TrimSpace(c.Code)
@@ -120,6 +127,7 @@ type ExternalServices struct {
LoTW ServiceConfig `json:"lotw"`
HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"`
}
// UploadResult is the outcome of a single upload attempt.
+69 -1
View File
@@ -138,7 +138,30 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.LoTW = cfg.LoTW.normalised()
cfg.HRDLog = cfg.HRDLog.normalised()
cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised()
m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save.
// It answers "is the service even switched on for this profile?" — the
// settings are per-profile, so a service configured under another profile
// looks enabled in the UI of the one and silent in the other.
var on []string
for _, s := range []struct {
name string
cfg ServiceConfig
}{
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
} {
if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
}
}
if len(on) == 0 {
m.logf("extsvc: auto-upload disabled for every service")
} else {
m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
}
}
// Config returns the current snapshot.
@@ -182,6 +205,28 @@ func (m *Manager) OnQSOLogged(id int64) {
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
m.route(ServiceEQSL, id, e)
}
// Cloudlog / Wavelog — the instance URL, an API key and the station id.
if c := cfg.Cloudlog; c.AutoUpload {
// Say WHY nothing happens when the toggle is on but a field is missing.
// Without this the whole path was silent — the operator saw no upload and
// no log line, with no way to tell "disabled" from "broken".
var missing []string
if c.URL == "" {
missing = append(missing, "URL")
}
if c.APIKey == "" {
missing = append(missing, "API key")
}
if c.StationID == "" {
missing = append(missing, "station ID")
}
if len(missing) > 0 {
m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
strings.Join(missing, ", "), id)
} else {
m.route(ServiceCloudlog, id, c)
}
}
}
// route sends a logged QSO down the configured timing path: queue it for the
@@ -229,6 +274,9 @@ func (m *Manager) onCloseServices() []Service {
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
out = append(out, ServiceEQSL)
}
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
out = append(out, ServiceCloudlog)
}
return out
}
@@ -288,6 +336,12 @@ func (m *Manager) FlushOnClose() int {
uploaded++
}
}
case ServiceCloudlog:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
uploaded++
}
}
}
}
return uploaded
@@ -376,6 +430,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
}
}
// One line per attempt, BEFORE the request: a failure that never returns
// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
// all, and "did it even try?" is the first question when an upload is missing.
m.logf("extsvc: %s uploading QSO %d…", svc, id)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -426,6 +485,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false
}
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
case ServiceCloudlog:
// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
// so the ADIF keeps the QSO's own station call (no override).
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false, false
}
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
default:
return false, false
}
@@ -441,7 +509,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, true // transient (rate-limit / network) → worth a retry
}
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID)
}
+24
View File
@@ -86,6 +86,22 @@ func (m *Manager) SetProviders(p ...Provider) {
// Lookup returns a Result for the callsign. Falls back through providers
// 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) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
@@ -97,6 +113,13 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
dxcc := m.dxcc
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 {
r.Source = "cache"
// 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)
return r, nil
}
}
var lastErr error
// An operational suffix (/M, /P, …) is never registered as such: skip the
+46 -1
View File
@@ -751,8 +751,20 @@ var bulkEditableCols = map[string]bool{
"qsl_rcvd": true,
"qsl_via": 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 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
"station_callsign": true,
"operator": true,
@@ -775,6 +787,14 @@ var bulkEditableCols = map[string]bool{
"my_arrl_sect": true,
"my_darc_dok": 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.
// (srx/stx serial numbers stay excluded: they are per-QSO.)
"contest_id": true,
@@ -1173,7 +1193,15 @@ var filterableColumns = map[string]bool{
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": 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,
"prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
@@ -2800,3 +2828,20 @@ func parseTimeLoose(s string) 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 }
+116 -3
View File
@@ -171,6 +171,27 @@ type Stats struct {
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
// Chronological activity ACROSS THE SELECTED PERIOD, one series per bucket
// size. These are what the activity chart's day/week/month/year selector
// drives: the rolling windows above ignored the period filter, so choosing a
// year in the panel left the chart showing the last 7 days regardless — two
// controls contradicting each other on screen.
//
// A series is EMPTY when it would exceed maxActivityBuckets (per-day over a
// seventeen-year log is ~6000 bars: unreadable, and pointless to transport).
// The UI treats an empty series as "too fine for this period" and steps up.
ByDayWin []Bucket `json:"by_day_win"`
ByWeekWin []Bucket `json:"by_week_win"`
ByMonthWin []Bucket `json:"by_month_win"`
ByYearWin []Bucket `json:"by_year_win"`
// Rhythm: WHEN the operator is on the air, over the selected period. Distinct
// question from the above ("how much, lately"), so it gets its own card. The
// hour histogram used to cover a single day, which is too little to read
// anything from.
ByHourOfDay []Bucket `json:"by_hour_of_day"` // 00h..23h UTC
ByWeekday []Bucket `json:"by_weekday"` // Mon..Sun
// ── Period / contest metrics ──
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
// nothing, but across a contest weekend it is the score. The window is the
@@ -492,11 +513,95 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
ref = time.Now()
}
s.recentSeries(times, ref)
s.windowSeries(times, first, last, from, to)
s.ensureNonNil()
return s, nil
}
// maxActivityBuckets caps a chronological series. Beyond this the chart is a
// grey smear and the payload grows for nothing; the series is dropped and the UI
// steps up to a coarser bucket.
const maxActivityBuckets = 750
// windowSeries builds the chronological activity series ACROSS THE SELECTED
// PERIOD, one per bucket size, plus the hour-of-day / weekday rhythm.
//
// The period is [from,to] when the caller filtered, else the span of the log —
// the same window the period metrics use, so every card on the panel answers for
// the same slice of time.
func (s *Stats) windowSeries(times []entry, first, last, from, to time.Time) {
start, end := from, to
if start.IsZero() {
start = first
}
if end.IsZero() {
end = last
}
if start.IsZero() || end.IsZero() || end.Before(start) {
return
}
start, end = start.UTC(), end.UTC()
day := map[string]int{}
week := map[string]int{}
month := map[string]int{}
year := map[string]int{}
hour := make([]int, 24)
weekday := make([]int, 7)
for _, e := range times {
t := e.t.UTC()
if t.Before(start) || t.After(end) {
continue
}
day[t.Format("2006-01-02")]++
// ISO week, so a week is Monday→Sunday everywhere and the key sorts.
wy, wn := t.ISOWeek()
week[fmt.Sprintf("%04d-W%02d", wy, wn)]++
month[t.Format("2006-01")]++
year[t.Format("2006")]++
hour[t.Hour()]++
weekday[(int(t.Weekday())+6)%7]++ // Go weeks start on Sunday; shift to Monday
}
// Every bucket in the range is emitted, including the empty ones: a gap in
// the log is real information and must show as zero, not be closed up.
d0 := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
dEnd := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
if n := int(dEnd.Sub(d0).Hours()/24) + 1; n >= 1 && n <= maxActivityBuckets {
for d := d0; !d.After(dEnd); d = d.AddDate(0, 0, 1) {
s.ByDayWin = append(s.ByDayWin, Bucket{Key: d.Format("2006-01-02"), Count: day[d.Format("2006-01-02")]})
}
}
// Weeks: start on the Monday of the first week and step by 7 days.
w0 := d0.AddDate(0, 0, -((int(d0.Weekday()) + 6) % 7))
if n := int(dEnd.Sub(w0).Hours()/24)/7 + 1; n >= 1 && n <= maxActivityBuckets {
for w := w0; !w.After(dEnd); w = w.AddDate(0, 0, 7) {
wy, wn := w.ISOWeek()
key := fmt.Sprintf("%04d-W%02d", wy, wn)
s.ByWeekWin = append(s.ByWeekWin, Bucket{Key: key, Count: week[key]})
}
}
m0 := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
mEnd := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
if n := (mEnd.Year()-m0.Year())*12 + int(mEnd.Month()) - int(m0.Month()) + 1; n >= 1 && n <= maxActivityBuckets {
for m := m0; !m.After(mEnd); m = m.AddDate(0, 1, 0) {
s.ByMonthWin = append(s.ByMonthWin, Bucket{Key: m.Format("2006-01"), Count: month[m.Format("2006-01")]})
}
}
for y := start.Year(); y <= end.Year(); y++ {
k := fmt.Sprintf("%04d", y)
s.ByYearWin = append(s.ByYearWin, Bucket{Key: k, Count: year[k]})
}
for h := 0; h < 24; h++ {
s.ByHourOfDay = append(s.ByHourOfDay, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
}
for i, name := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
s.ByWeekday = append(s.ByWeekday, Bucket{Key: name, Count: weekday[i]})
}
}
// recentSeries builds the rolling chronological activity windows for the chart's
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
// filtered, else now). Real dates in order — today and the days before it — so the
@@ -569,6 +674,14 @@ func (s *Stats) ensureNonNil() {
if s.ByMonth == nil {
s.ByMonth = []Bucket{}
}
// A window series stays EMPTY on purpose when the bucket would be too fine
// for the period — but it must be [] and not null, so the UI can tell
// "too fine" from "not computed".
for _, p := range []*[]Bucket{&s.ByDayWin, &s.ByWeekWin, &s.ByMonthWin, &s.ByYearWin, &s.ByHourOfDay, &s.ByWeekday} {
if *p == nil {
*p = []Bucket{}
}
}
if s.Rate == nil {
s.Rate = []Bucket{}
}
@@ -588,9 +701,10 @@ func (s *Stats) ensureNonNil() {
// log itself.
//
// Two rates are reported on purpose, because a single one always flatters:
// AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// - AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// - AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// how fast you go when you ARE at the radio.
//
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
if len(times) == 0 {
@@ -790,7 +904,6 @@ func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
return out
}
// fillMonths emits EVERY month between the first and last QSO — zeros included —
// so the trend line's x-axis is real time rather than "months that happen to have
// data". A quiet decade must read as a decade at zero, not vanish.
+105 -1
View File
@@ -16,6 +16,7 @@ import (
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
@@ -179,7 +180,7 @@ func (m *Manager) Connect(cfg Config) error {
stop, done := m.stopRead, m.doneRead
m.mu.Unlock()
applog.Printf("winkeyer: connected on %s (firmware byte %d)", cfg.Port, ver)
applog.Printf("winkeyer: connected on %s — %s", cfg.Port, firmwareFamily(ver))
go m.readLoop(p, stop, done)
if err := m.applyConfig(cfg); err != nil {
@@ -398,10 +399,112 @@ func (m *Manager) write(b []byte) error {
if p == nil {
return fmt.Errorf("winkeyer: not connected")
}
traceTX(b)
_, err := p.Write(b)
return err
}
// ── Protocol trace ─────────────────────────────────────────────────────
//
// The WinKeyer command set differs between WK1, WK2 and WK3, and the failures
// it produces are silent: the keyer accepts a byte meant for another command
// and keys something odd. Guessing at that from a description ("it sends one
// element then pauses ten seconds") is how a fix for one firmware breaks the
// others, so the wire is made visible instead.
//
// Off by default — 1200 baud is slow but a long message would still fill the
// log. Enabled per session from the CW settings panel.
var traceOn atomic.Bool
// SetTrace turns the byte-level protocol trace on or off.
func SetTrace(on bool) {
traceOn.Store(on)
if on {
applog.Printf("winkeyer: protocol trace ON — every byte to and from the keyer is logged")
}
}
func hexBytes(b []byte) string {
var sb strings.Builder
for i, c := range b {
if i > 0 {
sb.WriteByte(' ')
}
fmt.Fprintf(&sb, "%02X", c)
if c >= 0x20 && c < 0x7F {
fmt.Fprintf(&sb, "(%c)", c)
}
}
return sb.String()
}
func traceTX(b []byte) {
if !traceOn.Load() || len(b) == 0 {
return
}
applog.Printf("winkeyer: TX %s %s", hexBytes(b), cmdName(b))
}
func traceRX(b []byte) {
if !traceOn.Load() || len(b) == 0 {
return
}
applog.Printf("winkeyer: RX %s", hexBytes(b))
}
// cmdName names the command a TX frame carries, so the trace can be read
// without the datasheet open beside it.
func cmdName(b []byte) string {
if len(b) == 0 || b[0] >= 0x20 {
return "(text)"
}
switch b[0] {
case 0x00:
return "admin"
case 0x01:
return "sidetone"
case 0x02:
return "set wpm"
case 0x03:
return "set weight"
case 0x04:
return "ptt lead/tail"
case 0x05:
return "setup speed pot"
case 0x06:
return "pause"
case 0x0A:
return "clear buffer"
case 0x0D:
return "farnsworth wpm"
case 0x0E:
return "set mode register"
case 0x11:
return "0x11 (WK2: key compensation / WK3: see datasheet)"
case 0x15:
return "request status"
default:
return fmt.Sprintf("cmd 0x%02X", b[0])
}
}
// firmwareFamily names the keyer generation from the byte returned by Host
// Open. The version is what decides which command set applies, so it is spelled
// out in the log rather than left as a bare number.
func firmwareFamily(ver int) string {
switch {
case ver == 0:
return "no reply — the keyer did not answer Host Open"
case ver < 20:
return fmt.Sprintf("WK1 (v%d)", ver)
case ver < 30:
return fmt.Sprintf("WK2 (v%d)", ver)
default:
return fmt.Sprintf("WK3 (v%d)", ver)
}
}
// Disconnect sends Host Close and releases the port.
func (m *Manager) Disconnect() {
m.mu.Lock()
@@ -473,6 +576,7 @@ func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
}
return
}
traceRX(buf[:n])
for i := 0; i < n; i++ {
b := buf[i]
switch {
+10 -1
View File
@@ -112,6 +112,12 @@ func main() {
MinWidth: 1100,
MinHeight: 700,
WindowStartState: startState,
// No OS title bar: it was a dead 32-pixel band above a window that already
// has its own title strip. The app header takes over — it carries the drag
// region, the double-click-to-maximise, and the minimise/maximise/close
// buttons. Windows still draws the resize borders and honours Aero Snap,
// which is why the frame is dropped rather than the whole chrome.
Frameless: true,
// Start hidden and reveal only once the saved position has been applied and
// the DOM has painted (OnDomReady → domReady) — so the window appears
// already at its final size and position, with no post-launch jump.
@@ -119,7 +125,10 @@ func main() {
AssetServer: &assetserver.Options{
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,
OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose,
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// The portable-folder contract: a database that lives inside the application
// folder must be stored relative to it, so copying C:\OpsLog to D:\OpsLog (or to
// a USB stick) keeps working. A path the operator deliberately put elsewhere
// must be left exactly as it is.
func TestPortablePath(t *testing.T) {
base := appDir()
if base == "" {
t.Skip("no executable dir available")
}
inside := filepath.Join(base, "data", "logbook.db")
if got := portablePath(inside); got != "data/logbook.db" {
t.Errorf("inside the app folder: got %q, want %q", got, "data/logbook.db")
}
// Outside: an absolute path on another drive / a synced folder is a
// deliberate choice and must survive untouched.
outside := filepath.FromSlash("Z:/Sync/ham/logbook.db")
if got := portablePath(outside); got != outside {
t.Errorf("outside the app folder: got %q, want it unchanged", got)
}
if got := portablePath(""); got != "" {
t.Errorf("empty path: got %q", got)
}
}
func TestResolvePath(t *testing.T) {
base := appDir()
if base == "" {
t.Skip("no executable dir available")
}
// A relative path is anchored to the CURRENT install, whatever drive it is on.
want := filepath.Join(base, "data", "logbook.db")
if got := resolvePath("", "data/logbook.db"); got != want {
t.Errorf("relative: got %q, want %q", got, want)
}
// An absolute path that still exists is honoured as-is.
dir := t.TempDir()
real := filepath.Join(dir, "logbook.db")
if err := os.WriteFile(real, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if got := resolvePath(dir, real); got != real {
t.Errorf("existing absolute: got %q, want %q", got, real)
}
// The rescue: a path from ANOTHER machine, with the same file present in this
// install's data folder — the copied-folder case.
stale := filepath.FromSlash("C:/OldPC/OpsLog/data/logbook.db")
if got := resolvePath(dir, stale); got != real {
t.Errorf("stale absolute with a local twin: got %q, want %q", got, real)
}
// No local twin: leave it alone so the failure is REPORTED rather than
// silently replaced by an unrelated database.
missing := filepath.FromSlash("C:/OldPC/OpsLog/data/other.db")
if got := resolvePath(dir, missing); got != missing {
t.Errorf("stale absolute with no twin: got %q, want it unchanged", got)
}
}
+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 (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.21.3"
appVersion = "0.21.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.