Compare commits

...
36 Commits
Author SHA1 Message Date
rouggy a6172c4323 feat(dxhunter): the chase gets a page of its own, and US states a switch
The chase switches were born on the DX Cluster page because the cluster
was the only thing that drew their badges. They now decide what the FT
decode list and Chase new say as well, and settings that govern three
screens should not live under the name of one of them. They move to a
DXHunter page — named for what it is about, and for what is meant to
land beside it as more of DXHunter's ideas are ported over.

US states join the switchable markers, gated the same way as POTA,
counties, prefixes and squares: badge, filter chip and display marker,
all from the one chaseAllows question.
2026-09-01 16:26:31 +02:00
rouggy b70a679c64 feat(chase): the switches follow the operator, not the panel
Chase POTA, US counties, prefixes and grids were written for the cluster
and stayed there, so an operator who does not chase parks still met NEW
POTA in the FT decode list and in Chase new: the same badge withdrawn on
one screen and shouting on the next. The setting is about what is hunted.

One helper answers the question for every panel, keyed by both the status
field names and the shorter category names so a single call serves badges
and filter chips alike. Chase new also stops CATEGORISING a row by a
marker that is switched off, which had left rows listed with no visible
reason for being there. A new US state has no switch of its own, so it is
always allowed.
2026-09-01 16:20:28 +02:00
rouggy f39bda110a fix(chase): the unconfirmed flag survives the next QSO
Reported as 'South Africa shows NEW BAND although I have worked 15m'.
The verdict was right — his hunt counts only LoTW and paper QSL, and no
15m contact is confirmed by either — but the badge drew SOLID, saying
'never worked here' about a band with four contacts in the log.

Proved with his own data: the confirmed ledger holds 10/12/17/20/30/40/80
and not 15, the all-QSO ledger holds 15m FT8 four times, and the backend
sets UnconfStatus correctly for exactly that case (new test, end to end
through ClusterSpotStatuses).

The flag died in the frontend. FOUR hand-written copies of the same
backend-result mapping, and they had drifted: the two that RE-fetch —
after a QSO is logged, and when a pane becomes visible — rebuilt each
entry without the unconf_* flags, so the first refresh after any contact
turned every dimmed badge solid for good. None of the four ever copied
grid_state either, so a known-but-unconfirmed square never dimmed at all.
One mapper now, used by all four.
2026-09-01 11:56:20 +02:00
rouggy ab8ecd65fe test(adif): assert FREQ_RX reaches the forwarded record too
The fix filled both halves of the receive side, but the test only proved
BAND_RX came out the other end. FREQ_RX is the half the question was
really about.
2026-08-31 22:30:32 +02:00
rouggy de0771d797 feat(cat): USB for digital modes, on every backend
A soundcard mode is USB with audio in the mic path, and that is what
most rigs need. Asking for the mode by NAME is the better answer on a
modern transceiver with a DATA position and the wrong one on an older
set, where the CAT layer resolves 'digital' to RTTY/FSK — OmniRig does
exactly that, per rig file, and there is no arguing with it from here.
The operator clicking an FT8 spot landed in FSK, which keys from a
mark/space generator and cannot pass FT8 at all.

Applied in SetCATMode, the one funnel every backend and every caller
goes through, so it holds for a spot click, the band map and the mode
selector alike. The QSO is still logged as FT8: this is what the radio
is put in, not what the contact was.
2026-08-31 22:26:12 +02:00
rouggy 22e4266d38 fix(editor): a corrected frequency corrects its band
Band and frequency have to agree — the log, every award and every upload
are read on the BAND — and an operator fixing a wrong frequency is not
also expecting to fix the band by hand. That is exactly how a QSO ends
up filed on 20m at 7 MHz. Both sides follow, TX and RX.

Only when the number lands in a known allocation, because half a
frequency is typed on the way to all of it and a band must never be
blanked by that. bandForMHz moves out of App.tsx into lib/bandplan,
where the two callers can share one answer instead of drifting.
2026-08-31 22:21:22 +02:00
rouggy e3332d1e27 fix(hamqth): the whole-log upload reports itself
It ran into a panel nobody was showing: the button never set showLog, so
the tab sat on 'Pick a service' while a 25000-QSO upload came and went.
Now it opens the console like every other action here, and the console
has something to say: the callsign it is scoped to and how many of the
logbook's QSOs that leaves (a two-callsign database sends one of them,
which is the whole explanation for a count that looks short), the
exported record count and file size, and HamQTH's own reply.

Plus the two facts that make the site's own number readable: HamQTH
accepts the file and imports it in the BACKGROUND, and it reports what
it made of each record by e-mail — never in the reply we get. An
operator comparing counts a minute later is not looking at a failure.
2026-08-31 22:11:51 +02:00
rouggy 5cebf163c5 feat(hamqth): the sent stamp reaches the columns, the filter and bulk edit
HAMLOG.online was wired into all three when it arrived; HamQTH shipped
with only its uploader and its editor row, so the log could not be asked
'which contacts have never gone there' — the question that precedes
every backlog upload — and a log uploaded to HamQTH by hand could not be
marked, leaving OpsLog offering to send every one of those contacts
again. Sent side only, both whitelists, both panels, two hidden-by-
default columns.
2026-08-31 22:04:42 +02:00
rouggy cfd85ff9c3 fix(adif): a logged QSO carries its receive side
The importer has always read an absent BAND_RX/FREQ_RX as 'not split, so
RX = TX'. The logging paths never did, so what a contact carried
depended on which door it came in through — and it shows outside
OpsLog: the record forwarded over UDP is written from the QSO as logged,
so a receiver that reads BAND_RX (Log4OM does) found nothing there.

Filled at AddQSO, the one funnel every path goes through, so the
database, the export and the forwarded copy agree. A genuine split
contact keeps what it was given — table-tested both ways.
2026-08-31 22:02:24 +02:00
rouggy c6f479750f chore(changelog): open 0.27.7 — two changes landed after the .6 tag
v0.27.6 was tagged at 88f35e2, and the disconnected-pill fix and the
HamQTH whole-log upload came after it: their entries were promising a
released build features it does not carry. They open 0.27.7, and the
.6 pill entry goes back to describing what actually shipped.
2026-08-31 21:57:41 +02:00
rouggy 386a8ad531 feat(hamqth): upload the whole log in one file
The per-QSO API is the only correct way to send a SELECTION, and at the
pace it must be driven a 14k backlog costs the better part of an hour.
HamQTH's other endpoint takes a whole log as one file — and REPLACES
what is on the site with it: its documentation says plainly that partial
uploads do not exist. So it is offered as its own deliberate act behind
a confirmation, never as the batch path behind 'send these', where it
would delete every QSO the operator had not selected.

