Compare commits

..
58 Commits
Author SHA1 Message Date
rouggy 88f35e2c20 chore: release v0.27.6 2026-08-31 19:03:58 +02:00
rouggy d2194da28e feat(cluster): the server pills become the per-server switch
The colour already said connected; the word beside it said it again, in
the widest part of a row that has to hold five servers. It goes, and the
pills earn the job nothing else in the app had: reaching ONE cluster
without opening Settings. Click connects or drops that session — a
connecting one included, so a server retrying into a dead host can be
stopped. State, retry count, address and last error move into the
tooltip, where they cost no width.
2026-08-31 19:03:32 +02:00
rouggy 2f1d592497 revert(dxped): no chase badge on the news pane
Tried it, looked at it, took it out. A headline names no band, so the
only honest verdict is entity-level — and a pane where every row reads
NEW DXCC or worked says nothing an operator can act on. Two words
repeated forty times is noise wearing the clothes of information.

The announcements pane keeps its badge, where the announced bands and
modes make the verdict specific enough to be worth a colour. The news
pane keeps what it is good at: what is happening, and a Watch button.
Changelog entry withdrawn with it — it never shipped.
2026-08-31 19:00:44 +02:00
rouggy 2b6f1ba9d7 feat(bandmap,dxped): drop the ctrl+wheel zoom, badge the news
ctrl+wheel is the WINDOW zoom everywhere else in OpsLog, and one gesture
that resizes the whole app over one panel and one band map over another
is a gesture nobody can trust. The + and - buttons keep the zoom, and
the footer hint stops advertising what is gone.

The DX-World headlines now carry a chase badge like the announcements —
but ENTITY-LEVEL only. A headline names no band, and asking the slot
question without one answers 'new band' for every entity ever worked:
the one mistake that costs a QSO. So the news says NEW DXCC or worked,
and the sharper verdicts stay where the bands and modes are known.
2026-08-31 18:57:44 +02:00
rouggy 4a017f6290 feat(bandmap): fold the legend away
A legend is a reference, not a running display: once its colours are
learnt it spends four lines of a short screen saying nothing new. A
chevron in the footer folds it, the same chevron brings it back, and the
choice survives a restart.
2026-08-31 18:52:09 +02:00
rouggy 7bab30aa71 style(qsl): paper and LoTW first, then alphabetical
The channel list had grown in the order channels were added, so finding
one meant remembering when it arrived. Paper QSL and LoTW lead — they
are the two that carry an ARRL award — and the rest sort by name. One
ordering function feeds both the picker and the status table, so the two
cannot drift apart; the three extras-backed channels (OpsLog card,
HAMLOG.online, HamQTH) now sit in that same list instead of being
appended by hand after it.
2026-08-31 18:48:40 +02:00
rouggy 77b95289e7 feat(qsl): HamQTH in the QSO editor's confirmation panel
The channel picker and the status table both list it now, sent only —
HamQTH publishes no confirmation feed, so its received column shows a
dash rather than an N that would read as 'not confirmed yet'. Backed by
the same APP_OPSLOG_HAMQTH_SENT extras the uploader stamps.
2026-08-31 18:44:35 +02:00
rouggy 5b894b9dbc style(dxped): one Watch button for both feeds
The news pane had clickable callsign chips while an announcement had a
Watch button — two gestures for one act. The chips become plain labels
(they still show which calls are already watched) and the news card gets
the announcement's own Watch button and Open link.
2026-08-31 18:42:39 +02:00
rouggy 187ac9aa84 feat(dxped): the announced DX, judged against your own log
A DXpeditions tab holding the two feeds the DX world announces itself
on: NG3K's ADXO (structured — dates, entity, calls, bands, modes, QSL
route) and DX-World's headlines. What a logger can say that a news
reader cannot is whether the operation is worth chasing, so every
announcement is put through the SAME verdict the cluster paints on a
spot — one badge, strongest wins, dimmed when the need is only a missing
QSL — with an 'only what I need' filter. One click watches every
callsign of an operation; the news headlines are mined for callsigns so
they can be watched the same way.

Parsers ported from DXHunter's and pinned with table tests against real
feed text: the 'as PJ2/W2APF' mining, the DXCC-prefix-first
normalisation without which no spot ever matches, both ADXO date forms,
and the '160-6m' span whose unit is written once (DXHunter read that as
6m alone and lost the low end).

Watchlist membership now announces itself app-wide, on the same card as
a new version: the bindings emit watchlist:changed, so a call added from
the cluster or this tab is confirmed where the operator is actually
looking — the panel's own inline message never was.
2026-08-31 17:51:45 +02:00
rouggy 629bd8d84f feat(confirmations): a HamQTH default, and the pending status stops meaning 'sent'
HamQTH joins the Confirmations page with a sent side only — the site
publishes no confirmation feed, so there is nothing to receive — and it
defaults to R, the same 'still to upload' the other online services get.

That default could not have worked as written. HamQTH and HAMLOG.online
keep their sent state in an ADIF extra, and eligibility blocked on the
key being PRESENT — so an operator setting the natural R default would
have stamped every new QSO 'already gone' and silently disabled the very
auto-upload the default arms. Eligibility now reads the VALUE: the ADIF
pending statuses mean pending, anything else means sent. Guard-tested
both ways.
2026-08-31 16:48:11 +02:00
rouggy 7152d11007 feat(cluster): county and prefix get their chase switches, and the grid one tells the whole truth
Chase POTA already withdrew its badge, filter chip and column when
unchecked; US counties and new prefixes now have the same switch, and
unchecking Chase new grids finally withdraws the NEW GRID badge too (the
backend stopped learning squares but the display kept shouting the ones
it knew — the setting is mirrored to localStorage so the display layer
can gate synchronously). All withdrawal happens at the display layer:
the facts keep being computed, so re-ticking a box needs no rescan.
2026-08-31 16:41:13 +02:00
rouggy a03d907128 fix(lotw): MY_CNTY must not sink a non-US station's upload
TQSL validates MY_CNTY against the ADIF secondary-subdivision list,
which is the US county enumeration — 'XX,County' with a two-letter
state. A Canadian profile produced 'ONTARIO,Kawartha' (the export joined
MY_STATE onto the county wholesale) and TQSL refused the whole record.

Two layers: adifCounty only prefixes a two-letter state, so exports stop
manufacturing the invalid shape; and UploadLoTW scrubs any MY_CNTY that
is not the US shape before signing — MY_STATE and MY_GRIDSQUARE already
locate the station for LoTW, and the US form survives for the county
hunters. Table-tested.
2026-08-31 15:56:34 +02:00
rouggy 91569e12f4 feat(hamqth): QSO upload to the HamQTH logbook — the 8th external service
qso_realtime.php with the account's callbook credentials (which the
external-services config falls back to when its own are blank), one ADIF
record per QSO, prg=OpsLog. HTTP status IS the answer: 200 saved, 400
rejected (a duplicate counts as delivered, like HRDLog's insert 0), 403
credentials. Sent-state lives in APP_OPSLOG_HAMQTH_SENT extras like
HAMLOG.online — ADIF names no HamQTH field. Auto-upload on log, on-close
batch, right-click Send to, QSL Manager backlog, and a Test button that
authenticates against the callbook login, which cannot touch the log.

Fixes a real mis-route on the way: manual 'Send to HAMLOG.online' had no
branch in runManualUpload and fell through to QRZ.com — the selection was
uploaded to the wrong service with the QRZ key. Both extras-stamped
services now have their own branch.
2026-08-31 14:34:49 +02:00
rouggy 68f0d68980 fix(restart): the self-relaunch waits for its parent's mutex
A database switch relaunches OpsLog, and the child regularly won the
race against the old instance's teardown — so following the app's own
instructions produced 'OpsLog is already running'. The relaunch now
passes --relaunch, which gets the same 20-second mutex patience the
post-update restart has always had. Opens the 0.27.6 block.
2026-08-31 14:19:30 +02:00
rouggy 8790b98766 chore: release v0.27.5 2026-08-31 13:38:18 +02:00
rouggy 8846cba40c feat(confirmations): a Club Log received default, No out of the box
The Confirmations page's Club Log row gains its received side, applied to
every new QSO the way QRZ.com's already is — so the match download's Y
stands out against a ledger of explicit Ns.
2026-08-31 11:59:14 +02:00
rouggy 0cc6ad686a style(columns): the picker lists each group alphabetically
The catalog's order is the grid's column order, which appends new columns
at the end — so the two ClubLog rows sat at opposite ends of the Uploads
box. Sorted for display only; the grid keeps its own order.
2026-08-31 11:39:54 +02:00
rouggy 612cb67438 fix(kpa): a settings save must not flick the KPA500's power switch — and the maps stay under the menus
startAmps tore down and rebuilt every amplifier client on any settings
save, and closing a COM port makes the Windows driver drop DTR/RTS. On a
KPA500 those lines ARE the power switch — so 'save' meant 'amplifier off
twenty seconds later', exactly F1TPL's report, and the fix that held the
lines while OPEN couldn't survive a close. An amplifier whose config is
unchanged now keeps its running client across saves; the others still
rebuild.

Also: isolate on the FT Map panel — Leaflet's z-1000 panes were painting
over the menu bar and the Preferences dialog, as the grid map's own
comment warned.
2026-08-31 11:39:02 +02:00
rouggy cd5d8b503b fix(db): a migration aimed at a table a database no longer holds must not kill the startup
The 0031 clublog ALTER hit split settings databases (their qso table
moved to the logbook years of QSOs ago) and the whole open failed —
silently, because the error went to a println the GUI subsystem
discards. The app then ran with no settings store: every panel showed
defaults, 'db not initialized' in Preferences, and operators read it as
their database being lost. Nothing was ever touched: the failed
migration rolled back on every attempt.

The SQLite migration path now tolerates what the MySQL path always has —
plus the one case it never meets: ALTER/CREATE INDEX/DROP on a table
this database legitimately does not hold. And a failed open is written
to the rotating log, where the next such morning can actually be
diagnosed.
2026-08-31 11:17:45 +02:00
rouggy aa59431403 fix(changelog): scrub the stash-pop conflict and move the Club Log work to 0.27.5
A stash pop during a test-on-parent check left conflict markers inside
changelog.json and the next commit carried them. The 0.27.4 release had
already shipped with its seven entries, so the two Club Log entries that
landed after it open the 0.27.5 block, per the released-block rule.
2026-08-31 11:13:11 +02:00
rouggy 269701410f fix(profiles): the watchlist's Club Log enrichment follows a profile switch
The guard test said so: startWatchlistClubLog ran at startup only, so
switching profiles kept the previous profile's enrichment settings alive.
2026-08-31 10:16:16 +02:00
rouggy 3cb8096141 feat(clublog): the matches come home, and Club Log lends its call list
Two new QSO columns, clublog_qso_download_status/date — a Club Log MATCH,
the service's own confirmation (both stations uploaded the QSO, paired
within 15 minutes). Full promoted-column lockstep: migration 0031, repo
insert/scan, ADIF dictionary + import + export (app-defined
CLUBLOG_QSO_DOWNLOAD_*), table columns, filter builder, bulk edit and the
QSO editor's Club Log row.

The QSL Manager's Club Log entry now actually downloads: getmatches.php
with the existing account settings and the embedded application key,
incremental via the match-completion date filter, matched call+band+mode
±15 min with a mode-blind fallback because Club Log reports 'false' for
modes it cannot infer. Matches always exist on both sides, so unmatched
ones are listed rather than skeleton-added.

And Super Check Partial can merge Club Log's weekly SCP list (~180k calls
worked on the air in the last 3 years) with MASTER.SCP — an opt-in
checkbox under the SCP setting.
2026-08-31 10:14:52 +02:00
rouggy 1f667e4a4b chore: release v0.27.4 2026-08-30 22:24:05 +02:00
rouggy d5f7e290ab chore(changelog): the FTx menu, the FT Map and the map fixes enter the 0.27.4 block 2026-08-30 22:23:30 +02:00
rouggy f2ba798764 fix(maps): zooming stays centred on the grid and decode maps
The hard maxBounds added for the single-world look fought the zoom: with
the world smaller than the window the clamp dragged every zoom step into
a corner. The noWrap tiles already guarantee one world on their own, so
the bounds go — on both maps.
2026-08-30 22:18:27 +02:00
rouggy d7ee87f22d feat(ftx): the digital corner gets its own menu, and the watchlist learns to line up
An FTx menu between View and Tools gathers FT Decodes, FT Map and the
Grid squares map — three views of the same UDP feed that were buried in
Tools. And the watchlist spot rows swap the elastic country column for
fixed widths, so band, mode and frequency read as columns instead of
starting wherever each country name happened to end.
2026-08-30 22:14:40 +02:00
rouggy 2587e6bcfe style(ftmap): one world, themed ground — here and on every map
The FT map wrapped the planet into a row of copies and showed Leaflet's
hard-coded grey beyond it. noWrap tiles inside hard bounds now, like the
grid-square map already did — and the container background override moves to
style.css so EVERY map's surround follows the theme's card surface instead
of #ddd (the grid map's per-div fix was losing to leaflet.css specificity).
2026-08-30 22:05:25 +02:00
rouggy 755392e0fa feat(ftmap): the live decode feed as a world map — feature branch
A new FT Map tab (Tools menu, beside FT decodes): every station decoded in
the last half hour drawn as a great-circle arc from the operator's own
square, coloured by band the way PSK Reporter taught everyone to read it,
fading with age, one arc per call (its freshest sighting), a tooltip with
call/grid/SNR, the MainMap basemap picker and a legend of only the bands on
screen.

Performance was the open question and it is answered structurally: the panel
mounts only while its tab is ACTIVE (zero cost otherwise), the map renders
canvas (one element, not one node per arc), arcs are capped at 300, and
redraws follow the decode list — every period, not every frame.
2026-08-30 21:54:00 +02:00
rouggy a9fb428a0a fix(chase): band/mode/slot needs finally dim — 'worked' is not ''
spotEntityStatus says 'worked' when every grain is in the log; the
unconfirmed check compared its all-ledger verdict against the empty string,
so no band/mode/slot need ever dimmed however many unconfirmed QSOs stood
behind it — only the entity-level NEW, tested by map presence, behaved. The
check now runs for exactly the four needy statuses and asks for 'worked'.
2026-08-30 21:43:47 +02:00
rouggy bbb485b9cf fix(chase): the unconfirmed entity badge is visible
Dimming the filled chip to a transparent background left its light
foreground text on nothing — an invisible badge in the row. Unconfirmed now
renders in the category's own hue, dashed border and text, like the extras
beside it.
2026-08-30 21:40:26 +02:00
rouggy 3a94a69be3 style(chase): no question marks — the dashed dimmed chip says it alone 2026-08-30 21:38:14 +02:00
rouggy e4028f4bcd fix(chase): switching the rules invalidates every resolved verdict
The frontend caches statuses and only asks about unknown keys, so flipping
the chase mode left half the screen judged under the old rules until a
restart — which read as the mode not working. The backend now broadcasts an
invalidation and the panels re-ask as rows repaint. The entity badges also
say their name with a question mark, dashed, when the need is only a missing
QSL — the 50% opacity was invisible on a filled chip.
2026-08-30 21:37:31 +02:00
rouggy 7c10517bf5 fix(chase): each unconfirmed badge keeps its own name; the block moves up
The unconf label was hard-coded to GRID? for every category — a prefix
worked-but-unconfirmed showed as a second grid badge beside the real one,
which read as impossible. Each badge now says its own name plus a question
mark. And the global Chase block moves above the Chase POTA/SOTA/grid
checkboxes, where its operator expected the master switch.
2026-08-30 21:28:52 +02:00
rouggy cd4e5b1117 i18n: the global chase block's labels, dropped by the failed batch edit 2026-08-30 21:23:17 +02:00
rouggy a13ac7d917 test: the bulk-edit guard learns the integer My-station fields
my_dxcc/my_cq_zone/my_itu_zone go through BulkSetIntField, not the string
column map — the guard flagged them as unwritable ever since they were
added, which is the drift it exists to catch, one package over.
2026-08-30 21:03:03 +02:00
rouggy 4e28098a4e feat(chase): the global hunt — new, or new plus unconfirmed, every category
The gap between worked and confirmed becomes visible everywhere. A Chase
setting on the DX Cluster page picks the mode; in new-plus-unconfirmed the
verdicts for DXCC/band/mode/slot, prefix, county and state are judged against
CONFIRMED-only ledgers built from the operator's chosen sources (LoTW, card,
eQSL, QRZ.com — HRDLog has no confirmation field in ADIF to offer), and a
need that exists only because a QSL never arrived is flagged Unconf: the
cluster and decode badges render it dimmed, the convention the grid hunt
introduced. The grid's own Chase selector folds into the global one; its
square-matching scope stays where it was, being grid-specific. One snapshot
key change rebuilds the cache when any of it flips.
2026-08-30 21:02:34 +02:00
rouggy 52e98a71f4 feat(decodes): NEW STATE — badge and filter
The WAS gap made visible: a worked-states set joins the cluster status cache
(distinct states over the three US entities), the status entries carry a
NewState flag wherever the state resolves (log county first, ULS licence
second), and the decode panel gains the badge in its status column plus the
filter chip alongside NEW/BAND/MODE/GRID. Colour from the shared marker
palette, so the cluster can adopt it later without a second hue.
2026-08-30 20:44:58 +02:00
rouggy 81ff6a9b0e style(decodes): a gap between the state badge and its name 2026-08-30 20:38:50 +02:00
rouggy be52a23868 feat(decodes): a State column — badge plus full name
Between the locator and the country, per its operator: the two-letter badge
anchors the eye and the spelled-out name answers it — 'SD' alone is a quiz
for a European. The status-cell badge from the first pass moves here.
2026-08-30 20:36:45 +02:00
rouggy fc1561804c style(decodes): the US state is a proper badge
Bigger and boxed, per its operator — the plain grey text vanished into the
column it was meant to anchor.
2026-08-30 20:34:02 +02:00
rouggy 1288b3f998 feat(decodes): the two-letter US state in the status column
Plain text, not a badge — it is a fact, not an action — placed first so a
WAS chaser can scan the column edge. Shown whenever the status cache knows
the state (log county data or the licence address).
2026-08-30 20:31:05 +02:00
rouggy e00488fad8 chore: release v0.27.3 2026-08-30 20:19:39 +02:00
rouggy 12c61dc35a feat(dvk): delete a message — recording and label together
A deleted message is a free slot, per its operator; keeping the label made it
look half-deleted.
2026-08-30 20:15:45 +02:00
rouggy 0c0e8b06ba feat(dvk): twelve slots; perf: Settings pauses the heavy streams
The voice keyer grows to F1-F12 — the UI was already data-driven, so the
count constant, the F-key range and the labels are the whole change.

And the preferences stop stuttering on a busy station: every spot batch,
decode batch and CAT snapshot re-rendered the entire App tree behind the
dialog — cluster grid, decodes panel, thousands of nodes — and the pointer
janked over the very panel the operator was trying to use. While Settings is
open the flushes park their batches in the pending refs (bounded) and CAT
repaints at most every two seconds; closing the dialog drains everything.
2026-08-30 20:09:44 +02:00
rouggy 1bd3896ca7 feat(decodes): arrival order within a period, not strongest-first
The panel now mirrors the decoder's own window line for line — the operator
compares the two side by side, and the SNR sort scrambled that
correspondence. The report is still right there in its column.
2026-08-30 19:26:52 +02:00
rouggy 7e6c0b4f7e fix(kpa): stop switching the KPA500 off, and answer its operator instantly
Three faults, one report. The slow poll asks ^TP — the KPA1500's ATU, which
a KPA500 (no ATU) never answers — and ask() dropped the whole connection on
any read timeout: a two-second stall and a reconnect every slow cycle, which
is why buttons lagged and the status read wrong. Worse, every serial reopen
toggled DTR/RTS — and those lines are the KPA500's POWER SWITCH (that is how
the Elecraft utility turns it on), so the amplifier obediently switched off
twenty seconds after its operator pressed nothing but Standby.

Silence is no longer a dead link (write errors still are), ^TP is never asked
again after one silence, the control lines are asserted once and held, and
the baud field becomes a list of the rates these amplifiers actually speak.
2026-08-30 19:14:35 +02:00
rouggy 2cde1a2c27 chore: release v0.27.2 2026-08-30 16:06:51 +02:00
rouggy 53100eb6c8 debug(rigctld): always trace a client's first six commands
A connect that lasts 50 ms leaves nothing to say which answer the client
disliked — WSJT-X refusing to attach to the shared CAT was undiagnosable
without the full wire trace. The handshake is now always logged; steady-state
polling stays behind the trace switch.
2026-08-30 15:55:58 +02:00
rouggy 3b555219d2 style(i18n): trim the FR follow-mode hint, and fix its double-escaped accents 2026-08-30 15:38:14 +02:00
rouggy ba35d4094c style(i18n): trim the follow-mode hint to its first sentence 2026-08-30 15:36:33 +02:00
rouggy 9bd6d988aa feat(udp): spot clicks switch the decoder's mode
Configure (message 15), mode field only, every other field sent as
no-change: an FT4 spot clicked while WSJT-X sits in FT8 lands the operator
ready to decode instead of staring at gibberish. Sent to every instance
heard this session — one already in the right mode treats it as a no-op —
and only for modes the decoder actually speaks (FT8/FT4/JT65/JT9/MSK144/
Q65/FST4); CW and SSB are none of its business. Toggle in the Connections
panel, on by default.
2026-08-30 15:33:07 +02:00
rouggy daabbc63c7 fix(map): Zoom DX clamps its frame to Mercator's edge
The great-circle arc to a polar entity (Franz Josef Land) peaks near 88°N;
fitting the raw arc points framed a band of tile-less white above 85°, where
Web Mercator ends. The camera's bounds are clamped to ±85° — the line still
draws to wherever it goes, only the framing stays where there is a map.
2026-08-30 15:30:30 +02:00
rouggy 997bc81d5e fix(udp): edge-detect the decoder's DX Call stream
WSJT-X restates the same DX Call in every Status packet, and a spot clicked
in OpsLog put that call into the last-UDP memory (deliberately, for another
case) — so the guard let the decoder's next restatement straight through and
the entry snapped back to old news. The decoder's stream now only counts when
its value CHANGES; a clear over there resets the edge so re-selecting the
same station still lands. Explicit picks (N1MM, remote, spot relays) are
untouched.
2026-08-30 15:29:07 +02:00
rouggy 3c59507bc3 feat(udp): Highlight Callsign and Replay — the decoder becomes log-aware
Message 13 paints callsigns in WSJT-X/JTDX's own Band Activity window with
verdicts from the same cluster status cache that colours the spot grid:
watchlist pink, new DXCC green, new band for its entity orange. Deduplicated
per instance+call+verdict, one datagram each; a verdict that lapses (the
operator worked them) clears that call, and turning the option off clears
everything via the protocol's CLEARALL!. Off by default, switch in the
Connections panel.

Message 7 asks a program heard for the first time this session to replay the
decodes already on its screen, so the FT decodes panel starts full instead of
empty until the next period. Replayed lines arrive marked not-new and are
shown but never auto-answered — the auto-caller now checks, on top of its
30-second freshness gate.
2026-08-30 15:25:58 +02:00
rouggy 721c43d569 style(settings): the Connections panel drops its two explainer paragraphs
Same cleanup as the General and DX Cluster panels: the section speaks for
itself, and the per-service hints remain where the choice is made.
2026-08-30 15:09:08 +02:00
rouggy 25eda98612 style(watchlist): a pass toward DXHunter's look
The operator preferred the original's aesthetics. Adopted: the signature pink
callsign (a fixed identity colour, like the cluster palette), counters as
coloured pills, a quieter card surface with a hover, a stronger needed bar on
spot rows, and the lightning bolt back on the DXpedition badge. Everything
else stays on theme tokens.
2026-08-30 13:35:37 +02:00
rouggy 0b909a4d63 fix(elecraft): the K3 power meter converts to real watts
The operator measured the whole table: 10 W showed 83, 12 W showed 100, 13 W
showed 8 — the bargraph is relative to a meter RANGE that flips at 12 W
(0-12 QRP, 0-120 above). A bar percentage was never watts; with the PC
setting choosing the range, it converts, and the console prints the watts
beside the bar.
2026-08-30 13:31:34 +02:00
rouggy 37298afd77 fix(qso): every by-ids statement is chunked — 168k QSOs is a selection too
One placeholder per id in a single IN (…) hits SQLite's bound-variable cap:
bulk field set died at 168 000 QSOs with 'too many SQL variables' (10 000
passed). One chunker now serves bulk set (text/int/extra/frequency), the
delete, the post-upload markers and the export-selection iterator — the last
collecting and sorting once at the end so its chronological contract holds
across chunks. 500 ids a statement keeps every backend far from any limit.
2026-08-30 13:13:22 +02:00
59 changed files with 3949 additions and 376 deletions
+493 -33
View File
@@ -38,6 +38,7 @@ import (
"hamlog/internal/cwdecode"
"hamlog/internal/db"
"hamlog/internal/dxcc"
"hamlog/internal/dxped"
"hamlog/internal/email"
"hamlog/internal/extsvc"
"hamlog/internal/geo"
@@ -159,6 +160,8 @@ const (
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
keyChaseMode = "chase.mode" // "new" | "new_unconfirmed" — the global hunt, every category
keyChaseConfirm = "chase.confirm" // CSV of confirmation sources: lotw,card,eqsl,qrz
keyAudioMonitorOn = "audio.monitor.on" // play the network RX audio through the Listening device (the stream itself stays open for the recorder either way)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
@@ -327,6 +330,7 @@ const (
keyClusterSpotMax = "cluster.spot_max" // how many spots the list holds at once
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
keyScpClublog = "scp.clublog" // merge Club Log's weekly SCP list (~180k calls)
// Web publishing: the whole config as one JSON blob. A single key rather than
// twenty: it is read and written as a unit by one settings panel, and the FTP
@@ -354,9 +358,11 @@ const (
keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
keyQSLDefaultClublogStatus = "qsl.clublog_status"
keyQSLDefaultClublogCfm = "qsl.clublog_confirmed"
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
keyQSLDefaultHamqthStatus = "qsl.hamqth_status"
keyQSLDefaultHamlogStatus = "qsl.hamlog_status"
keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed"
@@ -397,6 +403,12 @@ const (
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
keyExtHamqthUsername = "extsvc.hamqth.username"
keyExtHamqthPassword = "extsvc.hamqth.password"
keyExtHamqthCallsign = "extsvc.hamqth.callsign"
keyExtHamqthAutoUpload = "extsvc.hamqth.auto_upload"
keyExtHamqthUploadMode = "extsvc.hamqth.upload_mode"
keyExtHamlogAPIKey = "extsvc.hamlog.api_key"
keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"
@@ -419,6 +431,8 @@ const (
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull
keyExtEQSLLastDownload = "extsvc.eqsl.last_download" // YYYY-MM-DD of last eQSL inbox pull
keyExtClublogLastDownload = "extsvc.clublog.last_download" // YYYY-MM-DD of last Club Log matches pull
)
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -434,6 +448,7 @@ type QSLDefaults struct {
EQSLSent string `json:"eqsl_sent"`
EQSLRcvd string `json:"eqsl_rcvd"`
ClublogStatus string `json:"clublog_status"`
ClublogCfm string `json:"clublog_confirmed"` // Club Log match/download status
HRDLogStatus string `json:"hrdlog_status"`
QRZComStatus string `json:"qrzcom_status"`
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
@@ -443,6 +458,9 @@ type QSLDefaults struct {
// at "N" here exactly as they do for Club Log.
HamlogStatus string `json:"hamlog_status"`
HamlogCfm string `json:"hamlog_confirmed"`
// HamQTH, same extras story — and SENT only: the site publishes no
// confirmation feed, so there is no received side to default.
HamqthStatus string `json:"hamqth_status"`
}
// CATSettings is the user-tweakable rig-control configuration. Stored as
@@ -741,10 +759,15 @@ type App struct {
watchlist *watchlist.Store // Tools → Watchlist (global watchlist.json)
watchAlertMu sync.Mutex // throttles watchlist alerts…
watchAlertAt map[string]time.Time // …per entry
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
// WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go.
wsjtHighlightOn atomic.Bool
wsjtHLMu sync.Mutex
wsjtHLSent map[string]string
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
// Program id of the last decoding application that reported its status.
// Halt Tx is routed by id, and the panel's Halt button must work even when
// nothing is transmitting at that instant — so the id is remembered from
@@ -783,6 +806,9 @@ type App struct {
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
scp *scp.Manager // Super Check Partial / N+1 callsign master list
// dxped reads the ADXO announcements and the DX-World feed for the
// DXpeditions tab. Built lazily: nothing fetches until the tab is opened.
dxped *dxped.Manager
// NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO).
@@ -1122,7 +1148,9 @@ func (a *App) startup(ctx context.Context) {
conn, err := db.Open(a.dbPath)
if err != nil {
a.startupErr = "cannot open db: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
// In the rotating log too: the GUI subsystem discards stdout, and this
// exact failure once hid for a whole morning behind a println.
applog.Printf("startup: %s", a.startupErr)
return
}
a.db = conn
@@ -1205,6 +1233,11 @@ func (a *App) startup(ctx context.Context) {
a.operating = operating.NewRepo(conn)
a.udpRepo = udp.NewRepo(conn)
a.udp = udp.NewManager(a.udpRepo)
// A program heard for the first time is asked to replay the decodes already
// on its screen, so the FT decodes panel starts full instead of waiting a
// period. Replayed decodes arrive marked not-new and are shown but never
// auto-answered.
a.udp.SetOnNewInstance(func(id string) { _ = a.udp.SendReplay(id) })
go a.consumeUDPEvents()
a.cache = lookup.NewCache(conn, 30*24*time.Hour)
a.lookup = lookup.NewManager(a.cache)
@@ -1383,6 +1416,9 @@ func (a *App) startup(ctx context.Context) {
// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is
// enabled, auto-(re)download it if missing or older than a week.
a.scp = scp.NewManager(dataDir)
if v, _ := a.settings.Get(a.ctx, keyScpClublog); v == "1" {
a.scp.SetClublogEnabled(true)
}
go func() {
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
return
@@ -1444,6 +1480,7 @@ func (a *App) startup(ctx context.Context) {
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
a.startWatchlistClubLog()
a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, ""))))
a.wsjtHighlightOn.Store(a.settingOr(keyWsjtHighlight, "0") == "1")
go a.pota.Run(a.ctx)
// DX Cluster (multi-server): the spot callback enriches each spot
@@ -2828,7 +2865,9 @@ func (a *App) RestartApp() error {
if err != nil {
return fmt.Errorf("locate executable: %w", err)
}
cmd := exec.Command(exe)
// --relaunch: the child waits for OUR mutex instead of declaring us a
// duplicate — this instance is quitting, just not always fast enough.
cmd := exec.Command(exe, "--relaunch")
cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err)
@@ -3133,6 +3172,7 @@ type ScpStatus struct {
Enabled bool `json:"enabled"`
Count int `json:"count"`
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
Clublog bool `json:"clublog"` // Club Log source merged in
}
// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
@@ -3144,6 +3184,7 @@ func (a *App) GetScpStatus() ScpStatus {
}
if a.scp != nil {
st.Count = a.scp.Count()
st.Clublog = a.scp.ClublogEnabled()
if u := a.scp.Updated(); !u.IsZero() {
st.Updated = u.UTC().Format(time.RFC3339)
}
@@ -3151,6 +3192,34 @@ func (a *App) GetScpStatus() ScpStatus {
return st
}
// SetScpClublogEnabled merges (or stops merging) Club Log's weekly SCP list
// into the Super Check Partial call list, and refreshes the download so the
// change is visible without waiting for the weekly cycle.
func (a *App) SetScpClublogEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if err := a.settings.Set(a.ctx, keyScpClublog, boolStr(on)); err != nil {
return err
}
if a.scp != nil {
a.scp.SetClublogEnabled(on)
if on {
go func() {
if n, err := a.scp.Download(context.Background()); err == nil {
applog.Printf("scp: downloaded %d callsigns (with Club Log list)", n)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "scp:updated")
}
} else {
applog.Printf("scp: download failed: %v", err)
}
}()
}
}
return nil
}
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
// download when the list is missing or stale.
func (a *App) SetScpEnabled(on bool) error {
@@ -6876,6 +6945,7 @@ var bulkFieldColumns = map[string]string{
"qrz_sent": "qrzcom_qso_upload_status",
"qrz_rcvd": "qrzcom_qso_download_status",
"clublog_sent": "clublog_qso_upload_status",
"clublog_rcvd": "clublog_qso_download_status",
"hrdlog_sent": "hrdlog_qso_upload_status",
// Old ids kept so anything holding one keeps working.
"qrz_upload": "qrzcom_qso_upload_status",
@@ -6892,6 +6962,7 @@ var bulkFieldColumns = map[string]string{
"qrz_sent_date": "qrzcom_qso_upload_date",
"qrz_rcvd_date": "qrzcom_qso_download_date",
"clublog_sent_date": "clublog_qso_upload_date",
"clublog_rcvd_date": "clublog_qso_download_date",
"hrdlog_sent_date": "hrdlog_qso_upload_date",
// My station / operator
"station_callsign": "station_callsign",
@@ -10228,7 +10299,7 @@ func titleEntity(s string) string {
// the configured "Recording mic", transmit via "To Radio", preview via
// "Listening".
const dvkSlots = 6
const dvkSlots = 12
// DVKMessage is one voice-keyer slot for the UI.
type DVKMessage struct {
@@ -10289,6 +10360,22 @@ func (a *App) GetDVKMessages() []DVKMessage {
return out
}
// DVKDelete removes a slot's recording AND its label — a deleted message is a
// free slot, per its operator.
func (a *App) DVKDelete(slot int) error {
if slot < 1 || slot > dvkSlots {
return fmt.Errorf("bad slot")
}
if err := os.Remove(a.dvkPath(slot)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete message %d: %w", slot, err)
}
if a.settings != nil {
_ = a.settings.Set(a.ctx, dvkLabelKey(slot), "")
}
applog.Printf("dvk: message F%d deleted (label cleared)", slot)
return nil
}
// SetDVKLabel renames a voice-keyer slot.
func (a *App) SetDVKLabel(slot int, label string) error {
if a.settings == nil {
@@ -10869,7 +10956,8 @@ func defaultQSLDefaults() QSLDefaults {
QSLSent: "N", QSLRcvd: "N",
EQSLSent: "R", EQSLRcvd: "N",
LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", HRDLogStatus: "R",
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
HamqthStatus: "R",
QRZComStatus: "R", QRZComCfm: "N",
}
}
@@ -10888,9 +10976,10 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
keyQSLDefaultHamqthStatus,
)
if err != nil {
return out, err
@@ -10902,10 +10991,12 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out.EQSLSent = m[keyQSLDefaultEQSLSent]
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
out.ClublogCfm = m[keyQSLDefaultClublogCfm]
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
return out, nil
}
@@ -10925,10 +11016,12 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
keyQSLDefaultClublogCfm: strings.ToUpper(strings.TrimSpace(d.ClublogCfm)),
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)),
keyQSLDefaultHamqthStatus: strings.ToUpper(strings.TrimSpace(d.HamqthStatus)),
keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)),
} {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
@@ -10977,6 +11070,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
fill(&q.EQSLSent, d.EQSLSent)
fill(&q.EQSLRcvd, d.EQSLRcvd)
fill(&q.ClublogUploadStatus, d.ClublogStatus)
fill(&q.ClublogDownloadStatus, d.ClublogCfm)
fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
fill(&q.QRZComUploadStatus, d.QRZComStatus)
fill(&q.QRZComDownloadStatus, d.QRZComCfm)
@@ -10984,6 +11078,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
// string field, and these two are map entries. Only set when the QSO does not
// already carry them, which is the same rule fill() applies.
setExtraDefault(q, hamlogSentKey, d.HamlogStatus)
setExtraDefault(q, hamqthSentKey, d.HamqthStatus)
setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm)
}
@@ -11101,7 +11196,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode)
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode,
keyExtHamqthUsername, keyExtHamqthPassword, keyExtHamqthCallsign,
keyExtHamqthAutoUpload, keyExtHamqthUploadMode)
if err != nil {
return out
}
@@ -11185,6 +11282,21 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
AutoUpload: m[keyExtHamlogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]),
}
out.HamQTH = extsvc.ServiceConfig{
Username: m[keyExtHamqthUsername],
Password: m[keyExtHamqthPassword],
Callsign: m[keyExtHamqthCallsign],
AutoUpload: m[keyExtHamqthAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamqthUploadMode]),
}
// The callbook lookup already knows these credentials; blanks fall back to
// them so an operator who set up the lookup years ago is one checkbox away.
if out.HamQTH.Username == "" && out.HamQTH.Password == "" {
u, _ := a.settings.Get(a.ctx, keyHQUser)
p, _ := a.settings.Get(a.ctx, keyHQPassword)
out.HamQTH.Username = strings.TrimSpace(u)
out.HamQTH.Password = p
}
return out
}
@@ -11259,6 +11371,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if hamMode == string(extsvc.ModeOnClose) {
hamMode = string(extsvc.ModeImmediate)
}
hqMode := modeOf(cfg.HamQTH.UploadMode)
hamAuto := "0"
if cfg.Hamlog.AutoUpload {
hamAuto = "1"
@@ -11314,6 +11427,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey),
keyExtHamlogAutoUpload: hamAuto,
keyExtHamlogUploadMode: hamMode,
keyExtHamqthUsername: strings.TrimSpace(cfg.HamQTH.Username),
keyExtHamqthPassword: cfg.HamQTH.Password,
keyExtHamqthCallsign: strings.ToUpper(strings.TrimSpace(cfg.HamQTH.Callsign)),
keyExtHamqthAutoUpload: boolStr(cfg.HamQTH.AutoUpload),
keyExtHamqthUploadMode: hqMode,
} {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
return err
@@ -11342,6 +11460,13 @@ func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
}
// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
// authenticated, and unable to touch the log.
func (a *App) TestHamQTHUpload() (string, error) {
cfg := a.loadExternalServices().HamQTH
return extsvc.TestHamQTH(a.ctx, nil, cfg)
}
// TestHRDLogUpload validates that the HRDLog credentials are complete.
func (a *App) TestHRDLogUpload() (string, error) {
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
@@ -11401,6 +11526,9 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if extsvc.Service(service) == extsvc.ServiceHamlog {
return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
}
if extsvc.Service(service) == extsvc.ServiceHamQTH {
return a.qso.ListMissingExtra(a.ctx, hamqthSentKey)
}
col := uploadColumnFor(service)
if col == "" {
return nil, fmt.Errorf("unknown service %q", service)
@@ -11416,7 +11544,7 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog {
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog && svc != extsvc.ServiceHamQTH {
return fmt.Errorf("unknown service %q", service)
}
cfg := a.loadExternalServices()
@@ -11640,6 +11768,43 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
flush()
}
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH {
// One record per request, extras-stamped services. Hamlog used to fall
// through to the QRZ branch below and got uploaded to the WRONG service
// with the QRZ key — the context menu offered what the loop never handled.
for i, id := range ids {
if i > 0 {
time.Sleep(manualUploadPace)
}
q, gerr := a.qso.GetByID(ctx, id)
call := ""
if gerr == nil {
call = q.Callsign
}
rec, ok := a.buildUploadADIF(id, "")
if !ok {
emit(call + " — skipped (no record)")
continue
}
var res extsvc.UploadResult
var err error
if svc == extsvc.ServiceHamlog {
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
} else {
res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
}
if err == nil && res.OK {
a.markExtUploaded(svc, id, res.LogID)
uploaded++
emit(call + " — OK")
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(call + " — FAILED: " + msg)
}
}
} else {
// QRZ.com: one record per request (its logbook API has no batch upload),
// paced for the same reason as HRDLog above.
@@ -11681,6 +11846,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
label := map[extsvc.Service]string{
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
}[svc]
if label == "" {
label = string(svc)
@@ -12906,6 +13072,90 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02"))
}
case extsvc.ServiceClublog:
sinceDate := resolveSince(keyExtClublogLastDownload)
if sinceDate != "" {
emit("Downloading Club Log matches (matched since " + sinceDate + ")…")
} else {
emit("Downloading ALL Club Log matches (full pull — the date filter is faster next time)…")
}
matches, err := extsvc.DownloadClublogMatches(ctx, nil, cfg.Clublog, sinceDate)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
return
}
mIdx, kerr := a.qso.BuildMatchIndex(ctx, a.uploadOwnerCall(extsvc.ServiceClublog))
if kerr != nil {
emit("Error reading local log: " + kerr.Error())
done(matched, total)
return
}
// Club Log pairs QSOs within ±15 minutes, same as LoTW.
const clublogMatchWindow = 15 * time.Minute
today := time.Now().UTC().Format("20060102")
// A Club Log match is Club Log's own kind of confirmation, so NEW is
// judged only against other Club Log matches.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"clublog_qso_download_status"})
var items []ConfirmationItem
var unmatched []string
for _, m := range matches {
if ctx.Err() != nil {
return
}
total++
id, found := int64(0), false
if m.Band != "" && m.Mode != "" {
id, found = mIdx.Match(m.Callsign, m.Band, m.Mode, m.When, clublogMatchWindow)
}
if !found && m.Band != "" {
// Club Log reports "false" for modes it can't infer — fall back to
// call+band+time, still unambiguous inside a 15-minute window.
id, found = mIdx.MatchBand(m.Callsign, m.Band, m.When, clublogMatchWindow)
}
if found {
if e := a.qso.MarkClublogConfirmed(ctx, id, today); e == nil {
matched++
}
} else {
unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
m.Callsign, m.When.Format("2006-01-02 15:04Z"), m.Band, m.Mode))
}
it := ConfirmationItem{
Callsign: m.Callsign,
QSODate: m.When.Format(time.RFC3339),
Band: m.Band, Mode: m.Mode,
}
if m.DXCC != 0 {
it.Country = dxcc.NameForDXCC(m.DXCC)
it.NewDXCC = !sets.DXCC[m.DXCC]
it.NewBand = !sets.Band[qso.BandKey(m.DXCC, m.Band)]
it.NewMode = !sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)]
it.NewSlot = !sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)]
sets.DXCC[m.DXCC] = true
sets.Band[qso.BandKey(m.DXCC, m.Band)] = true
sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)] = true
sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)] = true
}
items = append(items, it)
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
emit(fmt.Sprintf("Matched %d of %d Club Log match(es)", matched, total))
if addNotFound && len(unmatched) > 0 {
// A match means BOTH sides uploaded this QSO — one Club Log can't find
// locally was deleted or edited here, not missed; adding a skeleton
// row from call/band/time alone would just create a duplicate shadow.
emit("Club Log matches carry too little detail to add missing QSOs — unmatched ones are listed below.")
}
for _, u := range unmatched {
emit(" ⚠ no local QSO for: " + u)
}
if a.settings != nil {
a.setSetting(a.profileScope()+keyExtClublogLastDownload, time.Now().UTC().Format("2006-01-02"))
}
default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
}
@@ -13332,14 +13582,21 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
// is not remembered — hence no on-close mode and no manual backlog.
return true
case extsvc.ServiceHamlog:
// The stamp is an extra, not a column — see markExtUploaded. Present means
// it has gone, which is what stops an on-demand re-upload of a whole log
// from sending every contact twice.
if q.Extras != nil && strings.TrimSpace(q.Extras[hamlogSentKey]) != "" {
// The stamp is an extra, not a column — see markExtUploaded. A stamp that
// MEANS SENT is what stops an on-demand re-upload of a whole log from
// sending every contact twice.
if q.Extras != nil && extrasSaysSent(q.Extras[hamlogSentKey]) {
applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey])
return false
}
return true
case extsvc.ServiceHamQTH:
// Same extras stamp as HAMLOG.online — ADIF names no HamQTH field.
if q.Extras != nil && extrasSaysSent(q.Extras[hamqthSentKey]) {
applog.Printf("extsvc: QSO %d not eligible for hamqth — already sent on %s", id, q.Extras[hamqthSentKey])
return false
}
return true
case extsvc.ServiceLoTW:
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
if strings.EqualFold(q.LOTWSent, f) {
@@ -13360,8 +13617,26 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
const (
hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT"
hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE"
hamqthSentKey = "APP_OPSLOG_HAMQTH_SENT"
hamqthSentDateKey = "APP_OPSLOG_HAMQTH_SENT_DATE"
)
// extrasSaysSent reads an extras upload stamp as "this QSO has GONE".
//
// Mere presence will not do. markExtUploaded writes "Y" (older builds wrote the
// date), but the Confirmations page pre-fills the same key with a TO-DO status
// — "R", requested, is the natural default for a service you intend to upload
// to — and reading that as "already gone" would silently disable the very
// auto-upload the default was set to arm. So the pending statuses mean pending,
// and anything else means sent.
func extrasSaysSent(v string) bool {
switch strings.ToUpper(strings.TrimSpace(v)) {
case "", "R", "N", "Q", "I":
return false
}
return true
}
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
date := time.Now().UTC().Format("20060102")
// Use a fresh background context, NOT a.ctx: this stamp often runs during
@@ -13407,6 +13682,10 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date)
}
case extsvc.ServiceHamQTH:
if err = a.qso.SetExtra(ctx, id, hamqthSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamqthSentDateKey, date)
}
}
if err != nil {
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
@@ -13905,7 +14184,13 @@ func (a *App) consumeUDPEvents() {
"low_conf": ev.DecodeLowConf,
"mode_raw": ev.DecodeModeRaw,
"msg_raw": ev.DecodeMsgRaw,
// false on a Replay's resent history — shown, never auto-answered.
"is_new": ev.DecodeIsNew,
})
// Log-aware colour in the decoder's own window (see
// app_wsjt_highlight.go). After the emit: painting must never delay
// the panel.
a.maybeHighlightDecode(ev.ProgramID, ev.DecodeCall, bandForHz(ev.DecodeFreqHz))
// A WSJT-X decode (heard station). Render it on the FlexRadio
// panadapter when the option is on; green + SNR comment, auto-expiring
// after the configured duration. De-duped per call in the Flex backend.
@@ -16063,6 +16348,10 @@ func (a *App) reloadAfterProfileSwitch() {
a.restartAsync("tuner", a.startTunerGenius)
a.restartAsync("psu", a.startPSU)
a.startQSORecorderIfEnabled()
// The watchlist's Club Log enrichment (OQRS/QSO-count badges) reads
// per-profile settings too — rebuilt so a switch doesn't keep showing the
// previous profile's view of who is worth chasing.
a.startWatchlistClubLog()
}
// DuplicateProfile clones an existing profile under newName. Useful when
@@ -18514,19 +18803,51 @@ func (a *App) startAmps() {
old := a.ampInsts
a.ampInsts = map[string]*ampInst{}
a.ampsMu.Unlock()
for _, inst := range old {
a.pgxl, a.spe, a.acom = nil, nil, nil
list, err := a.GetAmplifiers()
if err != nil {
for _, inst := range old {
go inst.stopAll()
}
return
}
// An amplifier whose configuration did not change keeps its RUNNING client
// across a settings save. Rebuilding closes the serial port, and closing a
// COM port makes the Windows driver drop DTR/RTS — which on a KPA500 is the
// POWER SWITCH: every save of any Settings page was switching the amplifier
// off (F1TPL's report). Reuse also spares the other amps a reconnect blink.
reused := map[string]bool{}
for _, c := range list {
if !c.Enabled {
continue
}
if o, ok := old[c.ID]; ok && o.cfg == c {
reused[c.ID] = true
if o.pgxl != nil && a.pgxl == nil {
a.pgxl = o.pgxl
}
if o.spe != nil && a.spe == nil {
a.spe = o.spe
}
if o.acom != nil && a.acom == nil {
a.acom = o.acom
}
a.ampsMu.Lock()
a.ampInsts[c.ID] = o
a.ampsMu.Unlock()
}
}
for id, inst := range old {
if reused[id] {
continue
}
// Stop() can block up to the dial timeout waiting for an in-progress
// connect; tear down in the background so saving Settings (this runs on
// the Wails RPC goroutine) doesn't freeze the UI.
go inst.stopAll()
}
a.pgxl, a.spe, a.acom = nil, nil, nil
list, err := a.GetAmplifiers()
if err != nil {
return
}
for _, c := range list {
if !c.Enabled {
if !c.Enabled || reused[c.ID] {
continue
}
isPGXL := c.Type == "" || c.Type == "pgxl"
@@ -19786,9 +20107,19 @@ type SpotStatus struct {
// County and State are that resolved county, so the cluster can show it as a
// column instead of only flagging it. Free: the ULS lookup that decides
// NewCounty already has them in hand.
County string `json:"county,omitempty"`
State string `json:"state,omitempty"`
NewPOTA bool `json:"new_pota"`
County string `json:"county,omitempty"`
State string `json:"state,omitempty"`
// NewState says this US state has never been worked — the WAS gap the
// decode panel's filter chases. Orthogonal to Status, like NewCounty.
NewState bool `json:"new_state"`
// The Unconf flags say the matching need exists ONLY because the contact
// was never confirmed (under the operator's chosen QSL sources): a QSL to
// chase, not a QSO to make. The badges render them dashed, the grid way.
UnconfStatus bool `json:"unconf_status,omitempty"`
UnconfPfx bool `json:"unconf_pfx,omitempty"`
UnconfCty bool `json:"unconf_cty,omitempty"`
UnconfState bool `json:"unconf_state,omitempty"`
NewPOTA bool `json:"new_pota"`
// Grid is the 4-character square this station announced in a CQ on the UDP
// link, and NewGrid says that square has never been worked. Both are empty /
// false for any station this receiver has not decoded — a DX-cluster line
@@ -19836,6 +20167,17 @@ type clusterStatusCache struct {
workedCallSlots map[string]struct{}
workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on
workedCounties map[string]struct{}
workedStates map[string]struct{}
// The CONFIRMED-only ledgers, built when the global hunt is
// "new + unconfirmed": verdicts are judged against these, and a need that
// exists only because a contact was never confirmed is flagged Unconf so
// its badge can say "a QSL to chase", not "a QSO to make".
chaseUnconf bool
chaseKey string
entitiesConf map[int]*qso.EntitySlot
workedPfxConf map[string]struct{}
workedCountiesConf map[string]struct{}
workedStatesConf map[string]struct{}
// callCounties holds callsign → "STATE,County" for stations already logged
// with a county, so a spot shows the county the entry panel showed rather
// than the one derived from the licence ZIP. See qso.CallCounties.
@@ -19856,6 +20198,50 @@ type clusterStatusCache struct {
slotHighlight bool // (same: the slot index is built for either)
}
// ChaseSettings is the global hunt: does "worked but never confirmed" still
// count as something to chase, and which QSL systems count as confirmation.
type ChaseSettings struct {
Mode string `json:"mode"` // "new" | "new_unconfirmed"
Sources []string `json:"sources"` // subset of lotw,card,eqsl,qrz
}
// GetChaseSettings returns the global hunt settings.
func (a *App) GetChaseSettings() ChaseSettings {
mode := a.settingOr(keyChaseMode, "new")
if mode != "new_unconfirmed" {
mode = "new"
}
csv := a.settingOr(keyChaseConfirm, "lotw,card,eqsl")
var src []string
for _, part := range strings.Split(csv, ",") {
if part = strings.TrimSpace(part); part != "" {
src = append(src, part)
}
}
return ChaseSettings{Mode: mode, Sources: src}
}
// SaveChaseSettings stores them and drops the status snapshot so the next
// batch is judged under the new rules.
func (a *App) SaveChaseSettings(cs ChaseSettings) error {
mode := cs.Mode
if mode != "new_unconfirmed" {
mode = "new"
}
a.setSetting(keyChaseMode, mode)
a.setSetting(keyChaseConfirm, strings.Join(cs.Sources, ","))
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
// The frontend caches resolved verdicts and only asks about unknown keys —
// without this it kept judging half the screen under the OLD rules until a
// restart, which read as the new mode simply not working.
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
}
return nil
}
// clusterStatusMaps returns the cached worked-index snapshot, building it once
// per logbook change (invalidated by invalidateAwardStats) or when a setting
// that shapes the maps flips. This turns the per-batch full-logbook scans into
@@ -19868,16 +20254,23 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// holding the status mutex across that is how two subsystems deadlock.
gs := a.GetGridScopeSettings()
gridScopeNow := lookupGridScope(gs.Scope)
gridHuntNow := gs.Hunt
// The grid hunt FOLLOWS the global chase mode now — the per-grid selector
// grew into the global option, and two switches for one idea is one too
// many. (Same vocabulary: "new" / "new_unconfirmed".)
chase := a.GetChaseSettings()
gridHuntNow := chase.Mode
chaseKeyNow := chase.Mode + "|" + strings.Join(chase.Sources, ",")
a.clusterStatusMu.Lock()
defer a.clusterStatusMu.Unlock()
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot &&
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow {
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow &&
c.chaseKey == chaseKeyNow {
return c
}
c := &clusterStatusCache{
groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
gridScope: gridScopeNow, gridHunt: gridHuntNow,
chaseUnconf: chase.Mode == "new_unconfirmed", chaseKey: chaseKeyNow,
}
if a.qso == nil {
a.clusterStatusIdx = c
@@ -19934,6 +20327,7 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
c.workedStates, _ = a.qso.WorkedStateKeys(a.ctx)
c.callCounties, _ = a.qso.CallCounties(a.ctx)
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
@@ -19944,6 +20338,19 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this
// in step with the WPX award, which does the same thing.
if c.chaseUnconf {
pred := qso.ConfirmSourcesPredicate(chase.Sources)
c.entitiesConf, _ = a.qso.EntitySlotMapPred(a.ctx, keyFor, c.normMode, pred)
c.workedCountiesConf, _ = a.qso.WorkedCountyKeysPred(a.ctx, award.USCountyKey, pred)
c.workedStatesConf, _ = a.qso.WorkedStateKeysPred(a.ctx, pred)
confCalls, _ := a.qso.WorkedCallsignsPred(a.ctx, pred)
c.workedPfxConf = make(map[string]struct{}, len(confCalls))
for call := range confCalls {
if pfx := award.WPXPrefix(call); pfx != "" {
c.workedPfxConf[pfx] = struct{}{}
}
}
}
c.workedPfx = make(map[string]struct{}, len(c.workedCalls))
for call := range c.workedCalls {
if p := award.WPXPrefix(call); p != "" {
@@ -20269,6 +20676,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
workedCounties := idx.workedCounties
workedPOTA := idx.workedPOTA
workedPfx := idx.workedPfx
// The hunt decides the ledger: "new + unconfirmed" judges every category
// against the CONFIRMED sets, and the all-QSO sets then say whether a need
// is a fresh one or a missing QSL (the Unconf flags).
judgeEntities, judgePfx, judgeCounties, judgeStates := entities, workedPfx, workedCounties, idx.workedStates
if idx.chaseUnconf {
judgeEntities, judgePfx, judgeCounties, judgeStates = idx.entitiesConf, idx.workedPfxConf, idx.workedCountiesConf, idx.workedStatesConf
}
normMode := idx.normMode
sameSlot := idx.sameSlot
for i, q := range spots {
@@ -20312,8 +20726,11 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// NEW PFX: the spot's CQ WPX prefix, never worked before.
if p := award.WPXPrefix(q.Call); p != "" {
out[i].Pfx = p
if _, done := workedPfx[p]; !done {
if _, done := judgePfx[p]; !done {
out[i].NewPfx = true
if _, everWorked := workedPfx[p]; everWorked {
out[i].UnconfPfx = true
}
}
}
// NEW POTA: the spot's tagged park, never worked before.
@@ -20376,19 +20793,39 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if cnty, ok := idx.callCounties[q.Call]; ok {
st, name, _ := strings.Cut(cnty, ",")
out[i].State, out[i].County = st, name
// Logged means worked, so this can never be a new county — but say
// so through the same key the flag below uses, not by assumption.
if st != "" {
if _, done := judgeStates[strings.ToUpper(st)]; !done {
out[i].NewState = true
if _, ever := idx.workedStates[strings.ToUpper(st)]; ever {
out[i].UnconfState = true
}
}
}
if key := award.USCountyKey(st, name); key != "" {
if _, done := workedCounties[key]; !done {
if _, done := judgeCounties[key]; !done {
out[i].NewCounty = true
if _, ever := workedCounties[key]; ever {
out[i].UnconfCty = true
}
}
}
} else if a.uls != nil {
if loc, ok := a.uls.Resolve(q.Call); ok {
out[i].County, out[i].State = loc.County, loc.State
if loc.State != "" {
if _, done := judgeStates[strings.ToUpper(loc.State)]; !done {
out[i].NewState = true
if _, ever := idx.workedStates[strings.ToUpper(loc.State)]; ever {
out[i].UnconfState = true
}
}
}
if key := award.USCountyKey(loc.State, loc.County); key != "" {
if _, done := workedCounties[key]; !done {
if _, done := judgeCounties[key]; !done {
out[i].NewCounty = true
if _, ever := workedCounties[key]; ever {
out[i].UnconfCty = true
}
}
}
}
@@ -20416,9 +20853,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if dxccNum == 0 {
continue // can't resolve the spot's entity number → don't guess
}
e, worked := entities[dxccNum]
e, worked := judgeEntities[dxccNum]
if !worked {
out[i].Status = "new"
if _, ever := entities[dxccNum]; ever {
out[i].UnconfStatus = true
}
continue
}
// The check mode goes through the same normaliser as the slot map
@@ -20433,6 +20873,26 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
_, haveSlot := e.Slots[out[i].Band][checkMode]
out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode)
// A need judged against the confirmed ledger that the all-QSO ledger
// says was already worked is a missing QSL, and its badge should read
// as one. Judged at the SAME grain as the status itself.
switch out[i].Status {
case "new-band-mode", "new-band", "new-mode", "new-slot":
if idx.chaseUnconf {
if eAll, ok := entities[dxccNum]; ok {
_, bAll := eAll.Bands[out[i].Band]
_, mAll := eAll.Modes[checkMode]
_, sAll := eAll.Slots[out[i].Band][checkMode]
// "worked", not "": that is what the verdict function says
// when every grain is in the log — the first version
// compared against empty and no band/mode/slot need ever
// dimmed, however many unconfirmed QSOs stood behind it.
if spotEntityStatus(true, bAll, mAll, sAll, out[i].Mode) == "worked" {
out[i].UnconfStatus = true
}
}
}
}
}
return out
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"strings"
"hamlog/internal/applog"
"hamlog/internal/dxped"
)
// ── DXpeditions (ADXO announcements + DX-World news) ────────────────────
//
// What a logger can say that a news reader cannot: whether the announced
// operation is worth chasing. Every activation is judged against THIS log —
// the same verdict the cluster paints on a spot — so the list reads as "what I
// still need", not "what is on the air".
// DXpedition is one announced operation plus what it is worth here.
type DXpedition struct {
dxped.Activation
// Status is the strongest verdict across the announced callsigns, bands and
// modes: "new" (entity never worked) beats "new-band-mode", which beats
// "new-band", "new-mode", "new-slot", and finally "worked". Empty when the
// callsign resolves to no entity — a prefix ADXO knows and cty.dat does not.
Status string `json:"status_chase"`
// Unconfirmed marks a need that is only a missing QSL, so the badge can be
// drawn dimmed exactly as it is in the cluster and the decode list.
Unconfirmed bool `json:"unconfirmed"`
}
// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition
// list shows ONE badge, so the ranking is the whole decision.
var chaseRank = map[string]int{
"new": 6,
"new-band-mode": 5,
"new-band": 4,
"new-mode": 3,
"new-slot": 2,
"worked": 1,
}
// GetDXpeditions returns the announced operations, freshest feed permitting,
// each carrying its chase verdict.
func (a *App) GetDXpeditions() ([]DXpedition, error) {
if a.dxped == nil {
a.dxped = dxped.New()
}
acts, err := a.dxped.Activations(a.ctx)
if err != nil {
applog.Printf("dxped: adxo fetch: %v", err)
if len(acts) == 0 {
return nil, err
}
// Stale data with a logged error beats an empty tab.
}
out := make([]DXpedition, 0, len(acts))
for _, act := range acts {
out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false})
}
a.judgeDXpeditions(out)
return out, nil
}
// judgeDXpeditions fills in the chase verdict, in place.
//
// One ClusterSpotStatuses call for the whole list rather than one per row: the
// worked-index it builds is the expensive part (a full pass over the log), and
// asking it forty times to answer forty rows was the difference between a tab
// that opens and a tab that stalls a remote MySQL for a second.
func (a *App) judgeDXpeditions(list []DXpedition) {
if a.qso == nil || len(list) == 0 {
return
}
type slot struct{ row int }
var queries []SpotQuery
var owners []slot
for i, d := range list {
calls := d.Calls
if len(calls) == 0 {
if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" {
calls = []string{c}
}
}
for _, call := range calls {
// No announced band/mode: ask the entity-level question alone.
if len(d.Bands) == 0 && len(d.Modes) == 0 {
queries = append(queries, SpotQuery{Call: call})
owners = append(owners, slot{i})
continue
}
bands := d.Bands
if len(bands) == 0 {
bands = []string{""}
}
modes := d.Modes
if len(modes) == 0 {
modes = []string{""}
}
for _, b := range bands {
for _, m := range modes {
queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m})
owners = append(owners, slot{i})
}
}
}
}
if len(queries) == 0 {
return
}
res := a.ClusterSpotStatuses(queries)
for i, r := range res {
if i >= len(owners) {
break
}
row := owners[i].row
if chaseRank[r.Status] > chaseRank[list[row].Status] {
list[row].Status = r.Status
list[row].Unconfirmed = r.UnconfStatus
}
}
}
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
// of each one so the reader can watch them.
//
// Deliberately NOT judged against the log. A headline names no band, so the
// only verdict available is entity-level, and a pane of "NEW DXCC" and
// "worked" badges turned out to say nothing an operator could act on — the
// same two words on every row is noise wearing the clothes of information.
// The announcements pane, which knows the bands and modes, is where a chase
// verdict is worth drawing.
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
if a.dxped == nil {
a.dxped = dxped.New()
}
news, err := a.dxped.News(a.ctx)
if err != nil {
applog.Printf("dxped: dx-world fetch: %v", err)
if len(news) == 0 {
return nil, err
}
}
return news, nil
}
// RefreshDXpeditions drops both caches so the next read goes to the network.
// Wired to the tab's refresh button: an operator who has just read of a landing
// on a cluster should not wait out the cache to see it here.
func (a *App) RefreshDXpeditions() {
if a.dxped == nil {
a.dxped = dxped.New()
}
a.dxped.Invalidate()
}
+1
View File
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtHamqthPassword: true,
keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true,
// The web-publish config is one JSON blob and the FTP password lives inside
+26 -2
View File
@@ -55,7 +55,11 @@ func (a *App) WatchlistAdd(callsign string, contest bool) error {
if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized")
}
return a.watchlist.Add(callsign, contest)
if err := a.watchlist.Add(callsign, contest); err != nil {
return err
}
a.notifyWatchlist(callsign, true)
return nil
}
// WatchlistRemove deletes an entry.
@@ -63,7 +67,27 @@ func (a *App) WatchlistRemove(callsign string) error {
if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized")
}
return a.watchlist.Remove(callsign)
if err := a.watchlist.Remove(callsign); err != nil {
return err
}
a.notifyWatchlist(callsign, false)
return nil
}
// notifyWatchlist announces a membership change to the UI.
//
// Emitted from the BINDINGS rather than from the watchlist panel, because a
// call can now be added from three places (the panel, the cluster's menu, the
// DXpeditions tab) and an operator who added one from the cluster saw nothing
// at all — the confirmation lived inside a panel they were not looking at.
func (a *App) notifyWatchlist(callsign string, added bool) {
if a.ctx == nil {
return
}
wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{
"call": strings.ToUpper(strings.TrimSpace(callsign)),
"added": added,
})
}
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
+155
View File
@@ -0,0 +1,155 @@
package main
// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13),
// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a
// new band for its entity is highlighted where the operator is actually
// looking. The verdicts come from the same cluster status cache that colours
// the spot grid, so the two windows can never disagree.
import (
"strings"
"hamlog/internal/applog"
"hamlog/internal/dxcc"
udp "hamlog/internal/integrations/udp"
)
const (
keyWsjtHighlight = "udp.wsjt.highlight"
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
)
// wsjtModes are the modes a Configure message can meaningfully ask for — the
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
// business and is not sent.
var wsjtModes = map[string]bool{
"FT8": true, "FT4": true, "JT65": true, "JT9": true,
"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
}
// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
func (a *App) GetWsjtFollowMode() bool {
return a.settingOr(keyWsjtFollowMode, "1") == "1"
}
// SetWsjtFollowMode flips it.
func (a *App) SetWsjtFollowMode(on bool) {
v := "0"
if on {
v = "1"
}
a.setSetting(keyWsjtFollowMode, v)
}
// ConfigureDecoderMode asks the connected decoders to switch mode — called by
// the frontend after a spot click has tuned the radio. A no-op for modes the
// decoder does not speak, and when the option is off or nothing is connected.
func (a *App) ConfigureDecoderMode(mode string) {
mode = strings.ToUpper(strings.TrimSpace(mode))
if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
return
}
a.udp.SendConfigureMode(mode)
}
// The palette. Fixed colours, not theme tokens — they are painted into another
// application's window, which has no idea what theme OpsLog wears.
var (
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
hlWhite = udp.RGB{R: 255, G: 255, B: 255}
hlBlack = udp.RGB{R: 20, G: 20, B: 20}
)
// GetWsjtHighlight reports whether decode highlighting is on.
func (a *App) GetWsjtHighlight() bool {
return a.settingOr(keyWsjtHighlight, "0") == "1"
}
// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also
// clears every instruction OpsLog installed in the running applications — a
// disabled option that leaves stale colours behind looks broken, not disabled.
func (a *App) SetWsjtHighlight(on bool) {
v := "0"
if on {
v = "1"
}
a.setSetting(keyWsjtHighlight, v)
a.wsjtHighlightOn.Store(on)
if !on && a.udp != nil {
for _, inst := range a.udp.Instances() {
_ = a.udp.SendClearHighlights(inst)
}
a.wsjtHLMu.Lock()
a.wsjtHLSent = map[string]string{}
a.wsjtHLMu.Unlock()
applog.Printf("wsjt highlight: off — cleared in every instance")
}
}
// maybeHighlightDecode paints one decoded callsign in the instance that heard
// it, when the option is on and the verdict is worth a colour. De-duplicated
// per instance+call+verdict: a station CQing all evening is decoded four times
// a minute, and the instruction only needs to be said once.
func (a *App) maybeHighlightDecode(instance, call, band string) {
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
return
}
bg, fg, verdict := a.decodeHighlightVerdict(call, band)
key := instance + "|" + strings.ToUpper(call) + "|" + band
a.wsjtHLMu.Lock()
if a.wsjtHLSent == nil {
a.wsjtHLSent = map[string]string{}
}
if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few
a.wsjtHLSent = map[string]string{}
}
prev, had := a.wsjtHLSent[key]
if had && prev == verdict {
a.wsjtHLMu.Unlock()
return
}
a.wsjtHLSent[key] = verdict
a.wsjtHLMu.Unlock()
if verdict == "" {
// Was highlighted under an earlier verdict and no longer deserves it
// (the operator just worked them): clear that one callsign.
if had && prev != "" {
_ = a.udp.SendHighlight(instance, call, nil, nil, false)
}
return
}
_ = a.udp.SendHighlight(instance, call, bg, fg, false)
}
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
// new-band; anything else is "no colour". The empty verdict doubles as the
// clear signal in maybeHighlightDecode.
func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdict string) {
if a.watchlist != nil {
if _, ok := a.watchlist.Match(call); ok {
c := hlWatchlist
f := hlBlack
return &c, &f, "watchlist"
}
}
c := a.clusterStatusMaps()
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
num := dxcc.EntityDXCC(m.Entity.Name)
ent := c.entities[num]
if ent == nil {
bgc, fgc := hlNewDXCC, hlWhite
return &bgc, &fgc, "new-dxcc"
}
if band != "" {
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
bgc, fgc := hlNewBand, hlBlack
return &bgc, &fgc, "new-band"
}
}
}
}
return nil, nil, ""
}
+6
View File
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
id := m[1]
// Handled before the column map: freq takes a numeric path (freq_hz +
// band together) and the extras live in extras_json.
// The integer My-station fields take their own numeric path
// (BulkSetIntField + bulkEditableIntCols in the repo), added after this
// guard was written — which is exactly the drift it exists to catch.
if id == "my_dxcc" || id == "my_cq_zone" || id == "my_itu_zone" {
continue
}
if id == "freq" || qso.IsBulkEditableExtra(id) {
continue
}
+118
View File
@@ -1,4 +1,122 @@
[
{
"version": "0.27.6",
"date": "",
"en": [
"Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it.",
"HamQTH upload: 8th external service — real-time QSO upload to the HamQTH online logbook with your callbook credentials (auto-upload on log, “Send to…” right-click, QSL Manager backlog upload, connection test).",
"Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online.",
"LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county.",
"DX Cluster: two new chase switches — Chase US counties and Chase new prefixes — and unchecking Chase new grids now also withdraws the NEW GRID badge. Each switch removes its badge from the spots AND its chip from the status filters, like Chase POTA always did.",
"Confirmations: a HamQTH row — sent only, defaulting to R (to upload), since HamQTH publishes no confirmations to receive. Setting such a default no longer disables the auto-upload it was meant to arm (it also affected HAMLOG.online).",
"New DXpeditions tab (Tools): the announced operations from NG3Ks ADXO next to the DX-World news feed. Every announcement is judged against YOUR log and carries one badge — NEW DXCC, NEW BAND, NEW SLOT… — with an “only what I need” filter, and one click adds its callsigns to the watchlist. The news headlines carry the same Watch button, over the callsigns mined out of their titles.",
"Watchlist: adding or removing a callsign now raises the same kind of notification as a new version, instead of a message inside the watchlist page — a call can be added from the cluster or the DXpeditions tab, where that message was never seen.",
"QSO editor, QSL Info: a HamQTH channel and its row in the status table — sent only, the received column showing a dash since the site publishes no confirmations.",
"QSO editor, QSL Info: the confirmation channels are listed paper QSL and LoTW first — the two that carry an ARRL award — then alphabetically, in both the picker and the status table.",
"Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions.",
"Band map: ctrl+wheel no longer zooms it — that gesture is the window zoom everywhere else in OpsLog. The + and buttons keep the zoom.",
"DX Cluster: the server pills drop the CONNECTED/DISCONNECTED word — the colour already said it — and clicking one now connects or disconnects that server on its own, without opening Settings. State, retries, address and last error moved into the tooltip."
],
"fr": [
"Changer de base de réglages naffiche plus « OpsLog is already running » : la relance automatique attend désormais que linstance qui se ferme libère son verrou au lieu de la prendre de vitesse.",
"Upload HamQTH : 8e service externe — envoi des QSO en temps réel vers le logbook HamQTH avec vos identifiants du lookup (upload auto au log, « Envoyer vers… » au clic droit, rattrapage via le QSL Manager, test de connexion).",
"Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online.",
"LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf sil a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout lenregistrement). Lexport cesse aussi de coller un nom d’état complet devant le comté.",
"DX Cluster : deux nouvelles cases — Chasser les comtés US et Chasser les nouveaux préfixes — et décocher Chasser les nouveaux locators retire désormais aussi le badge NEW GRID. Chaque case enlève son badge des spots ET sa puce des filtres de statut, comme Chase POTA le faisait déjà.",
"Confirmations : une ligne HamQTH — envoi seulement, à R (à envoyer) par défaut, HamQTH ne publiant aucune confirmation à recevoir. Définir un tel défaut ne désactive plus lupload automatique quil était censé armer (cela touchait aussi HAMLOG.online).",
"Nouvel onglet DXpéditions (Outils) : les opérations annoncées par lADXO de NG3K à côté du fil dactualités DX-World. Chaque annonce est jugée sur VOTRE log et porte un badge — NOUVEAU DXCC, NOUVELLE BANDE, NOUVEAU SLOT… — avec un filtre « seulement ce quil me manque », et un clic ajoute ses indicatifs à la watchlist. Les actualités portent le même bouton Surveiller, sur les indicatifs extraits de leurs titres.",
"Watchlist : ajouter ou retirer un indicatif déclenche désormais une notification du même type que celle des nouvelles versions, au lieu dun message dans la page watchlist — un indicatif peut être ajouté depuis le cluster ou longlet DXpéditions, où ce message n’était jamais vu.",
"Éditeur de QSO, onglet QSL : un canal HamQTH et sa ligne dans le tableau des statuts — envoi seulement, la colonne reçu affichant un tiret puisque le site ne publie aucune confirmation.",
"Éditeur de QSO, onglet QSL : les canaux de confirmation sont classés QSL papier puis LoTW — les deux qui comptent pour un diplôme ARRL — puis par ordre alphabétique, dans le sélecteur comme dans le tableau.",
"Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé dune session à lautre.",
"Band map : ctrl+molette ne zoome plus la carte — ce geste est le zoom de la fenêtre partout ailleurs dans OpsLog. Les boutons + et gardent le zoom.",
"DX Cluster : les pastilles de serveur perdent le mot CONNECTED/DISCONNECTED — la couleur le disait déjà — et cliquer sur lune connecte ou déconnecte ce serveur seul, sans passer par les réglages. État, tentatives, adresse et dernière erreur passent dans linfobulle."
]
},
{
"version": "0.27.5",
"date": "",
"en": [
"QSL Manager: Club Log confirmations — downloads your log matches (getmatches API) and stamps two new columns, ClubLog match status and match date, available everywhere: table columns, filters, bulk edit and the QSO editor.",
"Super Check Partial: option to merge Club Logs weekly call list (~180k calls heard on the air in the last 3 years) with MASTER.SCP.",
"Fixed: opening the app could silently stop at startup (settings showing as default, “db not initialized”) when a database migration targeted a table that database no longer holds — migrations now skip what does not apply, and a startup failure is written to the log.",
"FT Map / Grid squares: the map no longer floats above the menus and the Preferences dialog.",
"KPA500: saving ANY settings page no longer power-cycles the amplifier. A save rebuilt every amplifier connection, and closing the COM port drops DTR/RTS — the KPA500s power switch. An unchanged amplifier now keeps its running connection across saves.",
"Column picker: columns are listed alphabetically inside each group.",
"Confirmations: a “Club Log received” default for new QSOs, set to No out of the box — the match download flips it to Y."
],
"fr": [
"QSL Manager : confirmations Club Log — télécharge vos matches de log (API getmatches) et remplit deux nouvelles colonnes, statut et date de match ClubLog, disponibles partout : colonnes du tableau, filtres, édition groupée et éditeur de QSO.",
"Super Check Partial : option pour fusionner la liste hebdomadaire de Club Log (~180k indicatifs entendus sur lair ces 3 dernières années) avec MASTER.SCP.",
"Corrigé : lapplication pouvait se figer silencieusement au démarrage (réglages par défaut, « db not initialized ») quand une migration visait une table absente de cette base — les migrations ignorent désormais ce qui ne sapplique pas, et un échec de démarrage est écrit dans le log.",
"FT Map / Grid squares : la carte ne passe plus au-dessus des menus et de la fenêtre Préférences.",
"KPA500 : sauvegarder nimporte quelle page des réglages n’éteint plus lampli. Une sauvegarde reconstruisait chaque connexion dampli, et fermer le port COM relâche DTR/RTS — linterrupteur dalimentation du KPA500. Un ampli inchangé garde désormais sa connexion à travers les sauvegardes.",
"Sélecteur de colonnes : les colonnes sont triées alphabétiquement dans chaque groupe.",
"Confirmations : un défaut « Club Log reçu » pour les nouveaux QSO, à Non par défaut — le téléchargement des matches le passe à Y."
]
},
{
"version": "0.27.4",
"date": "",
"en": [
"FT decodes: a State column between the locator and the country — the two-letter badge plus the full name — for the WAS chasers.",
"FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete.",
"The hunt goes global: a “Chase” setting (DX Cluster page) decides for EVERY category — DXCC, band, mode, slot, prefix, county, state, grid — whether “worked but never confirmed” still counts as something to chase, judged against the confirmation sources you pick (LoTW, QSL card, eQSL, QRZ.com). Such needs show as dimmed badges: a QSL to chase, not a QSO to make. The grids own Chase selector folds into it.",
"New FTx menu gathering FT Decodes, the new FT Map and the Grid squares map.",
"FT Map: a world map of the live FTx decodes — great-circle arcs from your QTH to every station heard in the last 30 minutes, coloured by band, with the PSK-Reporter palette and a basemap picker.",
"Maps: one single world (no more side-by-side copies), the surround follows the theme colour, and zooming stays centred — on the FT Map and the Grid squares map.",
"Watchlist: fixed columns in the spot rows, so band, mode and frequency line up instead of drifting with the country name."
],
"fr": [
"FT decodes : une colonne État entre le locator et le pays — le badge deux lettres plus le nom complet — pour les chasseurs de WAS.",
"FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté sallume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète.",
"La chasse devient globale : un réglage « Chasse » (page DX Cluster) décide pour TOUTES les catégories — DXCC, bande, mode, slot, préfixe, comté, état, grille — si « contacté mais jamais confirmé » reste à chasser, jugé selon les sources de confirmation choisies (LoTW, carte QSL, eQSL, QRZ.com). Ces besoins saffichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans.",
"Nouveau menu FTx regroupant FT Decodes, la nouvelle FT Map et la carte Grid squares.",
"FT Map : une carte du monde des décodages FTx en direct — arcs orthodromiques depuis votre QTH vers chaque station entendue dans les 30 dernières minutes, colorés par bande, avec la palette PSK Reporter et un choix de fond de carte.",
"Cartes : un seul monde (fini les copies côte à côte), le pourtour suit la couleur du thème et le zoom reste centré — sur la FT Map et la carte Grid squares.",
"Watchlist : colonnes fixes dans les lignes de spots — bande, mode et fréquence s'alignent au lieu de dériver avec le nom du pays."
]
},
{
"version": "0.27.3",
"date": "",
"en": [
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.",
"FT decodes: within a period, decodes are listed in arrival order — mirroring the decoders own window — instead of strongest-first.",
"Voice keyer: twelve message slots (F1F12) instead of six.",
"Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes.",
"Voice keyer: a delete button per message — removes the recording and clears the label."
],
"fr": [
"KPA500 : lampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont linterrupteur du KPA500. Les lignes sont désormais tenues stables, le silence nest plus traité comme un lien mort, et le baud se choisit dans une liste.",
"FT decodes : dans une période, les décodages sont listés dans lordre darrivée — comme la fenêtre du décodeur — au lieu du plus fort dabord.",
"Manipulateur vocal : douze messages (F1F12) au lieu de six.",
"Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture.",
"Manipulateur vocal : un bouton supprimer par message — efface lenregistrement et le libellé."
]
},
{
"version": "0.27.2",
"date": "",
"en": [
"Bulk operations work on any size of selection — setting a field, fixing frequencies, deleting, marking uploads and exporting the selection all failed with “too many SQL variables” past a few tens of thousands of QSOs. Statements are now issued in slices.",
"Elecraft console: the power meter reads in real watts. The K3s bargraph is relative to a range that flips at 12 W — calibrated against a real radios full table, the PC setting picks the range and the bar converts to watts.",
"Watchlist: a visual pass toward DXHunters look — pink callsigns, counter pills, quieter cards with a hover, the ⚡ back on the DXpedition badge.",
"WSJT-X / JTDX: OpsLog can highlight decodes in the decoders own Band Activity window from your log — watchlist members pink, new DXCC green, new band orange (option in Settings → Connections). And a freshly-started decoder is asked to replay its on-screen decodes, so the FT decodes panel starts full.",
"WSJT-X / JTDX / MSHV: only a CHANGED DX Call updates the entry — the decoder re-broadcasts the same call endlessly, and it kept overwriting a spot clicked in OpsLog.",
"Map: Zoom DX toward a polar entity no longer frames a band of blank white above the top of the world — the camera stays within the maps ±85°, the path still draws.",
"WSJT-X / JTDX / MSHV: clicking a spot in a digital mode the decoder speaks (FT8, FT4, JT65…) switches the decoders mode too — option in Settings → Connections, on by default."
],
"fr": [
"Les opérations groupées fonctionnent quelle que soit la taille de la sélection — définir un champ, corriger des fréquences, supprimer, marquer les uploads et exporter la sélection échouaient avec « too many SQL variables » au-delà de quelques dizaines de milliers de QSO. Les requêtes sont désormais émises par tranches.",
"Console Elecraft : le wattmètre lit en vrais watts. Le bargraph du K3 est relatif à une gamme qui bascule à 12 W — calibré sur la table complète dune vraie radio, le réglage PC choisit la gamme et la barre se convertit en watts.",
"Watchlist : une passe visuelle vers le look DXHunter — indicatifs roses, compteurs en pastilles, cartes plus feutrées avec survol, le ⚡ de retour sur le badge DXpedition.",
"WSJT-X / JTDX : OpsLog peut surligner les décodages dans la fenêtre Band Activity du décodeur selon votre log — watchlist en rose, nouveau DXCC en vert, nouvelle bande en orange (option dans Réglages → Connections). Et un décodeur fraîchement détecté rejoue ses décodages à l’écran, donc le panneau FT decodes démarre plein.",
"WSJT-X / JTDX / MSHV : seul un DX Call qui CHANGE met à jour la saisie — le décodeur rediffuse le même call sans fin, et il écrasait un spot cliqué dans OpsLog.",
"Carte : Zoom DX vers une entité polaire ne cadre plus une bande blanche au-dessus du haut du monde — la caméra reste dans les ±85° de la carte, le trajet se dessine toujours.",
"WSJT-X / JTDX / MSHV : cliquer un spot dans un mode numérique que le décodeur parle (FT8, FT4, JT65…) change aussi le mode du décodeur — option dans Réglages → Connections, activée par défaut."
]
},
{
"version": "0.27.1",
"date": "",
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"testing"
"hamlog/internal/qso"
)
// A Confirmations default of "R" means "I intend to upload this", not "it has
// gone" — reading it as sent would silently disable the auto-upload the default
// was set to arm. Only a real stamp blocks.
func TestExtrasSaysSent(t *testing.T) {
pending := []string{"", " ", "R", "r", "N", "Q", "I"}
for _, v := range pending {
if extrasSaysSent(v) {
t.Errorf("extrasSaysSent(%q) = true, want false (still to upload)", v)
}
}
sent := []string{"Y", "y", "20260831"} // "Y" today, a date in older builds
for _, v := range sent {
if !extrasSaysSent(v) {
t.Errorf("extrasSaysSent(%q) = false, want true (already uploaded)", v)
}
}
}
// The HamQTH default lands on the extras key the uploader reads, and must leave
// the QSO eligible.
func TestHamQTHDefaultStaysUploadable(t *testing.T) {
q := &qso.QSO{Callsign: "F4BPO"}
applyQSLDefaultsTo(q, defaultQSLDefaults())
if got := q.Extras[hamqthSentKey]; got != "R" {
t.Fatalf("HamQTH sent extra = %q, want %q", got, "R")
}
if extrasSaysSent(q.Extras[hamqthSentKey]) {
t.Error("a freshly logged QSO reads as already uploaded to HamQTH")
}
}
+220 -16
View File
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
@@ -27,13 +27,13 @@ import {
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
GetScpStatus, ScpLookup,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ConnectAllClusters, DisconnectAllClusters, ConnectClusterServer, DisconnectClusterServer, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
GetSolarData,
GetQSORate,
LoTWUserInfo,
OperatingDefaultForBand, ActiveRadioMyRig,
OperatingDefaultForBand, ActiveRadioMyRig, ConfigureDecoderMode,
LogUDPLoggedADIF,
ListCountries,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
@@ -75,6 +75,8 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel';
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
@@ -99,7 +101,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { applySpotDisplay, chaseCounty, chaseGrid, chasePfx, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
@@ -1313,6 +1315,28 @@ export default function App() {
writeUiPref('opslog.gridsTab', '0');
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
}
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
function openDxpedTab() {
setDxpedTabOpen(true);
writeUiPref('opslog.dxpedTab', '1');
setActiveTab('dxped');
}
function closeDxpedTab() {
setDxpedTabOpen(false);
writeUiPref('opslog.dxpedTab', '0');
setActiveTab((t) => (t === 'dxped' ? 'recent' : t));
}
function openFtmapTab() {
setFtmapTabOpen(true);
writeUiPref('opslog.ftmapTab', '1');
setActiveTab('ftmap');
}
function closeFtmapTab() {
setFtmapTabOpen(false);
writeUiPref('opslog.ftmapTab', '0');
setActiveTab((t) => (t === 'ftmap' ? 'recent' : t));
}
function openDecodesTab() {
setDecodesTabOpen(true);
writeUiPref('opslog.decodesTab', '1');
@@ -2088,7 +2112,7 @@ export default function App() {
// worked_slot must be carried explicitly like every other field: this map is
// assembled field by field, so a backend flag that nobody copies here simply
// never reaches the panels — silently, since the extra key is just dropped.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus);
@@ -2228,6 +2252,15 @@ export default function App() {
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false);
// While the Settings dialog is open, the spot/decode flushes and the CAT
// snapshot stream are PAUSED (data keeps accumulating in the pending refs).
// Every flush re-renders the whole App tree behind the dialog — cluster
// grid, decodes panel, thousands of nodes — and with a busy cluster plus
// two decoders the pointer visibly stuttered over the preferences.
const showSettingsRef = useRef(false);
useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]);
const flushSpotsRef = useRef<() => void>(() => {});
const flushDecodesRef = useRef<() => void>(() => {});
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
@@ -2325,6 +2358,18 @@ export default function App() {
}, [showSettings]);
const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
// Watchlist membership changes announce themselves here rather than inside
// the watchlist panel: a call can be added from the cluster or the
// DXpeditions tab, and the old inline message lived on a page nobody was
// looking at.
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
const wlNoticeTimer = useRef<number | undefined>(undefined);
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
if (!e?.call) return;
setWlNotice({ call: String(e.call), added: !!e.added });
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
}), []);
const [checkingUpdate, setCheckingUpdate] = useState(false);
// Fresh update check on demand (opening About), so it never shows a stale
// "you're up to date". Clears updateInfo when the latest check finds nothing.
@@ -2566,6 +2611,10 @@ export default function App() {
for (const d of decodes) {
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
if (autoSeenRef.current.has(seenKey)) continue;
// A Replay's resent history is display-only: answering a line the far
// end already dropped would fail anyway, and doing it at startup — the
// moment replays arrive — would be a transmitter firing on old news.
if ((d as any).is_new === false) { autoSeenRef.current.add(seenKey); continue; }
// Only decodes from the CURRENT period are worth answering: replying to a
// slot that has closed asks the far end to match a decode it has dropped.
if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; }
@@ -2663,6 +2712,11 @@ export default function App() {
// "the field still shows the previous broadcast" (safe to update) from "the
// user has typed a different call" (must not clobber).
const lastUdpCallRef = useRef('');
// Edge detection for the DECODER'S stream: WSJT-X/JTDX/MSHV re-broadcast the
// same DX Call in every Status packet, seconds apart, forever. Applying each
// one meant a spot clicked in OpsLog was overwritten moments later by the
// decoder restating old news. Only a CHANGE in this stream is an event.
const lastWsjtEdgeRef = useRef('');
// When the entered callsign turns out to be worked-before, jump to the
// Worked-before tab so the history is front-and-centre. Only once per call,
@@ -3338,6 +3392,10 @@ export default function App() {
void tuneRigCAT(s.freq_hz, m).then(() => window.setTimeout(zoom, 300));
} else zoom();
if (m) applyModeFromSpot(m);
// And the DECODER follows too: an FT4 spot clicked while WSJT-X sits in
// FT8 switches its mode (Configure, message 15). The backend filters —
// only modes the decoder speaks, only when the option is on.
if (m) ConfigureDecoderMode(m).catch(() => {});
onCallsignInput(s.dx_call, { force: true });
applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
@@ -3422,7 +3480,15 @@ export default function App() {
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
// just typed something (freeze window) or locked a field. Shared by the live
// cat:state event and the startup poll below.
const lastCatWhileSettingsRef = useRef(0);
function applyCatState(s: CATState) {
// Behind the Settings dialog nobody reads a frequency four times a second;
// each snapshot re-renders the whole App tree under the pointer.
if (showSettingsRef.current) {
const now = Date.now();
if (now - lastCatWhileSettingsRef.current < 2000) return;
lastCatWhileSettingsRef.current = now;
}
setCatState(s);
if (!s?.connected) return;
// A snapshot arriving during the freeze used to be DROPPED, and that lost the
@@ -3567,11 +3633,19 @@ export default function App() {
// Commit the staged spots: resolve the status for any slot we don't know yet
// FIRST, then insert the rows — so they appear with the right badge already
// painted instead of flashing plain text then flipping to a pill.
// eslint-disable-next-line prefer-const
const flushPendingSpots = async () => {
pendingSpotTimer.current = undefined;
// Settings open: leave everything queued (bounded) and repaint nothing.
if (showSettingsRef.current) {
const cap = spotsCapRef.current;
if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap);
return;
}
const batch = pendingSpotsRef.current;
pendingSpotsRef.current = [];
if (batch.length === 0) return;
// (registered below so closing Settings can drain the queue)
// Resolve unknown statuses before the rows go in.
try {
const known = spotStatusRef.current;
@@ -3601,6 +3675,11 @@ export default function App() {
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
@@ -3627,6 +3706,7 @@ export default function App() {
return next.length > cap ? next.slice(0, cap) : next;
});
};
flushSpotsRef.current = () => { void flushPendingSpots(); };
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
// Stage the spot; a short timer resolves its status then commits it.
pendingSpotsRef.current.push(sp);
@@ -3674,6 +3754,10 @@ export default function App() {
// decodes panel and plain worked in the cluster list two seconds later.
const flushDecodes = async () => {
pendingDecodeTimer.current = undefined;
if (showSettingsRef.current) {
if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000);
return;
}
const batch = pendingDecodesRef.current;
pendingDecodesRef.current = [];
if (batch.length === 0) return;
@@ -3702,6 +3786,11 @@ export default function App() {
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
@@ -3716,6 +3805,7 @@ export default function App() {
return next;
});
};
flushDecodesRef.current = () => { void flushDecodes(); };
const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
pendingDecodesRef.current.push(d);
@@ -3792,6 +3882,13 @@ export default function App() {
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
// over UDP…) is an explicit pick → force it over an existing call.
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
if (!force) {
// The decoder's stream: same value as last time = no edge = no update.
// Only a changed DX Call is the operator doing something over there.
const upper = String(p?.call ?? '').trim().toUpperCase();
if (upper && upper === lastWsjtEdgeRef.current) return;
lastWsjtEdgeRef.current = upper;
}
// External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
});
@@ -3801,7 +3898,16 @@ export default function App() {
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
// Only when something is actually in the entry, so an idle digital app doesn't
// wipe a call being typed by hand.
const unsubStatusInval = EventsOn('spotstatus:invalidate', () => {
// The chase rules changed: every resolved verdict is stale. Cleared, and
// the flushes re-ask as rows repaint.
setSpotStatus({});
spotStatusRef.current = {};
});
const unsubClear = EventsOn('udp:clear_call', () => {
// The decoder cleared its DX Call: the next call it announces — even the
// same one re-selected — is a fresh edge.
lastWsjtEdgeRef.current = '';
if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
});
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
@@ -3861,7 +3967,7 @@ export default function App() {
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
});
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubStatusInval?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -4352,6 +4458,10 @@ export default function App() {
// The stable wrapper the dialog actually receives.
const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
const closeSettings = useCallback(() => {
// Synchronously: the ref effect runs after the next render, and flushing
// through a still-true ref would hit the pause gate again.
showSettingsRef.current = false;
window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50);
setShowSettings(false);
setSettingsSection(undefined);
refreshChaseNew();
@@ -5031,12 +5141,18 @@ export default function App() {
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
]},
// The digital-mode corner gets its own menu: the decode list, its map,
// and the grid-square chase all live off the same UDP feed.
{ name: 'ftx', label: t('menu.ftx'), items: [
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
{ type: 'item', label: t('ftmap.tab'), action: 'tools.ftmap' },
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
]},
{ name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
{ type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
@@ -5089,7 +5205,9 @@ export default function App() {
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.dxped': openDxpedTab(); break;
case 'tools.decodes': openDecodesTab(); break;
case 'tools.ftmap': openFtmapTab(); break;
case 'tools.grids': openGridsTab(); break;
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
@@ -5180,7 +5298,7 @@ export default function App() {
if (dvkActiveRef.current) {
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; }
return;
}
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
@@ -6169,7 +6287,10 @@ export default function App() {
// worked spots; the separate "Hide worked" checkbox drops them — they
// are opposite controls, so don't use both at once.
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
]).filter((s: any) => s.k !== 'new-pota' || chasePota())
]).filter((s: any) => (s.k !== 'new-pota' || chasePota())
&& (s.k !== 'new-county' || chaseCounty())
&& (s.k !== 'new-pfx' || chasePfx())
&& (s.k !== 'new-grid' || chaseGrid()))
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
</div>,
@@ -6981,6 +7102,26 @@ export default function App() {
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
)}
{wlNotice && (
<div className={cn('fixed right-4 z-[150] w-72 rounded-lg border bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in',
wlNotice.added ? 'border-warning/40' : 'border-border',
// Stacked above the update card when both are up.
updateInfo ? 'bottom-44' : 'bottom-4')}>
<div className="flex items-start gap-2">
<Star className={cn('size-4 mt-0.5 shrink-0', wlNotice.added ? 'fill-current text-warning' : 'text-muted-foreground')} />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
{t(wlNotice.added ? 'wlnote.added' : 'wlnote.removed', { call: wlNotice.call })}
</p>
<p className="text-xs text-muted-foreground">{t('wlnote.hint')}</p>
</div>
<button onClick={() => setWlNotice(null)}
className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Close">
<X className="size-3.5" />
</button>
</div>
</div>
)}
{updateInfo && (
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
<div className="flex items-start gap-2">
@@ -7721,6 +7862,36 @@ export default function App() {
</span>
</TabsTrigger>
)}
{dxpedTabOpen && (
<TabsTrigger value="dxped" className="gap-1.5">
{t('dxp.tab')}
<span
role="button"
aria-label="Close DXpeditions"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeDxpedTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{ftmapTabOpen && (
<TabsTrigger value="ftmap" className="gap-1.5">
{t('ftmap.tab')}
<span
role="button"
aria-label="Close FT map"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeFtmapTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{stationTabOpen && (
<TabsTrigger value="station" className="gap-1.5">
{t('station.title')}
@@ -7962,22 +8133,37 @@ export default function App() {
const isMaster = clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
// The colour already says the state, so the word beside it was
// saying it twice — and the pills are the only place a single
// cluster can be reached without opening Settings. Clicking one
// now drops or reopens that session; everything the word used
// to carry (state, retries, last error, address) moves into the
// tooltip, where it costs no width.
const up = s.state === 'connected';
const busy = s.state === 'connecting' || s.state === 'reconnecting';
return (
<span
<button
key={s.server_id}
type="button"
onClick={async () => {
try {
if (up || busy) await DisconnectClusterServer(s.server_id);
else await ConnectClusterServer(s.server_id);
} catch (e: any) { setError(String(e?.message ?? e)); }
await reloadClusterMeta();
}}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border transition-opacity hover:opacity-80',
s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
busy ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-border',
)}
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
title={`${s.name}${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''} · ${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
>
{isMaster && <span className="text-warning" title="Master (commands go here)"></span>}
{s.name}
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
</span>
</button>
);
})}
<div className="flex-1" />
@@ -8319,6 +8505,24 @@ export default function App() {
</TabsContent>
)}
{dxpedTabOpen && (
<TabsContent value="dxped" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'dxped' && (
<div className="h-full w-full min-h-0 p-1">
<DXpeditionsPanel />
</div>
)}
</TabsContent>
)}
{ftmapTabOpen && (
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && (
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid} />
</div>
)}
</TabsContent>
)}
{stationTabOpen && (
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<StationControlPanel
+29 -17
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
@@ -283,6 +283,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
const range = bandRange(band);
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The legend is a reference, not a running display: once its colours are
// learnt it is four lines of a short screen spent saying nothing new. Folded
// away by a toggle, and the choice is remembered.
const [legendOpen, setLegendOpen] = useState(() => {
try { return localStorage.getItem('opslog.bmpLegend') !== '0'; } catch { return true; }
});
const toggleLegend = () => setLegendOpen((v) => {
const next = !v;
try { localStorage.setItem('opslog.bmpLegend', next ? '1' : '0'); } catch { /* private mode */ }
return next;
});
// The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom.
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
@@ -460,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (!range) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
changeZoom(e.deltaY > 0 ? -1 : 1);
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [range]);
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
// app over one panel and one band map over another is a gesture nobody can
// trust. The + / buttons keep the zoom, deliberately and visibly.
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
@@ -753,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
</div>
</div>
{/* Colour legend — what each pill colour means. */}
{legendOpen && (
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
@@ -769,9 +772,18 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
<LegendDot colour={SEG_COLOR.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')}
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
)}
<div className="px-3 py-1 flex items-center gap-2 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono shrink-0">
<span className="flex-1 text-center">
{t('bmp.footerHint')}
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
</span>
<button type="button" onClick={toggleLegend}
title={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
aria-label={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
className="shrink-0 inline-flex items-center gap-0.5 rounded px-1 py-px hover:bg-muted hover:text-foreground">
{legendOpen ? <ChevronDown className="size-3" /> : <ChevronUp className="size-3" />}
</button>
</div>
</div>
);
@@ -46,6 +46,8 @@ const FIELDS: FieldDef[] = [
{ 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: 'clublog_rcvd', label: 'bulk.fClublogRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_rcvd_date', label: 'bulk.fClublogRcvdDate', 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' },
// HAMLOG.online: no promoted column, written into extras_json (see
+8 -3
View File
@@ -65,6 +65,9 @@ export type SpotStatusEntry = {
state?: string;
new_pota?: boolean;
new_pfx?: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
pfx?: string;
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
lotw?: boolean;
@@ -304,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: s?.status === 'new-slot' ? t('clg2.newSlot')
: s?.status === 'new-call' ? t('clg2.newCall')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
// Dimmed when the need is only a missing confirmation — the grid's
// own convention, now spoken by every category.
parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main });
}
// Colours from lib/spotMarkers — shared with the band map so a marker is
// never one colour here and another there.
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
// held back, so it reads as "less" of the same thing rather than a
// different fact — and the label says which.
@@ -0,0 +1,240 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// DXpeditions — the two feeds the DX world announces itself on, side by side.
//
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
// so the list reads as "what I still need" rather than "what is on". Right:
// DX-World's headlines, whose callsigns are mined out of the title so they can
// be watched with the same one click.
type DXped = {
dxcc: string; callsign: string; calls?: string[];
start_date: string; end_date: string;
bands?: string[]; modes?: string[];
qsl: string; operators: string; source: string; link: string;
status: string; // active | upcoming
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
unconfirmed?: boolean;
};
type News = {
title: string; link: string; pub_date: string; excerpt: string;
creator: string; image_url: string; tag: string; calls?: string[];
};
// One badge per expedition, the strongest verdict winning — the same palette
// and the same words the cluster uses, so the two views teach one vocabulary.
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
'worked': { label: 'wl.worked', colour: 'var(--info)' },
};
export function DXpeditionsPanel() {
const { t } = useI18n();
const [peds, setPeds] = useState<DXped[]>([]);
const [news, setNews] = useState<News[]>([]);
const [watched, setWatched] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
const loadWatchlist = useCallback(async () => {
try {
const e: any[] = await WatchlistEntries();
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
} catch { /* the list simply shows every call as unwatched */ }
}, []);
const load = useCallback(async () => {
setBusy(true);
setErr('');
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
else setErr(String((p.reason as any)?.message ?? p.reason));
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
setBusy(false);
}, []);
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
const refresh = async () => { await RefreshDXpeditions(); await load(); };
const addAll = async (calls: string[]) => {
for (const c of calls) {
if (!watched.has(c.toUpperCase())) {
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
}
}
await loadWatchlist();
};
const shown = useMemo(
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
[peds, neededOnly]);
// A row's calls: the mined ones, else whatever the announcement called it.
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
return (
<div className="flex h-full min-h-0 gap-3">
{/* ── Announcements (ADXO) ── */}
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
<input type="checkbox" checked={neededOnly}
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
{t('dxp.neededOnly')}
</label>
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
</Button>
</header>
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{shown.length === 0 && !busy && (
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
)}
{shown.map((p, i) => {
const calls = callsOf(p);
const badge = CHASE_BADGE[p.status_chase];
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
return (
<article key={`${p.callsign}-${p.start_date}-${i}`}
className={cn('rounded-md border p-2 text-xs',
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
<span className="font-medium">{p.dxcc}</span>
{p.status === 'active' && (
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
{t('dxp.onAir')}
</span>
)}
{badge && (
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
style={p.unconfirmed
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
: { color: badge.colour, borderColor: badge.colour }}>
{t(badge.label)}
</span>
)}
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
{p.start_date} {p.end_date}
</span>
</div>
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
{p.qsl && <span>QSL: {p.qsl}</span>}
{p.source && <span>· {p.source}</span>}
</div>
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
<div className="mt-1.5 flex items-center gap-2">
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
disabled={calls.length === 0 || allWatched}
onClick={() => addAll(calls)}
title={t('dxp.watchTip')}>
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
{allWatched ? t('dxp.watched') : t('dxp.watch')}
</Button>
{p.link && (
<button type="button" onClick={() => BrowserOpenURL(p.link)}
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
<ExternalLink className="size-3" /> {t('dxp.open')}
</button>
)}
</div>
</article>
);
})}
</div>
</section>
{/* ── News (DX-World) ── */}
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-semibold">{t('dxp.news')}</span>
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{news.length === 0 && !busy && (
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
)}
{news.map((n, i) => {
const newsCalls = n.calls ?? [];
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
return (
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
<div className="flex items-start gap-2">
{n.image_url && (
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{n.tag && (
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
)}
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
className="text-xs font-medium text-left hover:underline">{n.title}</button>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
{/* The callsigns are shown, not clicked: watching is the same
one button as an announcement, so the gesture is learned
once for the whole tab. */}
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
{n.pub_date && (
<span className="text-[10px] text-muted-foreground">
{new Date(n.pub_date).toLocaleDateString()}
</span>
)}
{(n.calls ?? []).map((c) => (
<span key={c} className={cn('font-mono text-[10px]',
watched.has(c.toUpperCase()) ? 'text-warning' : 'text-info')}>
{c}
</span>
))}
</div>
<div className="mt-1.5 flex items-center gap-2">
<Button variant={newsAllWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
disabled={newsCalls.length === 0 || newsAllWatched}
onClick={() => addAll(newsCalls)}
title={t('dxp.watchTip')}>
<Star className={cn('size-3', newsAllWatched && 'fill-current text-warning')} />
{newsAllWatched ? t('dxp.watched') : t('dxp.watch')}
</Button>
{n.link && (
<button type="button" onClick={() => BrowserOpenURL(n.link)}
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
<ExternalLink className="size-3" /> {t('dxp.open')}
</button>
)}
</div>
</div>
</div>
</article>
);
})}
</div>
</section>
</div>
);
}
+63 -17
View File
@@ -72,8 +72,14 @@ type StatusEntry = {
new_pota?: boolean;
new_pfx?: boolean;
new_grid?: boolean;
new_state?: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
unconf_state?: boolean;
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
grid_state?: string;
state?: string;
grid?: string;
lotw?: boolean;
};
@@ -112,7 +118,7 @@ interface Props {
//
// All off means no filtering at all: this is a decode LOG first, and a panel
// that starts by hiding most of the band would be lying about what is on it.
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty';
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
@@ -123,6 +129,7 @@ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
{ key: 'state', label: 'dec.bgState', colour: markerColour('new_state') },
];
// catsOf lists everything a decode is new for. A station can be several at once
@@ -143,6 +150,7 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
if (e.new_grid) out.add('grid');
if (e.new_pfx) out.add('pfx');
if (e.new_county) out.add('cty');
if (e.new_state) out.add('state');
return out;
}
@@ -227,7 +235,7 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'country' | 'status';
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
@@ -241,9 +249,26 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
// an operator chasing squares could see the verdict but never the square.
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
// For the WAS chasers: the badge carries the two letters, the name spells
// them out — "SD" alone is a quiz for a European.
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
];
const US_STATES: Record<string, string> = {
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi',
MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina',
ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',
RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee',
TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington',
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
};
const COL_MAX = 600;
const COLW_KEY = 'opslog.decodeColWidths';
@@ -308,12 +333,15 @@ function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onR
//
// Colours match the cluster list and the band map — the same fact must not be
// amber in one panel and green in the next.
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
// colour is the category's own hue, for the UNCONFIRMED rendering: the
// filled cls puts light text on a filled chip, and dimming that to a
// transparent background left light-on-nothing — an invisible badge.
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground', colour: 'var(--warning)' },
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground', colour: 'var(--info)' },
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground', colour: 'var(--caution)' },
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground', colour: 'var(--muted-foreground)' },
};
// entityBadgesFor turns a status into the badges that describe it.
@@ -324,7 +352,7 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
// passed the BAND and MODE filters and then showed no reason for being there,
// which is precisely what was reported. catsOf has always split the status into
// its two categories; this is the same split, on the screen.
function entityBadgesFor(status: string): { label: string; cls: string }[] {
function entityBadgesFor(status: string): { label: string; cls: string; colour: string }[] {
if (status === 'new-band-mode') {
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
}
@@ -343,6 +371,7 @@ function entityBadgesFor(status: string): { label: string; cls: string }[] {
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
{ key: 'new_state', marker: 'new_state', label: 'dec.bgState' },
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
];
@@ -498,10 +527,11 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
// the same way it was grouped.
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
tx: g.tx,
// Strongest first inside a period: the eye should land on what is
// workable, and time within a slot means nothing — they were all
// transmitting simultaneously.
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
// ARRIVAL order inside a period, per the operator: it mirrors the
// decoder's own window line for line, which makes the two screens
// comparable at a glance — the strongest-first sort scrambled that
// correspondence, and SNR is right there in its column anyway.
decodes: g.decodes,
}));
}
@@ -1096,6 +1126,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<span className="truncate">{d.grid || e?.grid || ''}</span>
</span>
<span className={cn(CELL, 'gap-1.5')}>
{e?.state && (
<>
<span className="rounded px-1.5 py-px text-[11px] font-bold bg-info/15 text-info border border-info/40 shrink-0">{e.state}</span>
<span className="text-[11px] text-muted-foreground truncate">{US_STATES[e.state.toUpperCase()] ?? ''}</span>
</>
)}
</span>
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
<span className="truncate">{e?.country ?? ''}</span>
</span>
@@ -1113,7 +1152,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
</span>
)}
{entities.map((b) => (
<span key={b.label} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}>
<span key={b.label}
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0',
e?.unconf_status ? 'border bg-transparent' : b.cls)}
style={e?.unconf_status ? { color: b.colour, borderColor: b.colour, borderStyle: 'dashed', opacity: 0.6 } : undefined}>
{t(b.label)}
</span>
))}
@@ -1123,16 +1166,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// Same hue, held back — the badge reads as "less" of the
// same thing, and its label says which. One badge for both
// is what made a station worked an hour ago read as new.
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf';
const unconf = (b.key === 'new_grid' && e?.grid_state === 'unconf')
|| (b.key === 'new_state' && !!e?.unconf_state)
|| (b.key === 'new_county' && !!e?.unconf_cty)
|| (b.key === 'new_pfx' && !!e?.unconf_pfx);
const c = markerColour(b.marker);
return (
<span key={b.key as string}
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
title={unconf ? t('dec.unconfTip') : undefined}
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
style={unconf
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
: { borderColor: c, color: c }}>
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
{t(b.label)}
</span>
);
})}
+1 -1
View File
@@ -19,7 +19,7 @@ type Props = {
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
};
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F6
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F12
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
// the reserved area. Recording/labeling lives in Settings → Audio.
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
+3 -2
View File
@@ -18,7 +18,7 @@ type KenwoodState = {
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
transmitting: boolean; split: boolean; split_tx_hz?: number;
s_meter: number; s_meter_raw: number;
power_meter: number; swr: number; swr_raw: number;
power_meter: number; power_w?: number; swr: number; swr_raw: number;
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
@@ -222,7 +222,8 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
}}
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9" />
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9"
display={view.transmitting && view.elecraft ? `${view.power_w ?? 0} W` : undefined} />
{/* 0 means "not measured", and it must not render as a perfect 1.0:
a match that looks ideal on an antenna nobody has measured is the one
reading that can cost a radio. */}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead';
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// FT Map — the live decode feed as geography: every station decoded in the
// last half hour, an arc from the operator's own square to theirs, coloured by
// band the way PSK Reporter taught everyone to read it. Wholly display: the
// decode list is the same one the FT decodes tab shows, and a station with no
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
//
// Performance is a design constraint, not an afterthought: the panel only
// exists while its tab is active (the parent unmounts it otherwise), the map
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
// not per frame.
export type FTMapDecode = {
call: string;
grid?: string;
band?: string;
snr: number;
at: string;
};
// The band palette every PSK Reporter user already knows, near enough.
const BAND_COLOURS: Record<string, string> = {
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
'2m': '#ff1493', '70cm': '#999900',
};
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000;
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
const { t } = useI18n();
const divRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const baseRef = useRef<L.TileLayer | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(() =>
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
// The map itself, once.
useEffect(() => {
if (!divRef.current || mapRef.current) return;
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
// and the space beyond the edge is the theme's own surface (style.css).
const m = L.map(divRef.current, {
zoomControl: true, attributionControl: true,
// No maxBounds: with the world smaller than the window the clamp
// dragged every zoom into a corner. noWrap tiles alone keep one world.
worldCopyJump: false, preferCanvas: true,
center: [25, 0], zoom: 2, minZoom: 2,
});
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
return () => { m.remove(); mapRef.current = null; layerRef.current = null; };
}, []);
// Basemap follows the picker.
useEffect(() => {
const m = mapRef.current;
if (!m) return;
baseRef.current?.remove(); labelsRef.current?.remove();
const bm = BASEMAPS[basemap];
const opts: L.TileLayerOptions = {
maxNativeZoom: bm.maxNativeZoom,
noWrap: true,
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
};
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
localStorage.setItem('opslog.ftmapBase', basemap);
}, [basemap]);
// The arcs, redrawn when the decode list changes. Newest last so they paint
// on top; opacity falls with age so the map reads as "now" with a memory.
useEffect(() => {
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
const from = gridToLatLon(myGrid);
if (!from) return;
const now = Date.now();
const placed = decodes
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
.slice(-MAX_ARCS);
// One line per CALL (its freshest sighting): the same CQer decoded thirty
// times in ten minutes is one path on the air, not thirty strokes of it.
const byCall = new Map<string, FTMapDecode>();
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
L.circleMarker([from.lat, from.lon], {
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
}).addTo(layer);
for (const d of byCall.values()) {
const to = gridToLatLon(d.grid!);
if (!to) continue;
const age = now - Date.parse(d.at);
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
const colour = bandColour(d.band);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48);
L.polyline(pts as L.LatLngExpression[], {
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
}).addTo(layer);
L.circleMarker([to.lat, to.lon], {
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
.addTo(layer);
}
}, [decodes, myGrid]);
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
return (
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
// beat the app menus and the Settings dialog. A stacking context of our own
// keeps all of it inside this panel.
<div className="relative isolate z-0 h-full w-full min-h-0">
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
{/* Basemap picker, MainMap's own vocabulary. */}
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button key={k} type="button" onClick={() => setBasemap(k)}
className={cn('px-2 py-0.5 rounded text-[11px]',
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
{BASEMAPS[k].label}
</button>
))}
</div>
{/* Band legend — only the bands actually on screen. */}
{bands.length > 0 && (
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
{bands.map((b) => (
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
{b.toUpperCase()}
</span>
))}
</div>
)}
{!gridToLatLon(myGrid) && (
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
{t('ftmap.noGrid')}
</span>
</div>
)}
</div>
);
}
@@ -86,6 +86,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ 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: 'clublog_qso_download_status', label: 'fltb.fClublogRcvd', type: 'text' },
{ value: 'clublog_qso_download_date', label: 'fltb.fClublogRcvdDate', type: 'adifdate' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
// HAMLOG.online: no promoted column, filtered through extras_json (see
+1 -5
View File
@@ -128,15 +128,11 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
// impossible on any window wider than it is tall: one step out was already
// too far in.
minZoom: 0,
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
}).setView([20, 0], 2);
// The whole world, once, whatever the window is shaped like — computed by
// Leaflet from the container rather than guessed with a zoom number.
m.fitWorld({ animate: false });
// Latitude only: the poles are the edge of the projection and there is
// nothing beyond them, while leaving longitude free keeps a drag from
// fighting the operator near the date line.
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
// Leaflet measures its container ONCE, when the map is created, and never
+8 -2
View File
@@ -370,8 +370,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
if (autoZoom) {
if (from && to && arcPts) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
// Latitudes clamped to Mercator's edge (±85°): the arc to a polar
// entity (Franz Josef Land) peaks near 88°N, and fitting the raw
// points framed a band of tile-less white above the top of the world.
// The line itself still draws to wherever it goes — only the CAMERA
// stays where there is a map to show.
const clamp = (lat: number) => Math.max(-85, Math.min(85, lat));
const bounds = L.latLngBounds([[clamp(from.lat), from.lon], [clamp(to.lat), to.lon]]);
arcPts.forEach((p) => bounds.extend([clamp(p[0]), p[1]] as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} else if (to) {
wm.setView([to.lat, to.lon], 3);
@@ -42,6 +42,7 @@ const SERVICES = [
{ v: 'eqsl', label: 'eQSL.cc' },
{ v: 'lotw', label: 'LoTW' },
{ v: 'hamlog', label: 'HAMLOG.online' },
{ v: 'hamqth', label: 'HamQTH' },
{ v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' },
];
@@ -35,6 +35,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
{ service: 'eqsl', name: 'eQSL.cc' },
{ service: 'lotw', name: 'LoTW' },
{ service: 'hamlog', name: 'HAMLOG.online' },
{ service: 'hamqth', name: 'HamQTH' },
];
// Lightweight right-click menu for the QSO grids. AG Grid's native context
+69 -30
View File
@@ -71,7 +71,7 @@ const CONFIRMATIONS: ConfDef[] = [
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
];
// i18n label keys for confirmation channels whose label has translatable words
@@ -80,6 +80,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
QSL: 'qedit.confQslPaper',
};
// Reading order for the channel list: the two that carry an ARRL award first —
// paper QSL and LoTW — then everything else alphabetically, so a channel is
// found by its name rather than by remembering the order it was added in.
const CONF_FIRST: Record<string, number> = { QSL: 0, LOTW: 1 };
function confOrder<T extends { key: string; label: string }>(rows: T[]): T[] {
return rows.slice().sort((a, b) => {
const ra = CONF_FIRST[a.key] ?? 2;
const rb = CONF_FIRST[b.key] ?? 2;
if (ra !== rb) return ra - rb;
return a.label.localeCompare(b.label);
});
}
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
// columns and this channel is backed by ADIF extras — but it still belongs in
// the channel picker and the status table alongside the rest.
@@ -98,6 +111,15 @@ const HAMLOG_KEYS = {
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
};
// HamQTH — extras again (ADIF names no HamQTH field), and SENT only: the site
// publishes no confirmation feed, so a received column here would be a promise
// nothing can keep.
const HAMQTH_CONF = 'HAMQTH';
const HAMQTH_KEYS = {
sent: 'APP_OPSLOG_HAMQTH_SENT',
sentDate: 'APP_OPSLOG_HAMQTH_SENT_DATE',
};
// Colour-coded status cell for the confirmation grid.
function StatusCell({ value }: { value?: string }) {
const { t } = useI18n();
@@ -747,19 +769,34 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Select value={confSel} onValueChange={setConfSel}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
{/* Listed here but NOT in CONFIRMATIONS: that table maps
{confOrder([
...CONFIRMATIONS.map((c) => ({ key: c.key, label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label })),
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog') },
{ key: HAMLOG_CONF, label: 'HAMLOG.online' },
{ key: HAMQTH_CONF, label: 'HamQTH' },
]).map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}
{/* The three above that are NOT in CONFIRMATIONS the
QSO columns, and this channel lives in the ADIF
extras. It gets its own editor below rather than the
generic sent/received/date grid, which has no field
to bind to. */}
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
OpsLog card, HAMLOG.online and HamQTH are backed by
ADIF extras rather than QSO columns, and each gets its
own editor below instead of the generic
sent/received/date grid, which has no field to bind
to. */}
</SelectContent>
</Select>
</div>
{confSel === HAMLOG_CONF ? (
{confSel === HAMQTH_CONF ? (
<>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMQTH_KEYS.sent)} onChange={(v) => exPut(HAMQTH_KEYS.sent, v)} /></div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMQTH_KEYS.sentDate)} onChange={(v) => exPut(HAMQTH_KEYS.sentDate, v)} /></div>
</div>
<p className="text-[11px] text-muted-foreground">
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
</p>
</>
) : confSel === HAMLOG_CONF ? (
<>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
@@ -845,30 +882,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</tr>
</thead>
<tbody>
{CONFIRMATIONS.map((c) => (
{/* The extras-backed channels (OpsLog card,
HAMLOG.online, HamQTH) sit in the same ordered list
as the column-backed ones: the reader is looking for
a name, not for a storage detail. A dash in RECEIVED
means the channel has nothing to receive Club Log
and HamQTH publish no confirmations which is not
the same statement as "N". */}
{confOrder([
...CONFIRMATIONS.map((c) => ({
key: c.key,
label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label,
sent: val(c.sent),
rcvd: c.rcvd ? val(c.rcvd) : null,
})),
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog'), sent: opslogQslSent ? 'Y' : 'N', rcvd: qslReceived ? 'Y' : 'N' },
{ key: HAMLOG_CONF, label: 'HAMLOG.online', sent: exVal(HAMLOG_KEYS.sent), rcvd: exVal(HAMLOG_KEYS.rcvd) },
{ key: HAMQTH_CONF, label: 'HamQTH', sent: exVal(HAMQTH_KEYS.sent), rcvd: null },
]).map((c) => (
<tr key={c.key} className="text-xs">
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
<td className="w-24"><StatusCell value={c.sent} /></td>
<td className="w-24">{c.rcvd === null
? <span className="block text-center text-[11px] text-muted-foreground"></span>
: <StatusCell value={c.rcvd} />}</td>
</tr>
))}
{/* OpsLog's own card, read from the ADIF extras rather
than a QSO column hence a hand-written row instead
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
when the card actually goes out, so it stays
read-only here: an operator ticking it by hand would
be recording something that never happened. */}
<tr className="text-xs">
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
</tr>
{/* HAMLOG.online — extras again, same hand-written row. */}
<tr className="text-xs">
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
</tr>
</tbody>
</table>
</div>
+7 -1
View File
@@ -235,6 +235,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── Contest ──
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
@@ -784,7 +786,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
</div>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
// Alphabetical within the group: the catalog's order is the
// GRID's column order, which appends newcomers at the end — so
// the two ClubLog rows sat at opposite ends of the Uploads box.
const cols = COL_CATALOG.filter((c) => c.group === group)
.slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
if (cols.length === 0) return null;
return (
<div key={group} className="rounded-md border border-border p-2.5">
+164 -28
View File
@@ -9,7 +9,8 @@ import {
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios,
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
GetChaseSettings, SaveChaseSettings,
GetAudioMonitorPref,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
@@ -44,11 +45,11 @@ import {
SetCIVTrace,
CIVTraceEnabled,
WinkeyerTraceEnabled,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
GetScpStatus, SetScpEnabled, DownloadScp,
GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp,
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
@@ -1718,6 +1719,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// DVK voice-keyer messages (F1F6).
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
const [chaseCfg, setChaseCfg] = useState<{ mode: string; sources: string[] }>({ mode: 'new', sources: ['lotw', 'card', 'eqsl'] });
useEffect(() => { GetChaseSettings().then((c: any) => setChaseCfg({ mode: c?.mode ?? 'new', sources: c?.sources ?? ['lotw', 'card', 'eqsl'] })).catch(() => {}); }, []);
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState('');
@@ -1819,17 +1822,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
qsl_sent: string; qsl_rcvd: string;
lotw_sent: string; lotw_rcvd: string;
eqsl_sent: string; eqsl_rcvd: string;
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
qrzcom_confirmed: string;
hamlog_status: string; hamlog_confirmed: string;
hamqth_status: string;
};
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
qsl_sent: '', qsl_rcvd: '',
lotw_sent: '', lotw_rcvd: '',
eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
qrzcom_confirmed: '',
hamlog_status: '', hamlog_confirmed: '',
hamlog_status: '', hamlog_confirmed: '', hamqth_status: '',
});
// External services (logbook upload). One block per service; only QRZ is
@@ -1845,7 +1849,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
on_air?: boolean;
};
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean };
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; hamqth: ExtServiceCfg; delete_remote?: boolean };
const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
@@ -1853,7 +1857,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
auto_upload: false, upload_mode: 'immediate', on_air: false,
});
const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false,
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), hamqth: emptyExtCfg(), delete_remote: false,
});
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false);
@@ -1881,6 +1885,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
finally { setScpBusy(false); }
};
const refreshScp = async () => { try { const s = await GetScpStatus(); setScp(s as any); } catch {} };
const downloadScp = async () => {
setScpBusy(true);
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
@@ -2021,6 +2026,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [cloudlogTesting, setCloudlogTesting] = useState(false);
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hamlogTesting, setHamlogTesting] = useState(false);
const [hamqthTest, setHamqthTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hamqthTesting, setHamqthTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [eqslTesting, setEqslTesting] = useState(false);
const [stationLocations, setStationLocations] = useState<string[]>([]);
@@ -2028,7 +2035,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// could not hold hooks at all; PanelHost lifted that restriction, and this
// stays put because moving it down would reset the tab on every reopen —
// a choice now, not a workaround.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz');
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'hamqth' | '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);
@@ -2072,6 +2079,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
};
const [chaseGrids, setChaseGrids] = useState(false);
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
const [chaseCountyOn, setChaseCountyOn] = useState(() => localStorage.getItem('opslog.chaseCounty') !== '0');
const [chasePfxOn, setChasePfxOn] = useState(() => localStorage.getItem('opslog.chasePfx') !== '0');
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false);
const [spotTTL, setSpotTTL] = useState(0);
@@ -2093,7 +2102,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
useEffect(() => {
(async () => {
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
try {
const g = await GetChaseNewGrids();
setChaseGrids(g);
// Mirror for the display layer (spotDisplay.chaseGrid) — the cluster
// gates NEW GRID synchronously from localStorage.
writeUiPref('opslog.chaseGrids', g ? '1' : '0');
} catch { /* defaults stand */ }
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
@@ -4416,8 +4431,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Input type="number" min={1200} value={amp.baud}
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
{/* A list, not a free number: the KPA500 report that
began this had its operator wondering whether a typed
baud was the whole problem. These are the rates the
supported amplifiers actually speak. */}
<select value={String(amp.baud)}
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) })}
className="h-9 w-full px-2 rounded-md border border-border bg-background text-sm font-mono">
{[4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
<option key={b} value={String(b)}>{b}</option>
))}
</select>
</div>
</div>
) : (
@@ -5411,6 +5435,38 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{/* Chase switches: off, the marker stops being TOLD, everywhere at
once badge, colour, filter chip, reference column. A "new band
+ new POTA" spot then reads NEW BAND alone. */}
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
county, state, grid) judged as new-only or new-plus-unconfirmed,
against the confirmation sources the operator trusts. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
{chaseCfg.mode === 'new_unconfirmed' && (
<div className="flex items-center gap-4 flex-wrap pl-2">
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={chaseCfg.sources.includes(k)}
onCheckedChange={(c) => {
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
const next = { ...chaseCfg, sources };
setChaseCfg(next); void SaveChaseSettings(next as any);
}} />
{label}
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
<Checkbox checked={chasePotaOn}
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
@@ -5421,9 +5477,19 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
{t('clu.chaseSota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseCountyHint')}>
<Checkbox checked={chaseCountyOn}
onCheckedChange={(c) => { const v = !!c; setChaseCountyOn(v); writeUiPref('opslog.chaseCounty', v ? '1' : '0'); }} />
{t('clu.chaseCounty')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePfxHint')}>
<Checkbox checked={chasePfxOn}
onCheckedChange={(c) => { const v = !!c; setChasePfxOn(v); writeUiPref('opslog.chasePfx', v ? '1' : '0'); }} />
{t('clu.chasePfx')}
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseGrids} className="mt-0.5"
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
onCheckedChange={(c) => { setChaseGrids(!!c); writeUiPref('opslog.chaseGrids', c ? '1' : '0'); SetChaseNewGrids(!!c).catch(() => {}); }} />
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
</label>
{chaseGrids && (
@@ -5450,19 +5516,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Chase new its own option, NOT nested under grid chasing. Chasing
squares and chasing entities are different wants; they only share
the PSK Reporter feed, which either one brings up. */}
@@ -5672,7 +5730,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
{renderSelect('clublog_status', FULL_OPTIONS)}
</div>
<div />
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
{renderSelect('clublog_confirmed', FULL_OPTIONS)}
</div>
</div>
{/* HRDLog */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
@@ -5695,6 +5756,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
</div>
</div>
{/* HamQTH — no received side: the site has no confirmation feed. */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HamQTH</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
{renderSelect('hamqth_status', FULL_OPTIONS)}
</div>
<div />
</div>
{/* HAMLOG.online */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
@@ -5716,10 +5786,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
function UDPIntegrationsPanelWrapper() {
return (
<>
<SectionHeader
title={t('sec.udp')}
hint={t('udp.hint')}
/>
<SectionHeader title={t('sec.udp')} />
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
</>
);
@@ -5891,6 +5958,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{ k: 'lotw', label: 'LOTW', ready: true },
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
{ k: 'hamqth', label: 'HAMQTH', ready: true },
{ k: 'pota', label: 'POTA', ready: true },
];
@@ -5960,6 +6028,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
}
}
const hamqth = extSvc.hamqth ?? emptyExtCfg();
const setHamqth = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, hamqth: { ...(s.hamqth ?? emptyExtCfg()), ...patch } }));
async function testHamqth() {
setHamqthTesting(true);
setHamqthTest(null);
try {
const msg = await TestHamQTHUpload();
setHamqthTest({ ok: true, msg });
} catch (e: any) {
setHamqthTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setHamqthTesting(false);
}
}
const hamlog = extSvc.hamlog ?? emptyExtCfg();
const setHamlog = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
@@ -6339,6 +6422,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div>
</div>
</div>
) : extSvcTab === 'hamqth' ? (
<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.username')}</Label>
<Input value={hamqth.username} onChange={(e) => setHamqth({ username: e.target.value })} className="text-xs w-64" />
<Label className="text-sm">{t('es.password')}</Label>
<Input type="password" value={hamqth.password} onChange={(e) => setHamqth({ password: e.target.value })} className="text-xs w-64" />
<Label className="text-sm">{t('es.hamqthCall')}</Label>
<Input value={hamqth.callsign} onChange={(e) => setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} />
</div>
<div className="text-[10px] text-muted-foreground -mt-1">{t('es.hamqthHint')}</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={hamqth.auto_upload} onCheckedChange={(c) => setHamqth({ 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={hamqth.upload_mode === 'delayed' ? 'delayed' : 'immediate'} onValueChange={(v) => setHamqth({ 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>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testHamqth} disabled={hamqthTesting}>
<UploadCloud className="size-3.5" /> {hamqthTesting ? t('es.testing') : t('es.testConn')}
</Button>
{hamqthTest && (
<span className={cn('text-xs', hamqthTest.ok ? 'text-success' : 'text-danger')}>{hamqthTest.msg}</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'cloudlog' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
@@ -7172,6 +7292,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
>
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
</Button>
<Button
type="button"
variant="outline" size="sm" className="h-8 w-9 shrink-0 px-0 text-danger hover:text-danger"
title={t('aud.deleteMsg')}
disabled={!m.has_audio || dvkStat.recording || dvkStat.playing}
onClick={() => DVKDelete(m.slot).then(reloadDvk).catch((err) => setDvkErr(String(err?.message ?? err)))}
>
<Trash2 className="size-3.5" />
</Button>
</div>
);
})}
@@ -7264,6 +7393,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
{t('scp.enable')}
</label>
{scp.enabled && (
<label className="flex items-center gap-2 text-sm cursor-pointer pl-6" title={t('scp.clublogHint')}>
<Checkbox checked={!!(scp as any).clublog} disabled={scpBusy}
onCheckedChange={(c) => { void SetScpClublogEnabled(!!c).then(() => refreshScp()); }} />
{t('scp.clublog')}
</label>
)}
{scp.enabled && (
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import {
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
GetWsjtHighlight, SetWsjtHighlight, GetWsjtFollowMode, SetWsjtFollowMode,
} from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -158,6 +159,12 @@ const TRIGGERS = [
type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) {
const [highlightOn, setHighlightOn] = useState(false);
const [followMode, setFollowMode] = useState(true);
useEffect(() => {
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
}, []);
const { t } = useI18n();
const [items, setItems] = useState<UDPConfig[]>([]);
const [loading, setLoading] = useState(true);
@@ -229,10 +236,24 @@ export function UDPIntegrationsPanel({ onError }: Props) {
return (
<div className="space-y-4">
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
{t('udpp.intro')}
</div>
{/* Log-aware colours in WSJT-X / JTDX's own window lives HERE because
this panel is where the WSJT-X link is configured. */}
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={highlightOn}
onCheckedChange={(c) => { setHighlightOn(!!c); void SetWsjtHighlight(!!c); }} />
<span>
{t('udpp.highlight')}
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={followMode}
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
<span>
{t('udpp.followMode')}
<span className="block text-[11px] text-muted-foreground">{t('udpp.followModeHint')}</span>
</span>
</label>
<Section
title={t('udpp.inboundTitle')}
icon={<ArrowDownToLine className="size-4" />}
+26 -21
View File
@@ -181,7 +181,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
try {
await WatchlistAdd(c, addContest);
setAddCall('');
flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false);
// The confirmation is the app-level notice (App.tsx, on watchlist:changed)
// — saying it twice, once per place a call can be added from, was noise.
await refresh();
} catch (e: any) { flash(String(e?.message ?? e), true); }
};
@@ -190,7 +191,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// entry is cheap to undo (type it again), so it does not earn a confirmation
// the other two buttons do not have.
const remove = async (call: string) => {
try { await WatchlistRemove(call); flash(t('wl.removed', { call }), false); await refresh(); }
try { await WatchlistRemove(call); await refresh(); }
catch (e: any) { flash(String(e?.message ?? e), true); }
};
@@ -247,15 +248,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
{/* Header: the counters alone, centred they are the tab's headline.
Everything one INTERACTS with lives on the second row. */}
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-center gap-2.5 text-xs text-muted-foreground">
<Eye className="size-4 text-primary shrink-0" />
<span>
{t('wl.cTotal')} <b className="text-foreground">{counters.total}</b>
<span className="mx-1.5 opacity-50">|</span>
{t('wl.cActive')} <b className="text-info">{counters.active}</b>
<span className="mx-1.5 opacity-50">|</span>
{t('wl.cNeeded')} <b className="text-warning">{counters.needed}</b>
</span>
<span className="flex items-center gap-1.5">{t('wl.cTotal')}
<b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
<span className="opacity-40">|</span>
<span className="flex items-center gap-1.5">{t('wl.cActive')}
<b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
<span className="opacity-40">|</span>
<span className="flex items-center gap-1.5">{t('wl.cNeeded')}
<b className={cn('px-2 py-0.5 rounded border', counters.needed > 0
? 'text-warning border-warning/40 bg-warning/10'
: 'text-muted-foreground border-border bg-muted/40')}>{counters.needed}</b></span>
</div>
{/* toolbar */}
<div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
@@ -328,11 +332,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
return (
<div key={e.callsign}
className={cn('rounded-lg border bg-card p-3',
needed > 0 ? 'border-warning/50' : 'border-border',
className={cn('rounded-lg border bg-card/70 p-3 transition-colors hover:bg-accent/20',
needed > 0 ? 'border-warning/40' : 'border-border/70',
e.isContest && 'border-l-4 border-l-warning')}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-bold font-mono text-primary">{e.callsign}</span>
<span className="text-lg font-bold font-mono" style={{ color: '#f472b6' }}>{e.callsign}</span>
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
{e.isContest && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border"
@@ -340,7 +344,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
<Trophy className="size-3" /> {t('wl.contest')}
</span>
)}
{e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))}
{e.isExpedition && chip('var(--chart-5)', '⚡ ' + t('wl.expedition'))}
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
{e.clubLogLiveStream && (
@@ -385,14 +389,15 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
onClick={() => onSpotSelect?.(s)}
onDoubleClick={() => onSpotClick?.(s)}
title={t('wl.spotTip')}
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/40 hover:bg-muted text-left',
!done && 'border-l-2 border-warning')}>
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
!done && 'border-l-[3px] border-warning')}>
{done && <span className='font-bold shrink-0 text-success'></span>}
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
<span className="px-1.5 rounded bg-muted shrink-0">{s.band}</span>
{mode && <span className="px-1.5 rounded shrink-0" style={{ color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' }}>{mode}</span>}
<span className="font-mono text-muted-foreground shrink-0">{(s.freq_hz / 1e6).toFixed(4)}</span>
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(s.freq_hz / 1e6).toFixed(4)}</span>
{badge && chip(badge.color, badge.label)}
<div className="flex-1" />
{done
File diff suppressed because one or more lines are too long
+27 -2
View File
@@ -20,6 +20,7 @@ export type SpotDisplayOptions = {
// keep being computed; only the telling stops, so ticking the box back on
// needs no rescan.
chasePota: boolean; chaseSota: boolean;
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean;
};
// chasePota/chaseSota read the switches directly — for the places that show a
@@ -30,6 +31,18 @@ export function chasePota(): boolean {
export function chaseSota(): boolean {
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
}
export function chaseCounty(): boolean {
try { return localStorage.getItem('opslog.chaseCounty') !== '0'; } catch { return true; }
}
export function chasePfx(): boolean {
try { return localStorage.getItem('opslog.chasePfx') !== '0'; } catch { return true; }
}
// chaseGrid mirrors the backend "chase new grids" setting (Settings writes the
// mirror on load and on toggle) so the display layer can gate NEW GRID without
// an async round-trip per row.
export function chaseGrid(): boolean {
try { return localStorage.getItem('opslog.chaseGrids') !== '0'; } catch { return true; }
}
// Both options are withdrawn from the filter panel for now. The machinery below
// is deliberately kept whole — it is correct and hard-won — so putting the two
@@ -44,16 +57,17 @@ export function readSpotDisplayOptions(): SpotDisplayOptions {
// The EXPOSED flag only withdraws the two original switches; the chase
// switches are live regardless.
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota() };
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid() };
}
try {
return {
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
chasePota: chasePota(), chaseSota: chaseSota(),
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
};
} catch {
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true };
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true };
}
}
@@ -98,6 +112,17 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
if (!o.chasePota && e.new_pota) {
e = { ...e, new_pota: false } as NonNullable<T>;
}
// Same withdrawal for the other extra markers: the facts keep being
// computed, only the telling stops — re-ticking a box needs no rescan.
if (!o.chaseCounty && e.new_county) {
e = { ...e, new_county: false } as NonNullable<T>;
}
if (!o.chasePfx && e.new_pfx) {
e = { ...e, new_pfx: false } as NonNullable<T>;
}
if (!o.chaseGrid && e.new_grid) {
e = { ...e, new_grid: false } as NonNullable<T>;
}
return e;
}
+2 -1
View File
@@ -18,7 +18,7 @@
// grid magenta — the last hue in the categorical set that is not already
// spoken for here and does not read as a status; a grid is
// never urgent the way a new entity is
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid' | 'new_state';
export type SpotMarker = {
key: SpotMarkerKey;
@@ -32,6 +32,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
{ key: 'new_state', colour: 'var(--chart-3)', labelKey: 'clg2.newState' },
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
];
+7
View File
@@ -1086,3 +1086,10 @@
.overflow-scroll {
overscroll-behavior: contain;
}
/* The ground beside the planet, on every map: Leaflet paints its container a
hard-coded light grey (#ddd), which reads as a broken tile against any
theme. The surround follows the theme's own card surface instead. */
.leaflet-container {
background: var(--card) !important;
}
+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.27.1';
export const APP_VERSION = '0.27.6';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+27
View File
@@ -12,6 +12,7 @@ import {award} from '../models';
import {awardref} from '../models';
import {bandopen} from '../models';
import {cluster} from '../models';
import {dxped} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {pskr} from '../models';
@@ -142,6 +143,8 @@ export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
export function ConfigureDecoderMode(arg1:string):Promise<void>;
export function ConnectAllClusters():Promise<void>;
export function ConnectClusterServer(arg1:number):Promise<void>;
@@ -158,6 +161,8 @@ export function CreateDatabase(arg1:string):Promise<void>;
export function DVKCancelRecord():Promise<void>;
export function DVKDelete(arg1:number):Promise<void>;
export function DVKPlay(arg1:number):Promise<void>;
export function DVKPreview(arg1:number):Promise<void>;
@@ -448,6 +453,8 @@ export function GetChaseNewGrids():Promise<boolean>;
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
export function GetChaseSettings():Promise<main.ChaseSettings>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
@@ -470,6 +477,10 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>;
export function GetDXWorldNews():Promise<Array<dxped.News>>;
export function GetDXpeditions():Promise<Array<main.DXpedition>>;
export function GetDataDir():Promise<string>;
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
@@ -620,6 +631,10 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function GetWorkedCallVariants():Promise<boolean>;
export function GetWsjtFollowMode():Promise<boolean>;
export function GetWsjtHighlight():Promise<boolean>;
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
export function GetYaesuState():Promise<cat.YaesuTXState>;
@@ -934,6 +949,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshDXpeditions():Promise<void>;
export function RefreshKenwood():Promise<void>;
export function RefreshSolar():Promise<void>;
@@ -1020,6 +1037,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveCabrilloFile():Promise<string>;
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
@@ -1190,6 +1209,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
export function SetSpotMax(arg1:number):Promise<void>;
@@ -1246,6 +1267,10 @@ export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
export function SetYaesuAFGain(arg1:number):Promise<void>;
export function SetYaesuAGC(arg1:string):Promise<void>;
@@ -1316,6 +1341,8 @@ export function TestEmail(arg1:string):Promise<void>;
export function TestHRDLogUpload():Promise<string>;
export function TestHamQTHUpload():Promise<string>;
export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
+52
View File
@@ -222,6 +222,10 @@ export function ComputeStationInfo(arg1, arg2) {
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
}
export function ConfigureDecoderMode(arg1) {
return window['go']['main']['App']['ConfigureDecoderMode'](arg1);
}
export function ConnectAllClusters() {
return window['go']['main']['App']['ConnectAllClusters']();
}
@@ -254,6 +258,10 @@ export function DVKCancelRecord() {
return window['go']['main']['App']['DVKCancelRecord']();
}
export function DVKDelete(arg1) {
return window['go']['main']['App']['DVKDelete'](arg1);
}
export function DVKPlay(arg1) {
return window['go']['main']['App']['DVKPlay'](arg1);
}
@@ -834,6 +842,10 @@ export function GetChaseNewSpots() {
return window['go']['main']['App']['GetChaseNewSpots']();
}
export function GetChaseSettings() {
return window['go']['main']['App']['GetChaseSettings']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
@@ -878,6 +890,14 @@ export function GetDVKStatus() {
return window['go']['main']['App']['GetDVKStatus']();
}
export function GetDXWorldNews() {
return window['go']['main']['App']['GetDXWorldNews']();
}
export function GetDXpeditions() {
return window['go']['main']['App']['GetDXpeditions']();
}
export function GetDataDir() {
return window['go']['main']['App']['GetDataDir']();
}
@@ -1178,6 +1198,14 @@ export function GetWorkedCallVariants() {
return window['go']['main']['App']['GetWorkedCallVariants']();
}
export function GetWsjtFollowMode() {
return window['go']['main']['App']['GetWsjtFollowMode']();
}
export function GetWsjtHighlight() {
return window['go']['main']['App']['GetWsjtHighlight']();
}
export function GetYaesuBandAntennas() {
return window['go']['main']['App']['GetYaesuBandAntennas']();
}
@@ -1806,6 +1834,10 @@ export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat']();
}
export function RefreshDXpeditions() {
return window['go']['main']['App']['RefreshDXpeditions']();
}
export function RefreshKenwood() {
return window['go']['main']['App']['RefreshKenwood']();
}
@@ -1978,6 +2010,10 @@ export function SaveCabrilloFile() {
return window['go']['main']['App']['SaveCabrilloFile']();
}
export function SaveChaseSettings(arg1) {
return window['go']['main']['App']['SaveChaseSettings'](arg1);
}
export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1);
}
@@ -2318,6 +2354,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
export function SetScpClublogEnabled(arg1) {
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
}
export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1);
}
@@ -2430,6 +2470,14 @@ export function SetWorkedCallVariants(arg1) {
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
}
export function SetWsjtFollowMode(arg1) {
return window['go']['main']['App']['SetWsjtFollowMode'](arg1);
}
export function SetWsjtHighlight(arg1) {
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
}
export function SetYaesuAFGain(arg1) {
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
}
@@ -2570,6 +2618,10 @@ export function TestHRDLogUpload() {
return window['go']['main']['App']['TestHRDLogUpload']();
}
export function TestHamQTHUpload() {
return window['go']['main']['App']['TestHamQTHUpload']();
}
export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload']();
}
+107
View File
@@ -1083,6 +1083,7 @@ export namespace cat {
s_meter: number;
s_meter_raw: number;
power_meter: number;
power_w: number;
swr: number;
swr_raw: number;
rf_power: number;
@@ -1120,6 +1121,7 @@ export namespace cat {
this.s_meter = source["s_meter"];
this.s_meter_raw = source["s_meter_raw"];
this.power_meter = source["power_meter"];
this.power_w = source["power_w"];
this.swr = source["swr"];
this.swr_raw = source["swr_raw"];
this.rf_power = source["rf_power"];
@@ -1464,6 +1466,37 @@ export namespace contest {
}
export namespace dxped {
export class News {
title: string;
link: string;
pub_date: string;
excerpt: string;
creator: string;
image_url: string;
tag: string;
calls: string[];
static createFrom(source: any = {}) {
return new News(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.title = source["title"];
this.link = source["link"];
this.pub_date = source["pub_date"];
this.excerpt = source["excerpt"];
this.creator = source["creator"];
this.image_url = source["image_url"];
this.tag = source["tag"];
this.calls = source["calls"];
}
}
}
export namespace extsvc {
export class ServiceConfig {
@@ -1520,6 +1553,7 @@ export namespace extsvc {
eqsl: ServiceConfig;
cloudlog: ServiceConfig;
hamlog: ServiceConfig;
hamqth: ServiceConfig;
delete_remote: boolean;
static createFrom(source: any = {}) {
@@ -1535,6 +1569,7 @@ export namespace extsvc {
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
this.hamqth = this.convertValues(source["hamqth"], ServiceConfig);
this.delete_remote = source["delete_remote"];
}
@@ -2428,6 +2463,20 @@ export namespace main {
this.at = source["at"];
}
}
export class ChaseSettings {
mode: string;
sources: string[];
static createFrom(source: any = {}) {
return new ChaseSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mode = source["mode"];
this.sources = source["sources"];
}
}
export class ChatMessage {
id: number;
operator: string;
@@ -2656,6 +2705,44 @@ export namespace main {
this.rec_slot = source["rec_slot"];
}
}
export class DXpedition {
dxcc: string;
callsign: string;
calls: string[];
start_date: string;
end_date: string;
bands: string[];
modes: string[];
qsl: string;
operators: string;
source: string;
link: string;
status: string;
status_chase: string;
unconfirmed: boolean;
static createFrom(source: any = {}) {
return new DXpedition(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.dxcc = source["dxcc"];
this.callsign = source["callsign"];
this.calls = source["calls"];
this.start_date = source["start_date"];
this.end_date = source["end_date"];
this.bands = source["bands"];
this.modes = source["modes"];
this.qsl = source["qsl"];
this.operators = source["operators"];
this.source = source["source"];
this.link = source["link"];
this.status = source["status"];
this.status_chase = source["status_chase"];
this.unconfirmed = source["unconfirmed"];
}
}
export class DatabaseSettings {
path: string;
default_path: string;
@@ -3278,11 +3365,13 @@ export namespace main {
eqsl_sent: string;
eqsl_rcvd: string;
clublog_status: string;
clublog_confirmed: string;
hrdlog_status: string;
qrzcom_status: string;
qrzcom_confirmed: string;
hamlog_status: string;
hamlog_confirmed: string;
hamqth_status: string;
static createFrom(source: any = {}) {
return new QSLDefaults(source);
@@ -3297,11 +3386,13 @@ export namespace main {
this.eqsl_sent = source["eqsl_sent"];
this.eqsl_rcvd = source["eqsl_rcvd"];
this.clublog_status = source["clublog_status"];
this.clublog_confirmed = source["clublog_confirmed"];
this.hrdlog_status = source["hrdlog_status"];
this.qrzcom_status = source["qrzcom_status"];
this.qrzcom_confirmed = source["qrzcom_confirmed"];
this.hamlog_status = source["hamlog_status"];
this.hamlog_confirmed = source["hamlog_confirmed"];
this.hamqth_status = source["hamqth_status"];
}
}
export class QSLEmailTemplates {
@@ -3836,6 +3927,7 @@ export namespace main {
enabled: boolean;
count: number;
updated?: string;
clublog: boolean;
static createFrom(source: any = {}) {
return new ScpStatus(source);
@@ -3846,6 +3938,7 @@ export namespace main {
this.enabled = source["enabled"];
this.count = source["count"];
this.updated = source["updated"];
this.clublog = source["clublog"];
}
}
export class SecretStatus {
@@ -3955,6 +4048,11 @@ export namespace main {
new_county: boolean;
county?: string;
state?: string;
new_state: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
unconf_state?: boolean;
new_pota: boolean;
grid?: string;
new_grid: boolean;
@@ -3981,6 +4079,11 @@ export namespace main {
this.new_county = source["new_county"];
this.county = source["county"];
this.state = source["state"];
this.new_state = source["new_state"];
this.unconf_status = source["unconf_status"];
this.unconf_pfx = source["unconf_pfx"];
this.unconf_cty = source["unconf_cty"];
this.unconf_state = source["unconf_state"];
this.new_pota = source["new_pota"];
this.grid = source["grid"];
this.new_grid = source["new_grid"];
@@ -5243,6 +5346,8 @@ export namespace qso {
qrzcom_qso_upload_status?: string;
qrzcom_qso_download_date?: string;
qrzcom_qso_download_status?: string;
clublog_qso_download_date?: string;
clublog_qso_download_status?: string;
contest_id?: string;
srx?: number;
stx?: number;
@@ -5385,6 +5490,8 @@ export namespace qso {
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
this.clublog_qso_download_date = source["clublog_qso_download_date"];
this.clublog_qso_download_status = source["clublog_qso_download_status"];
this.contest_id = source["contest_id"];
this.srx = source["srx"];
this.stx = source["stx"];
+8
View File
@@ -267,6 +267,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
// --- Contest ---
w("CONTEST_ID", q.ContestID)
@@ -441,5 +443,11 @@ func adifCounty(state, county string) string {
if c == "" || s == "" || strings.Contains(c, ",") {
return c
}
// The "STATE,County" join is the ADIF secondary-subdivision format, and that
// enumeration is a US thing — a two-letter state code. Prefixing a Canadian
// "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere.
if len(s) != 2 {
return c
}
return strings.ToUpper(s) + "," + c
}
+3
View File
@@ -161,6 +161,9 @@ var Fields = []FieldDef{
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
// App-defined pair (no standard ADIF field): Club Log's log-match download.
{Name: "CLUBLOG_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "CLUBLOG_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
+3
View File
@@ -287,6 +287,7 @@ var adifPromoted = stringSet(
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
"clublog_qso_download_date", "clublog_qso_download_status",
// Contest
"contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect",
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
// Contest
q.ContestID = rec["contest_id"]
+17 -3
View File
@@ -52,9 +52,15 @@ type KenwoodTXState struct {
SMeterRaw int `json:"s_meter_raw"`
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
// measured", NOT a perfect match.
PowerMeter int `json:"power_meter"`
SWR float64 `json:"swr"`
SWRRaw int `json:"swr_raw"`
PowerMeter int `json:"power_meter"`
// PowerW is the transmit power in WATTS, derived from the bargraph and the
// meter's RANGE. The K3's bar is relative to a range that flips at 12 W —
// calibrated against a real one: 10 W showed 83 (10/12), 100 W showed 83
// too (100/120). The bar alone never was watts; with the PC setting to
// pick the range, it converts. 0 while receiving.
PowerW int `json:"power_w"`
SWR float64 `json:"swr"`
SWRRaw int `json:"swr_raw"`
RFPower int `json:"rf_power"` // watts, the PC setting
AFGain int `json:"af_gain"` // 0-100
@@ -162,6 +168,7 @@ func (k *Kenwood) readPanel(mode string, split bool, txHz int64, txNow bool) {
// Cleared, not frozen: a power bar left standing after the carrier drops
// reads as a live transmission.
k.panel.PowerMeter = 0
k.panel.PowerW = 0
k.panel.SWR, k.panel.SWRRaw = 0, 0
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
// The S-meter only means anything while receiving.
@@ -341,6 +348,13 @@ func (k *Kenwood) readTXMeters() {
defer func() { k.noLatch = false }()
if v, ok := k.askNum("BG;", "BG", 2); ok {
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
if k.elecraft {
scale := 120
if k.panel.RFPower > 0 && k.panel.RFPower <= 12 {
scale = 12 // the K3's QRP range
}
k.panel.PowerW = k.panel.PowerMeter * scale / 100
}
}
// SW; — SETTLED, from Elecraft's own release note: three digits, tenths of a
// ratio. "SW023;" is 2.3:1, and "SW999;" is the 99.9:1 it reports instead of
+29
View File
@@ -240,6 +240,25 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
}
// isIgnorableSQLiteDDLError reports a benign DDL failure: the change is
// already there, or the statement shapes a table this database does not hold.
// Scoped to shaping statements only — a CREATE TABLE or data statement that
// fails must still fail the migration.
func isIgnorableSQLiteDDLError(err error, stmt string) bool {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "duplicate column name") || strings.Contains(msg, "already exists") {
return true
}
if strings.Contains(msg, "no such table") {
head := strings.ToLower(strings.TrimSpace(stmt))
return strings.HasPrefix(head, "alter table") ||
strings.HasPrefix(head, "create index") ||
strings.HasPrefix(head, "create unique index") ||
strings.HasPrefix(head, "drop ")
}
return false
}
// migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system
// (no external dependency). translate, when non-nil, rewrites each statement
@@ -352,6 +371,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string,
continue
}
if _, err := tx.Exec(stmt); err != nil {
// Same self-healing as the MySQL path, plus one case it never
// meets: a table-shaping statement aimed at a table this
// database legitimately does not have. A split settings
// database dropped its qso table when the QSOs moved to the
// logbook, but its role is still RoleAll — so a later
// "ALTER TABLE qso ADD COLUMN" must be a no-op there, not a
// failure that silently kills the whole startup (v0.27.4+).
if isIgnorableSQLiteDDLError(err, stmt) {
continue
}
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
@@ -0,0 +1,4 @@
-- Club Log log-matching (getmatches.php): a QSO both sides uploaded to Club
-- Log is a confirmation in its own right. Mirrors qrzcom_qso_download_*.
ALTER TABLE qso ADD COLUMN clublog_qso_download_date TEXT;
ALTER TABLE qso ADD COLUMN clublog_qso_download_status TEXT;
+546
View File
@@ -0,0 +1,546 @@
// Package dxped reads the two feeds the DX world announces itself on.
//
// NG3K's ADXO is the STRUCTURED one: an RSS item per announced operation whose
// description is a fixed, dash-separated sentence — dates, entity, callsign,
// QSL route, source, then the operators/bands/modes prose. It is what a
// DXpedition list is actually made of.
//
// DX-World's feed is NEWS: WordPress posts with a headline and an excerpt. It
// carries no structure to act on, but the headline nearly always names the
// callsign, so the calls are mined from the title and the reader can act on
// them the same way (watchlist, chase status).
//
// Both are cached: the announcements change a few times a day, the news a few
// times an hour, and neither is worth a request per screen repaint.
package dxped
import (
"context"
"encoding/xml"
"fmt"
"html"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const (
adxoURL = "https://www.ng3k.com/adxo.xml"
dxworldURL = "https://dx-world.net/feed/"
adxoTTL = 1 * time.Hour
dxworldTTL = 30 * time.Minute
)
// Activation is one announced operation, as ADXO describes it.
type Activation struct {
DXCC string `json:"dxcc"`
Callsign string `json:"callsign"` // display form; "A, B" when several
Calls []string `json:"calls"` // the individual callsigns, normalised
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Bands []string `json:"bands"`
Modes []string `json:"modes"`
QSL string `json:"qsl"`
Operators string `json:"operators"`
Source string `json:"source"`
Link string `json:"link"`
Status string `json:"status"` // "active" | "upcoming"
}
// News is one DX-World post.
type News struct {
Title string `json:"title"`
Link string `json:"link"`
PubDate string `json:"pub_date"` // RFC3339, "" when unparseable
Excerpt string `json:"excerpt"`
Creator string `json:"creator"`
ImageURL string `json:"image_url"`
Tag string `json:"tag"` // NEWS / UPDATE / NEW ACTIVITY…
Calls []string `json:"calls"` // callsigns mined from the headline
}
// Manager holds both caches and fetches on demand.
type Manager struct {
mu sync.RWMutex
acts []Activation
actsAt time.Time
news []News
newsAt time.Time
client *http.Client
fetching sync.Mutex // one refresh at a time, whichever pane asked
}
func New() *Manager {
return &Manager{client: &http.Client{Timeout: 30 * time.Second}}
}
// Activations returns the cached announcements, refreshing when stale.
func (m *Manager) Activations(ctx context.Context) ([]Activation, error) {
m.mu.RLock()
fresh := time.Since(m.actsAt) < adxoTTL && m.acts != nil
out := m.acts
m.mu.RUnlock()
if fresh {
return out, nil
}
m.fetching.Lock()
defer m.fetching.Unlock()
// Someone else may have refreshed while we waited for the lock.
m.mu.RLock()
fresh = time.Since(m.actsAt) < adxoTTL && m.acts != nil
out = m.acts
m.mu.RUnlock()
if fresh {
return out, nil
}
acts, err := m.fetchADXO(ctx)
if err != nil {
// Stale beats empty: a feed that is down should not blank a list the
// operator was reading a minute ago.
m.mu.RLock()
defer m.mu.RUnlock()
return m.acts, err
}
m.mu.Lock()
m.acts, m.actsAt = acts, time.Now()
m.mu.Unlock()
return acts, nil
}
// News returns the cached DX-World posts, refreshing when stale.
func (m *Manager) News(ctx context.Context) ([]News, error) {
m.mu.RLock()
fresh := time.Since(m.newsAt) < dxworldTTL && m.news != nil
out := m.news
m.mu.RUnlock()
if fresh {
return out, nil
}
m.fetching.Lock()
defer m.fetching.Unlock()
m.mu.RLock()
fresh = time.Since(m.newsAt) < dxworldTTL && m.news != nil
out = m.news
m.mu.RUnlock()
if fresh {
return out, nil
}
news, err := m.fetchDXWorld(ctx)
if err != nil {
m.mu.RLock()
defer m.mu.RUnlock()
return m.news, err
}
m.mu.Lock()
m.news, m.newsAt = news, time.Now()
m.mu.Unlock()
return news, nil
}
// Invalidate drops both caches so the next read refetches.
func (m *Manager) Invalidate() {
m.mu.Lock()
m.actsAt, m.newsAt = time.Time{}, time.Time{}
m.mu.Unlock()
}
func (m *Manager) get(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}
// ── ADXO ────────────────────────────────────────────────────────────────
type rssItem struct {
Title string `xml:"title"`
Description string `xml:"description"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
Creator string `xml:"creator"`
Enclosure struct {
URL string `xml:"url,attr"`
} `xml:"enclosure"`
MediaContent struct {
URL string `xml:"url,attr"`
} `xml:"content"`
}
type rssFeed struct {
Items []rssItem `xml:"channel>item"`
}
func (m *Manager) fetchADXO(ctx context.Context) ([]Activation, error) {
body, err := m.get(ctx, adxoURL)
if err != nil {
return nil, fmt.Errorf("adxo: %w", err)
}
var feed rssFeed
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, fmt.Errorf("adxo: parse: %w", err)
}
now := time.Now()
out := make([]Activation, 0, len(feed.Items))
for _, it := range feed.Items {
a := parseActivation(it.Description, it.Link)
if a == nil {
continue
}
a.Status = activationStatus(a.StartDate, a.EndDate, now)
if a.Status == "ended" {
continue // the list is about what is on or coming
}
out = append(out, *a)
}
return out, nil
}
var spaceRe = regexp.MustCompile(`\s+`)
// parseActivation reads one ADXO description. Its shape is fixed and has been
// for twenty years:
//
// "Feb 17-Mar 30, 2026 -- Entity -- CALL -- QSL: route -- Source: who (date)
// -- By ops; bands; modes; notes"
func parseActivation(desc, link string) *Activation {
desc = spaceRe.ReplaceAllString(strings.NewReplacer("\n", " ", "\r", " ").Replace(desc), " ")
desc = strings.TrimSpace(html.UnescapeString(desc))
if desc == "" {
return nil
}
parts := strings.Split(desc, " -- ")
if len(parts) < 3 {
return nil
}
a := &Activation{Link: link}
a.StartDate, a.EndDate = parseDateRange(strings.TrimSpace(parts[0]))
a.DXCC = strings.TrimSpace(parts[1])
a.Callsign = strings.TrimSpace(parts[2])
for _, p := range parts[3:] {
p = strings.TrimSpace(p)
switch {
case strings.HasPrefix(p, "QSL:"):
a.QSL = strings.TrimSpace(strings.TrimPrefix(p, "QSL:"))
case strings.HasPrefix(p, "Source:"):
a.Source = strings.TrimSpace(strings.TrimPrefix(p, "Source:"))
}
}
// The tail carries "By <ops>; <bands>; <modes>; <notes>".
tail := parts[len(parts)-1]
if i := strings.Index(tail, "By "); i >= 0 {
sub := strings.Split(tail[i+3:], ";")
if len(sub) > 0 {
a.Operators = strings.TrimSpace(sub[0])
}
if len(sub) > 1 {
a.Bands = parseBands(sub[1])
}
if len(sub) > 2 {
a.Modes = parseModes(sub[2])
}
}
// The callsign field often holds only the PREFIX; the real calls hide in the
// operators prose as "W2APF as PJ2/W2APF". Prefer those when present.
if calls := callsAfterAs(a.Operators); len(calls) > 0 {
prefix := strings.ToUpper(strings.TrimSpace(parts[2]))
for i := range calls {
calls[i] = normalizeCall(calls[i], prefix)
}
a.Calls = calls
a.Callsign = strings.Join(calls, ", ")
} else if c := strings.ToUpper(a.Callsign); plausibleCall(c) {
a.Calls = []string{c}
}
return a
}
// parseDateRange handles the two forms ADXO writes: "Feb 17-Mar 30, 2026" and
// "Mar 3-20, 2026" (the month carried over).
var (
fullRangeRe = regexp.MustCompile(`(?i)(\w+ \d+)\s*-\s*(\w+ \d+),\s*(\d{4})`)
shortRangeRe = regexp.MustCompile(`(?i)(\w+) (\d+)\s*-\s*(\d+),\s*(\d{4})`)
singleDayRe = regexp.MustCompile(`(?i)(\w+ \d+),\s*(\d{4})`)
)
func parseDateRange(s string) (start, end string) {
if m := fullRangeRe.FindStringSubmatch(s); m != nil {
return m[1] + ", " + m[3], m[2] + ", " + m[3]
}
if m := shortRangeRe.FindStringSubmatch(s); m != nil {
return m[1] + " " + m[2] + ", " + m[4], m[1] + " " + m[3] + ", " + m[4]
}
if m := singleDayRe.FindStringSubmatch(s); m != nil {
return m[1] + ", " + m[2], m[1] + ", " + m[2]
}
return s, s
}
func parseADXODate(s string) (time.Time, bool) {
for _, layout := range []string{"Jan 2, 2006", "January 2, 2006"} {
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
return t, true
}
}
return time.Time{}, false
}
func activationStatus(start, end string, now time.Time) string {
s, okS := parseADXODate(start)
e, okE := parseADXODate(end)
if !okS || !okE {
return "upcoming" // unreadable dates: keep it, an operator can read them
}
switch {
case now.Before(s):
return "upcoming"
// The end date is a DAY, so an operation is on until that day is over.
case now.After(e.Add(24 * time.Hour)):
return "ended"
default:
return "active"
}
}
// callsAfterAs mines "…as CALL…" out of the operators prose:
//
// "W2APF as PJ2/W2APF" → PJ2/W2APF
// "SQ2RAD as VP2EAD, M0PLX as VP2ELX" → VP2EAD, VP2ELX
var asCallRe = regexp.MustCompile(`(?i)\bas\s+([A-Z0-9]+(?:/[A-Z0-9]+)*)`)
func callsAfterAs(operators string) []string {
var out []string
seen := map[string]bool{}
for _, m := range asCallRe.FindAllStringSubmatch(operators, -1) {
c := strings.ToUpper(strings.TrimSpace(m[1]))
if !plausibleCall(c) || seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
// normalizeCall puts the DXCC prefix first: a cluster spot says JD1/JG8NQJ, and
// matching the log against JG8NQJ/JD1 would find nothing.
func normalizeCall(call, dxccPrefix string) string {
if dxccPrefix == "" || !strings.Contains(call, "/") {
return call
}
left, right, _ := strings.Cut(call, "/")
if strings.HasPrefix(right, dxccPrefix) && !strings.HasPrefix(left, dxccPrefix) {
return right + "/" + left
}
return call
}
var (
// A span carries its unit only once — "160-6m" is the commonest way ADXO
// states coverage, and reading it as "6m" alone loses the whole low end.
bandRe = regexp.MustCompile(`(?i)\b(?:(\d{1,4})\s*-\s*)?(\d{1,4})\s*(m|cm)\b`)
// The modes ADXO actually writes. A list beats a pattern here: "FT8" and
// "SSB" have no shape in common, and inventing one invites "QSL" as a mode.
knownModes = []string{"SSB", "CW", "FT8", "FT4", "RTTY", "PSK", "SSTV", "AM", "FM", "JT65", "JS8", "Q65", "MSK144", "DIGI", "DATA"}
)
func parseBands(s string) []string {
var out []string
seen := map[string]bool{}
add := func(num, unit string) {
if num == "" {
return
}
b := strings.ToLower(num + unit)
if seen[b] {
return
}
seen[b] = true
out = append(out, b)
}
// A span keeps only its endpoints: ADXO states coverage in prose, and the
// two ends are the part it gives reliably.
for _, m := range bandRe.FindAllStringSubmatch(s, -1) {
add(m[1], m[3])
add(m[2], m[3])
}
return out
}
func parseModes(s string) []string {
up := strings.ToUpper(s)
var out []string
seen := map[string]bool{}
for _, mode := range knownModes {
if seen[mode] {
continue
}
if regexp.MustCompile(`\b` + mode + `\b`).MatchString(up) {
seen[mode] = true
out = append(out, mode)
}
}
return out
}
// ── DX-World ────────────────────────────────────────────────────────────
var (
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
tagPrefixRe = regexp.MustCompile(`(?i)^\s*\[([^\]]+)\]\s*`)
)
func stripHTML(s string) string {
s = htmlTagRe.ReplaceAllString(s, " ")
s = html.UnescapeString(s)
return strings.TrimSpace(spaceRe.ReplaceAllString(s, " "))
}
func (m *Manager) fetchDXWorld(ctx context.Context) ([]News, error) {
body, err := m.get(ctx, dxworldURL)
if err != nil {
return nil, fmt.Errorf("dx-world: %w", err)
}
var feed rssFeed
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, fmt.Errorf("dx-world: parse: %w", err)
}
out := make([]News, 0, len(feed.Items))
for _, it := range feed.Items {
title := stripHTML(it.Title)
tag, title := splitTag(title)
n := News{
Title: title,
Link: strings.TrimSpace(it.Link),
Creator: strings.TrimSpace(it.Creator),
Tag: tag,
Calls: callsInHeadline(title),
}
if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil {
n.PubDate = t.UTC().Format(time.RFC3339)
} else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil {
n.PubDate = t.UTC().Format(time.RFC3339)
}
ex := stripHTML(it.Description)
if t2, rest := splitTag(ex); t2 != "" {
if n.Tag == "" {
n.Tag = t2
}
ex = rest
}
n.Excerpt = truncateRunes(ex, 400)
if u := strings.TrimSpace(it.Enclosure.URL); u != "" {
n.ImageURL = u
} else if u := strings.TrimSpace(it.MediaContent.URL); u != "" {
n.ImageURL = u
}
out = append(out, n)
}
return out, nil
}
// splitTag pulls the leading "[UPDATE]" DX-World puts on most posts.
func splitTag(s string) (tag, rest string) {
m := tagPrefixRe.FindStringSubmatchIndex(s)
if m == nil {
return "", s
}
tag = strings.ToUpper(strings.TrimSpace(s[m[2]:m[3]]))
rest = strings.TrimSpace(s[m[1]:])
rest = strings.TrimPrefix(strings.TrimPrefix(rest, " "), "- ")
return tag, strings.TrimSpace(rest)
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return strings.TrimSpace(string(r[:max])) + "…"
}
// callsInHeadline mines callsigns out of a news headline — "3B7M, St Brandon"
// or "TX5S team lands". The reader can then chase or watch them, which is the
// whole reason a news feed sits next to the announcements.
func callsInHeadline(title string) []string {
var out []string
seen := map[string]bool{}
for _, tok := range strings.FieldsFunc(title, func(r rune) bool {
return !(r == '/' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'))
}) {
c := strings.ToUpper(tok)
if !plausibleCall(c) || seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
var (
bandTokenRe = regexp.MustCompile(`^\d{1,4}(M|CM)$`)
// Jargon shaped exactly like a callsign. Every one of these was seen in a
// real headline before it earned its place here.
notACall = map[string]bool{
"FT8": true, "FT4": true, "JT65": true, "JT9": true, "JS8": true, "Q65": true,
"MSK144": true, "PSK31": true, "SSTV": true, "OQRS": true, "LOTW": true,
"IOTA": true, "SOTA": true, "POTA": true, "WWFF": true, "DXCC": true,
"CQWW": true, "CQWPX": true, "ARRL": true, "3D": true, "4K": true,
}
)
// plausibleCall keeps tokens shaped like an amateur callsign: 312 characters
// of AZ/09 (with optional /prefix or /suffix), at least one letter AND one
// digit, and a letter somewhere after the first digit — which is what separates
// a callsign from a band or a year.
func plausibleCall(s string) bool {
s = strings.ToUpper(strings.TrimSpace(s))
if len(s) < 3 || len(s) > 12 || notACall[s] || bandTokenRe.MatchString(s) {
return false
}
// Judge the longest part — the real call in "PJ2/W2APF" either way.
base := s
if strings.Contains(s, "/") {
base = ""
for _, p := range strings.Split(s, "/") {
if len(p) > len(base) {
base = p
}
}
}
var hasLetter, hasDigit, letterAfterDigit bool
seenDigit := false
for _, r := range base {
switch {
case r >= 'A' && r <= 'Z':
hasLetter = true
if seenDigit {
letterAfterDigit = true
}
case r >= '0' && r <= '9':
hasDigit = true
seenDigit = true
default:
return false
}
}
return hasLetter && hasDigit && letterAfterDigit
}
+86
View File
@@ -0,0 +1,86 @@
package dxped
import (
"reflect"
"testing"
"time"
)
// Pinned against REAL feed text: the parser reads a fixed sentence, and a
// wrong split silently empties a DXpedition list rather than failing loudly.
func TestParseActivation(t *testing.T) {
desc := "Aug 24-31, 2026 -- St Kitts and Nevis -- V47JA -- QSL: LoTW -- " +
"Source: W5JON (Aug 1, 2026) -- By W5JON as V47JA fm Calypso Bay; 160-6m; SSB FT8; yagi, verticals"
a := parseActivation(desc, "https://www.qrz.com/lookup/v47ja")
if a == nil {
t.Fatal("parseActivation returned nil on a real ADXO description")
}
if a.DXCC != "St Kitts and Nevis" {
t.Errorf("DXCC = %q", a.DXCC)
}
if a.Callsign != "V47JA" {
t.Errorf("Callsign = %q, want V47JA (mined from 'as')", a.Callsign)
}
if a.QSL != "LoTW" {
t.Errorf("QSL = %q", a.QSL)
}
if a.StartDate != "Aug 24, 2026" || a.EndDate != "Aug 31, 2026" {
t.Errorf("dates = %q..%q", a.StartDate, a.EndDate)
}
if want := []string{"160m", "6m"}; !reflect.DeepEqual(a.Bands, want) {
t.Errorf("Bands = %v, want %v", a.Bands, want)
}
if want := []string{"SSB", "FT8"}; !reflect.DeepEqual(a.Modes, want) {
t.Errorf("Modes = %v, want %v", a.Modes, want)
}
}
// The callsign column often holds only the prefix; the operators prose holds
// the real thing, and a slashed call must lead with the DXCC prefix or no spot
// will ever match it.
func TestCallsAfterAsAndNormalise(t *testing.T) {
if got := callsAfterAs("SQ2RAD as VP2EAD, M0PLX as VP2ELX"); !reflect.DeepEqual(got, []string{"VP2EAD", "VP2ELX"}) {
t.Errorf("callsAfterAs = %v", got)
}
if got := normalizeCall("JG8NQJ/JD1", "JD1"); got != "JD1/JG8NQJ" {
t.Errorf("normalizeCall = %q, want JD1/JG8NQJ", got)
}
if got := normalizeCall("PJ2/W2APF", "PJ2"); got != "PJ2/W2APF" {
t.Errorf("normalizeCall rewrote an already-correct call: %q", got)
}
}
func TestActivationStatus(t *testing.T) {
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
cases := []struct{ start, end, want string }{
{"Aug 24, 2026", "Aug 31, 2026", "active"},
{"Sep 10, 2026", "Sep 20, 2026", "upcoming"},
{"Aug 1, 2026", "Aug 10, 2026", "ended"},
{"Aug 24, 2026", "Aug 27, 2026", "active"}, // ends TODAY: still on
{"garbage", "garbage", "upcoming"},
}
for _, c := range cases {
if got := activationStatus(c.start, c.end, now); got != c.want {
t.Errorf("activationStatus(%q,%q) = %q, want %q", c.start, c.end, got, c.want)
}
}
}
// Mining a headline must find calls without inventing them out of jargon.
func TestCallsInHeadline(t *testing.T) {
cases := []struct {
title string
want []string
}{
{"3B7M, St Brandon", []string{"3B7M"}},
{"TX5S team lands on Clipperton", []string{"TX5S"}},
{"FT8 activity on 160m in 2026", nil},
{"VP6D QSL via OQRS, LoTW", []string{"VP6D"}},
{"JD1/JG8NQJ from Minami Torishima", []string{"JD1/JG8NQJ"}},
}
for _, c := range cases {
if got := callsInHeadline(c.title); !reflect.DeepEqual(got, c.want) {
t.Errorf("callsInHeadline(%q) = %v, want %v", c.title, got, c.want)
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package extsvc
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Club Log's log-matching API. A "match" is a QSO that BOTH stations uploaded
// to Club Log, paired within ±15 minutes — Club Log's own equivalent of a LoTW
// confirmation. getmatches.php returns a JSON array of 5-element arrays:
//
// [["G0LGJ/M","223","2005-07-16 08:00:00","20","CW"], …]
// callsign dxcc qso datetime (UTC) band mode ("false" when unknown)
//
// The optional start date filters on when Club Log COMPLETED the match (not
// the QSO date), which is exactly what an incremental "since last download"
// pull wants.
const clublogMatchesURL = "https://clublog.org/getmatches.php"
// ClublogMatch is one confirmed pairing from getmatches.php.
type ClublogMatch struct {
Callsign string
DXCC int
When time.Time
Band string // ADIF band ("20m", "70cm"); "" if the id is unknown
Mode string // "" when Club Log doesn't know it
}
// clublogBandNames maps Club Log's numeric band ids to ADIF band names. The
// ids are the wavelength number; the only trap is that ids past the metre
// bands are centimetres (the docs' own example: 70 = 70CM).
var clublogBandNames = map[string]string{
"2200": "2200m", "630": "630m", "160": "160m", "80": "80m", "60": "60m",
"40": "40m", "30": "30m", "20": "20m", "17": "17m", "15": "15m",
"12": "12m", "10": "10m", "8": "8m", "6": "6m", "5": "5m", "4": "4m",
"2": "2m", "70": "70cm", "23": "23cm", "13": "13cm", "9": "9cm", "3": "3cm",
}
// DownloadClublogMatches pulls the account's log matches for cfg.Callsign,
// optionally only those Club Log completed since sinceDate ("2006-01-02").
func DownloadClublogMatches(ctx context.Context, client *http.Client, cfg ServiceConfig, sinceDate string) ([]ClublogMatch, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return nil, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return nil, fmt.Errorf("clublog: password not set")
case call == "":
return nil, fmt.Errorf("clublog: callsign not set")
}
v := url.Values{}
v.Set("api", clublogAppAPIKey)
v.Set("email", email)
v.Set("password", cfg.Password)
v.Set("callsign", call)
if t, err := time.Parse("2006-01-02", strings.TrimSpace(sinceDate)); err == nil {
v.Set("startyear", strconv.Itoa(t.Year()))
v.Set("startmonth", strconv.Itoa(int(t.Month())))
v.Set("startday", strconv.Itoa(t.Day()))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogMatchesURL+"?"+v.Encode(), nil)
if err != nil {
return nil, err
}
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
text := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK {
if looksLikeHTML(text) || len(text) > 300 {
return nil, fmt.Errorf("clublog: HTTP %d", resp.StatusCode)
}
return nil, fmt.Errorf("clublog: HTTP %d: %s", resp.StatusCode, text)
}
if looksLikeHTML(text) {
return nil, fmt.Errorf("clublog: got a web page instead of matches — check email/password/callsign")
}
var raw [][]any
if err := json.Unmarshal([]byte(text), &raw); err != nil {
return nil, fmt.Errorf("clublog: bad matches JSON: %w", err)
}
str := func(x any) string {
switch t := x.(type) {
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
}
}
out := make([]ClublogMatch, 0, len(raw))
for _, rec := range raw {
if len(rec) < 5 {
continue
}
m := ClublogMatch{Callsign: strings.ToUpper(strings.TrimSpace(str(rec[0])))}
m.DXCC, _ = strconv.Atoi(str(rec[1]))
if t, err := time.Parse("2006-01-02 15:04:05", str(rec[2])); err == nil {
m.When = t.UTC()
}
m.Band = clublogBandNames[strings.TrimSpace(str(rec[3]))]
if md := strings.TrimSpace(str(rec[4])); md != "" && !strings.EqualFold(md, "false") {
m.Mode = strings.ToUpper(md)
}
if m.Callsign == "" || m.When.IsZero() {
continue
}
out = append(out, m)
}
return out, nil
}
+4
View File
@@ -38,6 +38,9 @@ const (
ServiceCloudlog Service = "cloudlog"
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
ServiceHamlog Service = "hamlog"
// ServiceHamQTH is the HamQTH online logbook — the callbook credentials,
// one ADIF record per QSO.
ServiceHamQTH Service = "hamqth"
)
// UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -133,6 +136,7 @@ type ExternalServices struct {
EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"`
Hamlog ServiceConfig `json:"hamlog"`
HamQTH ServiceConfig `json:"hamqth"`
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
// it is deleted locally. Off unless the operator turns it on: neither
+129
View File
@@ -0,0 +1,129 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// HamQTH real-time QSO upload.
//
// One POST per QSO to qso_realtime.php with the account username/password —
// the same credentials the HamQTH callbook lookup uses. The answer is the
// HTTP status code, not a body format: 200 saved, 400 rejected (bad band,
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
// endpoint that cannot change anything in the log, which is what the settings
// Test button must call.
const hamqthLoginURL = "https://www.hamqth.com/xml.php"
// UploadHamQTH pushes one ADIF record to the HamQTH online logbook.
func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord)
}
func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
user := strings.TrimSpace(cfg.Username)
switch {
case user == "":
return UploadResult{}, fmt.Errorf("hamqth: username not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("hamqth: password not set")
}
rec := strings.TrimSpace(adifRecord)
if rec == "" {
return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record")
}
form := url.Values{}
form.Set("u", user)
form.Set("p", cfg.Password)
// c: the logbook callsign when the account holds several; empty means the
// account's own call, which is the common case.
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
form.Set("c", c)
}
form.Set("adif", rec)
form.Set("prg", "OpsLog")
form.Set("cmd", "insert")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(body))
switch resp.StatusCode {
case http.StatusOK:
return UploadResult{OK: true}, nil
case http.StatusBadRequest:
// "Rejected" covers duplicates too. A duplicate is a SUCCESS for our
// purposes — the QSO is in the logbook, retrying it forever isn't —
// same treatment HRDLog's <insert>0 gets.
if strings.Contains(strings.ToLower(msg), "dupl") {
return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil
}
if msg == "" {
msg = "QSO rejected"
}
return UploadResult{OK: false, Message: msg}, nil
case http.StatusForbidden:
return UploadResult{}, fmt.Errorf("hamqth: wrong username or password")
default:
if msg != "" && len(msg) < 200 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
}
// TestHamQTH verifies the credentials against the callbook session login —
// authenticated, and unable to touch the log.
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("hamqth: set the username and password first")
}
q := url.Values{}
q.Set("u", user)
q.Set("p", cfg.Password)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil)
if err != nil {
return "", err
}
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
s := string(body)
if strings.Contains(s, "<session_id>") {
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
}
if i := strings.Index(s, "<error>"); i >= 0 {
e := s[i+len("<error>"):]
if j := strings.Index(e, "</error>"); j >= 0 {
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
}
}
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
}
+31
View File
@@ -11,6 +11,8 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"syscall"
@@ -337,7 +339,36 @@ func fileExists(p string) bool {
// they were already uploaded OR outside the callsign certificate's date range.
// Reporting either as success is how a contact came to be stamped "uploaded"
// while LoTW had never seen it.
// scrubMyCnty removes MY_CNTY fields TQSL would refuse. LoTW's secondary
// subdivisions are the US county enumeration — "XX,County" with a two-letter
// state — and TQSL rejects the whole record over anything else, so a Canadian
// station's "ONTARIO,Kawartha" (or a bare county) must simply not be sent.
// MY_STATE and MY_GRIDSQUARE already locate the station for LoTW.
var myCntyRe = regexp.MustCompile(`(?i)<MY_CNTY:([0-9]+)(?::[A-Za-z])?>`)
func scrubMyCnty(adif string) string {
for {
loc := myCntyRe.FindStringSubmatchIndex(adif)
if loc == nil {
return adif
}
n, _ := strconv.Atoi(adif[loc[2]:loc[3]])
end := loc[1] + n
if end > len(adif) {
end = len(adif)
}
val := adif[loc[1]:end]
if len(val) > 3 && val[2] == ',' {
// "XX,..." — the US shape TQSL accepts; leave it for the county hunters.
rest := scrubMyCnty(adif[end:])
return adif[:end] + rest
}
adif = adif[:loc[0]] + strings.TrimLeft(adif[end:], " ")
}
}
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
adifRecord = scrubMyCnty(adifRecord)
tqsl := strings.TrimSpace(cfg.TQSLPath)
loc := strings.TrimSpace(cfg.StationLocation)
switch {
+23
View File
@@ -0,0 +1,23 @@
package extsvc
import "testing"
// TQSL refuses whole records over a MY_CNTY it cannot validate, and its
// validation is the US "XX,County" enumeration — so anything else must be
// stripped before signing, and the US shape must survive untouched.
func TestScrubMyCnty(t *testing.T) {
cases := []struct{ in, want string }{
{"<CALL:5>F4BPO<MY_CNTY:16>ONTARIO,Kawartha<MY_STATE:7>ONTARIO<EOR>",
"<CALL:5>F4BPO<MY_STATE:7>ONTARIO<EOR>"},
{"<MY_CNTY:8>Kawartha<EOR>", "<EOR>"},
{"<MY_CNTY:9>NY,Monroe<EOR>", "<MY_CNTY:9>NY,Monroe<EOR>"},
{"<CALL:4>K1AB<EOR>", "<CALL:4>K1AB<EOR>"},
{"<MY_CNTY:8>Kawartha<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>",
"<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>"},
}
for _, c := range cases {
if got := scrubMyCnty(c.in); got != c.want {
t.Errorf("scrubMyCnty(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+25 -2
View File
@@ -140,6 +140,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised()
cfg.Hamlog = cfg.Hamlog.normalised()
cfg.HamQTH = cfg.HamQTH.normalised()
m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save.
@@ -153,7 +154,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
}{
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
{"hamlog", cfg.Hamlog},
{"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH},
} {
if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
@@ -237,6 +238,14 @@ func (m *Manager) OnQSOLogged(id int64) {
m.route(ServiceHamlog, id, h)
}
}
// HamQTH — the callbook credentials double as the logbook login.
if h := cfg.HamQTH; h.AutoUpload {
if h.Username == "" || h.Password == "" {
m.logf("extsvc: hamqth auto-upload is ON but the username/password is not set (QSO %d not sent)", id)
} else {
m.route(ServiceHamQTH, id, h)
}
}
}
// route sends a logged QSO down the configured timing path: queue it for the
@@ -290,6 +299,9 @@ func (m *Manager) onCloseServices() []Service {
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
out = append(out, ServiceHamlog)
}
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
out = append(out, ServiceHamQTH)
}
return out
}
@@ -338,6 +350,8 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
case ServiceHamlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
case ServiceHamQTH:
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
}
}
return uploaded
@@ -577,7 +591,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
switch svc {
case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign
case ServiceClublog, ServiceHRDLog:
case ServiceClublog, ServiceHRDLog, ServiceHamQTH:
owner = cfg.Callsign
case ServiceEQSL:
owner = cfg.Username
@@ -669,6 +683,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false
}
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
case ServiceHamQTH:
// The c parameter names the logbook when the account holds several;
// the QSO keeps its own STATION_CALLSIGN in the ADIF.
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 = UploadHamQTH(ctx, m.deps.Client, cfg, record)
default:
return false, false
}
+34 -5
View File
@@ -156,6 +156,8 @@ type Event struct {
DecodeModeRaw string
// DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw.
DecodeMsgRaw string
// DecodeIsNew is false on the history a Replay resends: display-only lines.
DecodeIsNew bool
// ProgramID is the sending application's own id ("WSJT-X", "MSHV", or
// "WSJT-X - 2" for a second instance started with --rig-name). It is what
// tells two receivers apart on one multicast group — and it is the address a
@@ -212,6 +214,9 @@ type Server struct {
// lastFrom is the address each program's packets arrive from — where a Reply
// has to be sent. See SendReply.
lastFrom map[string]*net.UDPAddr
// onNewInstance fires (off the read loop) the first time a program id is
// heard on this listener — the hook the startup replay hangs from.
onNewInstance func(programID string)
// instLabel names each running application, keyed by id AND sending address.
//
// WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV
@@ -285,11 +290,12 @@ func describePacket(pkt []byte) string {
func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
return &Server{
cfg: cfg,
out: out,
mgr: mgr,
stop: make(chan struct{}),
done: make(chan struct{}),
cfg: cfg,
out: out,
mgr: mgr,
onNewInstance: mgr.onNewInstance,
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
@@ -515,13 +521,23 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// must go to the sender's own address, never to the group.
s.mu.Lock()
inst := s.instanceLabel(w.ProgramID, remote)
newInstance := false
if inst != "" && remote != nil {
if s.lastFrom == nil {
s.lastFrom = map[string]*net.UDPAddr{}
}
if _, known := s.lastFrom[inst]; !known {
newInstance = true
}
s.lastFrom[inst] = remote
}
onNew := s.onNewInstance
s.mu.Unlock()
// A program just heard for the first time this session: tell the app, so
// it can ask for a replay of the decodes already on that program's screen.
if newInstance && onNew != nil {
go onNew(inst)
}
// Status carries the current dial frequency; remember it so Decode audio
// offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode {
@@ -580,6 +596,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeModeRaw = w.Mode
ev.DecodeMsg = w.DecodeMsg
ev.DecodeMsgRaw = w.DecodeMsgRaw
ev.DecodeIsNew = w.DecodeIsNew
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
ev.DecodeTRPeriod = tr
ev.DecodeDial = dial
@@ -803,6 +820,10 @@ type Manager struct {
repo *Repo
out chan Event
// onNewInstance is copied onto every inbound listener as it starts; see
// Server.onNewInstance.
onNewInstance func(programID string)
// noADIFOnce keeps the "nothing to forward to" note to one line a session
// rather than one per QSO logged.
noADIFOnce sync.Once
@@ -940,3 +961,11 @@ func (m *Manager) StopAll() {
s.close()
}
}
// SetOnNewInstance installs the first-sighting hook. Call before Reload so
// listeners are born with it.
func (m *Manager) SetOnNewInstance(fn func(programID string)) {
m.mu.Lock()
m.onNewInstance = fn
m.mu.Unlock()
}
@@ -0,0 +1,47 @@
package udp
import (
"bytes"
"encoding/binary"
"hamlog/internal/applog"
)
// WSJT-X Configure (message 15) — change the decoder's settings remotely. Used
// for ONE thing here: clicking an FT4 spot while the decoder sits in FT8
// switches its mode too, so the operator lands ready to decode instead of
// staring at a band of gibberish. Every other field is sent as "no change"
// (empty strings, max-quint32), per the protocol.
const wsjtMsgConfigure = 15
// EncodeConfigureMode builds a Configure datagram that changes only the mode.
func EncodeConfigureMode(programID, mode string) []byte {
const noChange32 = ^uint32(0)
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2))
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgConfigure))
writeQString(&b, programID)
writeQString(&b, mode) // Mode
_ = binary.Write(&b, binary.BigEndian, noChange32) // Frequency Tolerance — no change
writeQString(&b, "") // Submode — no change
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // Fast Mode — off (right for every HF mode)
_ = binary.Write(&b, binary.BigEndian, noChange32) // T/R Period — no change
_ = binary.Write(&b, binary.BigEndian, noChange32) // Rx DF — no change
writeQString(&b, "") // DX Call — no change
writeQString(&b, "") // DX Grid — no change
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // Generate Messages — no
return b.Bytes()
}
// SendConfigureMode asks every decoder heard this session to switch mode.
// Sent to all instances rather than one: the spot click does not say which
// decoder the operator is looking at, and a second instance already in the
// right mode treats the message as a no-op.
func (m *Manager) SendConfigureMode(mode string) {
for _, inst := range m.Instances() {
if err := m.sendToInstance(inst, EncodeConfigureMode(inst, mode), "configure-mode"); err == nil {
applog.Printf("udp: asked %q to switch to %s", inst, mode)
}
}
}
+146
View File
@@ -0,0 +1,146 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Highlight Callsign (13) and Replay (7) — the two halves of making the
// Band Activity window log-aware.
//
// Highlight paints a callsign in the decoding application's own window with the
// colours OpsLog chooses — new DXCC, new band, a watchlist member — the way
// JTAlert does. Replay asks a freshly-discovered instance to resend the decodes
// it already has on screen, so the FT decodes panel starts full instead of
// empty until the next period.
const (
wsjtMsgReplay = 7
wsjtMsgHighlight = 13
)
// RGB is one highlight colour. A nil *RGB means "invalid QColor", which is the
// protocol's way of saying "remove the highlight".
type RGB struct{ R, G, B uint8 }
// writeQColor serializes a QColor as QDataStream does: a spec byte (1 = RGB,
// 0 = invalid) followed by five 16-bit channels (alpha, red, green, blue, pad),
// each 8-bit value doubled into 16 bits the way Qt stores them.
func writeQColor(b *bytes.Buffer, c *RGB) {
if c == nil {
b.WriteByte(0) // invalid — clears the highlight
for i := 0; i < 5; i++ {
_ = binary.Write(b, binary.BigEndian, uint16(0))
}
return
}
b.WriteByte(1) // spec = RGB
wide := func(v uint8) uint16 { return uint16(v) * 0x101 }
_ = binary.Write(b, binary.BigEndian, uint16(0xFFFF)) // alpha, opaque
_ = binary.Write(b, binary.BigEndian, wide(c.R))
_ = binary.Write(b, binary.BigEndian, wide(c.G))
_ = binary.Write(b, binary.BigEndian, wide(c.B))
_ = binary.Write(b, binary.BigEndian, uint16(0)) // pad
}
// EncodeHighlight builds a Highlight Callsign datagram. bg/fg nil = invalid
// colour; both nil clears the callsign's highlight.
func EncodeHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2))
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgHighlight))
writeQString(&b, programID)
writeQString(&b, callsign)
writeQColor(&b, bg)
writeQColor(&b, fg)
var last uint8
if lastPeriodOnly {
last = 1
}
_ = binary.Write(&b, binary.BigEndian, last)
return b.Bytes()
}
// EncodeReplay builds a Replay datagram — "resend what your window holds".
func EncodeReplay(programID string) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2))
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgReplay))
writeQString(&b, programID)
return b.Bytes()
}
// sendToInstance routes a raw datagram to the application that owns programID,
// the same way SendReply does: to the address its packets actually arrive from.
func (m *Manager) sendToInstance(programID string, pkt []byte, what string) error {
if strings.TrimSpace(programID) == "" {
return fmt.Errorf("no application id")
}
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
for _, s := range servers {
conn, addr := s.replyTarget(programID)
if conn == nil || addr == nil {
continue
}
if _, err := conn.WriteToUDP(pkt, addr); err != nil {
return fmt.Errorf("send %s to %s at %s: %w", what, programID, addr, err)
}
return nil
}
return fmt.Errorf("no packet has arrived from %q yet", programID)
}
// SendHighlight paints (or clears) one callsign in the given instance.
func (m *Manager) SendHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) error {
return m.sendToInstance(programID, EncodeHighlight(programID, callsign, bg, fg, lastPeriodOnly), "highlight")
}
// SendClearHighlights removes every highlighting instruction OpsLog installed
// in the instance. "CLEARALL!" is the protocol's own magic callsign for it.
func (m *Manager) SendClearHighlights(programID string) error {
return m.sendToInstance(programID, EncodeHighlight(programID, "CLEARALL!", nil, nil, false), "clear-highlights")
}
// SendReplay asks the instance to resend its on-screen decodes.
func (m *Manager) SendReplay(programID string) error {
err := m.sendToInstance(programID, EncodeReplay(programID), "replay")
if err == nil {
applog.Printf("udp: replay requested from %q — its existing decodes will arrive marked not-new", programID)
}
return err
}
// Instances lists every program id a packet has arrived from, for "clear the
// highlights everywhere" and the startup replay.
func (m *Manager) Instances() []string {
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
seen := map[string]struct{}{}
var out []string
for _, s := range servers {
s.mu.Lock()
for id := range s.lastFrom {
if _, dup := seen[id]; !dup {
seen[id] = struct{}{}
out = append(out, id)
}
}
s.mu.Unlock()
}
return out
}
+27 -4
View File
@@ -79,9 +79,10 @@ type Config struct {
type Client struct {
cfg Config
mu sync.Mutex // serialises the connection: one question at a time
conn io.ReadWriteCloser
rd *bufio.Reader
mu sync.Mutex // serialises the connection: one question at a time
conn io.ReadWriteCloser
rd *bufio.Reader
skipTP bool // ^TP went unanswered once — a KPA500, no ATU; never ask again
statusMu sync.RWMutex
status Status
@@ -183,6 +184,13 @@ func (c *Client) connectLocked() error {
if err != nil {
return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err)
}
// The KPA500 is POWER-CONTROLLED by these lines: the Elecraft utility
// switches the amplifier on by raising them. Held asserted, once, and
// never touched again — reconnect cycles that toggled them were
// switching a KPA500 OFF twenty seconds after its operator pressed
// nothing but Standby.
_ = p.SetDTR(true)
_ = p.SetRTS(true)
_ = p.SetReadTimeout(ioTimeout)
c.conn = p
}
@@ -214,7 +222,13 @@ func (c *Client) ask(cmd string) (string, error) {
// the frame.
line, err := c.rd.ReadString(';')
if err != nil {
c.dropLocked()
// NOT dropped. A command this model simply does not know (^TP is the
// KPA1500's ATU — a KPA500 never answers it) is silence, not a dead
// link, and dropping here tore the connection down on every slow poll
// cycle: two seconds of stalled commands, a reconnect, and a DTR
// toggle the amplifier read as the off switch. Nothing arrived, so
// nothing is left to desynchronise the next exchange. Write errors —
// the genuinely dead link — still drop, above.
return "", fmt.Errorf("no answer to %s: %w", cmd, err)
}
return strings.TrimSpace(line), nil
@@ -364,12 +378,21 @@ func (c *Client) pollOnce(n uint64) {
c.statusMu.Unlock()
}
}
if c.skipTP {
return
}
if reply, err := c.ask("^TP;"); err == nil {
if v, err := parseInt(reply, "^TP"); err == nil {
c.statusMu.Lock()
c.status.Tuning = v == 1
c.statusMu.Unlock()
}
} else {
// One silence is the model's answer for good: a KPA500 has no ATU and
// will never answer ^TP — asking again every cycle cost a two-second
// stall each time.
c.skipTP = true
applog.Printf("kpa: ^TP unanswered — no ATU on this model, not asking again")
}
}
+278 -113
View File
@@ -123,6 +123,10 @@ type QSO struct {
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
// Club Log match download (getmatches.php) — app-defined, no standard ADIF
// field exists; exported/imported as CLUBLOG_QSO_DOWNLOAD_*.
ClublogDownloadDate string `json:"clublog_qso_download_date,omitempty"`
ClublogDownloadStatus string `json:"clublog_qso_download_status,omitempty"`
// --- Contest ---
ContestID string `json:"contest_id,omitempty"`
@@ -261,6 +265,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
hrdlog_qso_upload_date, hrdlog_qso_upload_status,
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
qrzcom_qso_download_date, qrzcom_qso_download_status,
clublog_qso_download_date, clublog_qso_download_status,
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
@@ -336,6 +341,7 @@ func (q *QSO) args() []any {
q.HRDLogUploadDate, q.HRDLogUploadStatus,
q.QRZComUploadDate, q.QRZComUploadStatus,
q.QRZComDownloadDate, q.QRZComDownloadStatus,
q.ClublogDownloadDate, q.ClublogDownloadStatus,
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
@@ -722,15 +728,14 @@ func (r *Repo) MarkUploadedBatch(ctx context.Context, statusCol, dateCol, date s
if len(ids) == 0 {
return nil
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := make([]any, 0, len(ids)+2)
args = append(args, date, db.NowISO())
for _, id := range ids {
args = append(args, id)
}
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
args...)
now := db.NowISO()
_, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args := append([]any{date, now}, idArgs...)
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
args...)
return 0, err
})
if err != nil {
return fmt.Errorf("mark uploaded batch (%d): %w", len(ids), err)
}
@@ -772,30 +777,32 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
// zones, lat/lon) that are meaningless shared.
var bulkEditableCols = map[string]bool{
// QSL / upload status
"lotw_sent": true,
"lotw_rcvd": true,
"eqsl_sent": true,
"eqsl_rcvd": true,
"qsl_sent": true,
"qsl_rcvd": true,
"qsl_via": true,
"qsl_sent_via": true,
"qsl_rcvd_via": true,
"qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true,
"lotw_sent": true,
"lotw_rcvd": true,
"eqsl_sent": true,
"eqsl_rcvd": true,
"qsl_sent": true,
"qsl_rcvd": true,
"qsl_via": true,
"qsl_sent_via": true,
"qsl_rcvd_via": true,
"qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true,
"clublog_qso_download_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,
"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,
"clublog_qso_download_date": true,
"hrdlog_qso_upload_date": true,
// My station / operator
"station_callsign": true,
"operator": true,
@@ -872,6 +879,35 @@ var bulkEditableCols = map[string]bool{
// own path, not the text one: the columns are nullable integers, and while
// SQLite would coerce "14" quietly, a shared MySQL logbook would not — and an
// empty string is NULL here, never "".
// bulkByIDChunks runs one UPDATE per slice of ids, small enough for SQLite's
// bound-variable cap: the single IN (…) with one placeholder per id worked at
// 10 000 QSOs and failed at 168 000 with "too many SQL variables". Each call
// gets the placeholder string and the id arguments for its slice; affected
// rows are summed. 500 per statement keeps every backend far from any limit
// while costing a few hundred statements on the largest logs.
func bulkByIDChunks(ctx context.Context, ids []int64, run func(ph string, idArgs []any) (int64, error)) (int64, error) {
const chunk = 500
var total int64
for start := 0; start < len(ids); start += chunk {
end := start + chunk
if end > len(ids) {
end = len(ids)
}
part := ids[start:end]
ph := strings.Repeat("?,", len(part)-1) + "?"
args := make([]any, len(part))
for i, id := range part {
args[i] = id
}
n, err := run(ph, args)
if err != nil {
return total, err
}
total += n
}
return total, nil
}
var bulkEditableIntCols = map[string]bool{
"my_dxcc": true,
"my_cq_zone": true,
@@ -886,23 +922,23 @@ func (r *Repo) BulkSetIntField(ctx context.Context, ids []int64, column string,
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
var val any
if v != nil {
val = *v
}
args = append(args, val, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+strings.Join(ph, ",")+")", args...)
now := db.NowISO()
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args := append([]any{val, now}, idArgs...)
res, err := r.db.ExecContext(ctx,
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+ph+")", args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
})
if err != nil {
return 0, fmt.Errorf("bulk set %s: %w", column, err)
return n, fmt.Errorf("bulk set %s: %w", column, err)
}
n, _ := res.RowsAffected()
return n, nil
}
@@ -913,13 +949,6 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
args = append(args, value, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
set := column + " = ?, updated_at = ?"
if column == "mode" {
// A submode belongs to the mode it was recorded under. Left behind, it
@@ -928,13 +957,19 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
// only outcome that leaves the row meaning what the operator asked for.
set += ", submode = ''"
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
now := db.NowISO()
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args := append([]any{value, now}, idArgs...)
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+set+` WHERE id IN (`+ph+`)`, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
})
if err != nil {
return 0, fmt.Errorf("bulk set %s: %w", column, err)
return n, fmt.Errorf("bulk set %s: %w", column, err)
}
n, _ := res.RowsAffected()
return n, nil
}
@@ -995,26 +1030,26 @@ func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value str
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
head := []any{}
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
if value == "" {
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
} else {
args = append(args, value)
head = append(head, value)
}
args = append(args, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
head = append(head, db.NowISO())
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args := append(append([]any{}, head...), idArgs...)
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+ph+`)`, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
})
if err != nil {
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
return n, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
}
n, _ := res.RowsAffected()
return n, nil
}
@@ -1026,20 +1061,19 @@ func (r *Repo) BulkSetFrequency(ctx context.Context, ids []int64, freqHz int64,
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+3)
args = append(args, freqHz, band, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
now := db.NowISO()
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
args := append([]any{freqHz, band, now}, idArgs...)
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+ph+`)`, args...)
if err != nil {
return 0, err
}
return res.RowsAffected()
})
if err != nil {
return 0, fmt.Errorf("bulk set frequency: %w", err)
return n, fmt.Errorf("bulk set frequency: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
@@ -1201,17 +1235,16 @@ func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
ph[i] = "?"
args[i] = id
}
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+strings.Join(ph, ",")+`)`, args...)
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+ph+`)`, idArgs...)
if err != nil {
return 0, err
}
return res.RowsAffected()
})
if err != nil {
return 0, fmt.Errorf("delete qsos: %w", err)
return n, fmt.Errorf("delete qsos: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
@@ -1314,15 +1347,17 @@ var filterableColumns = map[string]bool{
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
"clublog_qso_upload_status": true, "clublog_qso_download_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,
"clublog_qso_upload_date": true, "clublog_qso_download_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,
"my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true,
@@ -1701,27 +1736,42 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
if len(ids) == 0 {
return nil
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...)
// Chunked like every other by-ids statement (the one-placeholder-per-id IN
// died at 168k with "too many SQL variables") — and because each chunk is
// only locally ordered, the rows are collected and sorted once at the end
// so the chronological contract holds across chunks.
var all []QSO
_, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`)`, idArgs...)
if err != nil {
return 0, err
}
defer rows.Close()
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return 0, err
}
all = append(all, q)
}
return 0, rows.Err()
})
if err != nil {
return fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return err
sort.Slice(all, func(i, j int) bool {
if !all[i].QSODate.Equal(all[j].QSODate) {
return all[i].QSODate.Before(all[j].QSODate)
}
return all[i].ID < all[j].ID
})
for _, q := range all {
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
return nil
}
// GridKey builds the lookup key for the worked-grid index.
@@ -2617,10 +2667,20 @@ type EntitySlot struct {
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
// one bucket. Callers must normalise their lookup mode the same way.
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
return r.EntitySlotMapPred(ctx, keyFor, normMode, "")
}
// EntitySlotMapPred is EntitySlotMap over only the QSOs matching pred — the
// confirmed-only ledger the "chase unconfirmed too" mode judges against.
func (r *Repo) EntitySlotMapPred(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string, pred string) (map[int]*EntitySlot, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
WHERE band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''`)
AND mode IS NOT NULL AND mode != ''`+where)
if err != nil {
return nil, err
}
@@ -2670,8 +2730,18 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
// One pass, used by the cluster spot colouring to flag "already worked this
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
return r.WorkedCallsignsPred(ctx, "")
}
// WorkedCallsignsPred restricts the callsign ledger to QSOs matching pred —
// the confirmed-prefix (WPX) chase reads its prefixes off this set.
func (r *Repo) WorkedCallsignsPred(ctx context.Context, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`)
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`+where)
if err != nil {
return nil, err
}
@@ -2692,9 +2762,18 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
// are considered. Empty keys (unresolvable state/county) are skipped.
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
return r.WorkedCountyKeysPred(ctx, keyFn, "")
}
// WorkedCountyKeysPred restricts the county ledger to QSOs matching pred.
func (r *Repo) WorkedCountyKeysPred(ctx context.Context, keyFn func(state, cnty string) string, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`+where)
if err != nil {
return nil, err
}
@@ -2712,6 +2791,38 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err()
}
// WorkedStateKeys returns the set of US states already worked, uppercased —
// the WAS ledger the cluster and decode panels judge NEW STATE against.
func (r *Repo) WorkedStateKeys(ctx context.Context) (map[string]struct{}, error) {
return r.WorkedStateKeysPred(ctx, "")
}
// WorkedStateKeysPred restricts the state ledger to QSOs matching pred.
func (r *Repo) WorkedStateKeysPred(ctx context.Context, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT UPPER(COALESCE(state,'')) FROM qso
WHERE dxcc IN (291,110,6) AND state IS NOT NULL AND state != ''`+where)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 64)
for rows.Next() {
var st string
if err := rows.Scan(&st); err != nil {
return nil, err
}
if st != "" {
out[st] = struct{}{}
}
}
return out, rows.Err()
}
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
// instead of a manager, per isRouting.
//
@@ -3163,6 +3274,7 @@ type matchRef struct {
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
type MatchIndex struct {
byMode map[string][]matchRef // call|band|canonMode → refs
byBand map[string][]matchRef // call|band → refs (mode-blind fallback)
}
// canonMode folds the phone sidebands into a single "SSB" bucket; every other
@@ -3198,7 +3310,7 @@ func parseQSODate(s string) time.Time {
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
// another (e.g. TM2Q).
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
idx := &MatchIndex{byMode: map[string][]matchRef{}}
idx := &MatchIndex{byMode: map[string][]matchRef{}, byBand: map[string][]matchRef{}}
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
var args []any
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
@@ -3226,6 +3338,11 @@ func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchInd
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
mk := matchKeyMode(call, band, mode)
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
if idx.byBand == nil { // tests build the index literally, without byBand
idx.byBand = map[string][]matchRef{}
}
bk := strings.ToUpper(call) + "|" + strings.ToLower(band)
idx.byBand[bk] = append(idx.byBand[bk], matchRef{when: when, id: id})
}
// Add registers a QSO in the index (exported wrapper for callers that inserted a
@@ -3241,6 +3358,12 @@ func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window tim
return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window)
}
// MatchBand matches ignoring the mode — for confirmations whose source doesn't
// carry one (Club Log reports "false" for modes it can't infer).
func (idx *MatchIndex) MatchBand(call, band string, when time.Time, window time.Duration) (int64, bool) {
return closestRef(idx.byBand[strings.ToUpper(call)+"|"+strings.ToLower(band)], when, window)
}
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
var best int64
bestD := window + time.Second
@@ -3322,6 +3445,30 @@ type SlotStats struct {
// only way those two agree.
const ConfirmedValues = "('Y','V')"
// ConfirmSourcesPredicate builds the SQL that says "this QSO is confirmed",
// from the operator's chosen sources — the same choice the award engine's
// fixed three (LoTW, card, eQSL) used to hard-code. QRZ.com's confirmation is
// its download status; HRDLog has no confirmation field in ADIF at all, which
// is why it cannot be offered. Empty input falls back to the classic three.
func ConfirmSourcesPredicate(sources []string) string {
cols := map[string]string{
"lotw": "lotw_rcvd",
"card": "qsl_rcvd",
"eqsl": "eqsl_rcvd",
"qrz": "qrzcom_qso_download_status",
}
var parts []string
for _, src := range sources {
if col, ok := cols[strings.ToLower(strings.TrimSpace(src))]; ok {
parts = append(parts, col+" IN "+ConfirmedValues)
}
}
if len(parts) == 0 {
return "(lotw_rcvd IN " + ConfirmedValues + " OR qsl_rcvd IN " + ConfirmedValues + " OR eqsl_rcvd IN " + ConfirmedValues + ")"
}
return "(" + strings.Join(parts, " OR ") + ")"
}
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
@@ -3375,10 +3522,11 @@ func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
// confirmedCols whitelists the received-status columns ConfirmedSlots may
// OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{
"lotw_rcvd": true,
"qsl_rcvd": true,
"eqsl_rcvd": true,
"qrzcom_qso_download_status": true,
"lotw_rcvd": true,
"qsl_rcvd": true,
"eqsl_rcvd": true,
"qrzcom_qso_download_status": true,
"clublog_qso_download_status": true,
}
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
@@ -3435,6 +3583,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
return nil
}
// MarkClublogConfirmed stamps CLUBLOG_QSO_DOWNLOAD_STATUS=Y and the date on a
// QSO Club Log reports as matched. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkClublogConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET clublog_qso_download_status = 'Y', clublog_qso_download_date = ?,
updated_at = ? WHERE id = ?`,
date, db.NowISO(), id)
if err != nil {
return fmt.Errorf("mark clublog confirmed %d: %w", id, err)
}
return nil
}
// ClearQRZConfirmed takes back a QRZ confirmation.
//
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
@@ -3513,6 +3674,7 @@ func scanQSO(s scanner) (QSO, error) {
hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
clublogDlDate, clublogDlStatus sql.NullString
contestID sql.NullString
srx, stx sql.NullInt64
srxStr, stxStr sql.NullString
@@ -3557,6 +3719,7 @@ func scanQSO(s scanner) (QSO, error) {
&hrdlogDate, &hrdlogStatus,
&qrzcomDate, &qrzcomStatus,
&qrzcomDlDate, &qrzcomDlStatus,
&clublogDlDate, &clublogDlStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
@@ -3654,6 +3817,8 @@ func scanQSO(s scanner) (QSO, error) {
q.QRZComUploadStatus = qrzcomStatus.String
q.QRZComDownloadDate = qrzcomDlDate.String
q.QRZComDownloadStatus = qrzcomDlStatus.String
q.ClublogDownloadDate = clublogDlDate.String
q.ClublogDownloadStatus = clublogDlStatus.String
q.ContestID = contestID.String
if srx.Valid {
v := int(srx.Int64)
+7 -1
View File
@@ -246,6 +246,11 @@ func (s *Server) serve(c net.Conn) {
s.releasePTT(fmt.Sprintf("client %s left", c.RemoteAddr()))
}()
s.log("rigctld: client connected from %s", c.RemoteAddr())
// The HANDSHAKE is always traced — the first few commands are where a
// client decides to stay or hang up, and a connect that lasted 50 ms left
// nothing in the log to say which answer it disliked. Steady-state polling
// stays behind the CAT trace switch.
traced := 0
r := bufio.NewReader(c)
w := bufio.NewWriter(c)
for {
@@ -265,7 +270,8 @@ func (s *Server) serve(c net.Conn) {
// that preceded it — the one thing needed to tell whether OpsLog answered
// something the client could not accept. Behind the same switch as the CAT
// wire trace: this is one line per poll and would drown an ordinary log.
if req != "" && cat.CIVTraceEnabled() {
if req != "" && (traced < 6 || cat.CIVTraceEnabled()) {
traced++
s.log("rigctld: %s → %q ⇒ %q", c.RemoteAddr(), req, strings.TrimRight(resp, "\r\n"))
}
if resp != "" {
+88 -20
View File
@@ -13,6 +13,7 @@ package scp
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
@@ -22,6 +23,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -29,7 +31,14 @@ import (
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
// clublogURL is Club Log's own SCP list, rebuilt weekly from real DX logs:
// every call from a current entity with 40+ QSOs in the last 3 years (~180k).
// Broader than MASTER.SCP (which leans contest), so the manager merges the two
// when the Club Log source is enabled.
const clublogURL = "https://cdn.clublog.org/clublog.scp.gz"
const cacheFile = "MASTER.SCP"
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed
// Result is the two suggestion lists for a typed fragment.
type Result struct {
@@ -44,6 +53,7 @@ type Manager struct {
updated time.Time // when the cache was last refreshed
dir string
client *http.Client
clublog atomic.Bool // merge Club Log's list into the master list
}
// NewManager loads any on-disk cache and returns a ready manager.
@@ -56,14 +66,30 @@ func NewManager(dataDir string) *Manager {
return m
}
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
func (m *Manager) clublogPath() string { return filepath.Join(m.dir, clublogCacheFile) }
// SetClublogEnabled turns the Club Log source on/off and reparses the on-disk
// caches so the in-memory list reflects the choice immediately.
func (m *Manager) SetClublogEnabled(on bool) {
if m.clublog.Swap(on) != on {
m.loadCache()
}
}
// ClublogEnabled reports whether the Club Log source is merged in.
func (m *Manager) ClublogEnabled() bool { return m.clublog.Load() }
func (m *Manager) loadCache() {
data, err := os.ReadFile(m.path())
if err != nil {
data, _ := os.ReadFile(m.path())
var extra []byte
if m.clublog.Load() {
extra, _ = os.ReadFile(m.clublogPath())
}
if data == nil && extra == nil {
return
}
m.parse(data)
m.parse(data, extra)
if fi, e := os.Stat(m.path()); e == nil {
m.mu.Lock()
m.updated = fi.ModTime()
@@ -91,7 +117,17 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
if err != nil {
return 0, fmt.Errorf("scp: read: %w", err)
}
n := m.parse(body)
var extra []byte
if m.clublog.Load() {
if cl, cerr := m.downloadClublog(ctx); cerr == nil {
extra = cl
} else {
// The master list alone is still worth having; fall back to any
// cached Club Log list rather than dropping the source silently.
extra, _ = os.ReadFile(m.clublogPath())
}
}
n := m.parse(body, extra)
if n == 0 {
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
}
@@ -102,25 +138,57 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
return n, nil
}
// downloadClublog fetches Club Log's gzipped SCP list and caches it decompressed.
func (m *Manager) downloadClublog(ctx context.Context) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return nil, fmt.Errorf("scp: clublog request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("scp: clublog http %d", resp.StatusCode)
}
gz, err := gzip.NewReader(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return nil, fmt.Errorf("scp: clublog gunzip: %w", err)
}
body, err := io.ReadAll(io.LimitReader(gz, 64*1024*1024))
if err != nil {
return nil, fmt.Errorf("scp: clublog read: %w", err)
}
_ = os.WriteFile(m.clublogPath(), body, 0o644)
return body, nil
}
// parse loads the SCP bytes into the sorted call slice and returns the count.
func (m *Manager) parse(data []byte) int {
func (m *Manager) parse(datasets ...[]byte) int {
seen := make(map[string]struct{}, 1<<17)
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
if line == "" || strings.HasPrefix(line, "#") {
continue // blank / header comment
}
// A call token only (the master file is one call per line, but guard
// against stray trailing fields).
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
line = line[:i]
}
if !plausibleCall(line) {
for _, data := range datasets {
if len(data) == 0 {
continue
}
seen[line] = struct{}{}
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
if line == "" || strings.HasPrefix(line, "#") {
continue // blank / header comment
}
// A call token only (the files are one call per line, but guard
// against stray trailing fields).
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
line = line[:i]
}
if !plausibleCall(line) {
continue
}
seen[line] = struct{}{}
}
}
if len(seen) == 0 {
return 0
+2
View File
@@ -190,6 +190,8 @@ var Columns = []Column{
{"eqsl_rcvd_date", "Eqsl rcvd date", "QSL", func(q *qso.QSO) string { return q.EQSLRcvdDate }},
{"clublog_qso_upload_date", "Clublog qso upload date", "QSL", func(q *qso.QSO) string { return q.ClublogUploadDate }},
{"clublog_qso_upload_status", "Clublog qso upload status", "QSL", func(q *qso.QSO) string { return q.ClublogUploadStatus }},
{"clublog_qso_download_date", "Clublog match date", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadDate }},
{"clublog_qso_download_status", "Clublog match status", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadStatus }},
{"hrdlog_qso_upload_date", "Hrdlog qso upload date", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadDate }},
{"hrdlog_qso_upload_status", "Hrdlog qso upload status", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadStatus }},
{"qrzcom_qso_upload_date", "Qrzcom qso upload date", "QSL", func(q *qso.QSO) string { return q.QRZComUploadDate }},
+10 -6
View File
@@ -48,14 +48,14 @@ func hasFlag(args []string, flag string) bool {
}
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the
// previous instance may still be shutting down and holding the mutex, so retry for
// a few seconds until it frees.
func acquireInstance(postUpdate bool) bool {
// try (fail → another OpsLog is running, so exit). On a --post-update or
// --relaunch start the previous instance may still be shutting down and holding
// the mutex, so retry for a few seconds until it frees.
func acquireInstance(wait bool) bool {
if acquireSingleInstance() {
return true
}
if !postUpdate {
if !wait {
return false
}
deadline := time.Now().Add(20 * time.Second)
@@ -90,7 +90,11 @@ func main() {
// to free instead of bailing out. Then clear the old exe it left behind.
bootLogLaunch()
postUpdate := hasFlag(os.Args[1:], "--post-update")
if !acquireInstance(postUpdate) {
// A self-relaunch (database switch) races its own parent: the new process
// regularly wins the start against the old one's teardown, and the operator
// got "OpsLog is already running" for following instructions. Same patience
// as post-update.
if !acquireInstance(postUpdate || hasFlag(os.Args[1:], "--relaunch")) {
// SAID, not merely done. This exit is correct — a second instance would
// fight the first over the rig — but it happened in total silence: no
// window, no data folder, no log, which is indistinguishable from a
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.1"
appVersion = "0.27.6"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.