Scoped to the callsign this profile uploads as, so a database holding
two operators' contacts cannot push one into the other's log; tar.gz
above 12 MB because the ceiling is 20 and a six-figure log passes it as
text; and every QSO not already stamped is marked sent afterwards, in
bulk, so the backlog list agrees with reality.
2026-08-31 20:07:46 +02:00
rouggy bcd7e409ba fix(cluster): a disconnected server keeps its pill
The pills listed live SESSIONS, so disconnecting one made it vanish —
along with the only way back. They now list every enabled server, and a
server with no session shows as disconnected: the state you click to
undo. The address, known only while a session exists, drops out of the
tooltip rather than reading ':0'.
2026-08-31 19:05:56 +02:00
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
48 changed files with 3352 additions and 352 deletions
+492 -20
View File
@@ -38,6 +38,7 @@ import (
"hamlog/internal/cwdecode" "hamlog/internal/cwdecode"
"hamlog/internal/db" "hamlog/internal/db"
"hamlog/internal/dxcc" "hamlog/internal/dxcc"
"hamlog/internal/dxped"
"hamlog/internal/email" "hamlog/internal/email"
"hamlog/internal/extsvc" "hamlog/internal/extsvc"
"hamlog/internal/geo" "hamlog/internal/geo"
@@ -115,7 +116,10 @@ const (
keyCATBackend = "cat.backend" // "omnirig" | "flex" keyCATBackend = "cat.backend" // "omnirig" | "flex"
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
// Which VFO to believe when OmniRig names one. "" = trust the rig file. // Which VFO to believe when OmniRig names one. "" = trust the rig file.
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B" keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
// Put the RADIO in USB for a digital mode instead of asking for the mode by
// name. Every backend, because the reason is the radio, not the link.
keyCATDigiUSB = "cat.digi_usb"
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
@@ -329,6 +333,7 @@ const (
keyClusterSpotMax = "cluster.spot_max" // how many spots the list holds at once keyClusterSpotMax = "cluster.spot_max" // how many spots the list holds at once
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on 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 // 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 // twenty: it is read and written as a unit by one settings panel, and the FTP
@@ -356,9 +361,11 @@ const (
keyQSLDefaultEQSLSent = "qsl.eqsl_sent" keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd" keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
keyQSLDefaultClublogStatus = "qsl.clublog_status" keyQSLDefaultClublogStatus = "qsl.clublog_status"
keyQSLDefaultClublogCfm = "qsl.clublog_confirmed"
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status" keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status" keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed" keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
keyQSLDefaultHamqthStatus = "qsl.hamqth_status"
keyQSLDefaultHamlogStatus = "qsl.hamlog_status" keyQSLDefaultHamlogStatus = "qsl.hamlog_status"
keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed" keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed"
@@ -399,6 +406,12 @@ const (
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload" keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode" 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" keyExtHamlogAPIKey = "extsvc.hamlog.api_key"
keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload" keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode" keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"
@@ -421,6 +434,8 @@ const (
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull 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 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 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 // QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -436,6 +451,7 @@ type QSLDefaults struct {
EQSLSent string `json:"eqsl_sent"` EQSLSent string `json:"eqsl_sent"`
EQSLRcvd string `json:"eqsl_rcvd"` EQSLRcvd string `json:"eqsl_rcvd"`
ClublogStatus string `json:"clublog_status"` ClublogStatus string `json:"clublog_status"`
ClublogCfm string `json:"clublog_confirmed"` // Club Log match/download status
HRDLogStatus string `json:"hrdlog_status"` HRDLogStatus string `json:"hrdlog_status"`
QRZComStatus string `json:"qrzcom_status"` QRZComStatus string `json:"qrzcom_status"`
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
@@ -445,6 +461,9 @@ type QSLDefaults struct {
// at "N" here exactly as they do for Club Log. // at "N" here exactly as they do for Club Log.
HamlogStatus string `json:"hamlog_status"` HamlogStatus string `json:"hamlog_status"`
HamlogCfm string `json:"hamlog_confirmed"` 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 // CATSettings is the user-tweakable rig-control configuration. Stored as
@@ -457,7 +476,10 @@ type CATSettings struct {
// reports, "A"/"B" force one. Needed because that report is only as good as // reports, "A"/"B" force one. Needed because that report is only as good as
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the // the .ini: an IC-7610 file was seen declaring VFO B permanently while the
// operator worked on the main VFO, so OpsLog wrote to A and read B. // operator worked on the main VFO, so OpsLog wrote to A and read B.
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B" OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
// than naming the mode. What the QSO is LOGGED as never changes.
DigiAsUSB bool `json:"digi_as_usb"`
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
@@ -790,6 +812,9 @@ type App struct {
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping 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) lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
scp *scp.Manager // Super Check Partial / N+1 callsign master list 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 // NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO). // session (in-memory only — active stations currently in QSO).
@@ -1129,7 +1154,9 @@ func (a *App) startup(ctx context.Context) {
conn, err := db.Open(a.dbPath) conn, err := db.Open(a.dbPath)
if err != nil { if err != nil {
a.startupErr = "cannot open db: " + err.Error() 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 return
} }
a.db = conn a.db = conn
@@ -1395,6 +1422,9 @@ func (a *App) startup(ctx context.Context) {
// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is // 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. // enabled, auto-(re)download it if missing or older than a week.
a.scp = scp.NewManager(dataDir) a.scp = scp.NewManager(dataDir)
if v, _ := a.settings.Get(a.ctx, keyScpClublog); v == "1" {
a.scp.SetClublogEnabled(true)
}
go func() { go func() {
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" { if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
return return
@@ -2841,7 +2871,9 @@ func (a *App) RestartApp() error {
if err != nil { if err != nil {
return fmt.Errorf("locate executable: %w", err) 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) cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err) return fmt.Errorf("relaunch OpsLog: %w", err)
@@ -2922,6 +2954,28 @@ func (a *App) reloadLookupProviders() {
// --- QSO bindings --- // --- QSO bindings ---
// fillRXDefaults stamps the receive side when the contact was not split.
//
// The ADIF importer has always done this — an absent BAND_RX/FREQ_RX means RX
// equals TX — but the LOGGING paths did not, so what a QSO carried depended on
// which door it came through. It shows up outside OpsLog: the record forwarded
// to another logger over UDP is written from the QSO as logged, and a receiver
// reading BAND_RX (Log4OM does) found nothing there for contacts logged by a
// path that left it blank.
//
// Applied at AddQSO, the one funnel every path goes through — manual entry, the
// WSJT-X/UDP log, CW, contest, net control, the ADIF monitor — so the database,
// the export and the forwarded copy all say the same thing.
func fillRXDefaults(q *qso.QSO) {
if strings.TrimSpace(q.BandRX) == "" {
q.BandRX = q.Band
}
if q.FreqRXHz == nil && q.FreqHz != nil {
v := *q.FreqHz
q.FreqRXHz = &v
}
}
func (a *App) AddQSO(q qso.QSO) (id int64, err error) { func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
if a.qso == nil { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") return 0, fmt.Errorf("db not initialized")
@@ -2937,6 +2991,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
} }
}() }()
a.applyStationDefaults(&q, true) a.applyStationDefaults(&q, true)
fillRXDefaults(&q)
fillDistance(&q) fillDistance(&q)
a.applyDXCCNumber(&q) a.applyDXCCNumber(&q)
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
@@ -3146,6 +3201,7 @@ type ScpStatus struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Count int `json:"count"` Count int `json:"count"`
Updated string `json:"updated,omitempty"` // RFC3339, empty if never 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. // GetScpStatus returns whether SCP is enabled and how many calls are loaded.
@@ -3157,6 +3213,7 @@ func (a *App) GetScpStatus() ScpStatus {
} }
if a.scp != nil { if a.scp != nil {
st.Count = a.scp.Count() st.Count = a.scp.Count()
st.Clublog = a.scp.ClublogEnabled()
if u := a.scp.Updated(); !u.IsZero() { if u := a.scp.Updated(); !u.IsZero() {
st.Updated = u.UTC().Format(time.RFC3339) st.Updated = u.UTC().Format(time.RFC3339)
} }
@@ -3164,6 +3221,34 @@ func (a *App) GetScpStatus() ScpStatus {
return st 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 // SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
// download when the list is missing or stale. // download when the list is missing or stale.
func (a *App) SetScpEnabled(on bool) error { func (a *App) SetScpEnabled(on bool) error {
@@ -6889,6 +6974,7 @@ var bulkFieldColumns = map[string]string{
"qrz_sent": "qrzcom_qso_upload_status", "qrz_sent": "qrzcom_qso_upload_status",
"qrz_rcvd": "qrzcom_qso_download_status", "qrz_rcvd": "qrzcom_qso_download_status",
"clublog_sent": "clublog_qso_upload_status", "clublog_sent": "clublog_qso_upload_status",
"clublog_rcvd": "clublog_qso_download_status",
"hrdlog_sent": "hrdlog_qso_upload_status", "hrdlog_sent": "hrdlog_qso_upload_status",
// Old ids kept so anything holding one keeps working. // Old ids kept so anything holding one keeps working.
"qrz_upload": "qrzcom_qso_upload_status", "qrz_upload": "qrzcom_qso_upload_status",
@@ -6905,6 +6991,7 @@ var bulkFieldColumns = map[string]string{
"qrz_sent_date": "qrzcom_qso_upload_date", "qrz_sent_date": "qrzcom_qso_upload_date",
"qrz_rcvd_date": "qrzcom_qso_download_date", "qrz_rcvd_date": "qrzcom_qso_download_date",
"clublog_sent_date": "clublog_qso_upload_date", "clublog_sent_date": "clublog_qso_upload_date",
"clublog_rcvd_date": "clublog_qso_download_date",
"hrdlog_sent_date": "hrdlog_qso_upload_date", "hrdlog_sent_date": "hrdlog_qso_upload_date",
// My station / operator // My station / operator
"station_callsign": "station_callsign", "station_callsign": "station_callsign",
@@ -8154,7 +8241,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -8255,6 +8342,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" { if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
out.OmniRigVFO = v out.OmniRigVFO = v
} }
out.DigiAsUSB = m[keyCATDigiUSB] == "1"
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 { if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
out.OmniRigNum = n out.OmniRigNum = n
} }
@@ -8363,6 +8451,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATBackend: s.Backend, keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)), keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
keyCATDigiUSB: boolStr(s.DigiAsUSB),
keyCATFlexHost: strings.TrimSpace(s.FlexHost), keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort), keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots, keyCATFlexSpots: flexSpots,
@@ -10898,7 +10987,8 @@ func defaultQSLDefaults() QSLDefaults {
QSLSent: "N", QSLRcvd: "N", QSLSent: "N", QSLRcvd: "N",
EQSLSent: "R", EQSLRcvd: "N", EQSLSent: "R", EQSLRcvd: "N",
LOTWSent: "R", LOTWRcvd: "N", LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", HRDLogStatus: "R", ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
HamqthStatus: "R",
QRZComStatus: "R", QRZComCfm: "N", QRZComStatus: "R", QRZComCfm: "N",
} }
} }
@@ -10917,9 +11007,10 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd, keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd, keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd, keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus, keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm, keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm, keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
keyQSLDefaultHamqthStatus,
) )
if err != nil { if err != nil {
return out, err return out, err
@@ -10931,10 +11022,12 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out.EQSLSent = m[keyQSLDefaultEQSLSent] out.EQSLSent = m[keyQSLDefaultEQSLSent]
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd] out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
out.ClublogStatus = m[keyQSLDefaultClublogStatus] out.ClublogStatus = m[keyQSLDefaultClublogStatus]
out.ClublogCfm = m[keyQSLDefaultClublogCfm]
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus] out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
out.QRZComStatus = m[keyQSLDefaultQRZComStatus] out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
out.QRZComCfm = m[keyQSLDefaultQRZComCfm] out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
out.HamlogStatus = m[keyQSLDefaultHamlogStatus] out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
out.HamlogCfm = m[keyQSLDefaultHamlogCfm] out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
return out, nil return out, nil
} }
@@ -10954,10 +11047,12 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)), keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)), keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)), keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
keyQSLDefaultClublogCfm: strings.ToUpper(strings.TrimSpace(d.ClublogCfm)),
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)), keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)), keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)), keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)), keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)),
keyQSLDefaultHamqthStatus: strings.ToUpper(strings.TrimSpace(d.HamqthStatus)),
keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)), keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)),
} { } {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil { if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
@@ -11006,6 +11101,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
fill(&q.EQSLSent, d.EQSLSent) fill(&q.EQSLSent, d.EQSLSent)
fill(&q.EQSLRcvd, d.EQSLRcvd) fill(&q.EQSLRcvd, d.EQSLRcvd)
fill(&q.ClublogUploadStatus, d.ClublogStatus) fill(&q.ClublogUploadStatus, d.ClublogStatus)
fill(&q.ClublogDownloadStatus, d.ClublogCfm)
fill(&q.HRDLogUploadStatus, d.HRDLogStatus) fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
fill(&q.QRZComUploadStatus, d.QRZComStatus) fill(&q.QRZComUploadStatus, d.QRZComStatus)
fill(&q.QRZComDownloadStatus, d.QRZComCfm) fill(&q.QRZComDownloadStatus, d.QRZComCfm)
@@ -11013,6 +11109,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
// string field, and these two are map entries. Only set when the QSO does not // 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. // already carry them, which is the same rule fill() applies.
setExtraDefault(q, hamlogSentKey, d.HamlogStatus) setExtraDefault(q, hamlogSentKey, d.HamlogStatus)
setExtraDefault(q, hamqthSentKey, d.HamqthStatus)
setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm) setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm)
} }
@@ -11130,7 +11227,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode, keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID, keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote, keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode) keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode,
keyExtHamqthUsername, keyExtHamqthPassword, keyExtHamqthCallsign,
keyExtHamqthAutoUpload, keyExtHamqthUploadMode)
if err != nil { if err != nil {
return out return out
} }
@@ -11214,6 +11313,21 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
AutoUpload: m[keyExtHamlogAutoUpload] == "1", AutoUpload: m[keyExtHamlogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]), 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 return out
} }
@@ -11288,6 +11402,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if hamMode == string(extsvc.ModeOnClose) { if hamMode == string(extsvc.ModeOnClose) {
hamMode = string(extsvc.ModeImmediate) hamMode = string(extsvc.ModeImmediate)
} }
hqMode := modeOf(cfg.HamQTH.UploadMode)
hamAuto := "0" hamAuto := "0"
if cfg.Hamlog.AutoUpload { if cfg.Hamlog.AutoUpload {
hamAuto = "1" hamAuto = "1"
@@ -11343,6 +11458,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey), keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey),
keyExtHamlogAutoUpload: hamAuto, keyExtHamlogAutoUpload: hamAuto,
keyExtHamlogUploadMode: hamMode, 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 { if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
return err return err
@@ -11371,6 +11491,142 @@ func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
} }
// UploadFullLogHamQTH replaces the HamQTH log with this one, in one request.
//
// The per-QSO API is the only correct way to send a SELECTION, and at the pace
// it has to be driven a full backlog costs the better part of an hour. This is
// the other endpoint HamQTH offers: a whole log as one file, which is why it
// only ever runs on an explicit "replace my HamQTH log" — the site keeps
// nothing that is not in the file.
//
// Scoped to the callsign this profile uploads as: a database holding two
// operators' contacts must not push one operator's QSOs into the other's log.
func (a *App) UploadFullLogHamQTH() error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
cfg := a.loadExternalServices().HamQTH
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
return fmt.Errorf("set the HamQTH username and password first")
}
go a.runFullLogHamQTH(cfg)
return nil
}
func (a *App) runFullLogHamQTH(cfg extsvc.ServiceConfig) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
done := func(n int) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": n, "total": n})
}
}
ctx := a.ctx
owner := a.uploadOwnerCall(extsvc.ServiceHamQTH)
// Written to a temp file rather than a buffer so the ordinary, tested
// exporter does the work — the same one the Export menu uses.
tmp, err := os.CreateTemp("", "opslog-hamqth-*.adi")
if err != nil {
emit("Export failed: " + err.Error())
done(0)
return
}
path := tmp.Name()
_ = tmp.Close()
defer os.Remove(path)
if owner != "" {
emit("Station callsign: " + owner + " — QSOs logged under another of your callsigns are NOT included.")
}
emit("Exporting the log…")
var res adif.ExportResult
if owner != "" {
// station_callsign empty OR the owner call — an old QSO logged before
// the field existed belongs to whoever is uploading now.
f := qso.QueryFilter{Match: "OR", Conditions: []qso.Condition{
{Field: "station_callsign", Op: "eq", Value: ""},
{Field: "station_callsign", Op: "eq", Value: owner},
}}
res, err = a.ExportADIFFiltered(path, false, f, nil)
} else {
res, err = a.ExportADIF(path, false, nil)
}
if err != nil {
emit("Export failed: " + err.Error())
done(0)
return
}
data, rerr := os.ReadFile(path)
if rerr != nil {
emit("Export failed: " + rerr.Error())
done(0)
return
}
total, _ := a.qso.Count(ctx)
if total > 0 && int64(res.Count) != total {
emit(fmt.Sprintf("%d of %d QSOs in this logbook match %s and will be sent.", res.Count, total, owner))
}
emit(fmt.Sprintf("Exported %d QSO(s), %d KB of ADIF.", res.Count, res.SizeKB))
emit(fmt.Sprintf("Uploading to HamQTH — this REPLACES the log there…"))
up, uerr := extsvc.UploadHamQTHFullLog(ctx, nil, cfg, string(data))
if uerr != nil || !up.OK {
msg := up.Message
if uerr != nil {
msg = uerr.Error()
}
emit("Upload failed: " + msg)
applog.Printf("hamqth: full-log upload failed: %s", msg)
done(0)
return
}
emit("HamQTH replied: " + up.Message)
// Said plainly, because the numbers will not agree for a while and an
// operator comparing them straight away has every reason to think the
// upload failed: HamQTH ACCEPTS the file here and imports it later, at its
// own pace. What it makes of each record is reported by E-MAIL, never in
// this reply — so a count that stops short means the site rejected records,
// and the mail says which.
emit("Accepted — HamQTH imports the file in the BACKGROUND, so its QSO count will lag for a while.")
emit("If the count stops short, HamQTH e-mails the ADIF errors to your account address — this reply cannot carry them.")
// Everything is on HamQTH now, so nothing is still waiting to be sent. Only
// the rows that are not already stamped need writing.
pending, lerr := a.qso.ListMissingExtra(ctx, hamqthSentKey)
if lerr != nil {
applog.Printf("hamqth: marking sent: %v", lerr)
} else if len(pending) > 0 {
ids := make([]int64, 0, len(pending))
for _, q := range pending {
ids = append(ids, q.ID)
}
date := time.Now().UTC().Format("20060102")
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentKey, "Y"); e != nil {
applog.Printf("hamqth: marking sent: %v", e)
}
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentDateKey, date); e != nil {
applog.Printf("hamqth: marking sent date: %v", e)
}
emit(fmt.Sprintf("Marked %d QSO(s) as sent to HamQTH.", len(ids)))
}
applog.Printf("hamqth: full-log upload OK (%d QSOs)", res.Count)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("HamQTH: %d QSO uploaded", res.Count))
}
done(res.Count)
}
// 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. // TestHRDLogUpload validates that the HRDLog credentials are complete.
func (a *App) TestHRDLogUpload() (string, error) { func (a *App) TestHRDLogUpload() (string, error) {
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog) return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
@@ -11430,6 +11686,9 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if extsvc.Service(service) == extsvc.ServiceHamlog { if extsvc.Service(service) == extsvc.ServiceHamlog {
return a.qso.ListMissingExtra(a.ctx, hamlogSentKey) return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
} }
if extsvc.Service(service) == extsvc.ServiceHamQTH {
return a.qso.ListMissingExtra(a.ctx, hamqthSentKey)
}
col := uploadColumnFor(service) col := uploadColumnFor(service)
if col == "" { if col == "" {
return nil, fmt.Errorf("unknown service %q", service) return nil, fmt.Errorf("unknown service %q", service)
@@ -11445,7 +11704,7 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
svc := extsvc.Service(service) 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) return fmt.Errorf("unknown service %q", service)
} }
cfg := a.loadExternalServices() cfg := a.loadExternalServices()
@@ -11669,6 +11928,43 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
} }
flush() 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 { } else {
// QRZ.com: one record per request (its logbook API has no batch upload), // QRZ.com: one record per request (its logbook API has no batch upload),
// paced for the same reason as HRDLog above. // paced for the same reason as HRDLog above.
@@ -11710,6 +12006,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
label := map[extsvc.Service]string{ label := map[extsvc.Service]string{
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog", extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL", extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
}[svc] }[svc]
if label == "" { if label == "" {
label = string(svc) label = string(svc)
@@ -12935,6 +13232,90 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02")) 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: default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
} }
@@ -13361,14 +13742,21 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
// is not remembered — hence no on-close mode and no manual backlog. // is not remembered — hence no on-close mode and no manual backlog.
return true return true
case extsvc.ServiceHamlog: case extsvc.ServiceHamlog:
// The stamp is an extra, not a column — see markExtUploaded. Present means // The stamp is an extra, not a column — see markExtUploaded. A stamp that
// it has gone, which is what stops an on-demand re-upload of a whole log // MEANS SENT is what stops an on-demand re-upload of a whole log from
// from sending every contact twice. // sending every contact twice.
if q.Extras != nil && strings.TrimSpace(q.Extras[hamlogSentKey]) != "" { 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]) applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey])
return false return false
} }
return true 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: case extsvc.ServiceLoTW:
for _, f := range a.loadExternalServices().LoTW.UploadFlags { for _, f := range a.loadExternalServices().LoTW.UploadFlags {
if strings.EqualFold(q.LOTWSent, f) { if strings.EqualFold(q.LOTWSent, f) {
@@ -13389,8 +13777,26 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
const ( const (
hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT" hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT"
hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE" 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) { func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
date := time.Now().UTC().Format("20060102") date := time.Now().UTC().Format("20060102")
// Use a fresh background context, NOT a.ctx: this stamp often runs during // Use a fresh background context, NOT a.ctx: this stamp often runs during
@@ -13436,6 +13842,10 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil { if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date) 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 { if err != nil {
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err) applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
@@ -14426,6 +14836,7 @@ func (a *App) SetCATMode(mode string) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
} }
mode = a.catModeForRadio(mode)
err := a.cat.SetMode(mode) err := a.cat.SetMode(mode)
if err != nil { if err != nil {
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err) applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
@@ -14433,6 +14844,31 @@ func (a *App) SetCATMode(mode string) error {
return err return err
} }
// catModeForRadio translates a LOGGED mode into what the RADIO should be put
// in, for operators who have asked for USB on digital.
//
// The soundcard modes — FT8, FT4, PSK, JS8, Q65… — are all USB with audio in
// the microphone path, and that is what most rigs need. Asking for the mode by
// name is the better answer on a modern transceiver with a DATA/PKT position,
// and the wrong one on an older set where the CAT layer resolves "digital" to
// RTTY/FSK: the operator lands in FSK, which keys the radio from a mark/space
// generator and cannot pass FT8 at all. OmniRig does exactly this, per rig
// file, and there is no arguing with it from here — so the option sends what
// the radio can actually do.
//
// This is the RADIO's mode only. The QSO is still logged as FT8: the mode is
// what the contact WAS, not what the front panel says.
func (a *App) catModeForRadio(mode string) string {
if strings.TrimSpace(mode) == "" || a.settingOr(keyCATDigiUSB, "") != "1" {
return mode
}
if qso.ModeClass(mode) != "DIG" {
return mode
}
applog.Printf("cat: %s → USB (digital modes set the radio to USB)", strings.ToUpper(mode))
return "USB"
}
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ── // ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
// These are no-ops / errors unless the active CAT backend is a FlexRadio. // These are no-ops / errors unless the active CAT backend is a FlexRadio.
@@ -16098,6 +16534,10 @@ func (a *App) reloadAfterProfileSwitch() {
a.restartAsync("tuner", a.startTunerGenius) a.restartAsync("tuner", a.startTunerGenius)
a.restartAsync("psu", a.startPSU) a.restartAsync("psu", a.startPSU)
a.startQSORecorderIfEnabled() 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 // DuplicateProfile clones an existing profile under newName. Useful when
@@ -18549,19 +18989,51 @@ func (a *App) startAmps() {
old := a.ampInsts old := a.ampInsts
a.ampInsts = map[string]*ampInst{} a.ampInsts = map[string]*ampInst{}
a.ampsMu.Unlock() 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 // 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 // connect; tear down in the background so saving Settings (this runs on
// the Wails RPC goroutine) doesn't freeze the UI. // the Wails RPC goroutine) doesn't freeze the UI.
go inst.stopAll() go inst.stopAll()
} }
a.pgxl, a.spe, a.acom = nil, nil, nil
list, err := a.GetAmplifiers()
if err != nil {
return
}
for _, c := range list { for _, c := range list {
if !c.Enabled { if !c.Enabled || reused[c.ID] {
continue continue
} }
isPGXL := c.Type == "" || c.Type == "pgxl" isPGXL := c.Type == "" || c.Type == "pgxl"
+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, keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true, keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true, keyExtHRDLogCode: true,
keyExtHamqthPassword: true,
keyExtEQSLPassword: true, keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true, keyExtCloudlogAPIKey: true,
// The web-publish config is one JSON blob and the FTP password lives inside // 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 { if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized") 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. // WatchlistRemove deletes an entry.
@@ -63,7 +67,27 @@ func (a *App) WatchlistRemove(callsign string) error {
if a.watchlist == nil { if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized") 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. // WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
+96 -2
View File
@@ -1,16 +1,110 @@
[ [
{
"version": "0.27.7",
"date": "",
"en": [
"DX Cluster: a disconnected server keeps its pill, so it can be reconnected — disconnecting one used to make it vanish along with the only way back.",
"HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit.",
"Outbound ADIF (forwarding a logged QSO to another logger such as Log4OM): the receive side is filled in when the contact was not split, so BAND_RX and FREQ_RX are present. The importer already did this; the logging paths did not, so what a QSO carried depended on which door it came in through.",
"HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.",
"HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbooks QSOs that leaves, the file size, and HamQTHs own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.",
"QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.",
"CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes.",
"Fixed: a “worked but not confirmed” badge turned solid again after the next QSO, so an entity worked on a band still read NEW BAND as if it had never been worked there. Four hand-written copies of the same status mapping had drifted apart — the two that re-fetch dropped the unconfirmed flags, and none of them ever carried the grid one. There is one mapping now.",
"The Chase switches (POTA, US counties, prefixes, grids) now hold in FT decodes and in Chase new, not only in the DX Cluster — they say what you hunt, not which panel is open. Unchecked, their badges and filter chips disappear from all three and only the entity verdicts remain: NEW DXCC, band, mode, slot.",
"New DXHunter page in Preferences: every Chase setting moves there from the DX Cluster page — the hunt, its confirmation sources, and the POTA / SOTA / US counties / prefixes / grids switches. They govern three screens now, so they no longer live under the name of one of them; more of DXHunters ideas will land beside them.",
"Chase US states joins the other switches: unchecked, the NEW STATE badge and its filter chip go quiet everywhere."
],
"fr": [
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen dy revenir.",
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu dune requête par QSO, une première synchro passe de près dune heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site na pas denvoi partiel) : il demande donc confirmation, se limite à lindicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo.",
"ADIF sortant (transfert dun QSO vers un autre log, Log4OM par exemple) : le côté réception est renseigné quand le contact n’était pas en split, donc BAND_RX et FREQ_RX sont présents. Limport le faisait déjà, pas les chemins de log — ce quun QSO transportait dépendait donc de la porte par laquelle il était entré.",
"HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date denvoi), le filtre de QSO et l’édition groupée — cette dernière pour quun log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à lenvoi.",
"Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — lindicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.",
"Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande nest touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne lefface jamais.",
"CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — cest le cas dOmniRig, selon le fichier radio — faisait basculer lopérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change.",
"Corrigé : un badge « contacté mais non confirmé » redevenait plein dès le QSO suivant, si bien quune entité contactée sur une bande affichait NEW BAND comme si elle ne lavait jamais été. Quatre copies écrites à la main de la même conversion de statut avaient divergé — les deux qui rafraîchissent perdaient les drapeaux « non confirmé », et aucune ne transportait celui des locators. Il ny en a plus quune.",
"Les cases Chasse (POTA, comtés US, préfixes, locators) sappliquent désormais aux FT decodes et à Chase new, plus seulement au DX Cluster — elles disent ce que vous chassez, pas quel panneau est ouvert. Décochées, leurs badges et leurs puces de filtre disparaissent des trois écrans et il ne reste que les verdicts dentité : NOUVEAU DXCC, bande, mode, slot.",
"Nouvelle page DXHunter dans les Préférences : tous les réglages de chasse y déménagent depuis la page DX Cluster — le mode de chasse, ses sources de confirmation et les cases POTA / SOTA / comtés US / préfixes / locators. Ils commandent trois écrans, ils ne vivent donc plus sous le nom dun seul ; dautres idées de DXHunter viendront sy ajouter.",
"Chasser les états US rejoint les autres cases : décochée, le badge NOUVEL ÉTAT et sa puce de filtre se taisent partout."
]
},
{
"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", "version": "0.27.4",
"date": "", "date": "",
"en": [ "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 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.", "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." "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": [ "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 : 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.", "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." "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."
] ]
}, },
{ {
+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")
}
}
+152 -77
View File
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock, 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'; } from 'lucide-react';
import { import {
@@ -27,7 +27,7 @@ import {
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate, GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
GetScpStatus, ScpLookup, GetScpStatus, ScpLookup,
OpenExternalURL, OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, ConnectClusterServer, DisconnectClusterServer, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot, ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp, GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
GetSolarData, GetSolarData,
@@ -59,6 +59,7 @@ import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App'; import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
import { formatDistance } from '@/lib/units'; import { formatDistance } from '@/lib/units';
import { bandForMHz } from '@/lib/bandplan';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime'; import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -76,6 +77,7 @@ import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel'; import { FTMapPanel } from '@/components/FTMapPanel';
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
import { FirstRunModal } from '@/components/FirstRunModal'; import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal'; import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap'; import { BandMap } from '@/components/BandMap';
@@ -100,7 +102,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; 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, chaseState, 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 { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors'; import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
@@ -431,21 +433,6 @@ function entryQSYCommand(text: string): { band?: string; hz?: number } | null {
return null; return null;
} }
function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
// modeAccent maps a mode to a theme-aware colour for the live-stations widget: // modeAccent maps a mode to a theme-aware colour for the live-stations widget:
// CW gold, phone green, digital blue, unknown muted. // CW gold, phone green, digital blue, unknown muted.
@@ -1315,6 +1302,17 @@ export default function App() {
setActiveTab((t) => (t === 'grids' ? 'recent' : t)); setActiveTab((t) => (t === 'grids' ? 'recent' : t));
} }
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1'); 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() { function openFtmapTab() {
setFtmapTabOpen(true); setFtmapTabOpen(true);
writeUiPref('opslog.ftmapTab', '1'); writeUiPref('opslog.ftmapTab', '1');
@@ -2100,7 +2098,41 @@ export default function App() {
// worked_slot must be carried explicitly like every other field: this map is // 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 // 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. // 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_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({}); // spotStatusEntry maps ONE backend verdict onto the entry the panels read.
//
// There were four of these, written by hand at each call site, and they had
// drifted apart: the two that RE-fetch — after a QSO is logged, and when a
// pane becomes visible — rebuilt every entry without the unconfirmed flags.
// So a badge drawn correctly as "worked, QSL missing" turned solid at the
// next refresh and stayed that way, which is how South Africa came to read
// NEW BAND on a band with four contacts in the log. grid_state was never
// copied by any of them, so a known-but-unconfirmed square never dimmed at
// all. One function now, so a field added to the backend cannot reach three
// callers and miss the fourth.
const spotStatusEntry = (r: any) => ({
status: r.status ?? '',
country: r.country,
continent: r.continent,
worked_call: !!r.worked_call,
worked_slot: !!r.worked_slot,
new_county: !!r.new_county,
lotw: !!r.lotw,
spotter_continent: r.spotter_continent,
grid: r.grid,
grid_state: r.grid_state,
new_grid: !!r.new_grid,
county: r.county,
state: r.state,
new_state: !!r.new_state,
new_pota: !!r.new_pota,
new_pfx: !!r.new_pfx,
pfx: r.pfx,
unconf_status: !!r.unconf_status,
unconf_pfx: !!r.unconf_pfx,
unconf_cty: !!r.unconf_cty,
unconf_state: !!r.unconf_state,
});
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; grid_state?: 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 // Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener. // still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus); const spotStatusRef = useRef(spotStatus);
@@ -2173,11 +2205,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '', country: r.country, continent: (r as any).continent,
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_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -2346,6 +2374,18 @@ export default function App() {
}, [showSettings]); }, [showSettings]);
const [showDuplicates, setShowDuplicates] = useState(false); const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null); 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); const [checkingUpdate, setCheckingUpdate] = useState(false);
// Fresh update check on demand (opening About), so it never shows a stale // 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. // "you're up to date". Clears updateInfo when the latest check finds nothing.
@@ -3644,22 +3684,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
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,
};
} }
return next; return next;
}); });
@@ -3753,23 +3778,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
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,
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,
};
} }
return next; return next;
}); });
@@ -4267,17 +4276,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
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_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -5126,6 +5125,7 @@ export default function App() {
]}, ]},
{ name: 'tools', label: t('menu.tools'), items: [ { name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { 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('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' }, { type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
@@ -5180,6 +5180,7 @@ export default function App() {
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break; case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.dxped': openDxpedTab(); break;
case 'tools.decodes': openDecodesTab(); break; case 'tools.decodes': openDecodesTab(); break;
case 'tools.ftmap': openFtmapTab(); break; case 'tools.ftmap': openFtmapTab(); break;
case 'tools.grids': openGridsTab(); break; case 'tools.grids': openGridsTab(); break;
@@ -6261,7 +6262,11 @@ export default function App() {
// worked spots; the separate "Hide worked" checkbox drops them — they // worked spots; the separate "Hide worked" checkbox drops them — they
// are opposite controls, so don't use both at once. // 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' }, { 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())
&& (s.k !== 'new-state' || chaseState()))
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k), .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))} () => 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>, </div>,
@@ -7073,6 +7078,26 @@ export default function App() {
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} /> <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 && ( {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="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"> <div className="flex items-start gap-2">
@@ -7813,6 +7838,21 @@ export default function App() {
</span> </span>
</TabsTrigger> </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 && ( {ftmapTabOpen && (
<TabsTrigger value="ftmap" className="gap-1.5"> <TabsTrigger value="ftmap" className="gap-1.5">
{t('ftmap.tab')} {t('ftmap.tab')}
@@ -8060,31 +8100,57 @@ export default function App() {
> >
Disconnect all Disconnect all
</Button> </Button>
{clusterServerStatuses.length === 0 && ( {clusterServers.filter((x) => x.enabled).length === 0 && (
<span className="text-xs text-muted-foreground italic"> <span className="text-xs text-muted-foreground italic">
No active sessions configure clusters in Settings DX Cluster. No active sessions configure clusters in Settings DX Cluster.
</span> </span>
)} )}
{clusterServerStatuses.map((s) => { {clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((srv) => {
// A disconnected server has no session to report, so it has no
// entry in the status list at all — the pill stands in for it
// as "disconnected", which is exactly the state you click to
// undo.
const live = clusterServerStatuses.find((x) => x.server_id === srv.id);
const s: ServerStatus = live ?? {
server_id: srv.id, name: srv.name, host: '', port: 0, state: 'disconnected',
};
const isMaster = clusterServers const isMaster = clusterServers
.filter((x) => x.enabled) .filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id; .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 ( return (
<span <button
key={s.server_id} 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( 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 === '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' : s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-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.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>} {isMaster && <span className="text-warning" title="Master (commands go here)"></span>}
{s.name} {s.name}
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span> </button>
</span>
); );
})} })}
<div className="flex-1" /> <div className="flex-1" />
@@ -8426,6 +8492,15 @@ export default function App() {
</TabsContent> </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 && ( {ftmapTabOpen && (
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden"> <TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && ( {activeTab === 'ftmap' && (
+29 -17
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan'; 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 range = bandRange(band);
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]); const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band)); 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 // The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom. // remembered zoom.
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]); }, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
useEffect(() => { // No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
const el = scrollerRef.current; // else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
if (!el) return; // app over one panel and one band map over another is a gesture nobody can
const onWheel = (e: WheelEvent) => { // trust. The + / buttons keep the zoom, deliberately and visibly.
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]);
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune // 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. // 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>
</div> </div>
{/* Colour legend — what each pill colour means. */} {/* 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"> <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-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} /> <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.digi} label={t("bmp.legendData")} />
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} /> <LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
</div> </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')} <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">
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>} <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>
</div> </div>
); );
@@ -46,6 +46,8 @@ const FIELDS: FieldDef[] = [
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' }, { 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', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' }, { 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', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' }, { id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
// HAMLOG.online: no promoted column, written into extras_json (see // HAMLOG.online: no promoted column, written into extras_json (see
@@ -54,6 +56,8 @@ const FIELDS: FieldDef[] = [
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' }, { id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' }, { id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' }, { id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', group: 'QSL / upload', kind: 'date' },
// My station / operator // My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true }, { id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true }, { id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
+9 -4
View File
@@ -12,6 +12,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Radar, Loader2, X } from 'lucide-react'; import { Radar, Loader2, X } from 'lucide-react';
import { formatDistance } from '@/lib/units'; import { formatDistance } from '@/lib/units';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour } from '@/lib/spotMarkers'; import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App'; import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
@@ -60,6 +61,10 @@ const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
]; ];
// categoryOf is the single thing a row says about a station. // categoryOf is the single thing a row says about a station.
//
// A category the operator does not chase is not a category here either: with
// prefixes and squares switched off this panel showed rows whose only reason
// for being listed had been withdrawn everywhere else.
function categoryOf(s: ChaseNewSpot): Category | null { function categoryOf(s: ChaseNewSpot): Category | null {
switch (s.status) { switch (s.status) {
case 'new': return 'dxcc'; case 'new': return 'dxcc';
@@ -68,8 +73,8 @@ function categoryOf(s: ChaseNewSpot): Category | null {
case 'new-mode': return 'mode'; case 'new-mode': return 'mode';
case 'new-slot': return 'slot'; case 'new-slot': return 'slot';
} }
if (s.new_pfx) return 'pfx'; if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
if (s.new_grid) return 'grid'; if (s.new_grid && chaseAllows('grid')) return 'grid';
return null; return null;
} }
@@ -83,7 +88,7 @@ function loadFilters(): Set<Category> {
if (Array.isArray(list)) return new Set(list); if (Array.isArray(list)) return new Set(list);
} }
} catch { /* a corrupt preference is not worth a broken panel */ } } catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.map((c) => c.key)); return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key));
} }
export function ChaseNewPanel({ onPick, onClose }: Props) { export function ChaseNewPanel({ onPick, onClose }: Props) {
@@ -132,7 +137,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
{/* Filters, in the same order and colours as the badges they hide. */} {/* Filters, in the same order and colours as the badges they hide. */}
<div className="flex flex-1 flex-wrap items-center gap-1"> <div className="flex flex-1 flex-wrap items-center gap-1">
{CATEGORIES.map((c) => ( {CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
<button <button
key={c.key} key={c.key}
type="button" type="button"
@@ -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>
);
}
+3 -2
View File
@@ -16,6 +16,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react'; import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
@@ -743,7 +744,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{/* Per-category badges, in the colours of the flags they select — the {/* Per-category badges, in the colours of the flags they select — the
same vocabulary as the Chase New panel. */} same vocabulary as the Chase New panel. */}
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}> <span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
{NEW_CATS.map((c) => { {NEW_CATS.filter((c) => chaseAllows(c.key)).map((c) => {
const on = cats.has(c.key); const on = cats.has(c.key);
return ( return (
<button <button
@@ -1051,7 +1052,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
const e = statusOf(d); const e = statusOf(d);
const st = e?.status && e.status !== 'worked' ? e.status : ''; const st = e?.status && e.status !== 'worked' ? e.status : '';
const entities = st ? entityBadgesFor(st) : []; const entities = st ? entityBadgesFor(st) : [];
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]); const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key] && chaseAllows(b.key as string));
const mine = !!me && d.call === me; const mine = !!me && d.call === me;
const hot = entities.length > 0 || extras.length > 0; const hot = entities.length > 0 || extras.length > 0;
// Someone answering us outranks everything else on the screen. // Someone answering us outranks everything else on the screen.
+4 -1
View File
@@ -119,7 +119,10 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))]; const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
return ( return (
<div className="relative h-full w-full min-h-0"> // 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" /> <div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
{/* Basemap picker, MainMap's own vocabulary. */} {/* 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"> <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">
@@ -86,6 +86,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' }, { value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' }, { value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' }, { 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_status', label: 'fltb.fHrdlogSent', type: 'text' },
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' }, { value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
// HAMLOG.online: no promoted column, filtered through extras_json (see // HAMLOG.online: no promoted column, filtered through extras_json (see
@@ -94,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' }, { value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' }, { value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' }, { value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
{ value: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' }, { value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' }, { value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' }, { value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
+21 -2
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -42,6 +42,7 @@ const SERVICES = [
{ v: 'eqsl', label: 'eQSL.cc' }, { v: 'eqsl', label: 'eQSL.cc' },
{ v: 'lotw', label: 'LoTW' }, { v: 'lotw', label: 'LoTW' },
{ v: 'hamlog', label: 'HAMLOG.online' }, { v: 'hamlog', label: 'HAMLOG.online' },
{ v: 'hamqth', label: 'HamQTH' },
{ v: 'pota', label: 'POTA hunter log' }, { v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' }, { v: 'paper', label: 'Paper QSL' },
]; ];
@@ -722,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{service !== 'pota' && service !== 'paper' && ( {service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0"> <div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{service === 'hamlog' ? ( {service === 'hamqth' ? (
// HamQTH's file endpoint REPLACES the remote log — its own
// documentation is explicit that partial uploads do not exist. So
// it is offered as its own deliberate act, never as the batch path
// behind "send these": that would delete everything not selected.
<Button variant="outline" size="sm" disabled={busy}
title={t('qslm.hqFullTitle')}
onClick={async () => {
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
// Same three lines every other action here runs: without
// setShowLog the whole upload reported itself into a panel
// nobody was showing, and the tab sat on "Pick a service".
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadFullLogHamQTH(); }
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
}}>
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
</Button>
) : service === 'hamlog' ? (
<> <>
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy} <Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
title={t('qslm.hamlogImportTitle')}> title={t('qslm.hamlogImportTitle')}>
@@ -35,6 +35,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
{ service: 'eqsl', name: 'eQSL.cc' }, { service: 'eqsl', name: 'eQSL.cc' },
{ service: 'lotw', name: 'LoTW' }, { service: 'lotw', name: 'LoTW' },
{ service: 'hamlog', name: 'HAMLOG.online' }, { service: 'hamlog', name: 'HAMLOG.online' },
{ service: 'hamqth', name: 'HamQTH' },
]; ];
// Lightweight right-click menu for the QSO grids. AG Grid's native context // Lightweight right-click menu for the QSO grids. AG Grid's native context
+87 -34
View File
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags'; import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { bandForMHz } from '@/lib/bandplan';
import { titleCase, sentenceCase } from '@/lib/textCase'; import { titleCase, sentenceCase } from '@/lib/textCase';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
@@ -71,7 +72,7 @@ const CONFIRMATIONS: ConfDef[] = [
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' }, { 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: '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: '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 }, { 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 // i18n label keys for confirmation channels whose label has translatable words
@@ -80,6 +81,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
QSL: 'qedit.confQslPaper', 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 // 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 // columns and this channel is backed by ADIF extras — but it still belongs in
// the channel picker and the status table alongside the rest. // the channel picker and the status table alongside the rest.
@@ -98,6 +112,15 @@ const HAMLOG_KEYS = {
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE', 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. // Colour-coded status cell for the confirmation grid.
function StatusCell({ value }: { value?: string }) { function StatusCell({ value }: { value?: string }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -290,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const splitHz = (hz?: number) => hz const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') } ? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
: { khz: '', hz: '' }; : { khz: '', hz: '' };
// Correcting a frequency corrects its band. The pair has to agree — the log,
// every award and every upload are read on the BAND — and an operator fixing
// a wrong frequency is not also expecting to fix the band by hand, which is
// exactly how a QSO ends up filed on 20m at 7 MHz.
//
// Only when the number lands in a known allocation: half a frequency is typed
// on the way to all of it, and a band must never be blanked by that.
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
if (!khz.trim()) return;
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
if (b) set(field, b as any);
};
const f0 = splitHz(draft.freq_hz); const f0 = splitHz(draft.freq_hz);
const fr0 = splitHz(draft.freq_rx_hz); const fr0 = splitHz(draft.freq_rx_hz);
const [freqKHz, setFreqKHz] = useState(f0.khz); const [freqKHz, setFreqKHz] = useState(f0.khz);
@@ -612,13 +648,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label> <Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> <Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> <Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label> <Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" /> <Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> <Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div> </div>
</div> </div>
@@ -747,19 +783,34 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Select value={confSel} onValueChange={setConfSel}> <Select value={confSel} onValueChange={setConfSel}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <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>)} {confOrder([
{/* Listed here but NOT in CONFIRMATIONS: that table maps ...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 QSO columns, and this channel lives in the ADIF
extras. It gets its own editor below rather than the OpsLog card, HAMLOG.online and HamQTH — are backed by
generic sent/received/date grid, which has no field ADIF extras rather than QSO columns, and each gets its
to bind to. */} own editor below instead of the generic
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem> sent/received/date grid, which has no field to bind
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem> to. */}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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 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> <div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
@@ -845,30 +896,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</tr> </tr>
</thead> </thead>
<tbody> <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"> <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="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td> <td className="w-24"><StatusCell value={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="w-24">{c.rcvd === null
? <span className="block text-center text-[11px] text-muted-foreground"></span>
: <StatusCell value={c.rcvd} />}</td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
+10 -1
View File
@@ -219,6 +219,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false }, { group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false }, { group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false }, { group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
// HamQTH, the extras again — sent only, the site having no confirmations.
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), defaultVisible: false },
// App-specific: when the QSO's audio recording was e-mailed to the station. // App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false }, { group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
@@ -235,6 +238,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_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_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.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 ── // ── 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 }, { 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 +789,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
</div> </div>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3"> <div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
{GROUP_ORDER.map((group) => { {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; if (cols.length === 0) return null;
return ( return (
<div key={group} className="rounded-md border border-border p-2.5"> <div key={group} className="rounded-md border border-border p-2.5">
+231 -102
View File
@@ -45,11 +45,11 @@ import {
SetCIVTrace, SetCIVTrace,
CIVTraceEnabled, CIVTraceEnabled,
WinkeyerTraceEnabled, WinkeyerTraceEnabled,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload, GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus, DownloadLoTWUsers, GetLoTWUsersStatus,
GetScpStatus, SetScpEnabled, DownloadScp, GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp,
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount, DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList, GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom, GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
@@ -213,6 +213,7 @@ type SectionId =
| 'lists-modes' | 'lists-modes'
| 'lists-satellites' | 'lists-satellites'
| 'cluster' | 'cluster'
| 'dxhunter'
| 'backup' | 'backup'
| 'database' | 'database'
| 'autostart' | 'autostart'
@@ -325,6 +326,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' }, { kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
]}, ]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' }, { kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.dxhunter'), id: 'dxhunter' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' }, { kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' }, { kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' }, { kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
@@ -1612,7 +1614,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001, digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, digi_as_usb: false,
}); });
// Brand + connection, derived from the stored backend rather than held // Brand + connection, derived from the stored backend rather than held
// separately: two sources for one fact drift apart the first time something // separately: two sources for one fact drift apart the first time something
@@ -1822,17 +1824,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
qsl_sent: string; qsl_rcvd: string; qsl_sent: string; qsl_rcvd: string;
lotw_sent: string; lotw_rcvd: string; lotw_sent: string; lotw_rcvd: string;
eqsl_sent: string; eqsl_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; qrzcom_confirmed: string;
hamlog_status: string; hamlog_confirmed: string; hamlog_status: string; hamlog_confirmed: string;
hamqth_status: string;
}; };
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({ const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
qsl_sent: '', qsl_rcvd: '', qsl_sent: '', qsl_rcvd: '',
lotw_sent: '', lotw_rcvd: '', lotw_sent: '', lotw_rcvd: '',
eqsl_sent: '', eqsl_rcvd: '', eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', hrdlog_status: '', qrzcom_status: '', clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
qrzcom_confirmed: '', qrzcom_confirmed: '',
hamlog_status: '', hamlog_confirmed: '', hamlog_status: '', hamlog_confirmed: '', hamqth_status: '',
}); });
// External services (logbook upload). One block per service; only QRZ is // External services (logbook upload). One block per service; only QRZ is
@@ -1848,7 +1851,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net. // HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
on_air?: boolean; 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 => ({ const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '', api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
@@ -1856,7 +1859,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
auto_upload: false, upload_mode: 'immediate', on_air: false, auto_upload: false, upload_mode: 'immediate', on_air: false,
}); });
const [extSvc, setExtSvc] = useState<ExternalServices>({ 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 [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false); const [qrzTesting, setQrzTesting] = useState(false);
@@ -1884,6 +1887,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {} try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
finally { setScpBusy(false); } finally { setScpBusy(false); }
}; };
const refreshScp = async () => { try { const s = await GetScpStatus(); setScp(s as any); } catch {} };
const downloadScp = async () => { const downloadScp = async () => {
setScpBusy(true); setScpBusy(true);
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {} try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
@@ -2024,6 +2028,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [cloudlogTesting, setCloudlogTesting] = useState(false); const [cloudlogTesting, setCloudlogTesting] = useState(false);
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hamlogTesting, setHamlogTesting] = useState(false); 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 [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [eqslTesting, setEqslTesting] = useState(false); const [eqslTesting, setEqslTesting] = useState(false);
const [stationLocations, setStationLocations] = useState<string[]>([]); const [stationLocations, setStationLocations] = useState<string[]>([]);
@@ -2031,7 +2037,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// could not hold hooks at all; PanelHost lifted that restriction, and this // 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 — // stays put because moving it down would reset the tab on every reopen —
// a choice now, not a workaround. // 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). // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState(''); const [potaToken, setPotaToken] = useState('');
const [potaBusy, setPotaBusy] = useState(false); const [potaBusy, setPotaBusy] = useState(false);
@@ -2075,6 +2081,9 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
}; };
const [chaseGrids, setChaseGrids] = useState(false); const [chaseGrids, setChaseGrids] = useState(false);
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0'); 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 [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0'); const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false); const [chaseNew, setChaseNew] = useState(false);
const [spotTTL, setSpotTTL] = useState(0); const [spotTTL, setSpotTTL] = useState(0);
@@ -2096,7 +2105,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ } 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 { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ } try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ } try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
@@ -3828,6 +3843,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</label> </> </label> </>
)} )}
</div> </div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('cat.digiUsbHint')}>
<Checkbox
checked={!!catCfg.digi_as_usb}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, digi_as_usb: !!c }))}
/>
{t('cat.digiUsb')}
</label>
{catCfg.backend === 'omnirig' && ( {catCfg.backend === 'omnirig' && (
<> <>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -5217,6 +5239,131 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
setEditingServer(next); setEditingServer(next);
} }
// Chasing is not a cluster feature.
//
// These switches were born on the DX Cluster page because the cluster was the
// only thing that drew their badges. They now decide what the FT decode list
// and Chase new say as well, and settings that govern three screens do not
// belong under the name of one of them. The page is called DXHunter because
// that is what it is about — hunting DX — and because more of DXHunter's
// ideas are meant to land beside them.
function DXHunterPanel() {
return (
<>
<h3 className="text-sm font-semibold">{t('sec.dxhunter')}</h3>
<p className="text-xs text-muted-foreground -mt-1">{t('dxh.intro')}</p>
<div className="space-y-3">
<div className="border-t border-border/60 pt-3 space-y-2">
{/* 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'); }} />
{t('clu.chasePota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
<Checkbox checked={chaseSotaOn}
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-center gap-2 text-sm cursor-pointer" title={t('clu.chaseStateHint')}>
<Checkbox checked={chaseStateOn}
onCheckedChange={(c) => { const v = !!c; setChaseStateOn(v); writeUiPref('opslog.chaseState', v ? '1' : '0'); }} />
{t('clu.chaseState')}
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseGrids} className="mt-0.5"
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 && (
<p className="pl-6 text-xs text-muted-foreground">
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
</p>
)}
{/* What counts as "I already have this square". There is no single
right answer a VUCC chaser counts a square per band, someone
filling a wall map counts it once so it is a choice, and the
same six GridTracker offers, because a square wanted in one and
not the other is a bug report every time. */}
<div className="pl-6 space-y-1.5 pt-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(gridScope.scopes ?? []).map((s: any) => (
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</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. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseNew} className="mt-0.5"
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
</label>
</div>
</div>
</>
);
}
function ClusterPanel() { function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// Written on every keystroke. This panel has no Save button, and a pair of // Written on every keystroke. This panel has no Save button, and a pair of
@@ -5419,97 +5566,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-2">
{/* 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'); }} />
{t('clu.chasePota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
<Checkbox checked={chaseSotaOn}
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
{t('clu.chaseSota')}
</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(() => {}); }} />
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
</label>
{chaseGrids && (
<p className="pl-6 text-xs text-muted-foreground">
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
</p>
)}
{/* What counts as "I already have this square". There is no single
right answer a VUCC chaser counts a square per band, someone
filling a wall map counts it once so it is a choice, and the
same six GridTracker offers, because a square wanted in one and
not the other is a bug report every time. */}
<div className="pl-6 space-y-1.5 pt-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(gridScope.scopes ?? []).map((s: any) => (
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</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. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseNew} className="mt-0.5"
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
</label>
</div>
{/* Band-opening watch. It lives HERE, with the cluster nodes, because {/* Band-opening watch. It lives HERE, with the cluster nodes, because
switching it on adds two of them the operator should see that switching it on adds two of them the operator should see that
happen where it happens rather than find nodes they did not add. */} happen where it happens rather than find nodes they did not add. */}
@@ -5708,7 +5764,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label> <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
{renderSelect('clublog_status', FULL_OPTIONS)} {renderSelect('clublog_status', FULL_OPTIONS)}
</div> </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> </div>
{/* HRDLog */} {/* HRDLog */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
@@ -5731,6 +5790,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)} {renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
</div> </div>
</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 */} {/* HAMLOG.online */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label> <Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
@@ -5924,6 +5992,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{ k: 'lotw', label: 'LOTW', ready: true }, { k: 'lotw', label: 'LOTW', ready: true },
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true }, { k: 'cloudlog', label: 'CLOUDLOG', ready: true },
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true }, { k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
{ k: 'hamqth', label: 'HAMQTH', ready: true },
{ k: 'pota', label: 'POTA', ready: true }, { k: 'pota', label: 'POTA', ready: true },
]; ];
@@ -5993,6 +6062,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 hamlog = extSvc.hamlog ?? emptyExtCfg();
const setHamlog = (patch: Partial<ExtServiceCfg>) => const setHamlog = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } })); setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
@@ -6372,6 +6456,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
</div> </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' ? ( ) : extSvcTab === 'cloudlog' ? (
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center"> <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
@@ -7306,6 +7427,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} /> <Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
{t('scp.enable')} {t('scp.enable')}
</label> </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 && ( {scp.enabled && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}> <Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
@@ -7942,6 +8070,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
'lists-modes': ModesPanel, 'lists-modes': ModesPanel,
'lists-satellites': SatellitesPanel, 'lists-satellites': SatellitesPanel,
cluster: ClusterPanel, cluster: ClusterPanel,
dxhunter: DXHunterPanel,
udp: UDPIntegrationsPanelWrapper, udp: UDPIntegrationsPanelWrapper,
// Module-scope components, wrapped so their props can be passed. The nested // Module-scope components, wrapped so their props can be passed. The nested
// panels below go through PanelHost instead — which is what now lets either // panels below go through PanelHost instead — which is what now lets either
+3 -2
View File
@@ -181,7 +181,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
try { try {
await WatchlistAdd(c, addContest); await WatchlistAdd(c, addContest);
setAddCall(''); 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(); await refresh();
} catch (e: any) { flash(String(e?.message ?? e), true); } } 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 // entry is cheap to undo (type it again), so it does not earn a confirmation
// the other two buttons do not have. // the other two buttons do not have.
const remove = async (call: string) => { 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); } catch (e: any) { flash(String(e?.message ?? e), true); }
}; };
+23
View File
@@ -111,3 +111,26 @@ export function bandRange(band: string): [number, number] | undefined {
export function bandSegments(band: string): Seg[] { export function bandSegments(band: string): Seg[] {
return plan().segments[band] ?? []; return plan().segments[band] ?? [];
} }
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' when it falls
// outside every known allocation.
//
// Lives here rather than in a panel because more than one place has to answer
// the same question the same way: the entry form retunes on a typed frequency,
// and the QSO editor has to keep band and frequency agreeing when one of them
// is corrected. Kept in step with BandFromHz on the Go side.
export function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
File diff suppressed because one or more lines are too long
+57 -2
View File
@@ -20,6 +20,7 @@ export type SpotDisplayOptions = {
// keep being computed; only the telling stops, so ticking the box back on // keep being computed; only the telling stops, so ticking the box back on
// needs no rescan. // needs no rescan.
chasePota: boolean; chaseSota: boolean; chasePota: boolean; chaseSota: boolean;
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean; chaseState: boolean;
}; };
// chasePota/chaseSota read the switches directly — for the places that show a // chasePota/chaseSota read the switches directly — for the places that show a
@@ -30,6 +31,43 @@ export function chasePota(): boolean {
export function chaseSota(): boolean { export function chaseSota(): boolean {
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; } 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; }
}
export function chaseState(): boolean {
try { return localStorage.getItem('opslog.chaseState') !== '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; }
}
// chaseAllows answers "does this operator chase this kind of thing at all?"
// for the ORTHOGONAL markers — park, square, prefix, county.
//
// The switches were written for the cluster and stayed there, so an operator
// who does not chase parks still met NEW POTA in the decode list and in Chase
// new: the same badge, withdrawn on one screen and shouting on the next. The
// setting is about what the operator hunts, not about which panel is open.
//
// The keys are both the status-entry field names (new_pota…) and the shorter
// category names the panels filter with (pota…), so one call serves both.
// Anything without a switch of its own — a new US state — is always allowed.
export function chaseAllows(key: string): boolean {
switch (key) {
case 'new_pota': case 'pota': return chasePota();
case 'new_grid': case 'grid': return chaseGrid();
case 'new_pfx': case 'pfx': return chasePfx();
case 'new_county': case 'cty': return chaseCounty();
case 'new_state': case 'state': return chaseState();
default: return true;
}
}
// Both options are withdrawn from the filter panel for now. The machinery below // 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 // is deliberately kept whole — it is correct and hard-won — so putting the two
@@ -44,16 +82,18 @@ export function readSpotDisplayOptions(): SpotDisplayOptions {
// The EXPOSED flag only withdraws the two original switches; the chase // The EXPOSED flag only withdraws the two original switches; the chase
// switches are live regardless. // switches are live regardless.
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) { 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(), chaseState: chaseState() };
} }
try { try {
return { return {
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1', muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1', slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
chasePota: chasePota(), chaseSota: chaseSota(), chasePota: chasePota(), chaseSota: chaseSota(),
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
chaseState: chaseState(),
}; };
} catch { } 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, chaseState: true };
} }
} }
@@ -65,6 +105,7 @@ type Entry = {
new_pota?: boolean; new_pota?: boolean;
new_pfx?: boolean; new_pfx?: boolean;
new_grid?: boolean; new_grid?: boolean;
new_state?: boolean;
} | undefined; } | undefined;
// applySpotDisplay rewrites a status entry per the options, so every consumer — // applySpotDisplay rewrites a status entry per the options, so every consumer —
@@ -98,6 +139,20 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
if (!o.chasePota && e.new_pota) { if (!o.chasePota && e.new_pota) {
e = { ...e, new_pota: false } as NonNullable<T>; 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>;
}
if (!o.chaseState && e.new_state) {
e = { ...e, new_state: false } as NonNullable<T>;
}
return e; return e;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.3'; export const APP_VERSION = '0.27.6';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+13
View File
@@ -12,6 +12,7 @@ import {award} from '../models';
import {awardref} from '../models'; import {awardref} from '../models';
import {bandopen} from '../models'; import {bandopen} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {dxped} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {powergenius} from '../models'; import {powergenius} from '../models';
import {pskr} from '../models'; import {pskr} from '../models';
@@ -476,6 +477,10 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>; 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 GetDataDir():Promise<string>;
export function GetDatabaseSettings():Promise<main.DatabaseSettings>; export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
@@ -944,6 +949,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>; export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshDXpeditions():Promise<void>;
export function RefreshKenwood():Promise<void>; export function RefreshKenwood():Promise<void>;
export function RefreshSolar():Promise<void>; export function RefreshSolar():Promise<void>;
@@ -1202,6 +1209,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):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 SetScpEnabled(arg1:boolean):Promise<void>;
export function SetSpotMax(arg1:number):Promise<void>; export function SetSpotMax(arg1:number):Promise<void>;
@@ -1332,6 +1341,8 @@ export function TestEmail(arg1:string):Promise<void>;
export function TestHRDLogUpload():Promise<string>; export function TestHRDLogUpload():Promise<string>;
export function TestHamQTHUpload():Promise<string>;
export function TestLoTWUpload():Promise<string>; export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
@@ -1386,6 +1397,8 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>; export function UploadCallsign(arg1:string):Promise<string>;
export function UploadFullLogHamQTH():Promise<void>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>; export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>; export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
+24
View File
@@ -890,6 +890,14 @@ export function GetDVKStatus() {
return window['go']['main']['App']['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() { export function GetDataDir() {
return window['go']['main']['App']['GetDataDir'](); return window['go']['main']['App']['GetDataDir']();
} }
@@ -1826,6 +1834,10 @@ export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat'](); return window['go']['main']['App']['RefreshCtyDat']();
} }
export function RefreshDXpeditions() {
return window['go']['main']['App']['RefreshDXpeditions']();
}
export function RefreshKenwood() { export function RefreshKenwood() {
return window['go']['main']['App']['RefreshKenwood'](); return window['go']['main']['App']['RefreshKenwood']();
} }
@@ -2342,6 +2354,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
export function SetScpClublogEnabled(arg1) {
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
}
export function SetScpEnabled(arg1) { export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1); return window['go']['main']['App']['SetScpEnabled'](arg1);
} }
@@ -2602,6 +2618,10 @@ export function TestHRDLogUpload() {
return window['go']['main']['App']['TestHRDLogUpload'](); return window['go']['main']['App']['TestHRDLogUpload']();
} }
export function TestHamQTHUpload() {
return window['go']['main']['App']['TestHamQTHUpload']();
}
export function TestLoTWUpload() { export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload'](); return window['go']['main']['App']['TestLoTWUpload']();
} }
@@ -2710,6 +2730,10 @@ export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1); return window['go']['main']['App']['UploadCallsign'](arg1);
} }
export function UploadFullLogHamQTH() {
return window['go']['main']['App']['UploadFullLogHamQTH']();
}
export function UploadQSOsManual(arg1, arg2) { export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2); return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
} }
+83
View File
@@ -1466,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 namespace extsvc {
export class ServiceConfig { export class ServiceConfig {
@@ -1522,6 +1553,7 @@ export namespace extsvc {
eqsl: ServiceConfig; eqsl: ServiceConfig;
cloudlog: ServiceConfig; cloudlog: ServiceConfig;
hamlog: ServiceConfig; hamlog: ServiceConfig;
hamqth: ServiceConfig;
delete_remote: boolean; delete_remote: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -1537,6 +1569,7 @@ export namespace extsvc {
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig); this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig); this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
this.hamqth = this.convertValues(source["hamqth"], ServiceConfig);
this.delete_remote = source["delete_remote"]; this.delete_remote = source["delete_remote"];
} }
@@ -2265,6 +2298,7 @@ export namespace main {
backend: string; backend: string;
omnirig_rig: number; omnirig_rig: number;
omnirig_vfo: string; omnirig_vfo: string;
digi_as_usb: boolean;
flex_host: string; flex_host: string;
flex_port: number; flex_port: number;
flex_spots: boolean; flex_spots: boolean;
@@ -2317,6 +2351,7 @@ export namespace main {
this.backend = source["backend"]; this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"]; this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"]; this.omnirig_vfo = source["omnirig_vfo"];
this.digi_as_usb = source["digi_as_usb"];
this.flex_host = source["flex_host"]; this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"]; this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"]; this.flex_spots = source["flex_spots"];
@@ -2672,6 +2707,44 @@ export namespace main {
this.rec_slot = source["rec_slot"]; 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 { export class DatabaseSettings {
path: string; path: string;
default_path: string; default_path: string;
@@ -3294,11 +3367,13 @@ export namespace main {
eqsl_sent: string; eqsl_sent: string;
eqsl_rcvd: string; eqsl_rcvd: string;
clublog_status: string; clublog_status: string;
clublog_confirmed: string;
hrdlog_status: string; hrdlog_status: string;
qrzcom_status: string; qrzcom_status: string;
qrzcom_confirmed: string; qrzcom_confirmed: string;
hamlog_status: string; hamlog_status: string;
hamlog_confirmed: string; hamlog_confirmed: string;
hamqth_status: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLDefaults(source); return new QSLDefaults(source);
@@ -3313,11 +3388,13 @@ export namespace main {
this.eqsl_sent = source["eqsl_sent"]; this.eqsl_sent = source["eqsl_sent"];
this.eqsl_rcvd = source["eqsl_rcvd"]; this.eqsl_rcvd = source["eqsl_rcvd"];
this.clublog_status = source["clublog_status"]; this.clublog_status = source["clublog_status"];
this.clublog_confirmed = source["clublog_confirmed"];
this.hrdlog_status = source["hrdlog_status"]; this.hrdlog_status = source["hrdlog_status"];
this.qrzcom_status = source["qrzcom_status"]; this.qrzcom_status = source["qrzcom_status"];
this.qrzcom_confirmed = source["qrzcom_confirmed"]; this.qrzcom_confirmed = source["qrzcom_confirmed"];
this.hamlog_status = source["hamlog_status"]; this.hamlog_status = source["hamlog_status"];
this.hamlog_confirmed = source["hamlog_confirmed"]; this.hamlog_confirmed = source["hamlog_confirmed"];
this.hamqth_status = source["hamqth_status"];
} }
} }
export class QSLEmailTemplates { export class QSLEmailTemplates {
@@ -3852,6 +3929,7 @@ export namespace main {
enabled: boolean; enabled: boolean;
count: number; count: number;
updated?: string; updated?: string;
clublog: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ScpStatus(source); return new ScpStatus(source);
@@ -3862,6 +3940,7 @@ export namespace main {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.count = source["count"]; this.count = source["count"];
this.updated = source["updated"]; this.updated = source["updated"];
this.clublog = source["clublog"];
} }
} }
export class SecretStatus { export class SecretStatus {
@@ -5269,6 +5348,8 @@ export namespace qso {
qrzcom_qso_upload_status?: string; qrzcom_qso_upload_status?: string;
qrzcom_qso_download_date?: string; qrzcom_qso_download_date?: string;
qrzcom_qso_download_status?: string; qrzcom_qso_download_status?: string;
clublog_qso_download_date?: string;
clublog_qso_download_status?: string;
contest_id?: string; contest_id?: string;
srx?: number; srx?: number;
stx?: number; stx?: number;
@@ -5411,6 +5492,8 @@ export namespace qso {
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"]; this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"]; this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"]; 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.contest_id = source["contest_id"];
this.srx = source["srx"]; this.srx = source["srx"];
this.stx = source["stx"]; 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_UPLOAD_STATUS", q.QRZComUploadStatus)
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate) w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus) w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
// --- Contest --- // --- Contest ---
w("CONTEST_ID", q.ContestID) w("CONTEST_ID", q.ContestID)
@@ -441,5 +443,11 @@ func adifCounty(state, county string) string {
if c == "" || s == "" || strings.Contains(c, ",") { if c == "" || s == "" || strings.Contains(c, ",") {
return 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 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_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, 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}, {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_DATE", Kind: KindDate, Category: "QSL"},
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"}, {Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, 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", "hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status", "qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
"qrzcom_qso_download_date", "qrzcom_qso_download_status", "qrzcom_qso_download_date", "qrzcom_qso_download_status",
"clublog_qso_download_date", "clublog_qso_download_status",
// Contest // Contest
"contest_id", "srx", "stx", "srx_string", "stx_string", "contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect", "check", "precedence", "arrl_sect",
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"] q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"] q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"] q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
// Contest // Contest
q.ContestID = rec["contest_id"] q.ContestID = rec["contest_id"]
+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) 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, // migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system // skipping those already applied. Intentionally minimal in-house system
// (no external dependency). translate, when non-nil, rewrites each statement // (no external dependency). translate, when non-nil, rewrites each statement
@@ -352,6 +371,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string,
continue continue
} }
if _, err := tx.Exec(stmt); err != nil { 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() _ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err) 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" ServiceCloudlog Service = "cloudlog"
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO. // ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
ServiceHamlog Service = "hamlog" 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. // UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -133,6 +136,7 @@ type ExternalServices struct {
EQSL ServiceConfig `json:"eqsl"` EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"` Cloudlog ServiceConfig `json:"cloudlog"`
Hamlog ServiceConfig `json:"hamlog"` Hamlog ServiceConfig `json:"hamlog"`
HamQTH ServiceConfig `json:"hamqth"`
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when // 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 // it is deleted locally. Off unless the operator turns it on: neither
+268
View File
@@ -0,0 +1,268 @@
package extsvc
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"mime/multipart"
"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"
// hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own
// documentation says "you always have to upload whole log. HamQTH doesn't
// support partial upload" — the file REPLACES what is on the site. That is why
// it is not the batch path for a selection, and why the caller must have said
// so out loud before we get here.
const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php"
// hamqthMaxUpload is the documented ceiling for one upload.
const hamqthMaxUpload = 20 << 20
// hamqthCompressAbove is where a plain .adi stops being sent as text. Well
// under the limit: the multipart envelope and the form fields ride along too.
const hamqthCompressAbove = 12 << 20
// 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)
}
}
// UploadHamQTHFullLog replaces the account's log with the given ADIF.
//
// DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH
// for this callsign that is not in this file stops existing. The caller owns
// the confirmation.
//
// The file goes in the multipart field "f" (HamQTH's own curl example:
// curl -F [email protected] -F send_log=OK -F u=… -F p=…). A large log is sent as a
// tar.gz — one of the archive formats the site unpacks — because the ceiling is
// 20 MB and a six-figure log passes it as plain text.
func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText 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")
case strings.TrimSpace(adifText) == "":
return UploadResult{}, fmt.Errorf("hamqth: nothing to upload")
}
payload := []byte(adifText)
name := "opslog.adi"
if len(payload) > hamqthCompressAbove {
gz, err := tarGzADIF(payload)
if err != nil {
return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err)
}
payload, name = gz, "opslog.tar.gz"
}
if len(payload) > hamqthMaxUpload {
return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit",
len(payload)>>20, hamqthMaxUpload>>20)
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("u", user)
_ = mw.WriteField("p", cfg.Password)
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
_ = mw.WriteField("c", c)
}
_ = mw.WriteField("send_log", "OK")
fw, err := mw.CreateFormFile("f", name)
if err != nil {
return UploadResult{}, err
}
if _, err := fw.Write(payload); err != nil {
return UploadResult{}, err
}
if err := mw.Close(); err != nil {
return UploadResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body)
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
// A whole log is a long POST on a slow uplink.
client = &http.Client{Timeout: 10 * time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(raw))
if looksLikeHTML(msg) {
msg = ""
}
if resp.StatusCode != http.StatusOK {
if msg != "" && len(msg) < 300 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
// The site answers in prose, and only its own refusals are worth reading
// back: the ADIF itself is validated later, in the background, and any
// complaint about it reaches the operator by e-mail rather than here.
low := strings.ToLower(msg)
switch {
case strings.Contains(low, "successfully"):
return UploadResult{OK: true, Message: msg}, nil
case strings.Contains(low, "wrong username"), strings.Contains(low, "password"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case strings.Contains(low, "cannot upload log for this callsign"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case msg == "":
// HTTP 200 with nothing to say: taken as accepted, and said so.
return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil
default:
return UploadResult{OK: false, Message: msg}, nil
}
}
// tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry
// a .adi/.adif member for HamQTH to find the log in it.
func tarGzADIF(adif []byte) ([]byte, error) {
var out bytes.Buffer
gz := gzip.NewWriter(&out)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{
Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)),
}); err != nil {
return nil, err
}
if _, err := tw.Write(adif); err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// 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"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -337,7 +339,36 @@ func fileExists(p string) bool {
// they were already uploaded OR outside the callsign certificate's date range. // 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" // Reporting either as success is how a contact came to be stamped "uploaded"
// while LoTW had never seen it. // 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) { func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
adifRecord = scrubMyCnty(adifRecord)
tqsl := strings.TrimSpace(cfg.TQSLPath) tqsl := strings.TrimSpace(cfg.TQSLPath)
loc := strings.TrimSpace(cfg.StationLocation) loc := strings.TrimSpace(cfg.StationLocation)
switch { 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.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised() cfg.Cloudlog = cfg.Cloudlog.normalised()
cfg.Hamlog = cfg.Hamlog.normalised() cfg.Hamlog = cfg.Hamlog.normalised()
cfg.HamQTH = cfg.HamQTH.normalised()
m.cfg = cfg m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save. // 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}, {"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog}, {"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
{"hamlog", cfg.Hamlog}, {"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH},
} { } {
if s.cfg.AutoUpload { if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode)) 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) 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 // 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 != "" { if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
out = append(out, ServiceHamlog) out = append(out, ServiceHamlog)
} }
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
out = append(out, ServiceHamQTH)
}
return out return out
} }
@@ -338,6 +350,8 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog) uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
case ServiceHamlog: case ServiceHamlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog) uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
case ServiceHamQTH:
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
} }
} }
return uploaded return uploaded
@@ -577,7 +591,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
switch svc { switch svc {
case ServiceQRZ, ServiceLoTW: case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign owner = cfg.ForceStationCallsign
case ServiceClublog, ServiceHRDLog: case ServiceClublog, ServiceHRDLog, ServiceHamQTH:
owner = cfg.Callsign owner = cfg.Callsign
case ServiceEQSL: case ServiceEQSL:
owner = cfg.Username owner = cfg.Username
@@ -669,6 +683,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false return false, false
} }
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record) 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: default:
return false, false return false, false
} }
+81 -31
View File
@@ -123,6 +123,10 @@ type QSO struct {
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"` QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"` QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,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 --- // --- Contest ---
ContestID string `json:"contest_id,omitempty"` 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, hrdlog_qso_upload_date, hrdlog_qso_upload_status,
qrzcom_qso_upload_date, qrzcom_qso_upload_status, qrzcom_qso_upload_date, qrzcom_qso_upload_status,
qrzcom_qso_download_date, qrzcom_qso_download_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, 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, 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, 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.HRDLogUploadDate, q.HRDLogUploadStatus,
q.QRZComUploadDate, q.QRZComUploadStatus, q.QRZComUploadDate, q.QRZComUploadStatus,
q.QRZComDownloadDate, q.QRZComDownloadStatus, 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.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.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, q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
@@ -771,30 +777,32 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
// zones, lat/lon) that are meaningless shared. // zones, lat/lon) that are meaningless shared.
var bulkEditableCols = map[string]bool{ var bulkEditableCols = map[string]bool{
// QSL / upload status // QSL / upload status
"lotw_sent": true, "lotw_sent": true,
"lotw_rcvd": true, "lotw_rcvd": true,
"eqsl_sent": true, "eqsl_sent": true,
"eqsl_rcvd": true, "eqsl_rcvd": true,
"qsl_sent": true, "qsl_sent": true,
"qsl_rcvd": true, "qsl_rcvd": true,
"qsl_via": true, "qsl_via": true,
"qsl_sent_via": true, "qsl_sent_via": true,
"qsl_rcvd_via": true, "qsl_rcvd_via": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "clublog_qso_upload_status": true,
"hrdlog_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. // Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
"qsl_sent_date": true, "qsl_sent_date": true,
"qsl_rcvd_date": true, "qsl_rcvd_date": true,
"lotw_sent_date": true, "lotw_sent_date": true,
"lotw_rcvd_date": true, "lotw_rcvd_date": true,
"eqsl_sent_date": true, "eqsl_sent_date": true,
"eqsl_rcvd_date": true, "eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true, "qrzcom_qso_upload_date": true,
"qrzcom_qso_download_date": true, "qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true, "clublog_qso_upload_date": true,
"hrdlog_qso_upload_date": true, "clublog_qso_download_date": true,
"hrdlog_qso_upload_date": true,
// My station / operator // My station / operator
"station_callsign": true, "station_callsign": true,
"operator": true, "operator": true,
@@ -987,6 +995,12 @@ var bulkEditableExtras = map[string]string{
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE", "hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM", "hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE", "hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
// HamQTH, same story and SENT only — the site publishes no confirmations,
// so there is no received side to edit. Bulk-editable for the one case that
// matters: a log uploaded to HamQTH by hand, which OpsLog would otherwise
// offer to send all over again.
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
} }
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "". // BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
@@ -1339,15 +1353,17 @@ var filterableColumns = map[string]bool{
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true, "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, "lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": 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 // Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
// also chronological — "before 20240101" works with no date parsing. // also chronological — "before 20240101" works with no date parsing.
"qsl_sent_date": true, "qsl_rcvd_date": true, "qsl_sent_date": true, "qsl_rcvd_date": true,
"lotw_sent_date": true, "lotw_rcvd_date": true, "lotw_sent_date": true, "lotw_rcvd_date": true,
"eqsl_sent_date": true, "eqsl_rcvd_date": true, "eqsl_sent_date": true, "eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true, "qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true, "hrdlog_qso_upload_date": true, "clublog_qso_upload_date": true, "clublog_qso_download_date": true,
"contest_id": true, "srx": true, "stx": true, "hrdlog_qso_upload_date": true,
"contest_id": true, "srx": true, "stx": true,
"prop_mode": true, "sat_name": true, "prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true, "station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
"my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true, "my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true,
@@ -1393,6 +1409,10 @@ var filterableExtras = map[string]string{
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE", "hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM", "hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE", "hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
// HamQTH — sent only, and filterable for the question that precedes every
// backlog upload: "which contacts have never gone there".
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
} }
// FilterableFields returns the whitelist (for the frontend to build its field // FilterableFields returns the whitelist (for the frontend to build its field
@@ -3264,6 +3284,7 @@ type matchRef struct {
// FT8, FT4 only FT4, CW only CW…). Built in one table scan. // FT8, FT4 only FT4, CW only CW…). Built in one table scan.
type MatchIndex struct { type MatchIndex struct {
byMode map[string][]matchRef // call|band|canonMode → refs 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 // canonMode folds the phone sidebands into a single "SSB" bucket; every other
@@ -3299,7 +3320,7 @@ func parseQSODate(s string) time.Time {
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under // for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
// another (e.g. TM2Q). // another (e.g. TM2Q).
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) { 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` query := `SELECT id, callsign, qso_date, band, mode FROM qso`
var args []any var args []any
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" { if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
@@ -3327,6 +3348,11 @@ func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchInd
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) { func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
mk := matchKeyMode(call, band, mode) mk := matchKeyMode(call, band, mode)
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id}) 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 // Add registers a QSO in the index (exported wrapper for callers that inserted a
@@ -3342,6 +3368,12 @@ func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window tim
return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window) 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) { func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
var best int64 var best int64
bestD := window + time.Second bestD := window + time.Second
@@ -3500,10 +3532,11 @@ func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
// confirmedCols whitelists the received-status columns ConfirmedSlots may // confirmedCols whitelists the received-status columns ConfirmedSlots may
// OR together (guards the dynamic SQL). // OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{ var confirmedCols = map[string]bool{
"lotw_rcvd": true, "lotw_rcvd": true,
"qsl_rcvd": true, "qsl_rcvd": true,
"eqsl_rcvd": true, "eqsl_rcvd": true,
"qrzcom_qso_download_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_download_status": true,
} }
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting // ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
@@ -3560,6 +3593,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
return nil 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. // ClearQRZConfirmed takes back a QRZ confirmation.
// //
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status, // Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
@@ -3638,6 +3684,7 @@ func scanQSO(s scanner) (QSO, error) {
hrdlogDate, hrdlogStatus sql.NullString hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString qrzcomDlDate, qrzcomDlStatus sql.NullString
clublogDlDate, clublogDlStatus sql.NullString
contestID sql.NullString contestID sql.NullString
srx, stx sql.NullInt64 srx, stx sql.NullInt64
srxStr, stxStr sql.NullString srxStr, stxStr sql.NullString
@@ -3682,6 +3729,7 @@ func scanQSO(s scanner) (QSO, error) {
&hrdlogDate, &hrdlogStatus, &hrdlogDate, &hrdlogStatus,
&qrzcomDate, &qrzcomStatus, &qrzcomDate, &qrzcomStatus,
&qrzcomDlDate, &qrzcomDlStatus, &qrzcomDlDate, &qrzcomDlStatus,
&clublogDlDate, &clublogDlStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect, &contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath, &propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA, &stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
@@ -3779,6 +3827,8 @@ func scanQSO(s scanner) (QSO, error) {
q.QRZComUploadStatus = qrzcomStatus.String q.QRZComUploadStatus = qrzcomStatus.String
q.QRZComDownloadDate = qrzcomDlDate.String q.QRZComDownloadDate = qrzcomDlDate.String
q.QRZComDownloadStatus = qrzcomDlStatus.String q.QRZComDownloadStatus = qrzcomDlStatus.String
q.ClublogDownloadDate = clublogDlDate.String
q.ClublogDownloadStatus = clublogDlStatus.String
q.ContestID = contestID.String q.ContestID = contestID.String
if srx.Valid { if srx.Valid {
v := int(srx.Int64) v := int(srx.Int64)
+88 -20
View File
@@ -13,6 +13,7 @@ package scp
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"compress/gzip"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -22,6 +23,7 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
@@ -29,7 +31,14 @@ import (
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls. // line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP" 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 cacheFile = "MASTER.SCP"
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed
// Result is the two suggestion lists for a typed fragment. // Result is the two suggestion lists for a typed fragment.
type Result struct { type Result struct {
@@ -44,6 +53,7 @@ type Manager struct {
updated time.Time // when the cache was last refreshed updated time.Time // when the cache was last refreshed
dir string dir string
client *http.Client 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. // NewManager loads any on-disk cache and returns a ready manager.
@@ -56,14 +66,30 @@ func NewManager(dataDir string) *Manager {
return m 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() { func (m *Manager) loadCache() {
data, err := os.ReadFile(m.path()) data, _ := os.ReadFile(m.path())
if err != nil { var extra []byte
if m.clublog.Load() {
extra, _ = os.ReadFile(m.clublogPath())
}
if data == nil && extra == nil {
return return
} }
m.parse(data) m.parse(data, extra)
if fi, e := os.Stat(m.path()); e == nil { if fi, e := os.Stat(m.path()); e == nil {
m.mu.Lock() m.mu.Lock()
m.updated = fi.ModTime() m.updated = fi.ModTime()
@@ -91,7 +117,17 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
if err != nil { if err != nil {
return 0, fmt.Errorf("scp: read: %w", err) 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 { if n == 0 {
return 0, fmt.Errorf("scp: file parsed to 0 callsigns") 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 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. // 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) seen := make(map[string]struct{}, 1<<17)
sc := bufio.NewScanner(bytes.NewReader(data)) for _, data := range datasets {
sc.Buffer(make([]byte, 1024*1024), 1024*1024) if len(data) == 0 {
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) {
continue 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 { if len(seen) == 0 {
return 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 }}, {"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_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_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_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 }}, {"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 }}, {"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 // 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 // try (fail → another OpsLog is running, so exit). On a --post-update or
// previous instance may still be shutting down and holding the mutex, so retry for // --relaunch start the previous instance may still be shutting down and holding
// a few seconds until it frees. // the mutex, so retry for a few seconds until it frees.
func acquireInstance(postUpdate bool) bool { func acquireInstance(wait bool) bool {
if acquireSingleInstance() { if acquireSingleInstance() {
return true return true
} }
if !postUpdate { if !wait {
return false return false
} }
deadline := time.Now().Add(20 * time.Second) 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. // to free instead of bailing out. Then clear the old exe it left behind.
bootLogLaunch() bootLogLaunch()
postUpdate := hasFlag(os.Args[1:], "--post-update") 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 // 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 // fight the first over the rig — but it happened in total silence: no
// window, no data folder, no log, which is indistinguishable from a // window, no data folder, no log, which is indistinguishable from a
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"strings"
"testing"
"hamlog/internal/adif"
"hamlog/internal/qso"
)
// A contact that was not split still has a receive side, and the record
// forwarded to another logger has to carry it: Log4OM reads BAND_RX.
func TestFillRXDefaults(t *testing.T) {
hz := int64(14074000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
fillRXDefaults(&q)
if q.BandRX != "20m" {
t.Errorf("BandRX = %q, want 20m", q.BandRX)
}
if q.FreqRXHz == nil || *q.FreqRXHz != hz {
t.Errorf("FreqRXHz = %v, want %d", q.FreqRXHz, hz)
}
// Assert on the RECORD another logger reads, not merely on the struct:
// both halves of the receive side have to reach it.
rec := strings.ToUpper(adif.SingleRecordADIF(q))
if !strings.Contains(rec, "<BAND_RX:3>20M") {
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
}
if !strings.Contains(rec, "<FREQ_RX:9>14.074000") {
t.Errorf("FREQ_RX missing from the forwarded record:\n%s", rec)
}
}
// A genuine split contact keeps what it was given.
func TestFillRXDefaultsKeepsSplit(t *testing.T) {
tx, rx := int64(14195000), int64(14205000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx}
fillRXDefaults(&q)
if q.BandRX != "17m" || q.FreqRXHz == nil || *q.FreqRXHz != rx {
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.3" appVersion = "0.27.6"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"testing"
"time"
"hamlog/internal/qso"
)
// The operator's report: South Africa worked on 15m FT8 but confirmed only by
// eQSL/Club Log/QRZ, with the chase set to "new + unconfirmed" over LoTW and
// paper QSL. The verdict must be NEW BAND — that is what the settings ask for —
// and it must be flagged UNCONFIRMED, so the badge reads "worked, QSL missing"
// rather than "never worked here".
func TestUnconfirmedBandIsFlagged(t *testing.T) {
a := syncTestApp(t)
hz := int64(21074000)
add := func(call, band, mode string, lotw, eqsl string, dxcc int) {
q := qso.QSO{
Callsign: call, Band: band, Mode: mode,
QSODate: time.Now().UTC().Add(-48 * time.Hour),
FreqHz: &hz, DXCC: &dxcc, Country: "South Africa",
LOTWRcvd: lotw, EQSLRcvd: eqsl,
}
if _, err := a.qso.Add(a.ctx, q); err != nil {
t.Fatalf("add %s: %v", call, err)
}
}
// 20m is LoTW-confirmed, so the ENTITY is confirmed…
add("ZS1AAA", "20m", "FT8", "Y", "Y", 462)
// …but every 15m contact is confirmed only by eQSL, which this operator
// does not count.
add("ZS6GAV", "15m", "FT8", "N", "Y", 462)
add("ZS4AW", "15m", "FT8", "N", "N", 462)
pred := qso.ConfirmSourcesPredicate([]string{"lotw", "card"})
all, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, "")
if err != nil {
t.Fatalf("all ledger: %v", err)
}
conf, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, pred)
if err != nil {
t.Fatalf("confirmed ledger: %v", err)
}
if _, ok := all[462]; !ok {
t.Fatal("the all-QSO ledger has no South Africa at all")
}
if _, ok := all[462].Bands["15m"]; !ok {
t.Error("all-QSO ledger: 15m missing — the dimming test can never fire")
}
if _, ok := all[462].Slots["15m"]["FT8"]; !ok {
t.Error("all-QSO ledger: 15m/FT8 slot missing")
}
c, ok := conf[462]
if !ok {
t.Fatal("confirmed ledger: South Africa missing — the 20m LoTW QSO should put it there")
}
if _, ok := c.Bands["15m"]; ok {
t.Error("confirmed ledger has 15m, but no 15m QSO is confirmed by LoTW or card")
}
// The two halves the UI shows.
status := spotEntityStatus(true, false, true, false, "FT8")
if status != "new-band" {
t.Errorf("status = %q, want new-band", status)
}
_, bAll := all[462].Bands["15m"]
_, mAll := all[462].Modes["FT8"]
_, sAll := all[462].Slots["15m"]["FT8"]
if got := spotEntityStatus(true, bAll, mAll, sAll, "FT8"); got != "worked" {
t.Errorf("all-ledger verdict = %q, want worked (→ the badge should be dimmed)", got)
}
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"context"
"path/filepath"
"testing"
"time"
"hamlog/internal/db"
"hamlog/internal/dxcc"
"hamlog/internal/qso"
"hamlog/internal/settings"
)
// The operator's exact case, end to end through the real verdict path.
//
// South Africa is confirmed (LoTW) on 20m and worked-but-unconfirmed on 15m,
// with the hunt set to "new + unconfirmed" over LoTW and paper QSL. A 15m FT8
// decode must come back NEW BAND — that is what the settings ask — and carry
// the UNCONFIRMED flag, which is what draws the badge as a missing QSL rather
// than a band never worked.
func TestClusterStatusFlagsUnconfirmedBand(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "log.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { conn.Close() })
a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)}
a.settingsScoped.Store(true)
if err := a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed"); err != nil {
t.Fatalf("set chase mode: %v", err)
}
if err := a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card"); err != nil {
t.Fatalf("set chase sources: %v", err)
}
// The real prefix table, so ZS4AW resolves the way it does in the app.
a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data"))
if err := a.dxcc.LoadFromDisk(); err != nil {
t.Skipf("cty.dat not available here: %v", err)
}
za := 462
add := func(call, band, mode, lotw string) {
hz := int64(21074000)
if _, err := a.qso.Add(a.ctx, qso.QSO{
Callsign: call, Band: band, Mode: mode, FreqHz: &hz,
QSODate: time.Now().UTC().Add(-72 * time.Hour),
DXCC: &za, Country: "South Africa", LOTWRcvd: lotw,
}); err != nil {
t.Fatalf("add %s: %v", call, err)
}
}
add("ZS1AAA", "20m", "FT8", "Y") // the entity IS confirmed, elsewhere
add("ZS6GAV", "15m", "FT8", "N") // 15m worked, never confirmed
add("ZS4AW", "15m", "FT8", "N")
got := a.ClusterSpotStatuses([]SpotQuery{{Call: "ZS4AW", Band: "15m", Mode: "FT8"}})
if len(got) != 1 {
t.Fatalf("got %d results", len(got))
}
if got[0].Status != "new-band" {
t.Fatalf("status = %q, want new-band", got[0].Status)
}
if !got[0].UnconfStatus {
t.Error("UnconfStatus is false — the badge draws solid, as if 15m had never been worked")
}
}