Compare commits

..
101 Commits
Author SHA1 Message Date
rouggy be48e58aa6 chore: release v0.16.5 2026-07-05 11:39:30 +02:00
rouggy 565c143536 fix: bug where some clusters would not show any spots 2026-07-05 11:39:11 +02:00
rouggy 2ce25339ab fix: Can now connect to clusters individually 2026-07-05 11:27:50 +02:00
rouggy 121ecae314 fix: Bandmap now follows freq properly and zoom by ddefault is 32px/Khz 2026-07-05 11:23:18 +02:00
rouggy dd0a34dc0a feat: Flexradio/Icom, click on signal to fill the RST tx 2026-07-05 11:12:39 +02:00
rouggy 45b9bcdea7 feat: New tool to remove duplicates in the log 2026-07-05 10:58:49 +02:00
rouggy 2d742be7df feat: Complete translation in French 2026-07-05 10:44:40 +02:00
rouggy 3a6afc28ac feat: Adding French language 2026-07-05 03:07:44 +02:00
rouggy e590a58702 feat: Contest mode added 2026-07-04 22:54:41 +02:00
rouggy dd4b0004a5 feat: icom Scope in Icom Tab 2026-07-04 22:06:26 +02:00
rouggy abbdde9367 feat: CAT Icom working as well as split (IC7610) 2026-07-04 18:43:11 +02:00
rouggy fda21e5a6f fix: Cabrillo contest 2026-07-04 18:06:11 +02:00
rouggy ee9fb51066 chore: release v0.16.4 2026-07-04 11:39:54 +02:00
rouggy 5bbcaab71a feat: added Cabrillo export 2026-07-04 11:39:33 +02:00
rouggy 18438d7737 chore: release v0.16.3 2026-07-04 09:01:55 +02:00
rouggy 97e24ea24f chore: release v0.16.2 2026-07-03 19:09:17 +02:00
rouggy 3e199f9ab6 feat: Cluster alert implemented 2026-07-03 19:08:50 +02:00
rouggy 8740a4ba66 chore: release v0.16.1 2026-07-03 15:31:29 +02:00
rouggy 8ccad7ca65 feat: spots to panadapter for TCI radio 2026-07-03 15:30:53 +02:00
rouggy fa7df57435 chore: release v0.16 2026-07-03 15:13:03 +02:00
rouggy 812e4f05e5 feat: TCI implementation for CAT Control of SunSDR 2026-07-03 15:11:32 +02:00
rouggy 6ec31b61ce fix: bug while creating a new profile 2026-07-02 11:41:53 +02:00
rouggy 93c8f6b9d3 fix: persistence of awards column bug corrected 2026-06-30 11:11:16 +02:00
rouggy 65c22232dd fix: bug on map zoom 2026-06-30 09:39:21 +02:00
rouggy edede0bc1e fix: zoom taking too long after clicking a spot 2026-06-30 09:14:50 +02:00
rouggy 2712902057 chore: release v0.15 2026-06-29 23:27:11 +02:00
rouggy 9281645359 feat: better looking buttons in flexradio panel 2026-06-29 23:21:54 +02:00
rouggy a05dd6b3a9 feat: Added Antenna selection to Flex FlexPanel
feat: New settings flexradio to select antennas per band
2026-06-29 23:07:52 +02:00
rouggy a2401d7fd3 fix: when in autocall cw writing a call stop transmission 2026-06-28 20:55:59 +02:00
rouggy 053b351dab feat: adding choice for recent qso in main tab 2026-06-28 20:49:14 +02:00
rouggy 64f2d38d87 chore: release v0.14.1 2026-06-28 20:39:15 +02:00
rouggy 299184712a fix: beam heading full color 2026-06-28 20:38:59 +02:00
rouggy 76c1e2df60 fix: forcing 3 sec for each notification instead of one replacing the other 2026-06-28 19:12:55 +02:00
rouggy 165f33caa5 fix: recording not being sent if mail enable was not checked 2026-06-28 19:08:32 +02:00
rouggy 464a1c702c feat: added mute and AGC-T to Flex control 2026-06-27 21:03:13 +02:00
rouggy 19c5045dc6 fix: autocall in remote not working properly 2026-06-27 20:28:29 +02:00
rouggy fa09251039 chore: release v0.14 2026-06-25 01:09:37 +02:00
rouggy d6626d96d0 fix: added functionality to net control 2026-06-24 19:09:34 +02:00
rouggy 8b831145ad feat: Added Net control 2026-06-22 23:40:25 +02:00
rouggy 81c60628c6 feat: While recording a QSO the count is clickable to start the recording again 2026-06-22 21:55:21 +02:00
rouggy 678787ec62 fix: app crashed sometimes while converting qso in MP3 2026-06-22 21:51:29 +02:00
rouggy 79dc20a859 fix: Showing beam heading on map even if no call is entered 2026-06-22 21:46:41 +02:00
rouggy 824971d0a1 fix: bug on the antenna genius 2026-06-22 19:48:29 +02:00
rouggy 60bcd2422d fix: small bug for PGXL fan and ultrabeam direction 2026-06-22 17:29:29 +02:00
rouggy 9b0d7ce1dc feat: While importing ADIF, can import default QSL confirmation
as well as My working conditions, locator, owner callsign
2026-06-22 13:03:57 +02:00
rouggy 572e8ca538 chore: release v0.13 2026-06-21 21:57:50 +02:00
rouggy 6ac9783f7c feat: qrz clickable in cw decoder 2026-06-21 21:57:11 +02:00
rouggy 725600c341 fix: bug where contextual menu was disappearing quickly 2026-06-21 20:31:54 +02:00
rouggy 5d9765be09 feat: Support for Power Genius XL 2026-06-21 20:27:53 +02:00
rouggy b302d4d87b feat: Support for Antenna Genius 2026-06-21 20:15:30 +02:00
rouggy 8b7c42ec9b fix: alignement of fields in QSO entry 2026-06-21 15:14:56 +02:00
rouggy cde0add5e0 fix: adjusted fields width in qso entry 2026-06-21 03:01:05 +02:00
rouggy 0e2ef317c3 feat: Added chat when MySQL is in use 2026-06-21 02:30:01 +02:00
rouggy a9f2e515e1 chore: release v0.12 2026-06-20 20:18:28 +02:00
rouggy 260172cd6d feat: Added --profile argument to start OpsLog on specific profile 2026-06-20 19:47:22 +02:00
rouggy 4b5e2e0b72 fix: Binding to Gui Client to get proper values for cw delay and pitch 2026-06-20 19:34:10 +02:00
rouggy 4a6ea45665 fix: bandmap opened or closed is now persistent over restart 2026-06-20 18:59:12 +02:00
rouggy 95d37da3bb feat: While importing ADIF, update MY fields 2026-06-20 15:48:21 +02:00
rouggy e1b3f0faf3 fix: CW decoder was loosing first caracters 2026-06-20 02:38:02 +02:00
rouggy 6379e2cd1f fix: persistence on columns awards 2026-06-20 02:32:32 +02:00
rouggy 2228816057 fix: improve cw decoding with qrm 2026-06-20 02:25:53 +02:00
rouggy 32878c17be fix: bug when autocall for cw keyer is on which was
autocalling no matter which macro now only on CQ
fix: ESC stop transmission but also autocall
2026-06-20 02:05:12 +02:00
rouggy 079d0c32df feat: cw decoder 2026-06-19 17:31:10 +02:00
rouggy 45d081ac0c feat: added colonns for awards in recent qso 2026-06-18 23:20:24 +02:00
rouggy 183db7ac2b fix: Upload to HRDLog 2026-06-18 22:58:00 +02:00
rouggy 4d074de27e fix: batch upload to HRDLog instead of one by one 2026-06-18 19:28:36 +02:00
rouggy 679e8f8d39 fix: added additional selection in recent qso filters 2026-06-18 19:08:38 +02:00
rouggy dd2deee939 feat: added support for eQSL 2026-06-18 14:56:13 +02:00
rouggy cdd71b17c8 feat: implemented HRDLog upload 2026-06-18 14:27:33 +02:00
rouggy e8eedcc1dc fix: updated gitignore 2026-06-18 13:14:26 +02:00
rouggy 3c47366f56 chore: stop tracking personal log.adi 2026-06-18 13:14:16 +02:00
rouggy bd11bb4763 fix: bug where worked filter did not work in cluster 2026-06-18 13:03:13 +02:00
rouggy 40e95e6a16 chore: release v0.11.3 2026-06-18 12:41:54 +02:00
rouggy cc0f9ffc64 fix: download lotw only for current callsign in case
of mixed logs (tm2q & f4bpo in same log)
2026-06-18 12:34:53 +02:00
rouggy e1f1ab4922 fix: bug sending LoTW on close 2026-06-18 12:16:39 +02:00
rouggy b6d991b799 fix: Bug where renaming the main folder did not update db path
settings where the ones of the previous folder.
2026-06-18 11:20:20 +02:00
rouggy 59f1775fcd fix: Updated README 2026-06-18 10:48:23 +02:00
rouggy b2a8b1946f chore: release v0.11.2 2026-06-17 23:15:12 +02:00
rouggy 8b1609f5ce feat: added live status for TM74TFR 2026-06-17 22:10:32 +02:00
rouggy bde1195b34 feat: added FlexRadio support (meters & basic functions) 2026-06-17 18:29:35 +02:00
rouggy abdab22010 feat: added selection of map 4 choices 2026-06-16 21:49:02 +02:00
rouggy 16dc864dbd feat: physical heading for ultrabeam antennas 2026-06-16 21:25:04 +02:00
rouggy 01235624ee chore: release v0.11.1 2026-06-16 21:09:00 +02:00
rouggy a7bbc53c35 feat: Mainview can choose Map, cluster or worked before 2026-06-16 21:04:19 +02:00
rouggy 3d15f20c7f fix: proper beam heading on the map 2026-06-16 20:24:49 +02:00
rouggy e5c6bddb29 fix: /AM not recognizing the country now showing 2026-06-16 20:05:03 +02:00
rouggy 75ee5344a4 chore: release v0.11 2026-06-16 20:00:15 +02:00
rouggy 957182611d feat: check for available updates 2026-06-16 19:52:23 +02:00
rouggy 69d0780bac feat: added versionning & About window 2026-06-16 19:36:56 +02:00
rouggy 33af122964 awards 2026-06-16 09:50:48 +02:00
rouggy 22e3bb4a18 up 2026-06-15 23:45:14 +02:00
rouggy 29fd832bcd up 2026-06-14 01:35:40 +02:00
rouggy 67203cd4a8 up 2026-06-14 00:55:27 +02:00
rouggy 08162fa126 mysql 2026-06-13 21:56:38 +02:00
rouggy 81e505e040 up 2026-06-13 19:14:24 +02:00
rouggy 0b3e22c97e fix 2026-06-13 17:25:48 +02:00
rouggy d3ba7c71f4 fix issues 2026-06-13 16:55:57 +02:00
rouggy 00cab6b204 up 2026-06-13 16:17:58 +02:00
rouggy ff53831be4 up seafile 2026-06-13 10:14:23 +02:00
rouggy 3cb2e466d8 Qsl 2026-06-13 01:34:45 +02:00
rouggy 408b29896c qsl designer 2026-06-11 21:54:35 +02:00
355 changed files with 37642 additions and 53228 deletions
+19 -1
View File
@@ -4,7 +4,25 @@
"Bash(go get *)",
"Bash(go build *)",
"Bash(wails generate *)",
"Bash(npm run *)"
"Bash(npm run *)",
"Bash(gofmt -w app.go internal/winkeyer/winkeyer.go)",
"Bash(timeout 20 git status --short)",
"Bash(echo \"exit: $?\")",
"Bash(GIT_TERMINAL_PROMPT=0 timeout 20 git push --dry-run)",
"Bash(echo \"push exit: $?\")",
"Bash(git config *)",
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
"Bash(which git-credential-manager *)",
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)",
"Bash(cd \"c:/Perso/Seafile/Programmation/Golang/OpsLog/frontend\" && npx tsc --noEmit 2>&1 | grep -v \"npm notice\" | head && cd .. && /c/Users/legre/go/bin/wails build 2>&1 | tail -2 && ls -la --time-style=+%H:%M build/bin/OpsLog.exe)",
"Read(//c/Perso/Seafile/Programmation/Golang/**)",
"Bash(gofmt -w internal/qslcard/*.go)",
"Bash(awk '{print $3}')",
"Bash(xargs grep -n \"CREATE TABLE\\\\|key\\\\|value\")",
"Bash(ls -la .claude/)",
"Bash(cat .claude/settings.local.json)",
"Bash(cat .claude/settings.json)"
]
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"permissions": {
"allow": [
"Bash(go:*)",
"Bash(gofmt:*)",
"Bash(/c/Users/legre/go/bin/wails:*)",
"Bash(wails:*)",
"Bash(npm:*)",
"Bash(npx:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(cat:*)",
"Bash(ls:*)",
"Bash(find:*)",
"Bash(echo:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(awk:*)",
"Bash(sed:*)",
"Bash(sort:*)",
"Bash(uniq:*)",
"Bash(wc:*)",
"Bash(xargs:*)",
"Bash(for f in *)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(curl -sI --max-time 10 https://raw.githubusercontent.com/google/fonts/main/ofl/archivoblack/ArchivoBlack-Regular.ttf)"
]
}
}
+1
View File
@@ -39,6 +39,7 @@ desktop.ini
hamlog.db*
cty.dat
cat.log
*.adi
# --- Secrets ---
.env
+152 -10
View File
@@ -1,16 +1,158 @@
# README
# OpsLog
## About
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
optional **shared MySQL** for the logbook so several operators can run one log.
This is the official Wails Svelte-TS template.
Developed by **F4BPO**.
## Live Development
---
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building / developing
## Building
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
`wails generate module`.
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task*
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
and publishes it to Gitea + GitHub releases.
To build a redistributable, production mode package, use `wails build`.
---
## Logging
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
**flag** is shown large next to the RST fields.
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
and the QRZ.com tab.
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
date overrides.
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
from cty/QRZ/ClubLog, bulk send to QSL services.
- **Profiles:** every setting is per-profile; each profile can point its logbook
at the local SQLite file or a **shared MySQL** database (multi-operator).
## Maps & antenna
- **Main view = two configurable panes** (per profile, Settings → General →
*Main view*): great-circle map, locator (street) map, the cluster grid, the
worked-before grid, or the **FlexRadio controls**.
- **Great-circle map** with short/long-path distance & azimuth, selectable
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
the **antenna beam lobe(s)** drawn from the rotor azimuth.
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
direction is shown in green and the **mechanical boom** in grey, on both the
compass and the map, so you never lose track of where the antenna points.
## DX Cluster
- Multiple cluster servers with auto-reconnect, a master for commands.
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
mode / status / source) shared by the Cluster tab and the Main-view cluster
pane, with a show/hide toggle.
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
rig, and a multi-band **Band Map** (panadapter-style strips).
## CAT control
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
backend over the radio's TCP API — real-time slice freq/mode/split, auto
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
Flex display, click → fill the call).
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
from the frequency.
### FlexRadio control tab (SmartSDR-style)
Shown only when the CAT backend is a FlexRadio:
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
VOX (+ level + delay), monitor (+ level), mic gain.
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
- **Antenna tuner (ATU):** tune / bypass / memories.
- **Amplifier:** PowerGenius XL operate/standby + fault.
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
## Keyers & audio
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
- **Digital Voice Keyer** (DVK) message playback.
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
audio in CW).
## QSL & awards
- **Awards engine:** built-in + custom award definitions (shared **globally**
across profiles), worked/confirmed/validated by band & mode, OR rules and
manual reference assignment, live reference detection on call entry, and a
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
and **confirmation download** (which auto-refreshes the award stats).
- **QSL Card Designer** (see below).
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
password, or a Microsoft app password.)
## Multi-operator live status (special events)
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
Settings → General → *Publish live operator status*. Each OpsLog instance
heartbeats its current activity (operator call, band, frequency, mode) into a
`live_status` table every ~15 s. A small PHP renderer
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
own web server reads that table and produces a live page/image you can embed on
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
only writes to the DB — it is not a web server.
## Other
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
OpsLog startup, skipping any already running.
- **Update check** at startup with a toast (toggleable).
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
version + OS — no callsign or QSO data; opt-out in Preferences).
---
## QSL Card Designer
Tools → *QSL Card Designer…* turns a few photos into a polished eQSL card:
1. Pick 16 photos (jpeg/png). OpsLog analyzes them offline (detail/luminance
grid) and proposes **3 designs** — callsign in the calmest zone of the best
photo, operator name, CQ/ITU zones + locator line, country flag, the other
photos as bordered inserts, and a per-QSO confirmation box.
2. Pick a proposal and fine-tune it: click an element to select, drag to move,
change font / style preset (gel gold, gel silver, classic white outline,
script, flat) and per-preset knobs in the right panel.
3. Save the template (photos are copied into `data/qsl/templates/<id>/`, so the
originals can move). One template can be the default per profile.
Sending: right-click a QSO → *Send eQSL by e-mail*. The card is rendered with
that QSO's data, rasterized to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/`
and sent through the configured SMTP account to the address found by the
QRZ/HamQTH lookup. On success the QSO is stamped `EQSL_SENT=Y` (ADIF). The
e-mail subject/body templates live in the designer
(`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables).
Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
commonly-worked DXCC entities.
---
## Data & storage
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
award lists, QSL templates) always lives in the local SQLite file under
`data/` — instant even when the logbook is on a far-away MySQL.
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
file or a per-profile shared **MySQL** database.
+3134 -347
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"fmt"
"time"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cwdecode"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
// only by the frontend, and only while the entry mode is CW.
//
// Pitch targeting: the single-channel decoder is far more reliable when it locks
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
// instead of auto-searching for the loudest tone. So we follow the radio's CW
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
// back to auto-search otherwise.
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
func (a *App) cwTargetPitch() int {
if a.cwPitchHz > 0 {
return a.cwPitchHz
}
if a.cat != nil {
if st, ok := a.cat.FlexState(); ok && st.Available {
// Only trust the radio's pitch when it's actually in CW.
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
if st.CWPitch > 0 {
return st.CWPitch
}
}
}
}
return 0
}
// StartCWDecoder begins decoding CW from the configured RX audio device. The
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
// call repeatedly; a second call is a no-op while already running.
func (a *App) StartCWDecoder() error {
a.cwMu.Lock()
defer a.cwMu.Unlock()
if a.cwStop != nil {
return nil // already running
}
dev := ""
if a.settings != nil {
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
}
if dev == "" {
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
}
dec := cwdecode.New(audio.SampleRate,
func(text string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:text", text)
}
},
func(st cwdecode.Status) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:status", st)
}
},
)
dec.SetTarget(a.cwTargetPitch())
a.cwDecoder = dec
stop := make(chan struct{})
a.cwStop = stop
go func() {
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
applog.Printf("cw: capture failed: %v", err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
}
}
a.cwMu.Lock()
if a.cwStop == stop {
a.cwStop = nil
a.cwDecoder = nil
}
a.cwMu.Unlock()
}()
// Follow the radio's CW pitch live (every second) while this run is active.
go a.cwFollowPitch(stop, dec)
return nil
}
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
dec.SetTarget(a.cwTargetPitch())
}
}
}
// StopCWDecoder halts the CW decoder if running.
func (a *App) StopCWDecoder() {
a.cwMu.Lock()
stop := a.cwStop
a.cwStop = nil
a.cwDecoder = nil
a.cwMu.Unlock()
if stop != nil {
close(stop)
}
}
// CWDecoderRunning reports whether the decoder is currently capturing.
func (a *App) CWDecoderRunning() bool {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwStop != nil
}
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
// the Flex CW pitch, or search). Applies live to a running decoder.
func (a *App) SetCWDecoderPitch(hz int) {
if hz < 0 {
hz = 0
}
a.cwMu.Lock()
a.cwPitchHz = hz
dec := a.cwDecoder
a.cwMu.Unlock()
if dec != nil {
dec.SetTarget(a.cwTargetPitch())
}
}
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
func (a *App) GetCWDecoderPitch() int {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwPitchHz
}
+693
View File
@@ -0,0 +1,693 @@
package main
// QSL card designer bindings. All logic lives in internal/qslcard — this file
// only adapts it to the Wails boundary (thin methods on *App, same receiver
// as app.go so bindings keep working). Template documents cross the bridge
// as JSON strings: the schema is the single source of truth and the frontend
// mirrors it in qslTypes.ts.
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"hamlog/internal/applog"
"hamlog/internal/dxcc"
"hamlog/internal/email"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// Settings keys for the eQSL e-mail templates (subject/body share the
// {CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} variables of the recording e-mail).
const (
keyQSLEmailSubject = "qsl.email_subject"
keyQSLEmailBody = "qsl.email_body"
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
)
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
const (
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
)
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
func (a *App) qslDir() string { return filepath.Join(a.dataDir, "qsl") }
// QSLTemplateInfo is the template-list row shown in the designer.
type QSLTemplateInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
ProfileID *int64 `json:"profile_id,omitempty"`
IsDefault bool `json:"is_default"`
UpdatedAt string `json:"updated_at"`
}
// QSLFontInfo is one font face offered by the designer, TTF bytes included
// so the frontend can build @font-face rules (and inline them into the SVG
// before rasterization).
type QSLFontInfo struct {
Family string `json:"family"`
Kind string `json:"kind"` // "display" | "script" | "system"
Variable bool `json:"variable"`
DataB64 string `json:"data_b64"`
}
// QSLPresetInfo describes one style preset for the picker.
type QSLPresetInfo struct {
Name string `json:"name"`
Label string `json:"label"`
Params []string `json:"params"` // accepted style_params keys
Defaults qslcard.StyleParams `json:"defaults"`
}
// QSLPickPhotos opens a multi-select dialog for the designer drop zone's
// "browse" path (drag & drop hands the webview File objects without paths,
// so picking through the native dialog is the reliable route to real paths).
func (a *App) QSLPickPhotos() ([]string, error) {
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose card photos (15)",
Filters: []wruntime.FileFilter{
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
},
})
}
// QSLGenerateProposals analyzes the photos and returns 3 distinct template
// documents (JSON) from the automatic placement engine.
func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
if len(photoPaths) == 0 {
return nil, fmt.Errorf("no photos selected")
}
if len(photoPaths) > 5 {
return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
}
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
for _, p := range photoPaths {
ph, err := qslcard.AnalyzePhoto(p)
if err != nil {
return nil, fmt.Errorf("analyze %s: %w", filepath.Base(p), err)
}
photos = append(photos, ph)
}
info, err := a.qslProfileInfo()
if err != nil {
return nil, err
}
var engine qslcard.LayoutEngine = qslcard.HeuristicEngine{}
templates, err := engine.Propose(photos, info)
if err != nil {
return nil, err
}
out := make([]string, len(templates))
for i, t := range templates {
b, err := qslcard.Encode(t)
if err != nil {
return nil, err
}
out[i] = string(b)
}
return out, nil
}
// QSLListTemplates returns all saved templates (defaults first).
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
if a.qslTemplates == nil {
return nil, fmt.Errorf("db not initialized")
}
recs, err := a.qslTemplates.List(a.ctx)
if err != nil {
return nil, err
}
out := make([]QSLTemplateInfo, 0, len(recs))
for _, r := range recs {
out = append(out, QSLTemplateInfo{
ID: r.ID, Name: r.Name, ProfileID: r.ProfileID,
IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"),
})
}
return out, nil
}
// QSLGetTemplate returns a stored template document (JSON).
func (a *App) QSLGetTemplate(id int64) (string, error) {
if a.qslTemplates == nil {
return "", fmt.Errorf("db not initialized")
}
rec, err := a.qslTemplates.Get(a.ctx, id)
if err != nil {
return "", err
}
return rec.JSON, nil
}
// QSLSaveTemplate validates and stores a template. id 0 creates; the photos
// the document references at their original (absolute) paths are copied into
// the template's asset folder so the design survives the user moving files.
// Returns the template id.
func (a *App) QSLSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) {
if a.qslTemplates == nil {
return 0, fmt.Errorf("db not initialized")
}
name = strings.TrimSpace(name)
if name == "" {
return 0, fmt.Errorf("template name required")
}
t, err := qslcard.Parse([]byte(doc))
if err != nil {
return 0, err
}
t.Name = name
if err := qslcard.Validate(t, nil); err != nil {
return 0, err
}
rec := qslcard.Record{ID: id, Name: name}
if forActiveProfile {
if p, err := a.profiles.Active(a.ctx); err == nil {
rec.ProfileID = &p.ID
}
}
// Two-phase save: the row first (a new template needs its id to own an
// asset folder), then photo import + the final document.
rec.JSON = doc
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
return 0, err
}
dir := qslcard.TemplateDir(a.qslDir(), rec.ID)
if err := qslcard.ImportPhotos(&t, dir); err != nil {
return 0, err
}
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
return 0, err
}
final, err := qslcard.Encode(t)
if err != nil {
return 0, err
}
rec.JSON = string(final)
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
return 0, err
}
applog.Printf("qsl: template %q saved (id %d)", name, rec.ID)
return rec.ID, nil
}
// QSLSetDefaultTemplate marks a template as default for its profile scope.
func (a *App) QSLSetDefaultTemplate(id int64) error {
if a.qslTemplates == nil {
return fmt.Errorf("db not initialized")
}
return a.qslTemplates.SetDefault(a.ctx, id)
}
// QSLDeleteTemplate removes a template and its asset folder.
func (a *App) QSLDeleteTemplate(id int64) error {
if a.qslTemplates == nil {
return fmt.Errorf("db not initialized")
}
if err := a.qslTemplates.Delete(a.ctx, id); err != nil {
return err
}
return qslcard.RemoveTemplateDir(a.qslDir(), id)
}
// QSLSavePreview stores the rasterized thumbnail (PNG, base64) the frontend
// renders at save time, shown in the template list.
func (a *App) QSLSavePreview(id int64, pngB64 string) error {
data, err := base64.StdEncoding.DecodeString(pngB64)
if err != nil {
return fmt.Errorf("decode preview: %w", err)
}
dir := qslcard.TemplateDir(a.qslDir(), id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create template dir: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "preview.png"), data, 0o644); err != nil {
return fmt.Errorf("write preview: %w", err)
}
return nil
}
// QSLPreviewDataURL returns the stored thumbnail as a data URL ("" if the
// template has none yet).
func (a *App) QSLPreviewDataURL(id int64) (string, error) {
data, err := os.ReadFile(filepath.Join(qslcard.TemplateDir(a.qslDir(), id), "preview.png"))
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read preview: %w", err)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), nil
}
// QSLPhotoDataURL returns a template photo as a data URL. templateID 0 reads
// a draft photo at its original absolute path (pre-save editing); otherwise
// ref is a name inside the template's asset folder.
func (a *App) QSLPhotoDataURL(templateID int64, ref string) (string, error) {
var path string
switch {
case templateID > 0 && !qslcard.IsDraftPhoto(ref):
path = filepath.Join(qslcard.TemplateDir(a.qslDir(), templateID), filepath.Base(ref))
case qslcard.IsDraftPhoto(ref):
path = ref
default:
return "", fmt.Errorf("photo %q not available without a template id", ref)
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read photo: %w", err)
}
mime := "image/jpeg"
if strings.EqualFold(filepath.Ext(path), ".png") {
mime = "image/png"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// QSLFlagDataURL returns an embedded flag SVG as a data URL ("" = no flag).
func (a *App) QSLFlagDataURL(iso string) (string, error) {
if iso == "" {
return "", nil
}
svg, err := qslcard.FlagSVG(iso)
if err != nil {
return "", err
}
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(svg), nil
}
// QSLFonts returns the embedded designer fonts (plus Cooper Black when the
// machine has it) for @font-face registration.
func (a *App) QSLFonts() ([]QSLFontInfo, error) {
fonts, err := qslcard.Fonts()
if err != nil {
return nil, err
}
out := make([]QSLFontInfo, 0, len(fonts))
for _, f := range fonts {
out = append(out, QSLFontInfo{
Family: f.Family, Kind: f.Kind, Variable: f.Variable,
DataB64: base64.StdEncoding.EncodeToString(f.Data),
})
}
return out, nil
}
// QSLStylePresets lists the built-in style presets for the picker.
func (a *App) QSLStylePresets() []QSLPresetInfo {
out := make([]QSLPresetInfo, 0, len(qslcard.Presets))
for _, p := range qslcard.Presets {
params := make([]string, 0, len(p.AllowedParams))
for k := range p.AllowedParams {
params = append(params, k)
}
sort.Strings(params)
out = append(out, QSLPresetInfo{Name: p.Name, Label: p.Label, Params: params, Defaults: p.Defaults})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// QSLResolvePreview fills a template's placeholders with the active profile
// and a representative sample QSO, for the live editor preview. Returns the
// RenderModel as JSON.
func (a *App) QSLResolvePreview(doc string) (string, error) {
t, err := qslcard.Parse([]byte(doc))
if err != nil {
return "", err
}
vars, country, err := a.qslVars(sampleQSO())
if err != nil {
return "", err
}
return encodeRenderModel(qslcard.Resolve(t, vars, country))
}
// QSLDefaultTemplateID returns the template the eQSL send flow should use
// for the active profile (0 when none exists yet).
func (a *App) QSLDefaultTemplateID() (int64, error) {
if a.qslTemplates == nil {
return 0, fmt.Errorf("db not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return 0, fmt.Errorf("no active profile: %w", err)
}
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
if err != nil {
return 0, nil // no templates yet — the UI offers to open the designer
}
return rec.ID, nil
}
// RenderEQSL loads a QSO and a template and returns the fully-resolved
// render model (JSON) for the frontend to rasterize.
func (a *App) RenderEQSL(qsoID int64, templateID int64) (string, error) {
if a.qso == nil || a.qslTemplates == nil {
return "", fmt.Errorf("db not initialized")
}
q, err := a.qso.GetByID(a.ctx, qsoID)
if err != nil {
return "", err
}
rec, err := a.qslTemplates.Get(a.ctx, templateID)
if err != nil {
return "", fmt.Errorf("template %d: %w", templateID, err)
}
t, err := qslcard.Parse([]byte(rec.JSON))
if err != nil {
return "", err
}
dir := qslcard.TemplateDir(a.qslDir(), templateID)
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
return "", err
}
vars, country, err := a.qslVars(q)
if err != nil {
return "", err
}
return encodeRenderModel(qslcard.Resolve(t, vars, country))
}
// SendEQSL e-mails the rasterized card (JPEG, base64) to the QSO's
// correspondent, archives it in the outbox and stamps eqsl_sent on success.
func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
q, err := a.qso.GetByID(a.ctx, qsoID)
if err != nil {
return err
}
to := strings.TrimSpace(q.Email)
if to == "" {
return fmt.Errorf("no e-mail address for %s — run a QRZ/HamQTH lookup first", q.Callsign)
}
data, err := base64.StdEncoding.DecodeString(jpegB64)
if err != nil {
return fmt.Errorf("decode card image: %w", err)
}
outbox := filepath.Join(a.qslDir(), "outbox")
if err := os.MkdirAll(outbox, 0o755); err != nil {
return fmt.Errorf("create outbox: %w", err)
}
safeCall := strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' {
return '_'
}
return r
}, q.Callsign)
path := filepath.Join(outbox, fmt.Sprintf("%s_%s.jpg", safeCall, q.QSODate.UTC().Format("20060102_1504")))
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write outbox file: %w", err)
}
s, _ := a.GetEmailSettings()
subject, _ := a.settings.Get(a.ctx, keyQSLEmailSubject)
if subject == "" {
subject = defaultQSLEmailSubject
}
body, _ := a.settings.Get(a.ctx, keyQSLEmailBody)
if body == "" {
body = defaultQSLEmailBody
}
if err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), path); err != nil {
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
return err
}
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
// independent. q came straight from GetByID, so a full Update rewrites the
// row unchanged apart from this field.
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("eQSL sent but status not saved: %w", err)
}
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
return nil
}
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
type QSLEmailTemplates struct {
Subject string `json:"subject"`
Body string `json:"body"`
AutoSend bool `json:"auto_send"`
}
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
out := QSLEmailTemplates{Subject: defaultQSLEmailSubject, Body: defaultQSLEmailBody}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
if err != nil {
return out, err
}
if s := m[keyQSLEmailSubject]; s != "" {
out.Subject = s
}
if b := m[keyQSLEmailBody]; b != "" {
out.Body = b
}
out.AutoSend = m[keyQSLAutoSend] == "1"
return out, nil
}
// QSLSaveEmailTemplates persists the eQSL e-mail templates and auto-send flag.
func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil {
return err
}
v := "0"
if t.AutoSend {
v = "1"
}
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
}
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
// user enabled auto-send and the prerequisites hold: a recipient e-mail, a
// default template for the active profile, and the QSO not already confirmed.
// Rasterization happens in the webview, so this only emits an event ("qsl:autosend")
// the frontend acts on — never sends from here. Silent when any condition fails.
func (a *App) maybeAutoSendEQSL(q qso.QSO) {
if a.settings == nil || a.qslTemplates == nil || a.profiles == nil || a.ctx == nil {
return
}
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
return
}
if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" {
return
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return
}
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
if err != nil {
applog.Printf("qsl: auto-send skipped for %s — no template (%v)", q.Callsign, err)
return
}
wruntime.EventsEmit(a.ctx, "qsl:autosend", map[string]any{
"qsoId": q.ID, "templateId": rec.ID, "callsign": q.Callsign,
})
}
// ── helpers ─────────────────────────────────────────────────────────────
func encodeRenderModel(m qslcard.RenderModel) (string, error) {
b, err := json.Marshal(m)
if err != nil {
return "", fmt.Errorf("encode render model: %w", err)
}
return string(b), nil
}
// qslProfileInfo extracts the placement-engine inputs from the active
// profile, filling missing zones from cty.dat.
func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
if a.profiles == nil {
return qslcard.ProfileInfo{}, fmt.Errorf("db not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return qslcard.ProfileInfo{}, fmt.Errorf("no active profile: %w", err)
}
info := qslcard.ProfileInfo{
Callsign: p.Callsign,
Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign)
Grid: p.MyGrid,
Rig: p.MyRig,
Antenna: p.MyAntenna,
}
if p.MyCQZone != nil {
info.CQZone = *p.MyCQZone
}
if p.MyITUZone != nil {
info.ITUZone = *p.MyITUZone
}
if (info.CQZone == 0 || info.ITUZone == 0) && a.dxcc != nil {
if m, ok := a.dxcc.Lookup(p.Callsign); ok {
if info.CQZone == 0 {
info.CQZone = m.CQZone
}
if info.ITUZone == 0 {
info.ITUZone = m.ITUZone
}
}
}
// Most users define rig/antenna in the operating manager (band defaults),
// not the profile's MY_RIG/MY_ANTENNA text fields. Fall back to a band
// default so the designer knows the station line has data to show.
if (info.Rig == "" || info.Antenna == "") && a.operating != nil {
for _, band := range []string{"20m", "40m", "80m", "10m", "2m", "160m"} {
d, _, e := a.operating.BandDefault(a.ctx, p.ID, band)
if e != nil {
continue
}
if info.Rig == "" {
info.Rig = d.StationName
}
if info.Antenna == "" {
info.Antenna = d.AntennaName
}
if info.Rig != "" && info.Antenna != "" {
break
}
}
}
return info, nil
}
// qslRigAntenna resolves the rig and antenna names for a QSO: the values
// stamped on it (MY_RIG/MY_ANTENNA), falling back to the operating manager's
// default for the QSO's band (so the preview and back-entered QSOs aren't
// blank).
func (a *App) qslRigAntenna(q qso.QSO) (rig, ant string) {
rig, ant = q.MyRig, q.MyAntenna
if (rig != "" && ant != "") || a.operating == nil || a.profiles == nil {
return rig, ant
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return rig, ant
}
band := q.Band
if band == "" {
band = "20m"
}
if d, _, e := a.operating.BandDefault(a.ctx, p.ID, band); e == nil {
if rig == "" {
rig = d.StationName
}
if ant == "" {
ant = d.AntennaName
}
}
return rig, ant
}
// qslVars builds the full placeholder map (profile + QSO) and the country
// block resolution for one render.
func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) {
info, err := a.qslProfileInfo()
if err != nil {
return nil, qslcard.CountryInfo{}, err
}
zone := func(z int) string { // unknown zone reads better blank than "0"
if z == 0 {
return ""
}
return strconv.Itoa(z)
}
vars := map[string]string{
"profile.callsign": info.Callsign,
"profile.operator_name": info.Operator,
"profile.grid": info.Grid,
"profile.cq_zone": zone(info.CQZone),
"profile.itu_zone": zone(info.ITUZone),
"profile.rig": info.Rig,
"profile.antenna": info.Antenna,
"qso.callsign": q.Callsign,
"qso.qso_date": q.QSODate.UTC().Format("2006-01-02"),
"qso.time_on": q.QSODate.UTC().Format("15:04"),
"qso.band": q.Band,
"qso.mode": q.Mode,
"qso.submode": q.Submode,
"qso.rst_sent": q.RSTSent,
"qso.qsl_msg": q.QSLMsg,
"qso.name": q.Name,
}
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
if q.FreqHz != nil {
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
}
return vars, a.qslCountryInfo(), nil
}
// qslCountryInfo resolves the operator's own country (label + flag) from the
// active profile: explicit MY_DXCC wins, then a cty.dat lookup of the
// callsign. No match → label only or nothing (graceful).
func (a *App) qslCountryInfo() qslcard.CountryInfo {
p, err := a.profiles.Active(a.ctx)
if err != nil {
return qslcard.CountryInfo{}
}
out := qslcard.CountryInfo{Label: p.MyCountry}
entityNum := 0
if p.MyDXCC != nil {
entityNum = *p.MyDXCC
if out.Label == "" {
out.Label = dxcc.NameForDXCC(entityNum)
}
} else if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
if out.Label == "" {
out.Label = m.Entity.Name
}
entityNum = dxcc.EntityDXCC(m.Entity.Name)
}
}
out.FlagISO = qslcard.FlagISO(entityNum)
return out
}
// sampleQSO is the canned contact used by the editor's live preview.
func sampleQSO() qso.QSO {
return qso.QSO{
Callsign: "DL1ABC",
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
Band: "20m",
Mode: "SSB",
RSTSent: "59",
QSLMsg: "TNX FB QSO — 73!",
}
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"encoding/base64"
"fmt"
"hamlog/internal/applog"
"hamlog/internal/secret"
)
// Secret-vault settings keys (NOT themselves sensitive — they hold the salt and
// an encrypted verifier token, never a password).
const (
keySecretSalt = "secret.salt" // base64 PBKDF2 salt
keySecretVerifier = "secret.verifier" // enc:v1: token, validates the passphrase
)
// sensitiveSettingKeys are the password fields encrypted at rest when the user
// sets a passphrase. Everything else stays plaintext.
var sensitiveSettingKeys = map[string]bool{
keyQRZPassword: true,
keyHQPassword: true,
keyEmailPassword: true,
keyExtClublogPassword: true,
keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtEQSLPassword: true,
}
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
// SecretStatus tells the UI whether a passphrase is configured and unlocked.
type SecretStatus struct {
HasPassphrase bool `json:"has_passphrase"`
Unlocked bool `json:"unlocked"`
}
// GetSecretStatus is polled at launch: if HasPassphrase && !Unlocked the UI
// shows the unlock prompt.
func (a *App) GetSecretStatus() SecretStatus {
if a.settings == nil {
return SecretStatus{}
}
salt, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
return SecretStatus{HasPassphrase: salt != "", Unlocked: a.settings.Unlocked()}
}
// reloadAfterSecretChange rebuilds anything that read passwords at startup while
// they were still locked (lookup providers + external-service config).
func (a *App) reloadAfterSecretChange() {
a.reloadLookupProviders()
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
}
// UnlockSecrets validates the passphrase against the stored verifier and, on
// success, installs the cipher so passwords decrypt for the rest of the session.
func (a *App) UnlockSecrets(passphrase string) error {
if a.settings == nil {
return fmt.Errorf("not initialized")
}
saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
verifier, _ := a.settings.GetRaw(a.ctx, keySecretVerifier)
if saltB64 == "" || verifier == "" {
return fmt.Errorf("no passphrase configured")
}
salt, err := base64.StdEncoding.DecodeString(saltB64)
if err != nil {
return fmt.Errorf("corrupt salt")
}
key, err := secret.DeriveKey(passphrase, salt)
if err != nil {
return fmt.Errorf("derive key: %w", err)
}
c, err := secret.New(key)
if err != nil {
return fmt.Errorf("cipher: %w", err)
}
if !c.CheckVerifier(verifier) {
return fmt.Errorf("wrong passphrase")
}
a.settings.Unlock(c)
a.reloadAfterSecretChange()
return nil
}
// SetPassphrase sets a new passphrase or changes an existing one, then
// re-encrypts every stored secret under the new key. Changing an existing
// passphrase requires the vault to be unlocked first (so current secrets are
// readable as plaintext).
func (a *App) SetPassphrase(passphrase string) error {
if a.settings == nil {
return fmt.Errorf("not initialized")
}
if len(passphrase) < 4 {
return fmt.Errorf("passphrase too short (min 4 characters)")
}
saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt)
if saltB64 != "" && !a.settings.Unlocked() {
return fmt.Errorf("unlock with the current passphrase first")
}
// Snapshot current plaintext (Get decrypts when unlocked, passes through
// when no passphrase yet).
current := map[string]string{}
for k := range sensitiveSettingKeys {
if v, _ := a.settings.Get(a.ctx, k); v != "" {
current[k] = v
}
}
salt, err := secret.NewSalt()
if err != nil {
return fmt.Errorf("salt: %w", err)
}
key, err := secret.DeriveKey(passphrase, salt)
if err != nil {
return fmt.Errorf("derive key: %w", err)
}
c, err := secret.New(key)
if err != nil {
return fmt.Errorf("cipher: %w", err)
}
// Persist salt + verifier first so an interrupted migration is still
// recoverable (the prompt appears next launch; plaintext secrets read
// through transparently until re-encrypted).
if err := a.settings.SetRaw(a.ctx, keySecretSalt, base64.StdEncoding.EncodeToString(salt)); err != nil {
return err
}
if err := a.settings.SetRaw(a.ctx, keySecretVerifier, c.MakeVerifier()); err != nil {
return err
}
a.settings.Unlock(c)
for k, v := range current {
if err := a.settings.Set(a.ctx, k, v); err != nil { // now encrypts
applog.Printf("secret: re-encrypt %q failed: %v", k, err)
}
}
a.reloadAfterSecretChange()
return nil
}
// RemovePassphrase decrypts all secrets back to plaintext and clears the vault.
// Requires the current passphrase (validated) so it can't be cleared blindly.
func (a *App) RemovePassphrase(passphrase string) error {
if a.settings == nil {
return fmt.Errorf("not initialized")
}
if !a.settings.Unlocked() {
if err := a.UnlockSecrets(passphrase); err != nil {
return err
}
}
// Read decrypted, then store back as plaintext and drop salt/verifier.
plain := map[string]string{}
for k := range sensitiveSettingKeys {
if v, _ := a.settings.Get(a.ctx, k); v != "" {
plain[k] = v
}
}
a.settings.Lock()
for k, v := range plain {
if err := a.settings.SetRaw(a.ctx, k, v); err != nil {
applog.Printf("secret: decrypt-on-remove %q failed: %v", k, err)
}
}
_ = a.settings.SetRaw(a.ctx, keySecretSalt, "")
_ = a.settings.SetRaw(a.ctx, keySecretVerifier, "")
a.reloadAfterSecretChange()
return nil
}
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// keyAutostartPrograms holds the per-profile list of external programs OpsLog
// launches on startup (JSON array of AutostartProgram).
const keyAutostartPrograms = "autostart.programs"
// AutostartProgram is one external application OpsLog can launch when it starts
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
type AutostartProgram struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Args string `json:"args"`
Enabled bool `json:"enabled"`
}
// AutostartLaunchResult reports what happened for one program when launching.
type AutostartLaunchResult struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // launched | already_running | missing | disabled | error
Message string `json:"message"`
}
// GetAutostartPrograms returns the active profile's autostart list.
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
out := []AutostartProgram{}
if a.settings == nil {
return out, nil
}
s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
if strings.TrimSpace(s) == "" {
return out, nil
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []AutostartProgram{}, nil
}
return out, nil
}
// SaveAutostartPrograms persists the autostart list for the active profile.
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(progs)
if err != nil {
return err
}
return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
}
// BrowseExecutable opens a native file picker for choosing a program to launch.
func (a *App) BrowseExecutable() (string, error) {
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose a program to launch on startup",
Filters: []wruntime.FileFilter{
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
}
// LaunchAutostartPrograms starts every enabled program that isn't already
// running, returning a per-program result. Used at startup (best effort) and by
// the "Launch now" button in settings.
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
progs, _ := a.GetAutostartPrograms()
running := runningProcessNames()
out := make([]AutostartLaunchResult, 0, len(progs))
for _, p := range progs {
if !p.Enabled {
continue
}
out = append(out, launchProgram(p, running))
}
return out
}
// LaunchAutostartProgram launches a single program by id on demand (the per-row
// "launch now" action), regardless of its enabled flag.
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
progs, err := a.GetAutostartPrograms()
if err != nil {
return AutostartLaunchResult{}, err
}
for _, p := range progs {
if p.ID == id {
return launchProgram(p, runningProcessNames()), nil
}
}
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
}
// launchProgram starts one program unless its executable is already running.
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
path := strings.TrimSpace(p.Path)
if path == "" {
res.Status, res.Message = "error", "no path configured"
return res
}
if _, err := os.Stat(path); err != nil {
res.Status, res.Message = "missing", "executable not found: "+path
return res
}
// Skip if a process with the same executable name is already running, so we
// never spawn a second copy of WSJT-X / JTAlert / etc.
base := strings.ToLower(filepath.Base(path))
if running[base] {
res.Status, res.Message = "already_running", base+" already running"
return res
}
cmd := exec.Command(path, splitArgs(p.Args)...)
cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
if err := cmd.Start(); err != nil {
res.Status, res.Message = "error", err.Error()
return res
}
// Don't wait on the child — it runs independently of OpsLog. Release the
// handle so we don't accumulate zombies.
go func() { _ = cmd.Wait() }()
res.Status, res.Message = "launched", "started "+base
return res
}
// splitArgs does a minimal shell-like split of an argument string, honouring
// double quotes so a path with spaces stays one argument.
func splitArgs(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var args []string
var cur strings.Builder
inQuote := false
for _, r := range s {
switch {
case r == '"':
inQuote = !inQuote
case r == ' ' && !inQuote:
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
// runningProcessNames returns the set of lowercase executable names currently
// running, via the Windows `tasklist`. Best effort — on failure the set is
// empty (we then just attempt to launch, which is acceptable).
func runningProcessNames() map[string]bool {
out := map[string]bool{}
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
data, err := cmd.Output()
if err != nil {
applog.Printf("autostart: tasklist failed: %v", err)
return out
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// CSV row: "image.exe","PID",... — take the first quoted field.
field := line
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
field = line[1 : i+1]
}
field = strings.Trim(field, "\"")
if field != "" {
out[strings.ToLower(field)] = true
}
}
return out
}
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"database/sql"
"fmt"
"strings"
"time"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// Multi-operator chat over the SHARED MySQL logbook. The database is the message
// bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so
// operators on the same shared log (e.g. a special-event call) can talk. No
// extra server. Presence is a lightweight heartbeat into chat_presence. Chat is
// only available on a MySQL logbook (SQLite/solo has no one else to talk to).
const (
chatPollInterval = 3 * time.Second
chatPresenceEvery = 20 * time.Second
chatRetentionDays = 7
chatHistoryDefault = 80
chatPresenceStaleSecs = 120 // a presence row older than this = offline
)
// ChatMessage is one chat line.
type ChatMessage struct {
ID int64 `json:"id"`
Operator string `json:"operator"`
Station string `json:"station"`
Message string `json:"message"`
CreatedAt string `json:"created_at"` // ISO UTC
}
// ChatPresence is one operator currently online (recent heartbeat).
type ChatPresence struct {
Operator string `json:"operator"`
Station string `json:"station"`
AgoSecs int `json:"ago_secs"`
}
// chatActive reports whether chat can run (shared MySQL logbook).
func (a *App) chatActive() bool {
return a.logDb != nil && a.dbBackend == "mysql"
}
// ChatAvailable lets the UI show/hide the chat icon (only on a shared log).
func (a *App) ChatAvailable() bool { return a.chatActive() }
func (a *App) ensureChatTables() error {
if _, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS chat_messages ("+
"id BIGINT AUTO_INCREMENT PRIMARY KEY, "+
"operator VARCHAR(32), station VARCHAR(32), "+
"message TEXT, created_at DATETIME)"); err != nil {
return err
}
_, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS chat_presence ("+
"operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)")
return err
}
// SendChatMessage posts a message to the shared chat and returns the stored row
// (with its id) so the UI can show it immediately; the poll loop dedupes by id.
func (a *App) SendChatMessage(text string) (ChatMessage, error) {
text = strings.TrimSpace(text)
if text == "" {
return ChatMessage{}, nil
}
if len(text) > 1000 {
text = text[:1000]
}
if !a.chatActive() {
return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook")
}
op, station := a.liveStatusOperator()
if op == "" {
return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first")
}
if err := a.ensureChatTables(); err != nil {
return ChatMessage{}, err
}
res, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())",
op, station, text)
if err != nil {
return ChatMessage{}, err
}
id, _ := res.LastInsertId()
return ChatMessage{ID: id, Operator: op, Station: station, Message: text,
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
}
// GetChatHistory returns the most recent messages (oldest first) for the panel.
func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) {
if !a.chatActive() {
return nil, nil
}
if limit <= 0 || limit > 500 {
limit = chatHistoryDefault
}
if err := a.ensureChatTables(); err != nil {
return nil, err
}
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatMessage
for rows.Next() {
var m ChatMessage
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
// Reverse to chronological order (we queried newest-first to honour LIMIT).
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
// GetOnlineOperators lists operators with a recent presence heartbeat.
func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
if !a.chatActive() {
return nil, nil
}
if err := a.ensureChatTables(); err != nil {
return nil, err
}
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+
"FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator",
chatPresenceStaleSecs)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatPresence
for rows.Next() {
var p ChatPresence
if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// chatLoop polls for new messages and heartbeats presence while on a shared
// MySQL logbook. Started once at startup; a cheap no-op otherwise.
func (a *App) chatLoop() {
defer func() { _ = recover() }()
var lastID int64 = -1 // -1 = not yet baselined
var lastDB *sql.DB // logbook the baseline belongs to
lastPresence := time.Time{}
lastPurge := time.Time{}
t := time.NewTicker(chatPollInterval)
defer t.Stop()
for range t.C {
if !a.chatActive() {
lastID = -1 // re-baseline if the backend changes
lastDB = nil
continue
}
// Profile switch swaps the logbook under us: re-baseline against the new
// DB so we don't query it with the previous log's id cursor.
if a.logDb != lastDB {
lastID = -1
lastDB = a.logDb
}
if err := a.ensureChatTables(); err != nil {
continue
}
now := time.Now()
// Presence heartbeat.
if now.Sub(lastPresence) >= chatPresenceEvery {
if op, station := a.liveStatusOperator(); op != "" {
_, _ = a.logDb.ExecContext(a.ctx,
"INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+
"ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()",
op, station)
}
lastPresence = now
}
// Baseline on first run so existing history isn't replayed as "new"
// (the panel loads it via GetChatHistory).
if lastID < 0 {
row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages")
_ = row.Scan(&lastID)
continue
}
// Emit new messages.
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID)
if err != nil {
continue
}
for rows.Next() {
var m ChatMessage
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
continue
}
if m.ID > lastID {
lastID = m.ID
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "chat:message", m)
}
}
rows.Close()
// Purge old messages occasionally (hourly).
if now.Sub(lastPurge) >= time.Hour {
_, err := a.logDb.ExecContext(a.ctx,
"DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays)
if err != nil {
applog.Printf("chat: purge failed: %v", err)
}
lastPurge = now
}
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
/**
* OpsLog multi-operator LIVE STATUS renderer.
*
* Reads the shared `live_status` table that every OpsLog instance heartbeats
* (operator call + station call + freq/band/mode, refreshed every ~15s) and
* shows the operators active in the last 2 minutes.
*
* Put this file on YOUR web server (the one reachable from the internet), point
* it at the SAME MySQL database OpsLog uses for the shared logbook, and embed it
* on the QRZ.com bio of the station call:
*
* <img src="https://your-server/tm74-status.php?img=1"> (image, cached ~min by QRZ)
* or <a href="https://your-server/tm74-status.php">Live operators</a> (real-time page)
*
* QRZ strips <script>/<iframe>, so only an <img> auto-updates the page.
*/
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
$DB_NAME = 'opslog'; // database name
$DB_USER = 'opslog';
$DB_PASS = 'CHANGE_ME';
$STALE_SECONDS = 120; // an operator is "active" if seen within this window
// PHP 8.1+ makes mysqli THROW on errors by default; turn that off so a missing
// `live_status` table (not yet created by OpsLog) just yields an empty list
// instead of a fatal "table doesn't exist".
mysqli_report(MYSQLI_REPORT_OFF);
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
if ($mysqli->connect_errno) {
http_response_code(500);
exit('DB error');
}
// The table is created by OpsLog on first publish; tolerate it not existing yet.
$rows = [];
$sql = "SELECT operator, station, freq_hz, band, mode, updated_at
FROM live_status
WHERE updated_at >= UTC_TIMESTAMP() - INTERVAL ? SECOND
ORDER BY band, freq_hz";
if ($stmt = @$mysqli->prepare($sql)) {
$stmt->bind_param('i', $STALE_SECONDS);
@$stmt->execute();
if ($res = $stmt->get_result()) {
while ($r = $res->fetch_assoc()) $rows[] = $r;
}
}
$station = $rows ? $rows[0]['station'] : 'OpsLog';
$fmtFreq = function ($hz) { return $hz > 0 ? number_format($hz / 1e6, 3) . ' MHz' : '—'; };
// ── Image output (SVG) for the QRZ <img> embed: ?img=1 ──────────────────────
if (isset($_GET['img'])) {
header('Content-Type: image/svg+xml');
header('Cache-Control: no-cache, max-age=30');
$rowH = 26; $h = 44 + max(1, count($rows)) * $rowH; $w = 440;
echo "<svg xmlns='http://www.w3.org/2000/svg' width='$w' height='$h' font-family='Segoe UI,Arial'>";
echo "<rect width='$w' height='$h' rx='8' fill='#0f172a'/>";
echo "<text x='14' y='26' fill='#38bdf8' font-size='15' font-weight='bold'>" . htmlspecialchars($station) . " — live operators</text>";
$y = 44 + 18;
if (!$rows) {
echo "<text x='14' y='$y' fill='#94a3b8' font-size='13'>No operator active right now.</text>";
}
foreach ($rows as $r) {
$line = sprintf('%-10s %-4s %-9s %s', $r['operator'], $r['band'], $r['mode'], $fmtFreq($r['freq_hz']));
echo "<text x='14' y='$y' fill='#e2e8f0' font-size='13' font-family='monospace'>" . htmlspecialchars($line) . "</text>";
$y += $rowH;
}
echo "</svg>";
exit;
}
// ── HTML page (real-time when opened directly; auto-refreshes every 20s) ─────
header('Content-Type: text/html; charset=utf-8');
?><!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta http-equiv="refresh" content="20">
<title><?= htmlspecialchars($station) ?> — live operators</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:16px; }
h1 { color:#38bdf8; font-size:18px; margin:0 0 12px; }
table { border-collapse:collapse; width:100%; max-width:560px; }
th,td { text-align:left; padding:6px 12px; border-bottom:1px solid #1e293b; font-size:14px; }
th { color:#94a3b8; text-transform:uppercase; font-size:11px; letter-spacing:.05em; }
td.mono { font-family:monospace; }
.none { color:#94a3b8; }
</style></head><body>
<h1><?= htmlspecialchars($station) ?> — operators active now</h1>
<?php if (!$rows): ?>
<p class="none">No operator active right now.</p>
<?php else: ?>
<table>
<tr><th>Operator</th><th>Band</th><th>Mode</th><th>Frequency</th></tr>
<?php foreach ($rows as $r): ?>
<tr>
<td><strong><?= htmlspecialchars($r['operator']) ?></strong></td>
<td><?= htmlspecialchars($r['band']) ?></td>
<td><?= htmlspecialchars($r['mode']) ?></td>
<td class="mono"><?= $fmtFreq($r['freq_hz']) ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</body></html>
+1603 -400
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -4,6 +4,7 @@ import { ADIFFields } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Combobox } from '@/components/ui/combobox';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
type FieldDef = {
name: string; kind: string; category: string;
@@ -21,6 +22,7 @@ interface Props {
// ADIF 3.1.7 field (plus custom/vendor tags) can be viewed and edited. This is
// what makes "100% of ADIF fields available" true without 160 DB columns.
export function AdifExtrasEditor({ value, onChange }: Props) {
const { t } = useI18n();
const [dict, setDict] = useState<FieldDef[]>([]);
const [showDeprecated, setShowDeprecated] = useState(false);
@@ -68,9 +70,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it,
or type a custom/vendor tag (e.g. <code className="bg-muted px-1 rounded font-mono">APP_*</code>).
Stored losslessly and exported in the <strong>full</strong> ADIF mode.
{t('adx.introPart1')}<code className="bg-muted px-1 rounded font-mono">APP_*</code>{t('adx.introPart2')}<strong>{t('adx.fullMode')}</strong>{t('adx.introPart3')}
</p>
{/* Add a field */}
@@ -79,21 +79,21 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
<Combobox
value=""
options={addable}
placeholder="Add ADIF field…"
placeholder={t('adx.addFieldPh')}
allowFreeText
onChange={addField}
/>
</div>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
Show deprecated
{t('adx.showDeprecated')}
</label>
</div>
{/* Current entries */}
{entries.length === 0 ? (
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
No extra ADIF fields. Use the picker above to add one.
{t('adx.noExtra')}
</div>
) : (
<div className="space-y-1.5">
@@ -105,11 +105,11 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
<span className="font-mono text-xs font-semibold">{k}</span>
{def && (
<span className="block text-[10px] text-muted-foreground leading-tight">
{def.category}{def.deprecated ? ' · deprecated' : ''}{def.intl ? ' · intl' : ''}
{def.category}{def.deprecated ? ' · ' + t('adx.deprecated') : ''}{def.intl ? ' · ' + t('adx.intl') : ''}
{!def && ''}
</span>
)}
{!def && <span className="block text-[10px] text-amber-600 leading-tight">non-standard</span>}
{!def && <span className="block text-[10px] text-amber-600 leading-tight">{t('adx.nonStandard')}</span>}
</div>
<Input
className="flex-1 h-8 text-xs font-mono"
@@ -118,7 +118,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
/>
<Button
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => remove(k)} title="Remove field"
onClick={() => remove(k)} title={t('adx.removeField')}
>
<X className="size-3.5" />
</Button>
+259
View File
@@ -0,0 +1,259 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import {
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
} from '@/../wailsjs/go/main/App';
import { alerts } from '@/../wailsjs/go/models';
type Rule = alerts.Rule;
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
function emptyRule(): Rule {
return alerts.Rule.createFrom({
id: '', name: 'New alert', enabled: true,
calls: [], countries: [], continents: [], bands: [], modes: [],
spotter_call: '', spotter_continents: [], spotter_countries: [],
sound: true, visual: true, email: false, again_after_min: 0, skip_worked: false,
});
}
// MultiCheck — a scrollable checkbox list for a set of options with an optional
// search box (used for the long country list). Empty selection = ALL.
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
}) {
const { t } = useI18n();
const [q, setQ] = useState('');
const shown = useMemo(
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
[options, q],
);
const sel = new Set((selected ?? []).map((s) => s.toLowerCase()));
return (
<div className="rounded-md border border-border">
{searchable && (
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
<Search className="size-3 text-muted-foreground" />
<input className="flex-1 bg-transparent text-xs outline-none" placeholder={t('altm.filterPh')} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
)}
<div className={cn('overflow-y-auto p-1', height)}>
{shown.map((o) => (
<label key={o} className="flex items-center gap-2 text-xs px-1.5 py-0.5 rounded hover:bg-accent/40 cursor-pointer">
<Checkbox checked={sel.has(o.toLowerCase())} onCheckedChange={() => onToggle(o)} />
<span className="truncate">{o}</span>
</label>
))}
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">{t('altm.noMatch')}</div>}
</div>
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
{(selected?.length ?? 0) === 0 ? t('altm.noneAll') : t('altm.nSelected', { n: selected!.length })}
</div>
</div>
);
}
export function AlertsModal({ onClose, bands, modes, countries }: {
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
}) {
const { t } = useI18n();
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<Rule | null>(null);
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
const [emailTo, setEmailTo] = useState('');
const [err, setErr] = useState('');
const refresh = useCallback(async () => {
try { setRules(((await ListAlertRules()) ?? []) as Rule[]); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}, []);
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
if (!d) return d;
const cur = ((d as any)[key] as string[]) ?? [];
const has = cur.some((x) => x.toLowerCase() === v.toLowerCase());
const next = has ? cur.filter((x) => x.toLowerCase() !== v.toLowerCase()) : [...cur, v];
return alerts.Rule.createFrom({ ...d, [key]: next });
});
async function save() {
if (!draft) return;
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function del() {
if (!draft) return;
if (!draft.id) { setDraft(null); return; }
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
<DialogDescription>{t('altm.desc')}</DialogDescription>
</DialogHeader>
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
{/* Rule list */}
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
</div>
<div className="flex-1 overflow-y-auto p-1">
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
{rules.map((r) => (
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
<span className="truncate flex-1">{r.name}</span>
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
</button>
))}
</div>
<div className="px-2 py-1.5 border-t border-border/60">
<Label className="text-[10px] text-muted-foreground">{t('altm.emailTo')}</Label>
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
onChange={(e) => setEmailTo(e.target.value)}
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
</div>
</div>
{/* Editor */}
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
{!draft ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">{t('altm.selectOrCreate')}</div>
) : (
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-2 pt-2">
<TabsList>
<TabsTrigger value="def">{t('altm.tabDef')}</TabsTrigger>
<TabsTrigger value="call">{t('altm.tabCall')}</TabsTrigger>
<TabsTrigger value="bm">{t('altm.tabBandMode')}</TabsTrigger>
<TabsTrigger value="orig">{t('altm.tabOrigin')}</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
<TabsContent value="def" className="mt-0 space-y-3">
<div>
<Label className="text-xs">{t('altm.ruleName')}</Label>
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> {t('altm.alertEnabled')}
</label>
<div className="flex items-center gap-2">
<Label className="text-xs w-40 shrink-0">{t('altm.againAfter')}</Label>
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
<span className="text-[11px] text-muted-foreground">{t('altm.againHint')}</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-xs font-semibold">{t('altm.actions')}</Label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> {t('altm.visual')}</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> {t('altm.sound')}</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> {t('altm.email')}</label>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> {t('altm.skipWorked')}
</label>
</TabsContent>
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">{t('altm.callsigns')}</Label>
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
placeholder={'DL1ABC\nIW3*\n*/P'}
value={(draft.calls ?? []).join('\n')}
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">{t('altm.countries')}</Label>
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">{t('altm.continents')}</Label>
<div className="flex gap-3 flex-wrap">
{CONTINENTS.map((c) => (
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={(draft.continents ?? []).includes(c)} onCheckedChange={() => toggleIn('continents', c)} /> {c}
</label>
))}
</div>
</div>
</TabsContent>
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">{t('altm.bands')}</Label>
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
</div>
<div className="space-y-1">
<Label className="text-xs">{t('altm.modes')}</Label>
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
</div>
</TabsContent>
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">{t('altm.spotterCall')}</Label>
<Input className="font-mono" placeholder={t('altm.spotterCallPh')} value={draft.spotter_call ?? ''}
onChange={(e) => patch({ spotter_call: e.target.value })} />
<Label className="text-xs mt-2 block">{t('altm.spotterContinents')}</Label>
<div className="flex gap-3 flex-wrap">
{CONTINENTS.map((c) => (
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={(draft.spotter_continents ?? []).includes(c)} onCheckedChange={() => toggleIn('spotter_continents', c)} /> {c}
</label>
))}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('altm.spotterCountries')}</Label>
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
</div>
</TabsContent>
</div>
{/* Editor actions */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
</div>
</Tabs>
)}
</div>
</div>
<div className="flex justify-end px-5 pb-4">
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> {t('altm.close')}</Button>
</div>
</DialogContent>
</Dialog>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { Antenna, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type AGAntenna = { index: number; name: string };
export type AGStatus = {
connected: boolean; host?: string; last_error?: string;
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
antennas: AGAntenna[];
};
// Format an antenna name: first letter uppercase, the rest lowercase
// (e.g. "DX COMMANDER" → "Dx commander").
function pretty(name: string): string {
const t = name.trim();
if (!t) return t;
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
}
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
// match the app's light theme with soft gradients + glows. Each antenna row has
// a port-A button (left) and port-B button (right). Colours: green = selected on
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
// Clicking an already-selected port deselects it (port → None).
export function AntGeniusPanel({ status, onActivate, onClose }: {
status: AGStatus;
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
onClose: () => void;
}) {
const { t } = useI18n();
const list = status.antennas ?? [];
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
};
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Antenna className={cn('size-4', status.connected ? 'text-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
<span className="flex-1" />
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
</span>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}>
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{!status.connected ? (
<div className="text-center py-6 text-xs space-y-2">
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
</div>
) : list.length === 0 ? (
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
) : list.map((a) => {
const aActive = status.port_a === a.index;
const bActive = status.port_b === a.index;
const aTx = aActive && !!status.tx_a;
const bTx = bActive && !!status.tx_b;
const nameCls = (aTx || bTx)
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]'
: aActive
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]'
: bActive
? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]'
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
return (
<div key={a.index} className="flex items-center gap-1.5">
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
{pretty(a.name)}
</div>
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
</div>
);
})}
</div>
</div>
);
}
+122 -72
View File
@@ -10,6 +10,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import {
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
GetAwardReferenceMeta, UpdateAwardReferenceList,
@@ -31,11 +32,17 @@ export type AwardDef = {
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
total: number; builtin?: boolean;
};
type AwardOrRule = {
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
leading_str?: string; trailing_str?: string; prefix?: string;
};
type AwardRef = {
code: string; name: string; dxcc: number; group: string; subgrp: string;
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
@@ -96,6 +103,7 @@ function Field2({ label, children }: { label: string; children: React.ReactNode
// DxccFilter — pick the entities an award is scoped to by country name (like the
// QSO editor), resolving each to its ADIF DXCC number. Stored as number[].
function DxccFilter({ value, onChange, countries }: { value: number[]; onChange: (v: number[]) => void; countries: string[] }) {
const { t } = useI18n();
const [names, setNames] = useState<Record<number, string>>({});
useEffect(() => {
let live = true;
@@ -118,7 +126,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
}
return (
<div className="space-y-1.5">
<Combobox value="" options={countries} placeholder="Add country…" onChange={addCountry} className="h-8 w-full" />
<Combobox value="" options={countries} placeholder={t('awed.addCountry')} onChange={addCountry} className="h-8 w-full" />
{value.length > 0 && (
<div className="flex flex-wrap gap-1">
{value.map((n) => (
@@ -135,6 +143,7 @@ function DxccFilter({ value, onChange, countries }: { value: number[]; onChange:
}
export function AwardEditor({ open, onClose, onSaved }: Props) {
const { t } = useI18n();
const [defs, setDefs] = useState<AwardDef[]>([]);
const [fields, setFields] = useState<string[]>([]);
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
@@ -162,7 +171,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (!open) return;
setErr('');
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.catch((e) => setErr(String(e?.message ?? e)));
loadMeta();
}, [open]);
@@ -202,7 +211,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
setErr('');
try {
const p = await ExportAwards();
if (p) setErr(`Awards exported to:\n${p}`);
if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Import an award bundle: definitions are upserted by code, reference lists
@@ -215,7 +224,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
setDefs((d ?? []) as any); setSel(0);
onSaved();
setErr(`Imported ${r.awards} award(s) and ${r.references} reference(s).`);
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function updateList(code: string) {
@@ -238,7 +247,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-5 py-3 border-b">
<DialogTitle>Award management</DialogTitle>
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
@@ -247,7 +256,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input className="h-7 pl-7 text-xs" placeholder="Search awards…" value={search} onChange={(e) => setSearch(e.target.value)} />
<Input className="h-7 pl-7 text-xs" placeholder={t('awed.searchAwards')} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
<div className="flex-1 overflow-auto">
@@ -262,22 +271,22 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
))}
</div>
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
<Plus className="size-3.5 mr-1" /> New award
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
</Button>
</div>
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden">
{err && <div onClick={() => setErr('')} title="Click to dismiss" className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : (
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
<TabsList className="px-3 justify-start">
<TabsTrigger value="info">Award info</TabsTrigger>
<TabsTrigger value="type">Award type</TabsTrigger>
<TabsTrigger value="conf">Confirmation</TabsTrigger>
<TabsTrigger value="refs">References</TabsTrigger>
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-auto p-4">
@@ -285,29 +294,29 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<TabsContent value="info" className="mt-0 space-y-2.5">
<div className="flex items-center gap-2">
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder="Award name" />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> Valid</label>
<button className="text-muted-foreground hover:text-destructive" title="Delete award" onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
</div>
<Field2 label="Description"><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
<Field2 label="Award URL"><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
<Field2 label="Reference URL"><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label="Valid from"><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
<Field2 label="Valid to"><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<Label className="text-xs text-muted-foreground pt-1.5">DXCC filter</Label>
<Label className="text-xs text-muted-foreground pt-1.5">{t('awed.dxccFilter')}</Label>
<DxccFilter value={cur.dxcc_filter ?? []} onChange={(v) => patch({ dxcc_filter: v })} countries={countries} />
</div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid bands (empty = all)</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Emission (empty = all)</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid modes (empty = all)</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.validBands')}</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.emission')}</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">{t('awed.validModes')}</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
</TabsContent>
{/* ── Award type ── */}
<TabsContent value="type" className="mt-0 space-y-2.5">
<Field2 label="Award type">
<Field2 label={t('awed.awardType')}>
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
<SelectContent>{
@@ -318,18 +327,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
}</SelectContent>
</Select>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> Dynamic references (not predefined any value counts, like POTA)</label>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
<div className="border-t pt-2.5 mt-1 space-y-2.5">
<p className="text-[11px] text-muted-foreground">QSO parameters (used by QSOFIELDS / REFERENCE types)</p>
<Field2 label="Search in field">
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
<Field2 label={t('awed.searchInField')}>
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
</Select>
</Field2>
<Field2 label="Match by">
<Field2 label={t('awed.matchBy')}>
<div className="flex items-center gap-3 text-xs">
{['code', 'description', 'pattern'].map((m) => (
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
@@ -338,11 +347,51 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
))}
</div>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> Exact match (else search reference inside the field)</label>
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder="group 1 = reference (for match-by pattern / dynamic)" /></Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
</div>
{/* Additional OR searches: a QSO earns a reference if the
primary rule OR any of these match. */}
<div className="border-t pt-2.5 space-y-2">
<div className="flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
<Button size="sm" variant="outline" className="h-7"
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
<Plus className="size-3.5" /> {t('awed.addOr')}
</Button>
</div>
{(cur.or_rules ?? []).map((r, ri) => {
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
return (
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{t('awed.orSearchIn')}</span>
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
</Select>
<div className="flex items-center gap-2 text-[11px]">
{['code', 'description', 'pattern'].map((m) => (
<label key={m} className="flex items-center gap-1 cursor-pointer">
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
</label>
))}
</div>
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> {t('awed.exact')}</label>
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title={t('awed.removeOrSearch')}><Trash2 className="size-4" /></button>
</div>
<div className="grid grid-cols-[1fr_120px] gap-2">
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder={t('awed.orPatternPlaceholder')} />
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} />
</div>
</div>
);
})}
</div>
</div>
</TabsContent>
@@ -351,20 +400,20 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<TabsContent value="conf" className="mt-0 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Confirmation (worked confirmed)</Label>
<Label className="text-xs font-semibold">{t('awed.confirmationLabel')}</Label>
{CONFIRM_SRC.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleIn('confirm', c.id)} /> {c.label}</label>
))}
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Validation (confirmed validated)</Label>
<Label className="text-xs font-semibold">{t('awed.validationLabel')}</Label>
{CONFIRM_SRC.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.validate ?? []).includes(c.id)} onCheckedChange={() => toggleIn('validate', c.id)} /> {c.label}</label>
))}
</div>
</div>
<Field2 label="Grant codes"><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> Export award in ADIF credit_granted field</label>
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
</TabsContent>
{/* ── References ── */}
@@ -382,16 +431,16 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div>
<DialogFooter className="px-5 py-3 border-t !flex-row">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
<Button variant="outline" onClick={exportAwards} title="Export all award definitions + reference lists to a JSON backup">
<Download className="size-3.5 mr-1" /> Export
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
<Download className="size-3.5 mr-1" /> {t('awed.export')}
</Button>
<Button variant="outline" onClick={importAwards} title="Import an award bundle (definitions + reference lists)">
<Upload className="size-3.5 mr-1" /> Import
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
</Button>
<div className="flex-1" />
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -405,6 +454,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
code: string; presets: Preset[]; meta?: RefMeta;
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
}) {
const { t } = useI18n();
const [refs, setRefs] = useState<AwardRef[]>([]);
const [q, setQ] = useState('');
const [selCode, setSelCode] = useState<string | null>(null);
@@ -440,7 +490,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
}, [code, q, large]);
async function populateBuiltin() {
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(t('awed.populatedMsg', { n })); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
@@ -459,7 +509,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function addRef() {
const c = prompt('New reference code:')?.trim().toUpperCase();
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
if (!c) return;
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
await saveRef(r); setSelCode(c);
@@ -474,7 +524,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function importBulk() {
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(`Imported ${n} references.`); }
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(t('awed.importedRefsMsg', { n })); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
@@ -482,46 +532,46 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<div className="space-y-2">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
<span className="text-xs text-muted-foreground">{t('awed.referenceCount')} <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
<div className="flex-1" />
<Select value="" onValueChange={applyPreset}>
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder={t('awed.applyPreset')} /></SelectTrigger>
<SelectContent>{presets.map((p) => <SelectItem key={p.key} value={p.key}>{p.name}</SelectItem>)}</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>Paste / CSV</Button>
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>{t('awed.pasteCsv')}</Button>
{hasBuiltin && (
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title="Replace with the shipped built-in list (DXCC entities, French departments, …)">
Populate built-in
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title={t('awed.populateBuiltinTitle')}>
{t('awed.populateBuiltin')}
</Button>
)}
{meta?.can_update && (
<Button variant="outline" size="sm" className="h-7" disabled={updating} onClick={onUpdateOnline}>
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} Update online
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} {t('awed.updateOnline')}
</Button>
)}
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> Add</Button>
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> {t('awed.add')}</Button>
</div>
{showBulk && (
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
<p className="text-[11px] text-muted-foreground">One reference per line: <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> (comma/semicolon/tab). Replaces the whole list.</p>
<p className="text-[11px] text-muted-foreground">{t('awed.onePerLine')} <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> {t('awed.replacesList')}</p>
<Textarea rows={6} className="font-mono text-xs" value={bulk} onChange={(e) => setBulk(e.target.value)} placeholder={'ON,Ontario,,,1\nQC,Quebec,,,1'} />
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>Cancel</Button><Button size="sm" className="h-7" onClick={importBulk}>Import</Button></div>
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>{t('awed.cancel')}</Button><Button size="sm" className="h-7" onClick={importBulk}>{t('awed.import2')}</Button></div>
</div>
)}
<div className="grid grid-cols-[200px_1fr] gap-3">
{/* List */}
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder={t('awed.search')} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="flex-1 overflow-auto">
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Searching</div>}
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>}
{!busy && large && q.trim().length < 2 && (
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters).
{t('awed.tooManyItems', { total: total.toLocaleString() })}
</div>
)}
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('awed.noReferences')}</div>}
{filtered.map((r) => (
<button key={r.code} onClick={() => setSelCode(r.code)}
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
@@ -535,37 +585,37 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
{/* Per-reference editor */}
<div className="border rounded p-3">
{!sel ? (
<div className="grid place-items-center h-full text-xs text-muted-foreground">Select a reference, or Add / import a list.</div>
<div className="grid place-items-center h-full text-xs text-muted-foreground">{t('awed.selectReference')}</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> Valid</label>
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
<div className="flex-1" />
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
</div>
<Field2 label="Description"><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label="Group"><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
<Field2 label="Subgroup"><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
</div>
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Score</Label>
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Bonus</Label>
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">Grid</Label>
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
</div>
</div>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
</div>
)}
</div>
+6 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { X, Loader2 } from 'lucide-react';
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
import { useI18n } from '@/lib/i18n';
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
@@ -17,6 +18,7 @@ interface Props {
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
// result assigns it to the QSO.
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
const { t } = useI18n();
const [q, setQ] = useState('');
const [results, setResults] = useState<Ref[]>([]);
const [open, setOpen] = useState(false);
@@ -50,12 +52,12 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
{value ? (
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
<span className="font-mono font-semibold">{value}</span>
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title="Remove"><X className="size-3.5" /></button>
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
</div>
) : (
<input
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={`Search ${label}`}
placeholder={t('awrp.searchLabel', { label })}
value={q}
onFocus={() => setOpen(true)}
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
@@ -63,9 +65,9 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
)}
{open && !value && (
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Searching</div>}
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('awrp.searching')}</div>}
{!busy && results.length === 0 && (
<div className="px-3 py-2 text-muted-foreground">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
<div className="px-3 py-2 text-muted-foreground">{countryOnly && dxcc ? t('awrp.noMatchDxcc') : t('awrp.noMatch')}</div>
)}
{results.map((r) => (
<button
+47 -26
View File
@@ -4,6 +4,7 @@ import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../.
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
@@ -29,9 +30,14 @@ interface Props {
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
// automatically — we surface that so the operator needn't pick it by hand.
fieldValues?: Record<string, string>;
// Height of the selector. Default is a fixed 210px (the QSO editor modal);
// the live entry panel passes "flex-1 min-h-0" so it fills the available
// space instead of overflowing and forcing a scrollbar.
heightClass?: string;
}
export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) {
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
const { t } = useI18n();
const [defs, setDefs] = useState<AwardDef[]>([]);
const [metas, setMetas] = useState<Record<string, Meta>>({});
const [awardCode, setAwardCode] = useState('POTA');
@@ -48,6 +54,18 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
const entries = value ? value.split(';').filter(Boolean) : [];
// When the reference set is cleared (a QSO was logged or the call was reset),
// also blank the picker's own state — the search box and the highlighted /
// selected reference — so the Awards tab starts fresh for the next contact.
useEffect(() => {
if (!value) {
setSelectedEntry(null);
setSelectedRef(null);
setQ('');
setSearchResults([]);
}
}, [value]);
useEffect(() => {
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
.then(([d, m]) => {
@@ -63,13 +81,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
// for a French call but not for others.
const awards = useMemo(() => {
return defs.filter((d) => {
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
// Computed awards (field = dxcc/cqz/…) are derived automatically.
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
const m = metas[String(d.code).toUpperCase()];
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
if (!hasRefs) return false;
const scope = d.dxcc_filter ?? [];
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
// Offer the award even when its reference list isn't loaded yet: a custom
// award (e.g. "Worked All Provinces of China") has no built-in list, so
// requiring one would make it impossible to assign references by hand. The
// operator can pick from a loaded list when present, or add an unlisted
// reference (the right-hand panel). Dynamic / list-backed awards behave as
// before — they just always pass here now.
return true;
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
.sort((a, b) => a.code.localeCompare(b.code));
@@ -169,7 +190,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
}
return (
<div className="flex gap-2 h-[210px]">
<div className={`flex gap-2 ${heightClass}`}>
{/* Left panel */}
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
@@ -189,9 +210,9 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
{/* Group / Sub from selected ref */}
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
<span className="text-muted-foreground text-[11px]">Group</span>
<span className="text-muted-foreground text-[11px]">{t('awrs.group')}</span>
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
<span className="text-muted-foreground text-[11px]">Sub</span>
<span className="text-muted-foreground text-[11px]">{t('awrs.sub')}</span>
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
</div>
@@ -206,7 +227,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
</div>
) : (
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
pick a reference
{t('awrs.pickReference')}
</div>
)}
@@ -217,10 +238,10 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
onClick={() => selectedRef && addRef(selectedRef)}
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="size-3" />Add
<Plus className="size-3" />{t('awrs.add')}
</button>
<span className="text-[11px] text-muted-foreground">
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
{dxcc ? `DXCC #${dxcc}` : t('awrs.enterCallsignFirst')}
</span>
</div>
@@ -229,7 +250,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
{/* Added refs list */}
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
{entries.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic py-0.5">No references added yet</p>
<p className="text-[11px] text-muted-foreground italic py-0.5">{t('awrs.noRefsAdded')}</p>
) : (
entries.map((entry) => (
<div
@@ -254,7 +275,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
{/* Right panel: reference search */}
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
<span className="text-xs font-semibold">References</span>
<span className="text-xs font-semibold">{t('awrs.references')}</span>
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
{autoMatch && (
<button
@@ -266,17 +287,17 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
? 'border-emerald-300 bg-emerald-50 text-emerald-800 cursor-default'
: 'border-emerald-300 bg-emerald-50/60 text-emerald-800 hover:bg-emerald-100'
}`}
title={`The ${selField.toUpperCase()} field is ${autoMatch.code} — this award counts it automatically`}
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
>
{autoAlreadyAdded ? '✓ ' : '+ '}
<span className="font-mono font-semibold">{autoMatch.code}</span>
<span className="text-emerald-700"> from {selField}</span>
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">auto click to add</span>}
<span className="text-emerald-700"> {t('awrs.fromField', { field: selField })}</span>
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">{t('awrs.autoClickToAdd')}</span>}
</button>
)}
<input
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="Search…"
placeholder={t('awrs.search')}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
@@ -287,22 +308,22 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
type="button"
onClick={() => { addRef({ code: q.trim().toUpperCase(), name: '' } as AwardRef); setQ(''); }}
className="text-left rounded border border-dashed border-primary/50 px-1.5 py-1 text-[11px] text-primary hover:bg-primary/10"
title="Add this reference even though it isn't in the list yet (new / unlisted)"
title={t('awrs.addUnlistedTitle')}
>
+ Add <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
<span className="text-[10px] text-muted-foreground"> (unlisted)</span>
{t('awrs.addPrefix')} <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
<span className="text-[10px] text-muted-foreground"> {t('awrs.unlisted')}</span>
</button>
)}
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
{busy && (
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />Searching
<Loader2 className="size-3 animate-spin" />{t('awrs.searching')}
</div>
)}
{/* Too many auto-results → require typed search */}
{!busy && q.length < 2 && tooManyAuto && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
Type 2+ chars to search
{t('awrs.typeToSearch')}
</div>
)}
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
@@ -310,16 +331,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
{isDynamic && !dxcc
? 'Enter a callsign, or type to search.'
: 'No references for this entity.'}
? t('awrs.enterCallsignOrSearch')
: t('awrs.noRefsForEntity')}
</div>
)}
{/* Typed search, no results */}
{!busy && q.length >= 2 && results.length === 0 && (
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
No results.
{t('awrs.noResults')}
<br />
<span className="text-[10px]">Download reference lists in the Awards panel Import data.</span>
<span className="text-[10px]">{t('awrs.downloadLists')}</span>
</div>
)}
{results.map((r) => (
+265 -93
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor';
import { useI18n } from '@/lib/i18n';
type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
@@ -57,9 +59,10 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
);
}
type AwardListItem = { code: string; name: string; valid?: boolean };
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const { t } = useI18n();
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
@@ -75,6 +78,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
const [showMissing, setShowMissing] = useState(false);
const [stats, setStats] = useState<AwardStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Bumped by Rescan to force the stats matrix to re-fetch (the selected award
// didn't change, but the backend snapshot did).
const [rescanTick, setRescanTick] = useState(0);
// Lazily fetch the statistics matrix when the Stats view is shown.
useEffect(() => {
@@ -84,7 +90,24 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
.then((s) => setStats(s as any))
.catch(() => setStats(null))
.finally(() => setStatsLoading(false));
}, [view, selected]);
}, [view, selected, rescanTick]);
// Rescan: drop the backend snapshot (so confirmations from a fresh LoTW/QRZ
// download are picked up) and the cached results, then recompute everything.
async function rescan() {
if (!selected) return;
setLoading(true);
try {
await RescanAwards();
setByCode({});
setRescanTick((t) => t + 1);
await compute(selected, true);
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
}
// Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute(code: string, force = false) {
@@ -108,7 +131,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
try {
const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid }))
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
.sort((a, b) => a.code.localeCompare(b.code));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
@@ -121,6 +144,53 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
const current = byCode[selected];
// Bands relevant to the selected award, used by BOTH the grid and the stats
// matrix so neither is padded with bands the award doesn't use. Rule: the
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
// the bands the operator actually has contacts on (so DXCC, which has no band
// restriction, shows 160m6m instead of empty VHF/UHF columns). Empty set =
// no basis yet → callers fall back to all bands.
const awardBands = useMemo(() => {
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
if (vb.length > 0) return new Set(vb);
const worked = (current?.bands ?? [])
.filter((b) => (b.worked ?? 0) > 0)
.map((b) => String(b.band).toLowerCase());
return new Set(worked);
}, [awardList, selected, current]);
const gridBands = useMemo(() => {
if (awardBands.size === 0) return GRID_BANDS;
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
return filtered.length ? filtered : GRID_BANDS;
}, [awardBands]);
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
// shows only when the award has no emission restriction or lists that emission.
const statsRows = useMemo(() => {
if (!stats) return [];
const em = new Set((awardList.find((a) => a.code === selected)?.emission ?? []).map((e) => e.toUpperCase()));
if (em.size === 0) return stats.rows;
return stats.rows.filter((r) => {
const m = String(r.label).toUpperCase().match(/\b(CW|DIGITAL|PHONE)$/);
return !m || em.has(m[1]);
});
}, [stats, awardList, selected]);
// Indices into stats.bands to display (and to slice each row's cells by), same
// band basis as the grid.
const statsBandIdx = useMemo(() => {
if (!stats) return [] as number[];
const all = stats.bands.map((_, i) => i);
if (awardBands.size === 0) return all;
const idx = stats.bands
.map((b, i) => ({ b: b.toLowerCase(), i }))
.filter((x) => awardBands.has(x.b))
.map((x) => x.i);
return idx.length ? idx : all;
}, [stats, awardBands]);
const filteredRefs = useMemo(() => {
if (!current) return [];
const q = refSearch.trim().toUpperCase();
@@ -139,15 +209,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<AwardIcon className="size-4 text-primary" />
<span className="text-sm font-semibold">Awards</span>
<span className="text-sm font-semibold">{t('awp.awards')}</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title={t('awp.editAwards')}>
<Pencil className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
title="Rescan all QSOs and recompute this award">
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
title={t('awp.rescanTitle')}>
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
Rescan
{t('awp.rescan')}
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
@@ -155,7 +225,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{awardList.length > 0 && (
<div className="px-2 py-2 border-b border-border/40">
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select an award…" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder={t('awp.selectAward')} /></SelectTrigger>
<SelectContent className="max-h-80">
{awardList.map((a) => (
<SelectItem key={a.code} value={a.code} className="text-xs">
@@ -187,7 +257,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<span className="text-[11px] font-mono text-muted-foreground">
<span className="text-emerald-600">{r.confirmed}</span>
/<span className="text-foreground">{r.worked}</span>
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
</span>
) : (
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
@@ -205,7 +275,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="flex-1 flex flex-col min-h-0">
{!current ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
{loading ? 'Computing' : 'No data'}
{loading ? t('awp.computing') : t('awp.noData')}
</div>
) : (
<>
@@ -215,11 +285,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<span className="text-sm text-muted-foreground">{current.name}</span>
</div>
<div className="mt-1 flex items-center gap-4 text-sm">
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
{current.total > 0 && (
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
)}
</div>
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
@@ -228,10 +298,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{/* Band breakdown */}
{(current.bands ?? []).length > 0 && (
<div className="px-4 py-2 border-b border-border/60">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">{t('awp.byBand')}</div>
<div className="flex flex-wrap gap-1.5">
{(current.bands ?? []).map((b) => (
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
<span className="font-mono font-semibold">{b.band}</span>{' '}
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
<span className="text-muted-foreground font-mono">/{b.worked}</span>
@@ -245,23 +315,23 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs">
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
<button
onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-[11px] text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
title={t('awp.missingRefsTitle')}
>
<AlertTriangle className="size-3" /> Missing refs
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
</button>
<div className="flex-1" />
{/* Legend */}
@@ -271,9 +341,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden">
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title="Grid view" onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title="List view" onClick={() => setView('list')}><List className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.listView')} onClick={() => setView('list')}><List className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.statistics')} onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
</div>
</div>
@@ -281,29 +351,29 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{view === 'stats' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
{statsLoading || !stats ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div>
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.computing')}</div>
) : (
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th>
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)}
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th>
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th>
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
</tr>
</thead>
<tbody>
{stats.rows.map((row, i) => {
{statsRows.map((row, i) => {
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
return (
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap',
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
{row.cells.map((c, j) => (
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
))}
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
); })}
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
</tr>
);
})}
@@ -313,31 +383,31 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div>
) : view === 'grid' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th>
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th>
{GRID_BANDS.map((b) => (
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th>
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
{gridBands.map((b) => (
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
))}
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30">
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td>
{GRID_BANDS.map((b) => {
{gridBands.map((b) => {
const s = cellStatus(r, b);
return (
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-9 h-5" /> : (
{s === 'none' ? <span className="block w-11 h-7" /> : (
<button
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={`${r.ref} · ${b} — click to view QSOs`}
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={t('awp.cellTitle', { ref: r.ref, band: b })}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button>
)}
@@ -351,27 +421,27 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div>
) : (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="w-full text-xs">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1 pr-2 font-medium w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</th>
<th className="py-1 pr-2 font-medium w-40">Group</th>
<th className="py-1 pr-2 font-medium w-24">Status</th>
<th className="py-1 font-medium">Bands</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
<td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70"> missing</span>
: r.validated ? <span className="text-emerald-600">validated</span>
: r.confirmed ? <span className="text-amber-600">confirmed</span>
: <span className="text-stone-500">worked</span>}
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
: r.validated ? <span className="text-emerald-600">{t('awp.validated')}</span>
: r.confirmed ? <span className="text-amber-600">{t('awp.confirmed')}</span>
: <span className="text-stone-500">{t('awp.worked')}</span>}
</td>
<td className="py-1 font-mono text-muted-foreground">
{r.bands.map((b) => (
@@ -401,71 +471,172 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
// MissingQSOModal lists contacts within an award's scope that carry NO
// reference — the silent gaps. Rows open the QSO editor so the operator can add
// the missing reference (e.g. a department for DDFM).
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [sel, setSel] = useState<Set<number>>(new Set());
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
const [assignRef, setAssignRef] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const load = () => {
setLoading(true);
setSel(new Set());
AwardMissingQSOs(code)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [code]);
// Distinct stations vs total contacts (a station may appear on several QSOs).
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
useEffect(() => {
ListAwardReferences(code)
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
.catch(() => setRefs([]));
}, [code]);
const qthOf = (q: any) => String(q.qth || q.notes || '');
const sorted = useMemo(() => {
const dir = sortDir === 'asc' ? 1 : -1;
const val = (q: any): string => {
switch (sortKey) {
case 'qso_date': return String(q.qso_date ?? '');
case 'callsign': return String(q.callsign ?? '').toUpperCase();
case 'band': return String(q.band ?? '');
case 'mode': return String(q.mode ?? '');
case 'country': return String(q.country ?? '').toUpperCase();
case 'qth': return qthOf(q).toUpperCase();
}
};
return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
}, [qsos, sortKey, sortDir]);
const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const setSort = (k: MissingSortKey) => {
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortKey(k); setSortDir('asc'); }
};
async function applyAssign() {
if (!assignRef || sel.size === 0) return;
setBusy(true); setMsg('');
try {
const assignedIds = Array.from(sel);
const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
// Optimistic: the assigned contacts now carry a reference, so drop them
// from the list locally instead of re-running the slow whole-log scan.
const done = new Set(assignedIds);
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
setSel(new Set());
setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
} catch (e: any) {
setMsg(String(e?.message ?? e));
} finally { setBusy(false); }
}
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
<span className="inline-flex items-center gap-0.5">{label}
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</span>
</th>
);
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[760px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-amber-600" />
<span className="font-semibold text-sm">{code} contacts missing a reference</span>
<span className="font-semibold text-sm">{code} {t('awp.contactsMissingRef')}</span>
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
<div className="flex-1" />
<button onClick={load} disabled={loading}
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
title="Recompute now — contacts you've fixed drop off the list">
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
title={t('awp.recomputeTitle')}>
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
</button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
{onEditQSO && ' Click a row to open the QSO and add the reference.'}
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
</div>
{/* Bulk-assign toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
<Select value={assignRef} onValueChange={setAssignRef}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
<SelectContent className="max-h-72">
{refs.map((r) => (
<SelectItem key={r.code} value={r.code}>
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
</Button>
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning</div>
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.scanning')}</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity e.g. DDFM, WAS, RAC, WAJA.)
{t('awp.noGaps')}
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-2 font-medium">Country</th><th className="py-1 pr-3 font-medium">QTH / Note</th></tr>
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
<tr>
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<SortTh k="qso_date" label={t('awp.dateUtc')} className="pl-0" />
<SortTh k="callsign" label={t('awp.callsign')} />
<SortTh k="band" label={t('awp.band')} />
<SortTh k="mode" label={t('awp.mode')} />
<SortTh k="country" label={t('awp.country')} />
<SortTh k="qth" label={t('awp.qthNote')} />
</tr>
</thead>
<tbody>
{qsos.map((q, i) => (
<tr key={q.id ?? i}
className={cn('border-b border-border/30', onEditQSO && 'cursor-pointer hover:bg-accent/40')}
onClick={() => onEditQSO && q.id && onEditQSO(q.id as number)}>
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
<td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[120px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[200px]">{q.qth || q.notes}</td>
</tr>
))}
{sorted.map((q, i) => {
const id = q.id as number;
return (
<tr key={id ?? i}
className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
<td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
</td>
<td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
<td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
<td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
<span className="font-semibold text-foreground">{stations}</span> station{stations > 1 ? 's' : ''} ·{' '}
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
{qsos.length} {t('awp.contactsWithoutRef')}
</div>
</div>
</div>
@@ -474,6 +645,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -497,13 +669,13 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Loading</div>
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.loading')}</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">No QSOs.</div>
<div className="p-4 text-xs text-muted-foreground">{t('awp.noQsos')}</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
<tr><th className="py-1 px-3 font-medium">{t('awp.dateUtc')}</th><th className="py-1 pr-2 font-medium">{t('awp.callsign')}</th><th className="py-1 pr-2 font-medium">{t('awp.band')}</th><th className="py-1 pr-2 font-medium">{t('awp.mode')}</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
</thead>
<tbody>
{qsos.map((q, i) => (
+32 -47
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
// BandMap — vertical spectrum panel inspired by Log4OM.
@@ -79,13 +80,13 @@ function LegendDot({ cls, label }: { cls: string; label: string }) {
// Human-readable label for a spot status — used in the pill hover tooltip
// so the operator can see WHY a spot is coloured the way it is.
function statusLabel(s: string): string {
function statusLabel(s: string, t: (k: string) => string): string {
switch (s) {
case 'new': return 'NEW DXCC (entity never worked)';
case 'new-band': return 'NEW BAND (entity not worked on this band)';
case 'new-slot': return 'NEW SLOT (mode not worked on this band)';
case 'worked': return 'Worked (this band + mode already in log)';
default: return 'Entity not resolved';
case 'new': return t('bmp.statusNew');
case 'new-band': return t('bmp.statusNewBand');
case 'new-slot': return t('bmp.statusNewSlot');
case 'worked': return t('bmp.statusWorked');
default: return t('bmp.statusUnresolved');
}
}
@@ -147,9 +148,10 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) {
const { t } = useI18n();
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
const [zoomIdx, setZoomIdx] = useState(0);
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
const scrollerRef = useRef<HTMLDivElement | null>(null);
const [containerH, setContainerH] = useState(400);
@@ -282,36 +284,19 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;
// Auto-centre on the rig frequency when the map opens or the band changes
// (once per band, so it doesn't fight the user's manual scrolling). Waits
// for the scroller height to be measured and a valid in-band rig freq.
const centeredForRef = useRef<string>('');
// Keep the map centred on the rig frequency: the effect re-runs only when the
// frequency actually changes (or band/zoom/size), so tuning follows the rig
// while a stable frequency leaves your manual scroll alone.
useEffect(() => {
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
const kHz = currentFreqHz / 1000;
if (kHz < lo || kHz > hi) return;
if (centeredForRef.current === band) return;
const el = scrollerRef.current;
if (!el) return;
centeredForRef.current = band;
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
// freqToY is recomputed each render; intentionally excluded from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi]);
// Re-centre on the rig frequency whenever the zoom level changes — zooming
// would otherwise drift away from where you're operating. Skips the initial
// mount (the band-centre effect above handles that).
const firstZoomRef = useRef(true);
useEffect(() => {
if (firstZoomRef.current) { firstZoomRef.current = false; return; }
const el = scrollerRef.current;
if (!el || !range || containerH <= 0) return;
const kHz = currentFreqHz && currentFreqHz / 1000 >= lo && currentFreqHz / 1000 <= hi
? currentFreqHz / 1000 : (lo + hi) / 2;
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomIdx]);
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz]);
useEffect(() => {
const el = scrollerRef.current;
@@ -330,8 +315,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
if (!range) {
return (
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
<div className="text-sm font-semibold mb-1">Band map</div>
Not configured for {band || '—'}.
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
{t('bmp.notConfigured', { band: band || '—' })}
</div>
);
}
@@ -363,35 +348,35 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return (
<div className="h-full w-full flex flex-col min-h-0 bg-card">
<div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex items-center gap-1 shrink-0">
<span className="flex-1">Map · {band}</span>
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={zoomIdx === 0}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title="Zoom out">
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomOut')}>
<Minus className="size-3" />
</button>
<span className="font-mono text-[10px] w-12 text-center">{pxPerKHz}px/kHz</span>
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{pxPerKHz}px/kHz</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title="Zoom in">
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomIn')}>
<Plus className="size-3" />
</button>
<button type="button" onClick={recenterOnRig}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
title="Scroll to current rig frequency">
title={t('bmp.scrollToRig')}>
<Crosshair className="size-3" />
</button>
{onToggleSide && (
<button type="button" onClick={onToggleSide}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
title={side === 'right' ? 'Move band map to the left' : 'Move band map to the right'}>
title={side === 'right' ? t('bmp.moveLeft') : t('bmp.moveRight')}>
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
</button>
)}
{onClose && (
<button type="button" onClick={onClose}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
title="Hide band map">
title={t('bmp.hide')}>
<X className="size-3" />
</button>
)}
@@ -496,7 +481,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
'hover:translate-x-0.5 hover:shadow',
style.pill,
)}
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
>
{/* Status accent strip on the left */}
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
@@ -515,14 +500,14 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
</div>
{/* Colour legend — what each pill colour means. */}
<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-rose-400" label="New DXCC" />
<LegendDot cls="bg-amber-400" label="New band" />
<LegendDot cls="bg-yellow-300" label="New slot (mode)" />
<LegendDot cls="bg-muted-foreground/30" label="Worked" />
<LegendDot cls="bg-rose-400" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-amber-400" label={t('bmp.legendNewBand')} />
<LegendDot cls="bg-yellow-300" label={t('bmp.legendNewSlot')} />
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
</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">
scroll · ctrl+wheel = zoom · = jump to rig
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)</span>}
{t('bmp.footerHint')}
{hidden > 0 && <span className="text-amber-600"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
</div>
</div>
);
+34 -2
View File
@@ -12,6 +12,7 @@ interface Props {
currentBand: string;
currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
}
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -78,7 +79,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Props) {
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
// Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set.
const cols = useMemo(
@@ -100,6 +101,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
return m;
}, [wb]);
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list).
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
// lists every real (band, mode) the entity was worked on.
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
const curMode = (currentMode || '').toUpperCase().trim();
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
// Mutually-exclusive badges, shown only when the entity is worked but this
// exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity.
// New Band = the band is new (the mode was worked on another band).
// New Mode = the mode is new (the band was worked in another mode).
// New Slot = both band and mode already worked — just not together.
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
const newBandMode = slotNew && !bandWorked && !modeWorked;
const newBand = slotNew && !bandWorked && modeWorked;
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
return (
<section
className={cn(
@@ -135,6 +159,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
</>
)}
</span>
{(newBand || newMode || newBandMode || newSlot) && (
<div className="flex flex-wrap items-center gap-1">
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band &amp; Mode</Badge>}
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
</div>
)}
</>
) : busy ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
@@ -181,7 +213,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
</th>
{cols.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = b.tag === currentBand && classCurrent;
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
return (
<td
key={b.tag}
+176
View File
@@ -0,0 +1,176 @@
import { useMemo, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { BulkUpdateField } from '../../wailsjs/go/main/App';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
type FieldKind = 'status' | 'text';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
// the backend BulkUpdateField whitelist.
// label holds an i18n key (resolved with t() at render time).
const FIELDS: FieldDef[] = [
// QSL / upload status
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
// My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
{ id: 'my_city', label: 'bulk.fMyCity', group: 'My station', kind: 'text' },
{ id: 'my_postal_code', label: 'bulk.fMyPostal', group: 'My station', kind: 'text' },
{ id: 'my_country', label: 'bulk.fMyCountry', group: 'My station', kind: 'text' },
{ id: 'my_state', label: 'bulk.fMyState', group: 'My station', kind: 'text', upper: true },
{ id: 'my_cnty', label: 'bulk.fMyCounty', group: 'My station', kind: 'text' },
{ id: 'my_iota', label: 'bulk.fMyIota', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sota_ref', label: 'bulk.fMySota', group: 'My station', kind: 'text', upper: true },
{ id: 'my_pota_ref', label: 'bulk.fMyPota', group: 'My station', kind: 'text', upper: true },
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
// Misc
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
{ id: 'ant', label: 'bulk.fAnt', group: 'Misc', kind: 'text' },
];
// label holds an i18n key.
const STATUS_VALUES: { v: string; label: string }[] = [
{ v: 'Y', label: 'bulk.statusY' },
{ v: 'N', label: 'bulk.statusN' },
{ v: 'R', label: 'bulk.statusR' },
{ v: 'I', label: 'bulk.statusI' },
{ v: '_', label: 'bulk.statusBlank' },
];
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
// Maps the internal group key → its i18n label key.
const GROUP_LABELS: Record<string, string> = {
'QSL / upload': 'bulk.groupQsl',
'My station': 'bulk.groupMyStation',
'Misc': 'bulk.groupMisc',
};
type Props = {
open: boolean;
ids: number[];
onClose: () => void;
onApplied: (n: number) => void;
};
// BulkEditModal sets one QSL/upload status field to a chosen value across the
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
// so they become eligible for upload.
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
const { t } = useI18n();
const [field, setField] = useState('hrdlog_upload');
const [statusValue, setStatusValue] = useState('R');
const [textValue, setTextValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
const isStatus = def.kind === 'status';
const effectiveValue = isStatus
? (statusValue === '_' ? '' : statusValue)
: textValue.trim();
async function apply() {
setBusy(true);
setError('');
try {
const n = await BulkUpdateField(ids as any, field, effectiveValue);
onApplied(Number(n) || 0);
onClose();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('bulk.title')}</DialogTitle>
<DialogDescription>
{t('bulk.desc', { n: ids.length })}
</DialogDescription>
</DialogHeader>
<div className="px-5 py-2 space-y-3">
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
<Label className="text-sm">{t('bulk.fieldLabel')}</Label>
<Select value={field} onValueChange={setField}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{GROUPS.map((g) => (
<div key={g}>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
{FIELDS.filter((f) => f.group === g).map((f) => (
<SelectItem key={f.id} value={f.id}>{t(f.label)}</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
<Label className="text-sm">{t('bulk.valueLabel')}</Label>
{isStatus ? (
<Select value={statusValue} onValueChange={setStatusValue}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs"
value={textValue}
placeholder={t('bulk.clearPlaceholder')}
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
/>
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
</div>
{error && <div className="text-xs text-rose-700">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>{t('bulk.cancel')}</Button>
<Button onClick={apply} disabled={busy || ids.length === 0}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
{t('bulk.applyTo', { n: ids.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+15 -13
View File
@@ -1,5 +1,6 @@
import { Star } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { useI18n } from '@/lib/i18n';
import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView;
@@ -26,6 +27,7 @@ function fmtDateTime(s: any): string {
}
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
const { t } = useI18n();
const hasCall = currentCall.trim() !== '';
const count = wb?.count ?? 0;
const entries = wb?.entries ?? [];
@@ -35,7 +37,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
<div className="flex items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Worked before
{t('chp.workedBefore')}
</span>
{hasCall && (
<span className="font-mono text-sm font-bold text-primary tracking-wider">
@@ -52,31 +54,31 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
{!hasCall ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
Type a callsign to see prior contacts.
{t('chp.typeCallsign')}
</div>
) : busy && count === 0 ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
checking
{t('chp.checking')}
</div>
) : count === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
<Star className="size-6 text-primary fill-current" />
<div className="text-lg font-bold text-primary tracking-wider">NEW</div>
<div>No prior QSO with this callsign.</div>
<div className="text-lg font-bold text-primary tracking-wider">{t('chp.new')}</div>
<div>{t('chp.noPriorQso')}</div>
</div>
) : (
<>
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
{t('chp.first')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
{t('chp.last')}: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
</div>
<div className="flex-1 overflow-y-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Date UTC</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Band</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Mode</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
@@ -96,10 +98,10 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
{e.lotw_rcvd === 'Y' && (
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title="LoTW rcvd">L</span>
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title={t('chp.lotwRcvd')}>L</span>
)}
{e.qsl_rcvd === 'Y' && (
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title="Bureau rcvd">B</span>
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title={t('chp.bureauRcvd')}>B</span>
)}
</td>
</tr>
@@ -108,7 +110,7 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
</table>
{count > entries.length && (
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
+ {count - entries.length} older QSOs
{t('chp.olderQsos', { n: count - entries.length })}
</div>
)}
</div>
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from 'react';
import { MessageSquare, Send, Users, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
export type ChatPresence = { operator: string; station: string; ago_secs: number };
function hhmm(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
// ChatPanel — presentational multi-op chat panel, docked in the aside row next
// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx.
export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
onSend: (text: string) => void; onClose: () => void;
}) {
const { t } = useI18n();
const [text, setText] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const me = (myCall || '').toUpperCase();
useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]);
function send() {
const t = text.trim();
if (!t) return;
setText('');
onSend(t);
}
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
<MessageSquare className="size-4 text-sky-600" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
<span className="flex-1" />
{/* Online count — hover to see who's connected. */}
<div className="relative group">
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
<Users className="size-3.5" />{online.length}
</span>
{online.length > 0 && (
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">{t('chatp.online')}</div>
{online.map((o) => (
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
</div>
))}
</div>
)}
</div>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title={t('chatp.close')}>
<X className="size-3.5" />
</button>
</div>
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
{msgs.length === 0 ? (
<div className="text-muted-foreground italic text-center py-6">{t('chatp.noMessages')}</div>
) : msgs.map((m) => {
const mine = m.operator.toUpperCase() === me;
return (
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}>
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
<span className="whitespace-pre-wrap break-words">{m.message}</span>
</div>
<span className="text-[9px] text-muted-foreground/70 px-1">{hhmm(m.created_at)}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-1.5 p-2 border-t border-border/60 shrink-0">
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder={t('chatp.messagePh')}
maxLength={1000}
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
/>
<button type="button" onClick={send} disabled={!text.trim()}
className="inline-flex items-center justify-center size-8 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
<Send className="size-3.5" />
</button>
</div>
</div>
);
}
+137 -62
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
@@ -12,6 +12,9 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => string;
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -113,17 +116,31 @@ function statusFor(p: any): SpotStatusEntry | undefined {
];
}
const COL_CATALOG: ColEntry[] = [
// statusBadge maps a resolved spot status to a short labelled badge for the
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
switch (s?.status) {
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
}
}
const makeColCatalog = (t: TFn): ColEntry[] => [
{
group: 'Spot', label: 'Time', colId: 'time',
headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
defaultVisible: true,
sort: 'desc',
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Call', colId: 'call',
headerName: 'Call', field: 'dx_call' as any, width: 120,
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true,
cellClass: 'font-mono',
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
@@ -133,84 +150,127 @@ const COL_CATALOG: ColEntry[] = [
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
},
},
{
group: 'Spot', label: 'POTA', colId: 'pota',
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 96, sortable: true,
defaultVisible: true,
cellStyle: { color: '#166534' },
tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined),
// Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell.
valueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return t('clg2.newDxcc');
if (s?.status === 'new-band') return t('clg2.newBand');
if (s?.status === 'new-mode') return t('clg2.newMode');
if (s?.status === 'new-slot') return t('clg2.newSlot');
return s?.worked_call ? t('clg2.wkdCall') : '';
},
cellRenderer: (p: any) => {
const b = statusBadge(t, statusFor(p));
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
return (
<span style={{
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{b.text}</span>
);
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
if (s?.status === 'new-band') return t('clg2.tipNewBand');
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
if (s?.worked_call) return t('clg2.tipWorkedCall');
return undefined;
},
},
{
group: 'Spot', label: 'Freq', colId: 'freq',
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
defaultVisible: true,
cellStyle: { color: '#166534' },
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
},
{
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
defaultVisible: true,
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Spot', label: 'Band', colId: 'band',
headerName: 'Band', field: 'band' as any, width: 75,
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
defaultVisible: true,
cellClass: 'font-mono',
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
: undefined),
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined),
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
},
{
group: 'Spot', label: 'Mode', colId: 'mode',
headerName: 'Mode', colSpan: undefined, width: 80,
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
defaultVisible: true,
cellClass: 'font-mono',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
// NEW SLOT (mode not yet worked on this band) → fill the cell.
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot'
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }
: undefined),
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
cellStyle: (p: any) => {
const st = statusFor(p)?.status;
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
// Status badge text tells them apart.
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
return undefined;
},
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>,
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined),
tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-mode') return t('clg2.tipNewMode');
if (st === 'new-slot') return t('clg2.tipNewSlot');
return undefined;
},
},
{
group: 'Spot', label: 'Pfx', colId: 'pfx',
headerName: 'Pfx', width: 60, cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
cellStyle: { color: '#7a6b50' },
},
{
group: 'Geo', label: 'CQ Zone', colId: 'cqz',
headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
},
{
group: 'Geo', label: 'ITU Zone', colId: 'ituz',
headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
},
{
group: 'Geo', label: 'Distance (km)', colId: 'distance_km',
headerName: 'Dist km', field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Geo', label: 'Short path (°)', colId: 'sp_deg',
headerName: 'SP°', field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Geo', label: 'Long path (°)', colId: 'lp_deg',
headerName: 'LP°', field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Spot', label: 'Country', colId: 'country',
headerName: 'Country', width: 140,
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
headerName: t('clg2.c.country'), width: 140,
defaultVisible: true,
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
@@ -218,8 +278,8 @@ const COL_CATALOG: ColEntry[] = [
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Continent', colId: 'continent',
headerName: 'Cont', width: 60, cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
defaultVisible: true,
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
@@ -227,50 +287,57 @@ const COL_CATALOG: ColEntry[] = [
cellStyle: { color: '#7a6b50', fontSize: 10 },
},
{
group: 'Spot', label: 'Spotter', colId: 'spotter',
headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
defaultVisible: true,
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Source', colId: 'source',
headerName: 'Source', field: 'source_name' as any, width: 100,
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
defaultVisible: true,
cellStyle: { color: '#9a8870', fontSize: 10 },
},
{
group: 'Spot', label: 'Locator', colId: 'locator',
headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Comment', colId: 'comment',
headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160,
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
defaultVisible: true,
cellStyle: { color: '#7a6b50' },
},
{
group: 'Spot', label: 'Received at', colId: 'received_at',
headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
valueFormatter: (p) => fmtDateTimeUTC(p.value),
},
{
group: 'Spot', label: 'Raw', colId: 'raw',
headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono',
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
},
];
const GROUP_ORDER = ['Spot', 'Geo'];
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
}), [COL_CATALOG]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
@@ -282,6 +349,14 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// change below.
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
// won't re-render those cells on its own — force a refresh so e.g. a worked call
// turns blue once its status loads.
useEffect(() => {
gridRef.current?.api?.refreshCells({ force: true });
}, [spotStatus]);
function onGridReady(e: GridReadyEvent) {
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
@@ -339,11 +414,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title="Clear all column filters">
<FilterX className="size-3.5" /> Clear filters
title={t('clg2.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns
<Columns3 className="size-3.5" /> {t('clg2.columns')}
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
@@ -372,9 +447,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Cluster columns</DialogTitle>
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
<DialogDescription>
Pick the columns you want visible in the Cluster table.
{t('clg2.pickerDesc')}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto py-2">
@@ -384,10 +459,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
return (
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1">
@@ -406,8 +481,8 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
})}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState } from 'react';
import { Trophy } from 'lucide-react';
import { ListContests, ContestStats } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// ContestSession is the live contest-mode state, persisted as JSON in a UI pref
// so a running contest (serial counter included) survives a restart.
export type ContestSession = {
active: boolean;
code: string; // ADIF CONTEST_ID written to each QSO
name: string;
exchange: 'serial' | 'fixed';
fixedKind: string; // label for the fixed exchange (CQ Zone, Département…)
sentFixed: string; // the value YOU send when the exchange is fixed
nextSerial: number; // running sent serial number
startISO: string; // contest window start (UTC ISO) — only QSOs after count
endISO: string; // contest window end (UTC ISO, empty = open)
};
export const CONTEST_DEFAULT: ContestSession = {
active: false, code: '', name: '', exchange: 'serial',
fixedKind: 'CQ Zone', sentFixed: '', nextSerial: 1,
startISO: '', endISO: '',
};
// Contests run on UTC, so the date/time inputs are treated as UTC.
const utcInputToISO = (v: string) => (v ? new Date(v + ':00Z').toISOString() : '');
const isoToUtcInput = (iso: string) => (iso ? new Date(iso).toISOString().slice(0, 16) : '');
const FIXED_KINDS = ['CQ Zone', 'ITU Zone', 'State/Prov', 'Département', 'Grid', 'Name', 'Age', 'Section', 'Custom'];
const KIND_LABEL: Record<string, string> = {
'cq-zone': 'CQ Zone', 'itu-zone': 'ITU Zone', 'state': 'State/Prov',
'dept': 'Département', 'grid': 'Grid', 'name': 'Name', 'class-section': 'Section',
};
type Def = { code: string; name: string; exchange: string };
type Stats = { qsos: number; by_band?: { band: string; count: number }[] | null; mult: number; mult_label: string; score: number; last_hour: number };
function Stat({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="rounded-lg border border-border bg-card px-3 py-2 text-center">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="text-xl font-black tabular-nums" style={accent ? { color: accent } : undefined}>{value}</div>
</div>
);
}
export function ContestPanel({ session, onChange }: {
session: ContestSession;
onChange: (patch: Partial<ContestSession>) => void;
}) {
const { t } = useI18n();
const [contests, setContests] = useState<Def[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => { ListContests().then((c) => setContests((c ?? []) as Def[])).catch(() => {}); }, []);
// Poll the scoreboard while a contest is running.
useEffect(() => {
if (!session.active || !session.code) { setStats(null); return; }
let alive = true;
const load = () => ContestStats(
session.code,
session.exchange === 'fixed' ? kindKey(session.fixedKind) : 'serial',
session.startISO, session.endISO,
).then((s) => { if (alive) setStats(s as Stats); }).catch(() => {});
load();
const id = window.setInterval(load, 4000);
return () => { alive = false; window.clearInterval(id); };
}, [session.active, session.code, session.exchange, session.fixedKind, session.startISO, session.endISO]);
const pickContest = (code: string) => {
const def = contests.find((c) => c.code === code);
if (!def) { onChange({ code: '', name: '' }); return; }
if (def.exchange === 'serial' || def.exchange === 'rst') {
onChange({ code: def.code, name: def.name, exchange: 'serial' });
} else {
onChange({ code: def.code, name: def.name, exchange: 'fixed', fixedKind: KIND_LABEL[def.exchange] || 'Custom' });
}
};
return (
<div className="h-full overflow-y-auto p-4 space-y-4">
{/* Setup card. */}
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
<Trophy className="size-4 text-amber-500" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
{session.active && <span className="ml-auto rounded bg-amber-500 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-white">{t('ctp.live')}</span>}
</div>
<div className="p-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<select value={session.code} onChange={(e) => pickContest(e.target.value)}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm flex-1 min-w-[220px]">
<option value="">{t('ctp.chooseContest')}</option>
{contests.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
</select>
{session.code && <code className="text-xs text-muted-foreground">{session.code}</code>}
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-md border border-border overflow-hidden">
{(['serial', 'fixed'] as const).map((x) => (
<button key={x} type="button" onClick={() => onChange({ exchange: x })}
className={cn('px-3 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
session.exchange === x ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{x === 'serial' ? t('ctp.serial') : t('ctp.fixedExchange')}
</button>
))}
</div>
{session.exchange === 'serial' ? (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('ctp.nextSerial')}
<input type="number" min={1} value={session.nextSerial}
onChange={(e) => onChange({ nextSerial: Math.max(1, parseInt(e.target.value || '1', 10)) })}
className="w-20 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
</label>
) : (
<div className="flex items-center gap-2">
<select value={session.fixedKind} onChange={(e) => onChange({ fixedKind: e.target.value })}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm">
{FIXED_KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
</select>
<input value={session.sentFixed} onChange={(e) => onChange({ sentFixed: e.target.value.toUpperCase() })}
placeholder={t('ctp.yourExchangePh')} className="w-32 rounded-md border border-border bg-card px-2 py-1.5 text-sm font-mono" />
</div>
)}
</div>
{/* Contest window (UTC). Only QSOs inside it count toward the score and
dupe check — so old runs of the same contest aren't included. */}
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('ctp.startUtc')}
<input type="datetime-local" value={isoToUtcInput(session.startISO)}
onChange={(e) => onChange({ startISO: utcInputToISO(e.target.value) })}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('ctp.endUtc')}
<input type="datetime-local" value={isoToUtcInput(session.endISO)}
onChange={(e) => onChange({ endISO: utcInputToISO(e.target.value) })}
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm" />
</label>
<span className="text-[11px] text-muted-foreground">{t('ctp.windowHint')}</span>
</div>
<div className="flex items-center gap-2 pt-1">
{session.active ? (
<button type="button" onClick={() => onChange({ active: false })}
className="rounded-md border border-rose-500/60 bg-rose-500/10 px-4 py-1.5 text-sm font-bold text-rose-600 hover:bg-rose-500/20">
{t('ctp.stopContest')}
</button>
) : (
<button type="button" disabled={!session.code}
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
className="rounded-md bg-amber-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-40">
{t('ctp.startContest')}
</button>
)}
<span className="text-xs text-muted-foreground">
{session.active ? t('ctp.activeHint') : t('ctp.inactiveHint')}
</span>
</div>
</div>
</div>
{/* Scoreboard. */}
{session.active && (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.scoreboard')}</span>
<span className="text-[10px] text-muted-foreground">{t('ctp.estimate')}</span>
</div>
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Stat label={t('ctp.qsos')} value={stats?.qsos ?? 0} />
<Stat label={stats?.mult_label || t('ctp.mult')} value={stats?.mult ?? 0} accent="#f59e0b" />
<Stat label={t('ctp.scoreEst')} value={(stats?.score ?? 0).toLocaleString()} accent="#16a34a" />
<Stat label={t('ctp.last60')} value={stats?.last_hour ?? 0} />
</div>
{stats && stats.by_band && stats.by_band.length > 0 && (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="text-left px-3 py-1.5 font-bold">{t('ctp.band')}</th>
<th className="text-right px-3 py-1.5 font-bold">{t('ctp.qsos')}</th>
</tr></thead>
<tbody>
{stats.by_band.map((r) => (
<tr key={r.band} className="border-t border-border/60">
<td className="px-3 py-1 font-mono">{r.band}</td>
<td className="px-3 py-1 text-right tabular-nums">{r.count}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
);
}
// kindKey maps a fixed-kind label back to the backend's exchange key for mult
// counting.
function kindKey(label: string): string {
const m: Record<string, string> = {
'CQ Zone': 'cq-zone', 'ITU Zone': 'itu-zone', 'State/Prov': 'state',
'Département': 'dept', 'Grid': 'grid', 'Name': 'name', 'Section': 'section', 'Age': 'age',
};
return m[label] || 'state';
}
+124 -63
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -6,6 +7,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { AwardRefSelector } from '@/components/AwardRefSelector';
@@ -34,8 +36,11 @@ export interface DetailsState {
sat_name: string;
sat_mode: string;
contest_id: string;
srx?: number;
stx?: number;
// Contest exchanges as free text — most are serials (001) but some are
// alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
srx_string?: string;
stx_string?: string;
email: string;
// Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
@@ -71,35 +76,35 @@ export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
const PROP_MODES: { value: string; label: string }[] = [
{ value: 'NONE', label: '—' },
{ value: 'AS', label: 'Aircraft Scatter' },
{ value: 'AUR', label: 'Aurora' },
{ value: 'AUE', label: 'Aurora-E' },
{ value: 'BS', label: 'Back Scatter' },
{ value: 'AS', label: 'detp.propAS' },
{ value: 'AUR', label: 'detp.propAUR' },
{ value: 'AUE', label: 'detp.propAUE' },
{ value: 'BS', label: 'detp.propBS' },
{ value: 'ECH', label: 'EchoLink' },
{ value: 'EME', label: 'Earth-Moon-Earth' },
{ value: 'ES', label: 'Sporadic E' },
{ value: 'FAI', label: 'Field Aligned Irregularities' },
{ value: 'F2', label: 'F2 Reflection' },
{ value: 'GWAVE', label: 'Ground Wave' },
{ value: 'INTERNET', label: 'Internet-assisted' },
{ value: 'ION', label: 'Ionoscatter' },
{ value: 'EME', label: 'detp.propEME' },
{ value: 'ES', label: 'detp.propES' },
{ value: 'FAI', label: 'detp.propFAI' },
{ value: 'F2', label: 'detp.propF2' },
{ value: 'GWAVE', label: 'detp.propGWAVE' },
{ value: 'INTERNET', label: 'detp.propINTERNET' },
{ value: 'ION', label: 'detp.propION' },
{ value: 'IRL', label: 'IRLP' },
{ value: 'LOS', label: 'Line of Sight' },
{ value: 'MS', label: 'Meteor Scatter' },
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
{ value: 'RS', label: 'Rain Scatter' },
{ value: 'SAT', label: 'Satellite' },
{ value: 'TEP', label: 'Trans-Equatorial' },
{ value: 'TR', label: 'Tropospheric Ducting' },
{ value: 'LOS', label: 'detp.propLOS' },
{ value: 'MS', label: 'detp.propMS' },
{ value: 'RPT', label: 'detp.propRPT' },
{ value: 'RS', label: 'detp.propRS' },
{ value: 'SAT', label: 'detp.propSAT' },
{ value: 'TEP', label: 'detp.propTEP' },
{ value: 'TR', label: 'detp.propTR' },
];
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
const ANT_PATHS: { value: string; label: string }[] = [
{ value: 'NONE', label: '—' },
{ value: 'S', label: 'Short Path' },
{ value: 'L', label: 'Long Path' },
{ value: 'G', label: 'Grayline' },
{ value: 'O', label: 'Other' },
{ value: 'S', label: 'detp.pathShort' },
{ value: 'L', label: 'detp.pathLong' },
{ value: 'G', label: 'detp.pathGrayline' },
{ value: 'O', label: 'detp.pathOther' },
];
function numOrUndef(v: string): number | undefined {
@@ -118,9 +123,44 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
);
}
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
// Live award detection: run the SAME engine used at log time over the current
// contact (callsign + looked-up address/state/zones) so award references the
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
// surfaced and auto-added the moment you enter a call or click a spot, instead
// of only appearing after logging. Pickable matches are merged into award_refs
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
useEffect(() => {
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
const t = window.setTimeout(async () => {
try {
const q: any = {
callsign, band, mode,
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
qso_date: new Date().toISOString(),
};
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
setDetected(all as any);
const cur = details.award_refs ?? '';
const have = new Set(cur.split(';').filter(Boolean));
let next = cur;
for (const r of all) {
if (!r.pickable) continue;
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
}
if (next !== cur) onChange({ award_refs: next });
} catch { /* leave detection empty on failure */ }
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
const path = useMemo(() => {
@@ -153,11 +193,11 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
}
const tabs: { key: TabName; label: string }[] = [
{ key: 'stats', label: `Stats (${fk}1)` },
{ key: 'info', label: `Info (${fk}2)` },
{ key: 'awards', label: `Awards (${fk}3)` },
{ key: 'my', label: `My (${fk}4)` },
{ key: 'extended', label: `Extended (${fk}5)` },
{ key: 'stats', label: `${t('detp.tabStats')} (${fk}1)` },
{ key: 'info', label: `${t('detp.tabInfo')} (${fk}2)` },
{ key: 'awards', label: `${t('detp.tabAwards')} (${fk}3)` },
{ key: 'my', label: `${t('detp.tabMy')} (${fk}4)` },
{ key: 'extended', label: `${t('detp.tabExtended')} (${fk}5)` },
];
return (
@@ -179,35 +219,45 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
))}
</nav>
<div className="overflow-y-auto min-h-0">
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} />
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
</div>
)}
{open === 'info' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="State / pref">
<Field label={t('detp.statePref')}>
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
</Field>
<Field label="County">
<Field label={t('detp.county')}>
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
</Field>
<Field label="Prefix">
<Field label={t('detp.prefix')}>
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
</Field>
<Field label="CQ zone">
<Field label={t('detp.cqZone')}>
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
</Field>
<Field label="ITU zone">
<Field label={t('detp.ituZone')}>
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
</Field>
{/* DXCC #, Continent and Azimuth SP live in the main entry strip /
bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */}
<Field label="Azimuth LP">
{/* DXCC # closes the top row (next to the zones); Continent and
Azimuth SP live in the main entry strip / bandeau. The long-path
bearing and distances move to the row below. */}
<Field label={t('detp.dxcc')}>
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={details.dxcc ?? ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.azimuthLp')}>
<Input
readOnly
tabIndex={-1}
@@ -216,7 +266,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
placeholder="—"
/>
</Field>
<Field label="Distance SP">
<Field label={t('detp.distanceSp')}>
<Input
readOnly
tabIndex={-1}
@@ -225,7 +275,7 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
placeholder="—"
/>
</Field>
<Field label="Distance LP">
<Field label={t('detp.distanceLp')}>
<Input
readOnly
tabIndex={-1}
@@ -234,74 +284,85 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
placeholder="—"
/>
</Field>
<Field label="Address" span={3}>
<Field label={t('detp.address')} span={3}>
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
</Field>
<Field label="QSL message" span={3}>
<Field label={t('detp.qslMessage')} span={3}>
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
</Field>
<Field label="QSL via" span={2}>
<Field label={t('detp.qslVia')} span={2}>
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
</Field>
</div>
)}
{open === 'awards' && (
<div className="px-3 py-2.5">
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
<AwardRefSelector
dxcc={details.dxcc}
value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })}
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
heightClass="flex-1 min-h-0"
/>
{detected.length > 0 && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
{detected.map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
</span>
))}
</div>
)}
</div>
)}
{open === 'my' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="Azimuth (°)">
<Field label={t('detp.azimuth')}>
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
</Field>
<Field label="Elevation (°)">
<Field label={t('detp.elevation')}>
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
</Field>
<Field label="TX power (W)">
<Field label={t('detp.txPower')}>
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
</Field>
<div className="col-span-3 flex items-end pb-1.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
Satellite mode
{t('detp.satelliteMode')}
</label>
</div>
<Field label="Ant. path" span={2}>
<Field label={t('detp.antPath')} span={2}>
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label="Propagation" span={4}>
<Field label={t('detp.propagation')} span={4}>
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{t(p.label)}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label="Rig" span={3}>
<Field label={t('detp.rig')} span={3}>
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
</Field>
<Field label="Antenna" span={3}>
<Field label={t('detp.antenna')} span={3}>
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
</Field>
{satelliteMode && (
<>
<Field label="Satellite name" span={3}>
<Field label={t('detp.satName')} span={3}>
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
</Field>
<Field label="Satellite mode" span={3}>
<Field label={t('detp.satelliteMode')} span={3}>
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
</Field>
</>
@@ -311,16 +372,16 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
{open === 'extended' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="Contest ID" span={2}>
<Field label={t('detp.contestId')} span={2}>
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
</Field>
<Field label="SRX">
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
<Input value={details.srx_string ?? ''} placeholder={t('detp.rcvdExchangePh')} onChange={(e) => onChange({ srx_string: e.target.value })} />
</Field>
<Field label="STX">
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
</Field>
<Field label="Contacted email" span={3}>
<Field label={t('detp.contactedEmail')} span={3}>
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
</Field>
</div>
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react';
import { Copy, Loader2, Trash2, X } from 'lucide-react';
import { FindDuplicates, DeleteQSOs } from '../../wailsjs/go/main/App';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type QSO = {
id: number; callsign: string; qso_date: string; band?: string; mode?: string;
freq_hz?: number; rst_sent?: string; rst_rcvd?: string; name?: string;
};
type Group = { key: string; qsos: QSO[] };
const fmtDate = (iso: string) => {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
};
const fmtFreq = (hz?: number) => (hz && hz > 0 ? (hz / 1e6).toFixed(3) : '—');
// DuplicatesModal — scans the log for duplicate QSOs (same call+band+mode on the
// same day; optionally same minute), groups them, and lets the operator pick
// which copies to delete. The oldest in each group is left unchecked by default.
export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; onDeleted: () => void }) {
const { t } = useI18n();
const [windowMin, setWindowMin] = useState(5); // duplicates land a few minutes apart
const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<Group[]>([]);
const [sel, setSel] = useState<Set<number>>(new Set());
const [busy, setBusy] = useState(false);
const load = (w: number) => {
setLoading(true);
FindDuplicates(w)
.then((g) => {
const gs = (g ?? []) as Group[];
setGroups(gs);
// Default: select every QSO except the first (oldest) of each group.
const next = new Set<number>();
gs.forEach((grp) => grp.qsos.slice(1).forEach((q) => next.add(q.id)));
setSel(next);
})
.catch(() => { setGroups([]); setSel(new Set()); })
.finally(() => setLoading(false));
};
useEffect(() => { load(windowMin); /* eslint-disable-next-line */ }, [windowMin]);
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const selectExtras = () => { const n = new Set<number>(); groups.forEach((g) => g.qsos.slice(1).forEach((q) => n.add(q.id))); setSel(n); };
const deselectAll = () => setSel(new Set());
const totalQsos = groups.reduce((a, g) => a + g.qsos.length, 0);
const doDelete = async () => {
if (sel.size === 0) return;
if (!confirm(t('dup.deleteConfirm', { n: sel.size }))) return;
setBusy(true);
try {
await DeleteQSOs(Array.from(sel) as any);
onDeleted();
load(windowMin); // rescan so the list reflects what's left
} catch { /* ignore */ } finally { setBusy(false); }
};
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4" onMouseDown={onClose}>
<div className="w-[820px] max-w-full max-h-[88vh] flex flex-col rounded-2xl border border-border bg-card shadow-2xl"
onMouseDown={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
<Copy className="size-4 text-primary" />
<span className="font-bold">{t('dup.title')}</span>
<button type="button" onClick={onClose} className="ml-auto rounded-md p-1 hover:bg-muted"><X className="size-4" /></button>
</div>
{/* Controls */}
<div className="px-5 py-3 border-b border-border/60 space-y-2">
<p className="text-xs text-muted-foreground">{t('dup.hint')}</p>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('dup.window')}
<select value={windowMin} onChange={(e) => setWindowMin(parseInt(e.target.value, 10))}
className="rounded-md border border-border bg-card px-2 py-1 text-xs">
{[2, 3, 5, 10, 15, 30].map((m) => <option key={m} value={m}>{m} {t('dup.minutes')}</option>)}
<option value={0}>{t('dup.sameDay')}</option>
</select>
</label>
<div className="ml-auto flex items-center gap-2">
<button type="button" onClick={selectExtras} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.selectExtras')}</button>
<button type="button" onClick={deselectAll} className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-muted">{t('dup.deselectAll')}</button>
</div>
</div>
<div className="text-[11px] text-muted-foreground">
{t('dup.summary', { groups: groups.length, qsos: totalQsos, sel: sel.size })}
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-3 min-h-0">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> {t('dup.scanning')}
</div>
) : groups.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">{t('dup.none')}</div>
) : (
groups.map((g) => (
<div key={g.key} className="rounded-lg border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs font-bold">
<span className="font-mono">{g.qsos[0].callsign}</span>
<span className="text-muted-foreground font-normal">
{g.qsos[0].band} · {g.qsos[0].mode} · {g.qsos.length}×
</span>
</div>
<table className="w-full text-xs">
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground">
<tr className="border-b border-border/60">
<th className="w-8"></th>
<th className="text-left px-2 py-1 font-semibold">{t('dup.colDate')}</th>
<th className="text-left px-2 py-1 font-semibold">{t('dup.colFreq')}</th>
<th className="text-left px-2 py-1 font-semibold">{t('dup.colRst')}</th>
<th className="text-left px-2 py-1 font-semibold">{t('dup.colName')}</th>
</tr>
</thead>
<tbody>
{g.qsos.map((q, i) => {
const checked = sel.has(q.id);
return (
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-rose-500/10')}>
<td className="text-center py-1">
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
</td>
<td className="px-2 py-1 font-mono whitespace-nowrap">
{fmtDate(q.qso_date)}
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-emerald-600">{t('dup.keep')}</span>}
</td>
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
<td className="px-2 py-1 truncate max-w-[160px]">{q.name || ''}</td>
</tr>
);
})}
</tbody>
</table>
</div>
))
)}
</div>
{/* Footer */}
<div className="flex items-center gap-2 px-5 py-3 border-t border-border">
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-1.5 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-40">
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
{t('dup.deleteSel', { n: sel.size })}
</button>
</div>
</div>
</div>
);
}
+9 -7
View File
@@ -1,6 +1,7 @@
import { Mic, Square, X, Radio } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
@@ -17,19 +18,20 @@ type Props = {
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
// the reserved area. Recording/labeling lives in Settings → Audio.
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
const { t } = useI18n();
const anyAudio = messages.some((m) => m.has_audio);
return (
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
<Mic className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
{status.playing && <span className="text-[10px] text-amber-600 font-medium">transmitting</span>}
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
<Square className="size-3" /> Stop
<Square className="size-3" /> {t('dvkp.stop')}
</Button>
<button className="text-muted-foreground hover:text-foreground" title="Disable voice keyer" onClick={onClose}>
<button className="text-muted-foreground hover:text-foreground" title={t('dvkp.disable')} onClick={onClose}>
<X className="size-3.5" />
</button>
</div>
@@ -38,7 +40,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
{!anyAudio ? (
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
<Radio className="size-5 opacity-50" />
No messages recorded yet. Open <strong>Settings Audio devices &amp; voice keyer</strong> to record F1F6.
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
</div>
) : (
<div className="grid grid-cols-1 gap-1">
@@ -48,7 +50,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
type="button"
disabled={!m.has_audio}
onClick={() => onPlay(m.slot)}
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
className={cn(
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
m.has_audio
@@ -57,7 +59,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
)}
>
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? 'message' : '—')}</span>
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span>
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
</button>
))}
+94 -77
View File
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
// adds field/operator/value conditions, joins them with AND or OR, and can
@@ -27,70 +28,83 @@ export interface QueryFilter {
// (qso.FilterableFields); `type` only drives which operators/value input we show.
type FieldType = 'text' | 'number' | 'date';
const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'callsign', label: 'Callsign', type: 'text' },
{ value: 'qso_date', label: 'Date / time (UTC)', type: 'date' },
{ value: 'qso_date_off', label: 'End date / time', type: 'date' },
{ value: 'band', label: 'Band', type: 'text' },
{ value: 'band_rx', label: 'RX band', type: 'text' },
{ value: 'mode', label: 'Mode', type: 'text' },
{ value: 'submode', label: 'Submode', type: 'text' },
{ value: 'freq_hz', label: 'Frequency (Hz)', type: 'number' },
{ value: 'freq_rx_hz', label: 'RX frequency (Hz)', type: 'number' },
{ value: 'rst_sent', label: 'RST sent', type: 'text' },
{ value: 'rst_rcvd', label: 'RST rcvd', type: 'text' },
{ value: 'name', label: 'Name', type: 'text' },
{ value: 'qth', label: 'QTH', type: 'text' },
{ value: 'address', label: 'Address', type: 'text' },
{ value: 'email', label: 'E-mail', type: 'text' },
{ value: 'grid', label: 'Grid', type: 'text' },
{ value: 'country', label: 'Country', type: 'text' },
{ value: 'state', label: 'State', type: 'text' },
{ value: 'cnty', label: 'County', type: 'text' },
{ value: 'dxcc', label: 'DXCC #', type: 'number' },
{ value: 'cont', label: 'Continent', type: 'text' },
{ value: 'cqz', label: 'CQ zone', type: 'number' },
{ value: 'ituz', label: 'ITU zone', type: 'number' },
{ value: 'iota', label: 'IOTA', type: 'text' },
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
{ value: 'rig', label: 'Rig', type: 'text' },
{ value: 'ant', label: 'Antenna', type: 'text' },
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
{ value: 'qsl_rcvd', label: 'QSL rcvd', type: 'text' },
{ value: 'qsl_via', label: 'QSL via', type: 'text' },
{ value: 'lotw_sent', label: 'LoTW sent', type: 'text' },
{ value: 'lotw_rcvd', label: 'LoTW rcvd', type: 'text' },
{ value: 'eqsl_sent', label: 'eQSL sent', type: 'text' },
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
{ value: 'stx', label: 'Serial sent', type: 'number' },
{ value: 'prop_mode', label: 'Propagation mode', type: 'text' },
{ value: 'sat_name', label: 'Satellite', type: 'text' },
{ value: 'station_callsign', label: 'My callsign', type: 'text' },
{ value: 'operator', label: 'Operator', type: 'text' },
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
{ value: 'my_grid', label: 'My grid', type: 'text' },
{ value: 'my_country', label: 'My country', type: 'text' },
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
{ value: 'comment', label: 'Comment', type: 'text' },
{ value: 'notes', label: 'Notes', type: 'text' },
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
{ value: 'band', label: 'fltb.fBand', type: 'text' },
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
{ value: 'mode', label: 'fltb.fMode', type: 'text' },
{ value: 'submode', label: 'fltb.fSubmode', type: 'text' },
{ value: 'freq_hz', label: 'fltb.fFreq', type: 'number' },
{ value: 'freq_rx_hz', label: 'fltb.fRxFreq', type: 'number' },
{ value: 'rst_sent', label: 'fltb.fRstSent', type: 'text' },
{ value: 'rst_rcvd', label: 'fltb.fRstRcvd', type: 'text' },
{ value: 'name', label: 'fltb.fName', type: 'text' },
{ value: 'qth', label: 'fltb.fQth', type: 'text' },
{ value: 'address', label: 'fltb.fAddress', type: 'text' },
{ value: 'email', label: 'fltb.fEmail', type: 'text' },
{ value: 'grid', label: 'fltb.fGrid', type: 'text' },
{ value: 'country', label: 'fltb.fCountry', type: 'text' },
{ value: 'state', label: 'fltb.fState', type: 'text' },
{ value: 'cnty', label: 'fltb.fCounty', type: 'text' },
{ value: 'dxcc', label: 'fltb.fDxcc', type: 'number' },
{ value: 'cont', label: 'fltb.fContinent', type: 'text' },
{ value: 'cqz', label: 'fltb.fCqz', type: 'number' },
{ value: 'ituz', label: 'fltb.fItuz', type: 'number' },
{ value: 'iota', label: 'fltb.fIota', type: 'text' },
{ value: 'sota_ref', label: 'fltb.fSota', type: 'text' },
{ value: 'pota_ref', label: 'fltb.fPota', type: 'text' },
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
{ value: 'prop_mode', label: 'fltb.fPropMode', type: 'text' },
{ value: 'sat_name', label: 'fltb.fSatellite', type: 'text' },
{ value: 'station_callsign', label: 'fltb.fMyCallsign', type: 'text' },
{ value: 'operator', label: 'fltb.fOperator', type: 'text' },
{ value: 'owner_callsign', label: 'fltb.fOwnerCallsign', type: 'text' },
{ value: 'my_grid', label: 'fltb.fMyGrid', type: 'text' },
{ value: 'my_country', label: 'fltb.fMyCountry', type: 'text' },
{ value: 'my_state', label: 'fltb.fMyState', type: 'text' },
{ value: 'my_cnty', label: 'fltb.fMyCounty', type: 'text' },
{ value: 'my_iota', label: 'fltb.fMyIota', type: 'text' },
{ value: 'my_sota_ref', label: 'fltb.fMySota', type: 'text' },
{ value: 'my_pota_ref', label: 'fltb.fMyPota', type: 'text' },
{ value: 'my_wwff_ref', label: 'fltb.fMyWwff', type: 'text' },
{ value: 'my_street', label: 'fltb.fMyStreet', type: 'text' },
{ value: 'my_city', label: 'fltb.fMyCity', type: 'text' },
{ value: 'my_postal_code', label: 'fltb.fMyPostal', type: 'text' },
{ value: 'my_rig', label: 'fltb.fMyRig', type: 'text' },
{ value: 'my_antenna', label: 'fltb.fMyAntenna', type: 'text' },
{ value: 'tx_pwr', label: 'fltb.fTxPower', type: 'number' },
{ value: 'comment', label: 'fltb.fComment', type: 'text' },
{ value: 'notes', label: 'fltb.fNotes', type: 'text' },
];
const OPS: { value: FilterOp; label: string }[] = [
{ value: 'eq', label: 'equals (=)' },
{ value: 'ne', label: 'not equal (≠)' },
{ value: 'contains', label: 'contains' },
{ value: 'startswith', label: 'starts with' },
{ value: 'endswith', label: 'ends with' },
{ value: 'gt', label: 'greater than (>)' },
{ value: 'lt', label: 'less than (<)' },
{ value: 'ge', label: 'greater or equal (≥)' },
{ value: 'le', label: 'less or equal (≤)' },
{ value: 'empty', label: 'is empty' },
{ value: 'notempty', label: 'is not empty' },
{ value: 'eq', label: 'fltb.opEq' },
{ value: 'ne', label: 'fltb.opNe' },
{ value: 'contains', label: 'fltb.opContains' },
{ value: 'startswith', label: 'fltb.opStartsWith' },
{ value: 'endswith', label: 'fltb.opEndsWith' },
{ value: 'gt', label: 'fltb.opGt' },
{ value: 'lt', label: 'fltb.opLt' },
{ value: 'ge', label: 'fltb.opGe' },
{ value: 'le', label: 'fltb.opLe' },
{ value: 'empty', label: 'fltb.opEmpty' },
{ value: 'notempty', label: 'fltb.opNotEmpty' },
];
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
@@ -119,6 +133,7 @@ interface Props {
}
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
const { t } = useI18n();
const [conditions, setConditions] = useState<FilterCondition[]>([]);
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
@@ -171,25 +186,25 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader className="px-6 py-4">
<DialogTitle>QSO filter</DialogTitle>
<DialogTitle>{t('fltb.title')}</DialogTitle>
</DialogHeader>
<div className="px-6 py-4 space-y-5">
{/* Match mode + presets */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Match</span>
<span className="text-muted-foreground">{t('fltb.match')}</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{(['AND', 'OR'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMatch(m)}
className={`px-3 py-1 text-xs font-medium ${match === m ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}`}>
{m === 'AND' ? 'ALL (AND)' : 'ANY (OR)'}
{m === 'AND' ? t('fltb.all') : t('fltb.any')}
</button>
))}
</div>
<div className="flex-1" />
{presetNames.length > 0 && (
<Select onValueChange={loadPreset}>
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder="Load preset…" /></SelectTrigger>
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder={t('fltb.loadPreset')} /></SelectTrigger>
<SelectContent>
{presetNames.map((n) => (
<SelectItem key={n} value={n}>
@@ -207,13 +222,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
{/* Conditions */}
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
{conditions.length === 0 && (
<div className="text-xs text-muted-foreground py-4 text-center">No conditions the list shows all QSOs. Add one below.</div>
<div className="text-xs text-muted-foreground py-4 text-center">{t('fltb.noConditions')}</div>
)}
{conditions.map((c, i) => {
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
return (
<div key={i} className="flex items-center gap-2">
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? t('fltb.where') : match}</span>
<Select value={c.field} onValueChange={(v) => {
// Reset op if the new field type doesn't allow the current one.
const allowed = opsFor(v).map((o) => o.value);
@@ -221,49 +237,50 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
}}>
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{t(f.label)}</SelectItem>)}
</SelectContent>
</Select>
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{t(o.label)}</SelectItem>)}
</SelectContent>
</Select>
<Input
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
className="h-8 flex-1 text-xs"
disabled={!needsValue}
placeholder={needsValue ? 'value' : '—'}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
value={c.value}
onChange={(e) => setCond(i, { value: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
/>
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title="Remove">
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
<X className="size-4" />
</button>
</div>
);
})}
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
<Plus className="size-3.5 mr-1" /> Add condition
<Plus className="size-3.5 mr-1" /> {t('fltb.addCondition')}
</Button>
</div>
{/* Save preset */}
<div className="flex items-center gap-2 border-t border-border pt-3">
<Input className="h-8 w-56 text-xs" placeholder="Preset name…" value={presetName}
<Input className="h-8 w-56 text-xs" placeholder={t('fltb.presetNamePh')} value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
<Save className="size-3.5 mr-1" /> Save preset
<Save className="size-3.5 mr-1" /> {t('fltb.savePreset')}
</Button>
</div>
</div>
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
<Button variant="ghost" onClick={() => { setConditions([]); }}>Clear</Button>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={apply}>Apply &amp; close</Button>
<Button variant="ghost" onClick={() => { setConditions([]); }}>{t('fltb.clear')}</Button>
<Button variant="outline" onClick={onClose}>{t('fltb.cancel')}</Button>
<Button onClick={apply}>{t('fltb.applyClose')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { Radio } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
type Profile = Omit<profileModels.Profile, 'convertValues'>;
// FirstRunModal collects the mandatory station identity on the very first launch
// (no callsign configured yet). It writes straight into the active profile, so
// OpsLog has a valid station before any QSO is logged. Not dismissable.
export function FirstRunModal({ onDone }: { onDone: () => void }) {
const { t } = useI18n();
const [p, setP] = useState<Profile | null>(null);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const [refsState, setRefsState] = useState<'idle' | 'loading' | 'done'>('idle');
const [refsMsg, setRefsMsg] = useState('');
async function downloadRefs() {
setRefsState('loading');
try {
const summary = await DownloadAllReferenceLists();
setRefsMsg(summary);
setRefsState('done');
} catch (e: any) {
setRefsMsg(String(e?.message ?? e));
setRefsState('idle');
}
}
useEffect(() => {
GetActiveProfile().then((x) => setP(x as Profile)).catch(() => setP({} as Profile));
}, []);
const set = (patch: Partial<Profile>) => setP((s: Profile | null) => ({ ...(s as Profile), ...patch }));
const callsign = (p?.callsign ?? '').trim().toUpperCase();
const grid = (p?.my_grid ?? '').trim().toUpperCase();
const operator = (p?.operator ?? '').trim().toUpperCase();
const canSave = callsign.length >= 3 && grid.length >= 4;
async function save() {
if (!p || !canSave) return;
setSaving(true);
setErr('');
try {
await SaveProfile({
...p,
callsign,
my_grid: grid,
operator: operator || callsign,
owner_callsign: (p.owner_callsign ?? '').trim().toUpperCase() || callsign,
op_name: (p.op_name ?? '').trim(),
} as any);
onDone();
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
<div className="flex items-center gap-2 mb-1">
<Radio className="size-5 text-primary" />
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('frm.intro')}
</p>
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
<Label className="text-sm">{t('frm.callsign')} <span className="text-red-500">*</span></Label>
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
<Label className="text-sm">{t('frm.locator')} <span className="text-red-500">*</span></Label>
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
<Label className="text-sm">{t('frm.operator')}</Label>
<Input className="h-9 font-mono uppercase" placeholder={t('frm.operatorPh')} value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
<Label className="text-sm">{t('frm.owner')}</Label>
<Input className="h-9 font-mono uppercase" placeholder={t('frm.ownerPh')} value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
<Label className="text-sm">{t('frm.name')}</Label>
<Input className="h-9" placeholder={t('frm.namePh')} value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
</div>
{/* Optional: grab the award reference lists now (also in Tools later). */}
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs">
<div className="font-medium">{t('frm.awardRefs')}</div>
<div className="text-muted-foreground">{t('frm.awardRefsHint')}</div>
</div>
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
{refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')}
</Button>
</div>
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
</div>
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
<div className="mt-5 flex items-center justify-end gap-2">
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
<Button disabled={!canSave || saving} onClick={save}>{saving ? t('frm.saving') : t('frm.startLogging')}</Button>
</div>
</div>
</div>
);
}
+596
View File
@@ -0,0 +1,596 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
type FlexState = {
available: boolean; model?: string;
rf_power: number; tune_power: number; tune: boolean; transmitting: boolean;
vox_enable: boolean; vox_level: number; vox_delay: number;
proc_enable: boolean; proc_level: number;
mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
meters?: Meter[];
};
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
amp_available: false, amp_operate: false,
};
function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max = 100 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; max?: number;
}) {
const v = Math.max(0, Math.min(max, value));
const pct = max > 0 ? (v / max) * 100 : 0;
const ref = useRef<HTMLInputElement>(null);
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
// is ignored), so attach a non-passive native listener; read live values via
// refs to avoid stale closures.
const valRef = useRef(value); valRef.current = value;
const cbRef = useRef(onChange); cbRef.current = onChange;
const disRef = useRef(disabled); disRef.current = disabled;
const stepRef = useRef(step); stepRef.current = step;
const maxRef = useRef(max); maxRef.current = max;
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (disRef.current) return;
e.preventDefault();
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d));
if (nv !== valRef.current) cbRef.current(nv);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<input
ref={ref}
type="range" min={0} max={max} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
/>
);
}
// Segmented — radio-style multi-choice (AGC, Processor preset).
function Segmented({ value, options, onChange, disabled }: {
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; disabled?: boolean;
}) {
return (
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{options.map((o) => (
<button key={o.v} type="button" disabled={disabled} onClick={() => onChange(o.v)}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
);
}
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
}) {
const onCls = {
emerald: 'bg-emerald-600 border-emerald-600 text-white',
violet: 'bg-violet-600 border-violet-600 text-white',
cyan: 'bg-cyan-600 border-cyan-600 text-white',
amber: 'bg-amber-500 border-amber-500 text-white',
}[accent];
return (
<button type="button" onClick={onClick} disabled={disabled}
className={cn(
// min width (not fixed) so a longer label like STONE keeps symmetric
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
{label}
</button>
);
}
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
}) {
return (
<div className="flex items-center gap-2">
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent={accent} />
<Slider value={value} disabled={disabled || !on} accent={sliderAccent} onChange={onLevel} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
</div>
);
}
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div onClick={onClick} title={title}
className={cn('rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
<div className="flex items-baseline justify-between gap-1 mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
);
})}
</div>
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
// host can keep the WinKeyer (which actually sends the macros) in sync.
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
const { t } = useI18n();
const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({});
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
// read steadily instead of jumping every poll.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const s = (await GetFlexState()) as any as FlexState;
if (!alive) return;
setSt((prev) => {
const now = Date.now();
const merged: any = { ...s };
for (const k in hold.current) {
if (hold.current[k] > now) merged[k] = (prev as any)[k];
}
return merged as FlexState;
});
} catch { /* not connected */ }
};
tick();
const id = window.setInterval(tick, 400);
return () => { alive = false; window.clearInterval(id); };
}, []);
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
useEffect(() => {
let alive = true;
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
tick();
const id = window.setInterval(tick, 2000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900;
setSt((p) => ({ ...p, [key]: val }));
send().catch(() => {});
};
const off = !st.available;
const rxOff = off || !st.rx_avail;
const isCW = (st.mode || '').toUpperCase().includes('CW');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
const CW_BW = [100, 200, 300, 400, 500];
const SSB_BW = [1800, 2100, 2400, 2700, 3000, 4000, 6000];
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
// Highlight the preset CLOSEST to the radio's actual filter width. The rig
// rarely reports a width that lands exactly on a preset (e.g. 2.7k presets as
// 1002790), so an exact/±50 match would leave nothing lit — "doesn't pick up
// the current filter". Snapping to the nearest preset always reflects the rig.
const ssbActiveBW = curBW > 0
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
: -1;
return (
<div className="h-full min-h-0 overflow-auto bg-background">
<div className="max-w-5xl mx-auto p-3 space-y-3">
{/* Header strip */}
<div className="flex items-center gap-3 rounded-xl px-4 py-3 text-white shadow-sm"
style={{ background: 'linear-gradient(135deg,#1e293b,#0f172a)' }}>
<Radio className="size-6 text-sky-400" />
<div className="flex flex-col leading-tight">
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
</div>
<div className="flex-1" />
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
!st.available ? 'bg-slate-700 text-slate-300'
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
<span className="size-2 rounded-full bg-current" />
{!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
</span>
</div>
{off && (
<div className="text-center text-sm text-muted-foreground py-6">
{t('flxp.waiting')}
</div>
)}
{/* TX + RX columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* TRANSMIT */}
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
</div>
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.tunePwr')}</span>
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
</div>
<div className="flex items-center gap-2 pt-0.5">
<button type="button" disabled={off}
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}>
TUNE
</button>
<button type="button" disabled={off}
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}>
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
</button>
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={off}
title={t('flxp.splitHint')}
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
SPLIT
</button>
{st.split && !!st.tx_freq_hz && (
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
{!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`}
</span>
)}
</div>
{!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
onClick={() => change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} />
<Segmented value={String(st.proc_level)} options={PROC} disabled={off || !st.proc_enable}
onChange={(v) => change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} />
<span className="flex-1" />
</div>
<LevelRow label="VOX" on={st.vox_enable} disabled={off} value={st.vox_level} sliderAccent="#16a34a"
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">{t('flxp.voxDly')}</span>
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
</div>
<LevelRow label="MON" on={st.mon} disabled={off} value={st.mon_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('mon', !st.mon, () => FlexSetMon(!st.mon))}
onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">MIC</span>
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
</div>
</div>
) : (
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.speed')}</span>
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.pitch')}</span>
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.delay')}</span>
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
</div>
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
</div>
)}
</Card>
{/* RECEIVE */}
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-[10px] text-muted-foreground">RX</span>
<select disabled={rxOff} value={st.rx_ant ?? ''}
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<span className="text-[10px] text-muted-foreground">TX</span>
<select disabled={rxOff} value={st.tx_ant ?? ''}
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
</div>
)}
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<button type="button" disabled={rxOff}
title={st.mute ? t('flxp.muted') : t('flxp.mute')}
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button>
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
{/* ANF (auto notch) is for carriers in voice — meaningless on a CW tone, so hide it in CW. */}
{!isCW && (
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
)}
</div>
{isCW && (
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.filter')}</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{CW_BW.map((bw) => (
<button key={bw} type="button" disabled={rxOff}
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{bw}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
</div>
</div>
)}
{!isCW && (
<div className="border-t border-border/60 pt-3">
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.filter')}</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{SSB_BW.map((bw) => (
<button key={bw} type="button" disabled={rxOff}
onClick={() => {
const lsb = (st.mode || '').toUpperCase().includes('LSB');
let lo: number, hi: number;
if (lsb) { const near = (st.filter_hi && st.filter_hi < 0) ? st.filter_hi : -100; hi = near; lo = near - bw; }
else { const near = (st.filter_lo && st.filter_lo > 0) ? st.filter_lo : 100; lo = near; hi = near + bw; }
setSt((p) => ({ ...p, filter_lo: lo, filter_hi: hi }));
FlexSetFilter(lo, hi).catch(() => {});
}}
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
bw === ssbActiveBW ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{(bw / 1000).toFixed(1)}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground/70 font-mono">kHz</span>
</div>
</div>
)}
</Card>
</div>
{/* External amplifier (PowerGenius XL) — only when detected. */}
{st.amp_available && (
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3">
<button type="button" disabled={off}
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}>
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
</button>
<span className="text-xs text-muted-foreground">
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{/* Fan mode shown when the PowerGenius is configured (Settings
PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */}
{(pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} />
<span className="text-muted-foreground">{t('flxp.fan')}</span>
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40"
>
<option value="STANDARD">{t('flxp.fanStandard')}</option>
<option value="CONTEST">{t('flxp.fanContest')}</option>
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
</select>
</label>
)}
<div className="flex-1" />
{st.amp_fault && st.amp_fault !== 'NONE' && (
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
)}
</div>
</Card>
)}
{/* Live meters (UDP VITA-49 stream) */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
].filter(Boolean);
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
{amp.length > 0 && (
<div className="border-t border-border/60 pt-2 mt-1">
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">{t('flxp.amplifierHdr')}</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
}
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
})}
</div>
</div>
)}
</>
);
})()}
</Card>
</div>
</div>
);
}
+484
View File
@@ -0,0 +1,484 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react';
import {
GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
type IcomState = {
available: boolean; model?: string; mode?: string;
transmitting: boolean; split: boolean;
s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_gain: number;
af_gain: number; rf_gain: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
agc?: string; preamp: number; att: number; filter: number;
};
const ZERO: IcomState = {
available: false, transmitting: false, split: false,
s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_gain: 0,
af_gain: 0, rf_gain: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
preamp: 0, att: 0, filter: 1,
};
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
}) {
const v = Math.max(0, Math.min(100, value));
const ref = useRef<HTMLInputElement>(null);
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
// is ignored), so attach a non-passive native listener; read live values via
// refs to avoid stale closures.
const valRef = useRef(value); valRef.current = value;
const cbRef = useRef(onChange); cbRef.current = onChange;
const disRef = useRef(disabled); disRef.current = disabled;
const stepRef = useRef(step); stepRef.current = step;
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (disRef.current) return;
e.preventDefault();
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
const nv = Math.max(0, Math.min(100, valRef.current + d));
if (nv !== valRef.current) cbRef.current(nv);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<input
ref={ref}
type="range" min={0} max={100} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
/>
);
}
function Segmented({ value, options, onChange }: {
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
}) {
return (
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{options.map((o) => (
<button key={o.v} type="button" onClick={() => onChange(o.v)}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
);
}
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
return (
<button type="button" onClick={onClick}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label}
</button>
);
}
function LevelRow({ label, on, onToggle, value, onLevel }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
}) {
return (
<div className="flex items-center gap-2">
<Chip on={on} onClick={onToggle} label={label} />
<Slider value={value} disabled={!on} onChange={onLevel} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
{children}
</div>
);
}
// Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR).
// Optional onClick makes the row a button (used to send the S reading to RST tx).
function Meter({ label, value, accent, scale, onClick, title }: { label: string; value: number; accent: string; scale?: string; onClick?: () => void; title?: string }) {
const v = Math.max(0, Math.min(100, value));
const body = (
<>
<span className="w-9 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
</div>
<span className="w-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
</>
);
if (onClick) {
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
}
return <div className="flex items-center gap-2">{body}</div>;
}
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
// the RST-tx value on click.
function sParts(v: number): { s: number; over: number; label: string } {
if (v >= 47) {
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
}
const s = Math.max(0, Math.min(9, Math.round(v / 5.2)));
return { s, over: 0, label: `S${s}` };
}
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
// reassembled sweep on a canvas. The amplitude array is raw rig scale (~0-160);
// we normalise to the tallest recent peak so the trace fills the height.
function ScopePanadapter() {
const { t } = useI18n();
const [on, setOn] = useState(false);
const [fixed, setFixed] = useState(true);
const canvasRef = useRef<HTMLCanvasElement>(null);
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
const toggle = () => {
const next = !on;
setOn(next);
IcomSetScope(next).catch(() => {});
};
const setMode = (nextFixed: boolean) => {
setFixed(nextFixed);
IcomSetScopeMode(nextFixed).catch(() => {});
};
// Stop the stream when the panel unmounts.
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
useEffect(() => {
if (!on) return;
let raf = 0, lastSeq = -1, alive = true;
const tick = async () => {
if (!alive) return;
try {
const sw = await IcomScopeData();
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
lastSeq = sw.seq;
setFixed(sw.fixed);
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
let vfo = 0;
try {
const cs = await GetCATState();
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
} catch {}
if (vfo > 0) vfoRef.current = vfo;
draw(sw.amp, sw.low_hz, sw.high_hz, vfo);
}
} catch {}
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
};
raf = requestAnimationFrame(tick);
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on]);
// Double-click tunes the rig to the clicked frequency.
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const cv = canvasRef.current;
const { low, high } = spanRef.current;
if (!cv || !(low > 0 && high > low)) return;
const rect = cv.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
SetCATFrequency(hz).catch(() => {});
};
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
// accumulate before the poll reconciles.
useEffect(() => {
const el = canvasRef.current;
if (!el || !on) return;
const onWheel = (e: WheelEvent) => {
if (!vfoRef.current) return;
e.preventDefault();
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
vfoRef.current = next;
SetCATFrequency(next).catch(() => {});
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [on]);
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number) => {
const cv = canvasRef.current;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
const peak = Math.max(...amp);
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
const scale = peakRef.current;
// Grid.
ctx.strokeStyle = 'rgba(120,120,120,0.15)';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
// Spectrum trace, filled under the line.
const n = amp.length;
ctx.beginPath();
ctx.moveTo(0, h);
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * w;
const y = h - Math.min(1, amp[i] / scale) * h;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, 'rgba(56,189,248,0.55)');
grad.addColorStop(1, 'rgba(56,189,248,0.05)');
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 1.25;
ctx.stroke();
// VFO marker: a vertical line at the current frequency within the span.
if (vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz) {
const x = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
ctx.strokeStyle = 'rgba(239,68,68,0.95)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
// Frequency scale (edges + centre), only when the rig reported the span.
if (lowHz > 0 && highHz > lowHz) {
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
ctx.fillStyle = 'rgba(226,232,240,0.75)';
ctx.font = '10px ui-monospace, monospace';
ctx.textBaseline = 'bottom';
ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2);
ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2);
ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2);
}
};
return (
<Card icon={Activity} title={t('icmp.spectrum')} accent="#38bdf8">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground truncate">
{on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')}
</span>
<div className="flex items-center gap-2 shrink-0">
{on && (
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
onChange={(v) => setMode(v === 'FIX')} />
)}
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
</div>
</div>
<div className="rounded-lg border border-border bg-black/70 overflow-hidden">
<canvas ref={canvasRef} onDoubleClick={onDblClick}
className="w-full block cursor-crosshair" style={{ height: 150 }} />
</div>
</Card>
);
}
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
const { t } = useI18n();
const [st, setSt] = useState<IcomState>(ZERO);
const [busy, setBusy] = useState(false);
const [tuning, setTuning] = useState(false);
const txRef = useRef(false);
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
const refresh = async () => {
setBusy(true);
try { await IcomRefresh(); } catch {}
await load();
setBusy(false);
};
useEffect(() => {
refresh();
const id = window.setInterval(load, 500); // fast poll so meters/TX feel live
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Optimistic local update + fire the command; the cache poll reconciles.
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
setSt((s) => ({ ...s, ...patch }));
fn().catch(() => {});
};
const toggleMox = () => {
const next = !txRef.current;
txRef.current = next;
set({ transmitting: next }, () => IcomSetPTT(next));
};
const tune = async () => {
setTuning(true);
try { await IcomTune(); } catch {}
window.setTimeout(() => setTuning(false), 4000);
};
if (!st.available) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
{t('icmp.notConnected')}
</div>
);
}
const tx = st.transmitting;
return (
<div className="h-full min-h-0 overflow-auto bg-background">
<div className="max-w-5xl mx-auto p-3 space-y-3">
{/* Header strip: model + mode + live RX/TX indicator + split badge. */}
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
<div className="flex items-center gap-2">
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
tx ? 'bg-red-600 text-white' : 'bg-emerald-600 text-white')}>
<span className={cn('size-2 rounded-full', tx ? 'bg-white animate-pulse' : 'bg-white/90')} />
{tx ? 'TX' : 'RX'}
</span>
<span className="text-sm font-bold">{st.model || 'Icom'}</span>
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
{st.split ? <span className="rounded-md bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600">Split</span> : null}
</div>
<button type="button" onClick={refresh} disabled={busy}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
</button>
</div>
{/* Spectrum panadapter (full width). */}
<ScopePanadapter />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Live meters. */}
<Card icon={Zap} title={t('icmp.meters')} accent="#f59e0b">
{tx ? (
<>
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
</>
) : (() => { const sp = sParts(st.s_meter); return (
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
); })()}
</Card>
{/* Transmit controls. */}
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
<Row label={t('icmp.power')}>
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
</Row>
<Row label={t('icmp.mic')}>
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
</Row>
<div className="flex items-center gap-2 pt-1">
<button type="button" onClick={toggleMox}
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
tx ? 'bg-red-600 border-red-600 text-white' : 'bg-card text-foreground border-border hover:bg-muted')}>
{tx ? 'TX ON' : 'MOX'}
</button>
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
<button type="button" onClick={tune} disabled={tuning}
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
tuning ? 'bg-amber-500 border-amber-500 text-white animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
TUNE
</button>
</div>
</Card>
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
<Row label="AF">
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
</Row>
<Row label="RF">
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
</Row>
<Row label="AGC">
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
</Row>
<Row label={t('icmp.preamp')}>
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
</Row>
<Row label="Att">
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
</Row>
<Row label={t('icmp.filter')}>
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
</Row>
</Card>
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
<LevelRow label="NB" on={st.nb} value={st.nb_level}
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
<LevelRow label="NR" on={st.nr} value={st.nr_level}
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
<div className="flex items-center gap-2">
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
</div>
</Card>
</div>
</div>
</div>
);
}
+231 -135
View File
@@ -14,22 +14,15 @@ function saveMapView(m: L.Map) {
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
}
// MainMap — Log4OM-style dual map for the Main tab:
// • Left: a world map with the great-circle path drawn from the operator to
// the contacted station, plus distance + short/long-path azimuth.
// • Right: a street map zoomed onto the contacted station's grid locator.
// The Main tab is built from two independent map panes that the operator can
// place on either side (Settings → Main view):
// • WorldMap ("map1"): a world map with the great-circle path from the
// operator to the contacted station, distance, short/long-path azimuth and
// the antenna beam lobe.
// • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid.
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
interface Props {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
}
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
@@ -53,6 +46,24 @@ const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const CARTO_ATTR = '&copy; OpenStreetMap &copy; CARTO';
const OSM_ATTR = '&copy; OpenStreetMap contributors';
// Selectable basemaps for the world (great-circle) map. All key-free and all
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
// overlay on top of an imagery basemap (so satellite keeps its names too).
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attr: CARTO_ATTR, subdomains: 'abcd' },
street: { label: 'Street', url: OSM, attr: OSM_ATTR },
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr: 'Tiles &copy; Esri — Source: Esri, Maxar, Earthstar Geographics',
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
};
function loadBasemap(): BasemapKey {
const v = localStorage.getItem('opslog.mapBasemap');
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
}
function dot(color: string): L.DivIcon {
return L.divIcon({
className: '',
@@ -62,14 +73,25 @@ function dot(color: string): L.DivIcon {
});
}
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) {
interface WorldProps {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
}
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) {
const worldRef = useRef<HTMLDivElement>(null);
const locatorRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null);
const locatorMap = useRef<L.Map | null>(null);
// Layers we add/remove as the QSO changes (kept separate from the basemap).
const worldOverlay = useRef<L.LayerGroup | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
const baseLayer = useRef<L.TileLayer | null>(null);
const labelsLayer = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
@@ -83,15 +105,192 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
if (worldRef.current && !worldMap.current) {
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
.setView([20, 0], 1);
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m;
// Restore the saved free-pan view when not auto-zooming.
const sv = loadMapView();
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
// Remember the view as the user pans/zooms (only meaningful when free).
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
}
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t);
}, []);
// Swap the basemap (and its optional place-name overlay) when the operator
// picks a different one. Vector overlays (path/beam) live in Leaflet's
// overlayPane, always above any tile layer, so nothing to re-stack there.
useEffect(() => {
const m = worldMap.current;
if (!m) return;
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
}, [basemap]);
// Redraw overlays whenever the operator/DX grids (or beam) change.
useEffect(() => {
const wm = worldMap.current, wo = worldOverlay.current;
if (!wm || !wo) return;
wo.clearLayers();
const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid);
// Station marker + antenna beam/boom are drawn whenever the station grid is
// known — independent of any DX. The antenna is always pointed somewhere, so
// the beam heading should show even before a callsign is entered.
if (from) {
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2;
const D = 5500; // lobe length (km)
// Great-circle radial out to distance D, stopping just short of the pole
// so a poleward line doesn't snap across the top of the Mercator map.
const radial = (b: number): [number, number][] => {
const out: [number, number][] = [];
const N = 64;
for (let i = 1; i <= N; i++) {
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
out.push([d.lat, d.lon]);
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
}
return out;
};
for (const az of beamAzimuths) {
// Draw the lobe as a DENSE fan of translucent great-circle radials, not
// a filled polygon: a polygon smears badly near the poles on Mercator
// (a poleward beam degenerates into a giant triangle), while each radial
// LINE stays clean at any azimuth. A small angular step makes the lines
// overlap into a solid-looking lobe (no separate strokes), darker near
// the antenna and fanning out toward the front.
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
}
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
// Dark casing under the boresight so the bright dashed line stays
// readable on any basemap. Same dashArray so the casing tracks each dash.
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
}
}
// Mechanical boom (rotor) direction — thin grey dashed line. Drawn when the
// Ultrabeam radiates elsewhere (reverse/bi) so the boom heading stays visible
// next to the red radiating lobe(s).
if (boomAzimuth != null) {
const bpts: [number, number][] = [[from.lat, from.lon]];
const N = 64, D = 5500;
for (let i = 1; i <= N; i++) {
const d = destinationPoint(from.lat, from.lon, boomAzimuth, (D * i) / N);
bpts.push([d.lat, d.lon]);
if (Math.abs(d.lat) > 86) break;
}
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
}
}
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
let arcPts: [number, number][] | null = null;
if (from && to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
}
if (autoZoom) {
if (from && to && arcPts) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} else if (to) {
wm.setView([to.lat, to.lon], 3);
} else if (from) {
wm.setView([from.lat, from.lon], 3);
}
}
setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
const path = pathBetween(fromGrid, toGrid);
return (
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" />
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button
key={k}
type="button"
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
title={`Basemap: ${BASEMAPS[k].label}`}
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
}`}
>
{BASEMAPS[k].label}
</button>
))}
</div>
{/* Auto-zoom toggle: on = frame QTHDX each QSO; off = free pan/zoom
(remembered across restarts), so the beam heading stays visible. */}
<button
type="button"
onClick={() => {
const v = !autoZoom;
setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current;
if (!v && m) saveMapView(m);
}}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
}`}
>
Zoom DX
</button>
{path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
</div>
)}
</div>
);
}
interface LocatorProps {
toGrid: string; // contacted-station grid
toLabel?: string; // DX callsign
}
// LocatorMap — street map zoomed onto the DX grid, the "map2" pane.
export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
const locatorRef = useRef<HTMLDivElement>(null);
const locatorMap = useRef<L.Map | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
useEffect(() => {
if (locatorRef.current && !locatorMap.current) {
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
@@ -99,79 +298,15 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
// The Main tab may have just become visible — fix tile sizing.
const t = window.setTimeout(() => {
worldMap.current?.invalidateSize();
locatorMap.current?.invalidateSize();
}, 80);
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t);
}, []);
// Redraw overlays whenever the operator/DX grids change.
useEffect(() => {
const wm = worldMap.current, lm = locatorMap.current;
const wo = worldOverlay.current, lo = locatorOverlay.current;
if (!wm || !lm || !wo || !lo) return;
wo.clearLayers();
const lm = locatorMap.current, lo = locatorOverlay.current;
if (!lm || !lo) return;
lo.clearLayers();
const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (from && beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2;
const D = 9000; // lobe length (km)
const radial = (b: number): [number, number][] =>
Array.from({ length: 14 }, (_, i) => {
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
return [d.lat, d.lon] as [number, number];
});
for (const az of beamAzimuths) {
const arc: [number, number][] = [];
for (let b = az - half; b <= az + half + 0.001; b += 2) {
const d = destinationPoint(from.lat, from.lon, b, D);
arc.push([d.lat, d.lon]);
}
const ring = unwrapLon([
[from.lat, from.lon],
...radial(az - half),
...arc,
...radial(az + half).reverse(),
]);
L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo);
// Boresight (dashed centre line).
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
}
}
// ── Left: world + great-circle arc ──
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo);
}
if (from && to) {
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
// Only re-frame the map when auto-zoom is on; otherwise keep the user's
// chosen (remembered) view so the beam heading stays visible.
if (autoZoom) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // include the arc
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
} else if (autoZoom && to) {
wm.setView([to.lat, to.lon], 3);
} else if (autoZoom && from) {
wm.setView([from.lat, from.lon], 3);
}
// ── Right: street map on the DX locator ──
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
@@ -181,57 +316,18 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
}
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
} else if (from) {
lm.setView([from.lat, from.lon], 5);
}
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
const path = pathBetween(fromGrid, toGrid);
setTimeout(() => { lm.invalidateSize(); }, 0);
}, [toGrid, toLabel]);
return (
<div className="flex flex-col h-full min-h-0 gap-2 p-2">
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
<div className="relative isolate rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" />
{/* Auto-zoom toggle: on = frame QTHDX each QSO; off = free pan/zoom
(remembered across restarts), so the beam heading stays visible. */}
<button
type="button"
onClick={() => {
const v = !autoZoom;
setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current;
if (!v && m) saveMapView(m); // entering free mode → remember current view
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
}}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
}`}
>
Zoom DX
</button>
{path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
</div>
)}
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
<div ref={locatorRef} className="absolute inset-0" />
{!gridToLatLon(toGrid) && (
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
Enter a grid or look up the callsign to center the map.
</div>
<div className="relative isolate rounded-lg overflow-hidden border border-border">
<div ref={locatorRef} className="absolute inset-0" />
{!gridToLatLon(toGrid) && (
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
Enter a grid or look up the callsign to center the map.
</div>
)}
</div>
</div>
)}
</div>
);
}
+399
View File
@@ -0,0 +1,399 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { QSOEditModal } from '@/components/QSOEditModal';
import { useI18n } from '@/lib/i18n';
import type { QSOForm } from '@/types';
import {
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
} from '@/../wailsjs/go/main/App';
import { netctl } from '@/../wailsjs/go/models';
ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
type Net = netctl.Net;
type Station = netctl.Station;
function fmtTimeOn(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
}
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
type Props = {
onLogged?: () => void;
countries?: string[];
bands?: string[];
modes?: string[];
};
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const { t } = useI18n();
const [nets, setNets] = useState<Net[]>([]);
const [selId, setSelId] = useState<string>('');
const [openId, setOpenId] = useState<string>('');
const [roster, setRoster] = useState<Station[]>([]);
const [active, setActive] = useState<QSOForm[]>([]);
const [error, setError] = useState('');
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
// Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false);
const [contact, setContact] = useState<Station>(emptyStation());
const [looking, setLooking] = useState(false);
const activeGrid = useRef<any>(null);
const rosterGrid = useRef<any>(null);
const isOpen = openId !== '' && openId === selId;
const refreshNets = useCallback(async () => {
try {
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
const arr = (list ?? []) as Net[];
setNets(arr);
setOpenId(oid ?? '');
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
} catch (e: any) { setError(String(e?.message ?? e)); }
}, []);
const refreshRoster = useCallback(async (id: string) => {
if (!id) { setRoster([]); return; }
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
catch (e: any) { setError(String(e?.message ?? e)); }
}, []);
const refreshActive = useCallback(async () => {
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
catch { /* ignore */ }
}, []);
useEffect(() => { refreshNets(); }, [refreshNets]);
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
// The roster side hides callsigns currently on the air (they live in the left
// grid until logged), mirroring Log4OM's two-list behaviour.
const activeCalls = useMemo(
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
[active],
);
const rosterShown = useMemo(
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
[roster, activeCalls],
);
async function newNet() {
const name = window.prompt(t('ncp.newNetPrompt'));
if (!name?.trim()) return;
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function renameNet() {
if (!selId) return;
const cur = nets.find((n) => n.id === selId);
const name = window.prompt(t('ncp.renamePrompt'), cur?.name ?? '');
if (!name?.trim()) return;
try { await NetRename(selId, name.trim()); await refreshNets(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function deleteNet() {
if (!selId) return;
const cur = nets.find((n) => n.id === selId);
if (!window.confirm(t('ncp.deleteConfirm', { name: cur?.name ?? '' }))) return;
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function toggleOpen() {
try {
if (isOpen) {
if (active.length > 0 &&
!window.confirm(t('ncp.closeConfirm', { n: active.length }))) return;
await NetClose(); setOpenId('');
} else {
await NetOpen(selId); setOpenId(selId); await refreshActive();
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Roster → active (start QSO).
async function activate(call: string) {
if (!isOpen || !call) return;
try { await NetActivate(call); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
async function deactivate(id?: number) {
if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Edit-modal handlers (operate on the in-memory draft, not the DB).
async function saveDraft(q: QSOForm) {
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function discardDraft(id: number) {
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Add-contact dialog.
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
async function lookupContact() {
const call = (contact.callsign ?? '').trim();
if (!call) return;
setLooking(true);
try {
const r = await NetLookup(call);
setContact((c) => netctl.Station.createFrom({
...c, callsign: (r.callsign || call).toUpperCase(),
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
}));
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setLooking(false); }
}
async function saveContact() {
const call = (contact.callsign ?? '').trim().toUpperCase();
if (!call || !selId) return;
try {
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
setContactOpen(false);
await refreshRoster(selId);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
async function removeSelectedRoster() {
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
if (sel.length === 0) return;
if (!window.confirm(t('ncp.removeConfirm', { n: sel.length }))) return;
try {
for (const s of sel) await NetRosterRemove(selId, s.callsign);
await refreshRoster(selId);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
], [t]);
const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
], [t]);
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
return (
<div className="flex flex-col min-h-0 flex-1">
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
<Plus className="size-3.5" /> {t('ncp.newNet')}
</Button>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
value={selId}
disabled={isOpen}
onChange={(e) => setSelId(e.target.value)}
title={isOpen ? t('ncp.closeToSwitch') : t('ncp.selectNetTitle')}
>
<option value="">{t('ncp.selectNetOption')}</option>
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
</select>
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
</Button>
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
<Trash2 className="size-3.5" /> {t('ncp.delete')}
</Button>
<div className="flex-1" />
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
<span className="text-[11px] text-muted-foreground">
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
</span>
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
</div>
<div className="flex min-h-0 flex-1">
{/* ACTIVE USERS (left) */}
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
{t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<QSOForm>
ref={activeGrid}
theme={hamlogTheme}
rowData={active}
columnDefs={activeCols}
defaultColDef={defaultColDef}
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div>
{isOpen && active.length > 0 && (
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button>
</div>
)}
</div>
{/* NET USERS / roster (right) */}
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
{t('ncp.netUsersRoster')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Station>
ref={rosterGrid}
theme={hamlogTheme}
rowData={rosterShown}
columnDefs={rosterCols}
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false}
getRowId={(p) => String((p.data as any).callsign)}
/>
</div>
</div>
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
</Button>
{isOpen && (
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
<PlusCircle className="size-3.5" /> {t('ncp.putOnAir')}
</Button>
)}
</div>
</div>
</div>
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
{editingDraft && (
<QSOEditModal
qso={editingDraft}
onSave={saveDraft}
onDelete={discardDraft}
onClose={() => setEditingDraft(null)}
countries={countries}
bands={bands}
modes={modes}
/>
)}
{/* Add / edit contact dialog */}
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('ncp.addContactTitle')}</DialogTitle>
<DialogDescription>{t('ncp.addContactDesc')}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 px-5 py-2">
<div className="flex items-end gap-2">
<div className="flex-1">
<Label className="text-[11px]">{t('ncp.callsign')}</Label>
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
</div>
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
<Search className="size-3.5" /> {looking ? '…' : t('ncp.search')}
</Button>
</div>
<div>
<Label className="text-[11px]">{t('ncp.name')}</Label>
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
</div>
<div>
<Label className="text-[11px]">QTH</Label>
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
</div>
<div>
<Label className="text-[11px]">{t('ncp.country')}</Label>
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>{t('ncp.cancel')}</Button>
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
<PlusCircle className="size-3.5" /> {t('ncp.saveInNet')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+22 -23
View File
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
type Band = { band: string; is_default: boolean };
type Antenna = {
@@ -38,6 +39,7 @@ type Props = {
};
export function OperatingPanel({ bands, onError }: Props) {
const { t } = useI18n();
const [tree, setTree] = useState<Station[]>([]);
const [loading, setLoading] = useState(true);
// expanded keeps which stations show their antennas; everything open by
@@ -131,7 +133,7 @@ export function OperatingPanel({ bands, onError }: Props) {
} catch (e: any) { onError(String(e?.message ?? e)); }
}
async function removeAntenna(stationId: number, antId: number) {
if (!confirm('Delete this antenna?')) return;
if (!confirm(t('op.deleteAntConfirm'))) return;
try {
await DeleteOperatingAntenna(antId);
setTree((prev) => prev.map((s) =>
@@ -149,23 +151,18 @@ export function OperatingPanel({ bands, onError }: Props) {
return (
<div className="space-y-3">
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
Define your rigs (stations) and the antennas connected to each one.
For every antenna, tick the bands it covers. <Star className="inline size-3 text-amber-500 fill-current align-text-bottom" /> marks
the default antenna for that band when you change the band in the
entry strip, the matching rig + antenna auto-fill the MY_RIG and
MY_ANTENNA ADIF fields. Only one antenna can be the default per
band; setting one clears the previous default.
{t('op.intro1')}<Star className="inline size-3 text-amber-500 fill-current align-text-bottom" />{t('op.intro2')}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={addStation}>
<Plus className="size-3.5" /> Add rig
<Plus className="size-3.5" /> {t('op.addRig')}
</Button>
</div>
{tree.length === 0 ? (
<div className="rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-xs text-muted-foreground italic">
No rig configured yet. Click "Add rig" to get started.
{t('op.noRig')}
</div>
) : (
<div className="space-y-2">
@@ -212,6 +209,7 @@ function StationRow({
onToggleExpanded, onUpdate, onDelete, onAddAntenna,
onUpdateAntenna, onDeleteAntenna,
}: StationRowProps) {
const { t } = useI18n();
const editKey = `station:${station.id}`;
const isEditing = editing === editKey;
const [draft, setDraft] = useState({
@@ -242,7 +240,7 @@ function StationRow({
type="button"
onClick={onToggleExpanded}
className="p-0.5 hover:bg-accent/40 rounded"
title={expanded ? 'Collapse' : 'Expand'}
title={expanded ? t('op.collapse') : t('op.expand')}
>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
@@ -253,12 +251,12 @@ function StationRow({
<Input
autoFocus
className="h-7 text-sm flex-1"
placeholder="Rig name (also stamped as MY_RIG)"
placeholder={t('op.rigNamePh')}
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
/>
<Label className="text-[11px] text-muted-foreground">Power (W)</Label>
<Label className="text-[11px] text-muted-foreground">{t('op.powerW')}</Label>
<Input
type="number"
min={0}
@@ -269,8 +267,8 @@ function StationRow({
onChange={(e) => setDraft((d) => ({ ...d, tx_pwr: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(null); }}
/>
<Button size="sm" onClick={commit}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
<Button size="sm" onClick={commit}>{t('btn.save')}</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
</div>
) : (
<>
@@ -279,13 +277,13 @@ function StationRow({
<span className="text-[11px] text-muted-foreground font-mono">{station.tx_pwr} W</span>
)}
<div className="flex-1" />
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title="Edit">
<Button size="icon" variant="ghost" className="size-6" onClick={() => setEditing(editKey)} title={t('op.edit')}>
<Edit2 className="size-3" />
</Button>
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={onAddAntenna}>
<Plus className="size-3" /> Antenna
<Plus className="size-3" /> {t('op.antenna')}
</Button>
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="Delete rig">
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title={t('op.deleteRig')}>
<Trash2 className="size-3" />
</Button>
</>
@@ -296,7 +294,7 @@ function StationRow({
<div className="p-2 space-y-2">
{(station.antennas ?? []).length === 0 ? (
<div className="text-[11px] text-muted-foreground italic pl-6">
No antenna yet click "Antenna" above to add one.
{t('op.noAntenna')}
</div>
) : (
(station.antennas ?? []).map((a) => (
@@ -329,6 +327,7 @@ type AntennaRowProps = {
};
function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }: AntennaRowProps) {
const { t } = useI18n();
const editKey = `antenna:${antenna.id}`;
const isEditing = editing === editKey;
const [draft, setDraft] = useState({ name: antenna.name });
@@ -377,13 +376,13 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
<Input
autoFocus
className="h-7 text-sm flex-1"
placeholder="Antenna name (also stamped as MY_ANTENNA)"
placeholder={t('op.antNamePh')}
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') commitNames(); if (e.key === 'Escape') setEditing(null); }}
/>
<Button size="sm" onClick={commitNames}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
<Button size="sm" onClick={commitNames}>{t('btn.save')}</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(null)}>{t('btn.cancel')}</Button>
</div>
) : (
<>
@@ -434,11 +433,11 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
? 'bg-amber-400 text-white'
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
)}
title={isDefault ? 'Default antenna for this band — click to unset' : 'Click to make this antenna the default for this band'}
title={isDefault ? t('op.defaultOn') : t('op.defaultOff')}
>
<Star className={cn('size-3', isDefault && 'fill-current')} />
<span className="text-[9px] font-bold uppercase tracking-wider">
{isDefault ? 'Default' : 'Set'}
{isDefault ? t('op.default') : t('op.set')}
</span>
</button>
)}
+198 -158
View File
@@ -1,20 +1,44 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]);
// Warm theme matching the other grids (Recent QSOs / Cluster).
const qslTheme = themeQuartz.withParams({
fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12,
});
type UploadRow = {
id: number; qso_date: string; callsign: string;
band: string; mode: string; country: string; status: string;
};
const UPLOAD_COLS: ColDef<UploadRow>[] = [
{ field: 'qso_date', headerName: 'Date UTC', valueFormatter: (p) => fmtDate(p.value), minWidth: 150 },
{ field: 'callsign', headerName: 'Callsign', cellClass: 'font-mono font-bold', width: 130 },
{ field: 'band', headerName: 'Band', width: 90 },
{ field: 'mode', headerName: 'Mode', width: 100 },
{ field: 'country', headerName: 'Country', flex: 1, minWidth: 140 },
{ field: 'status', headerName: 'Sent', width: 90, valueFormatter: (p) => p.value || '—' },
];
type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
@@ -23,19 +47,37 @@ type Confirmation = {
const SERVICES = [
{ v: 'qrz', label: 'QRZ.com' },
{ v: 'clublog', label: 'Club Log' },
{ v: 'hrdlog', label: 'HRDLog.net' },
{ v: 'eqsl', label: 'eQSL.cc' },
{ v: 'lotw', label: 'LoTW' },
{ v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' },
];
// label holds an i18n key (resolved with t() at render time).
const QSL_STATUSES = [
{ v: '_', label: 'leave' },
{ v: 'Y', label: 'Yes' },
{ v: 'N', label: 'No' },
{ v: 'R', label: 'Requested' },
{ v: 'I', label: 'Ignore' },
{ v: '_', label: 'qslm.leave' },
{ v: 'Y', label: 'qslm.yes' },
{ v: 'N', label: 'qslm.no' },
{ v: 'R', label: 'qslm.requested' },
{ v: 'I', label: 'qslm.ignore' },
];
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [
{ v: '_', label: 'qslm.leave' },
{ v: 'Bureau', label: 'qslm.viaBureau' },
{ v: 'Direct', label: 'qslm.viaDirect' },
{ v: 'Electronic', label: 'qslm.viaElectronic' },
];
// Maps a service value → its i18n label key (only for services with
// translatable names; brand names like QRZ.com stay as-is in SERVICES).
const SERVICE_LABEL_KEYS: Record<string, string> = {
pota: 'qslm.svcPota',
paper: 'qslm.svcPaper',
};
type LogQSO = {
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
@@ -44,13 +86,14 @@ type LogQSO = {
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[]; skipped_other_call: number; my_call: string };
// label holds an i18n key.
const SENT_STATUSES = [
{ v: 'R', label: 'Requested' },
{ v: 'N', label: 'No' },
{ v: 'Q', label: 'Queued' },
{ v: 'Y', label: 'Yes (already sent)' },
{ v: 'I', label: 'Invalid' },
{ v: '_', label: '— blank' },
{ v: 'R', label: 'qslm.sentRequested' },
{ v: 'N', label: 'qslm.sentNo' },
{ v: 'Q', label: 'qslm.sentQueued' },
{ v: 'Y', label: 'qslm.sentYes' },
{ v: 'I', label: 'qslm.sentInvalid' },
{ v: '_', label: 'qslm.sentBlank' },
];
function fmtDate(iso: string): string {
@@ -76,7 +119,16 @@ export function fmtQslDate(s?: string): string {
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
// and download confirmations, while the rest of the app stays usable.
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const { t } = useI18n();
const [service, setService] = useState('lotw');
// The callsign this profile signs/uploads/downloads as for the selected
// service (Force station callsign, else the profile call). Shown so the user
// knows WHICH of their calls a download/upload targets in a mixed-call log.
const [uploadCall, setUploadCall] = useState('');
useEffect(() => {
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
}, [service]);
const [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
@@ -95,13 +147,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [paperCall, setPaperCall] = useState('');
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
const [paperBusy, setPaperBusy] = useState(false);
const [paperMsg, setPaperMsg] = useState('');
const [qslRcvd, setQslRcvd] = useState('Y');
const [qslRcvd, setQslRcvd] = useState('N');
const [qslSent, setQslSent] = useState('_');
const [qslRcvdDate, setQslRcvdDate] = useState('');
const [qslSentDate, setQslSentDate] = useState('');
const [qslVia, setQslVia] = useState('');
const [qslVia, setQslVia] = useState('_');
const [qslNotes, setQslNotes] = useState('');
const [qslComment, setQslComment] = useState('');
const searchPaper = useCallback(async () => {
const c = paperCall.trim().toUpperCase();
@@ -112,14 +167,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const list = (r ?? []) as LogQSO[];
setPaperRows(list);
setPaperSel(new Set(list.map((x) => x.id)));
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
finally { setPaperBusy(false); }
}, [paperCall]);
function togglePaper(id: number) {
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
async function applyPaper() {
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
@@ -132,16 +185,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
sent_date: ymd(qslSentDate),
rcvd_date: ymd(qslRcvdDate),
via: qslVia,
via: qslVia === '_' ? '' : qslVia,
notes: qslNotes,
comment: qslComment,
} as any);
setPaperMsg(`${n} QSO updated.`);
setPaperMsg(t('qslm.qsoUpdated', { n }));
await searchPaper();
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
finally { setPaperBusy(false); }
}
const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
// Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
const [rows, setRows] = useState<any[]>([]);
const [selectedCount, setSelectedCount] = useState(0);
const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
@@ -172,10 +230,14 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
return () => { offLog(); offDone(); offConf(); };
}, []);
const selectedCount = selected.size;
const allSelected = rows.length > 0 && selected.size === rows.length;
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
// Grid selection → just track the count; ids are read from the grid at upload.
function onUploadRowSelected(ids: number[]) {
setUploadSelIds(ids);
setSelectedCount(ids.length);
}
const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) {
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
@@ -191,33 +253,24 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
setError('');
try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[];
const list = (r ?? []) as any[];
setRows(list);
setSelected(new Set(list.map((x) => x.id)));
setUploadSelIds(list.map((x) => x.id));
setSelectedCount(list.length);
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
setViewMode('upload');
setShowLog(false);
} catch (e: any) {
setError(String(e?.message ?? e));
setRows([]);
setSelected(new Set());
setSelectedCount(0);
} finally {
setSearching(false);
}
}, [service, sent]);
function toggle(id: number) {
setSelected((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id); else n.add(id);
return n;
});
}
function toggleAll() {
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
}
async function upload() {
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
const ids = uploadSelIds;
if (ids.length === 0) return;
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); }
@@ -245,91 +298,102 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{/* Search toolbar */}
<div className="flex items-end gap-3 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.service')}</label>
<Select value={service} onValueChange={setService}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{SERVICE_LABEL_KEYS[s.v] ? t(SERVICE_LABEL_KEYS[s.v]) : s.label}</SelectItem>)}</SelectContent>
</Select>
</div>
{uploadCall && (
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
<span
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
title={t('qslm.callsignScopeTitle')}
>
{uploadCall}
</span>
</div>
)}
{service === 'pota' ? (
<>
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
Sync hunter log
{t('qslm.syncHunterLog')}
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook">
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.onlyMyCallTitle')}>
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
Only my profile callsign
{t('qslm.onlyMyCall')}
</label>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title={t('qslm.addMissingTitle')}>
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
Add not-found QSOs to my log
{t('qslm.addMissing')}
</label>
<span className="text-[11px] text-muted-foreground self-center">Token in Settings External services POTA.</span>
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.potaToken')}</span>
</>
) : service === 'paper' ? (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.callsign')}</label>
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
onChange={(e) => setPaperCall(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
placeholder="e.g. DL1ABC" />
placeholder={t('qslm.callsignPlaceholder')} />
</div>
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Search
{t('qslm.search')}
</Button>
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
</>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.sentStatus')}</label>
<Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Select required
{t('qslm.selectRequired')}
</Button>
</>
)}
<div className="flex-1" />
{service === 'pota' && potaRes && (
<span className="text-xs text-muted-foreground">
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched{potaRes.skipped_other_call > 0 ? ` · ${potaRes.skipped_other_call} other call` : ''} / {potaRes.fetched}
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
</span>
)}
{service === 'paper' && paperRows.length > 0 && (
<span className="text-xs text-muted-foreground">{paperRows.length} QSO · {paperSel.size} selected</span>
<span className="text-xs text-muted-foreground">{t('qslm.paperCount', { total: paperRows.length, selected: paperSel.size })}</span>
)}
{!showLog && viewMode === 'confirmations' && (
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.filter')}</label>
<Select value={confFilter} onValueChange={setConfFilter}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="new">New (any)</SelectItem>
<SelectItem value="dxcc">New DXCC</SelectItem>
<SelectItem value="band">New band</SelectItem>
<SelectItem value="slot">New slot</SelectItem>
<SelectItem value="all">{t('qslm.filterAll')}</SelectItem>
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
</SelectContent>
</Select>
</div>
)}
{logLines.length > 0 && (
<Button variant="ghost" size="sm" className="h-8" onClick={() => (showLog ? viewResults() : setShowLog(true))}>
{showLog ? <><ListChecks className="size-3.5" /> Results</> : <><ScrollText className="size-3.5" /> Log</>}
{showLog ? <><ListChecks className="size-3.5" /> {t('qslm.results')}</> : <><ScrollText className="size-3.5" /> {t('qslm.log')}</>}
</Button>
)}
<span className="text-xs text-muted-foreground">
{viewMode === 'confirmations'
? `${shownConfs.length} / ${confirmations.length} confirmation(s)`
: `${rows.length} found · ${selectedCount} selected`}
? t('qslm.confCount', { shown: shownConfs.length, total: confirmations.length })
: t('qslm.foundCount', { found: rows.length, selected: selectedCount })}
</span>
</div>
@@ -339,58 +403,42 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{service === 'paper' ? (
paperRows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.paperEmpty')}</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th>
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
</tr>
</thead>
<tbody>
{paperRows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
onClick={() => togglePaper(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
</tr>
))}
</tbody>
</table>
// Same grid as Recent QSOs (full columns + column picker). Selection
// drives which QSOs the apply-form below updates; a search selects all.
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
rows={paperRows as any}
total={paperRows.length}
selectAllSignal={paperSelAllSig}
onRowSelected={(ids) => setPaperSel(new Set(ids))}
/>
</div>
)
) : service === 'pota' ? (
<div className="space-y-3">
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
{!potaRes && !potaErr && !potaSyncing && (
<div className="text-sm text-muted-foreground py-10 text-center">Click Sync hunter log to fetch your pota.app log and stamp park references.</div>
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.potaEmpty')}</div>
)}
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> Syncing with pota.app</div>}
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
{potaRes && (
<>
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries).
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
{potaRes.skipped_other_call > 0 && (
<> {potaRes.skipped_other_call} hunt(s) made under another callsign were skipped{potaRes.my_call ? ` (kept only ${potaRes.my_call})` : ''}.</>
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
)}
{' '}Rescan the POTA award to count the new references.
{' '}{t('qslm.potaRescan')}
</div>
{potaRes.unmatched_list?.length > 0 && (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">Activator</th><th className="py-1.5 px-2">Date UTC</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Park</th>
<th className="py-1.5 px-2">Why unmatched</th>
<th className="py-1.5 px-2">{t('qslm.thActivator')}</th><th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th>
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thPark')}</th>
<th className="py-1.5 px-2">{t('qslm.thWhyUnmatched')}</th>
</tr>
</thead>
<tbody>
@@ -398,7 +446,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<tr key={i}
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)}
title={u.qso_id > 0 ? 'Open this QSO to fix it' : ''}>
title={u.qso_id > 0 ? t('qslm.openToFix') : ''}>
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
<td className="py-1 px-2 font-mono">{u.date}</td>
<td className="py-1 px-2">{u.band}</td>
@@ -418,27 +466,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : showLog ? (
<div className="font-mono text-[11px] space-y-0.5 py-1">
{logLines.length === 0 ? (
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting</div>
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
) : logLines.map((l, i) => (
<div key={i} className={cn(
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
: 'text-foreground/90')}>{l}</div>
))}
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> working</div>}
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
</div>
) : viewMode === 'confirmations' ? (
shownConfs.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">
{confirmations.length === 0 ? 'No new confirmations.' : 'No confirmations match this filter.'}
{confirmations.length === 0 ? t('qslm.noNewConf') : t('qslm.noConfMatch')}
</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">New?</th>
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
</tr>
</thead>
<tbody>
@@ -450,9 +498,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2">
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">{t('qslm.newDxcc')}</span>
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">{t('qslm.newBand')}</span>
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">{t('qslm.newSlot')}</span>
: <span className="text-muted-foreground/50"></span>}
</td>
</tr>
@@ -461,35 +509,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</table>
)
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
<div className="text-sm text-muted-foreground py-10 text-center">{t('qslm.uploadEmpty')}</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
</td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
rows={rows as any}
total={rows.length}
selectAllSignal={uploadSelAllSig}
onRowSelected={onUploadRowSelected}
/>
</div>
)}
</div>
@@ -497,33 +526,44 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
{service === 'paper' && (
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL received</label>
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
<div className="flex gap-1.5">
<Select value={qslRcvd} onValueChange={setQslRcvd}>
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
</Select>
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title="QSL received date" />
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL sent</label>
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslSent')}</label>
<div className="flex gap-1.5">
<Select value={qslSent} onValueChange={setQslSent}>
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
</Select>
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title="QSL sent date" />
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
<Select value={qslVia} onValueChange={setQslVia}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.comment')}</label>
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder={t('qslm.commentPlaceholder')} />
</div>
<div className="flex-1" />
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
Apply to {paperSel.size} selected
{t('qslm.applyToSelected', { n: paperSel.size })}
</Button>
</div>
)}
@@ -533,18 +573,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<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">
<Button variant="outline" size="sm" onClick={download} disabled={busy}
title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" /> Download confirmations
title={t('qslm.downloadTitle')}>
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
</Button>
{/* Date window */}
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
<SelectTrigger className="h-8 w-[150px] text-xs" title="How far back to download">
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last">Since last download</SelectItem>
<SelectItem value="date">Since date</SelectItem>
<SelectItem value="all">All</SelectItem>
<SelectItem value="last">{t('qslm.sinceLast')}</SelectItem>
<SelectItem value="date">{t('qslm.sinceDate')}</SelectItem>
<SelectItem value="all">{t('qslm.sinceAll')}</SelectItem>
</SelectContent>
</Select>
{sinceMode === 'date' && (
@@ -553,16 +593,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
value={sinceDate}
onChange={(e) => setSinceDate(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
title={service === 'qrz' ? 'QRZ: filters by QSO date (no server-side received-date filter)' : 'LoTW: confirmations received since this date'}
title={service === 'qrz' ? t('qslm.sinceDateTitleQrz') : t('qslm.sinceDateTitleLotw')}
/>
)}
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.addNotFoundTitle')}>
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
{t('qslm.addNotFound')}
</label>
</div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
</Button>
</div>
)}
+88 -25
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -11,32 +12,39 @@ type Props = {
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => void;
};
const UPLOAD_TARGETS: { service: string; label: string }[] = [
{ service: 'qrz', label: 'Send to QRZ.com' },
{ service: 'clublog', label: 'Send to Club Log' },
{ service: 'lotw', label: 'Send to LoTW' },
const UPLOAD_TARGETS: { service: string; name: string }[] = [
{ service: 'qrz', name: 'QRZ.com' },
{ service: 'clublog', name: 'Club Log' },
{ service: 'hrdlog', name: 'HRDLog.net' },
{ service: 'eqsl', name: 'eQSL.cc' },
{ service: 'lotw', name: 'LoTW' },
];
// Lightweight right-click menu for the QSO grids. AG Grid's native context
// menu is an Enterprise feature, so this is a plain floating menu driven by
// onCellContextMenu. Closes on any outside click, scroll or Escape.
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
// onCellContextMenu. Stays open until the user clicks outside, presses Escape,
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
const { t } = useI18n();
useEffect(() => {
if (!menu) return;
const close = () => onClose();
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('mousedown', close);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', close);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
window.removeEventListener('keydown', onKey);
};
}, [menu, onClose]);
@@ -54,21 +62,21 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
onMouseDown={(e) => e.stopPropagation()}
>
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
{n} QSO{n > 1 ? 's' : ''} selected
{t('qctx.selected', { n })}
</div>
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
>
<Globe2 className="size-4 text-primary" />
<span>Fix country &amp; zones from cty.dat</span>
<span>{t('qctx.fixCountry')}</span>
</button>
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
>
<RefreshCw className="size-4 text-sky-600" />
<span>Update from QRZ.com</span>
<span>{t('qctx.updateQrz')}</span>
</button>
{onUpdateFromClublog && (
<button
@@ -76,19 +84,43 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
>
<BadgeCheck className="size-4 text-violet-600" />
<span>Update from ClubLog (exceptions)</span>
<span>{t('qctx.updateClublog')}</span>
</button>
)}
{onSendRecording && (
{(onSendRecording || onSendEQSL) && (
<>
<div className="my-1 border-t border-border" />
{onSendEQSL && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
>
<Mail className="size-4 text-amber-600" />
<span>{t('qctx.sendQslEmail')}</span>
</button>
)}
{onSendRecording && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }}
>
<Mail className="size-4 text-rose-600" />
<span>{t('qctx.sendRecording')}</span>
</button>
)}
</>
)}
{onBulkEdit && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }}
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
>
<Mail className="size-4 text-rose-600" />
<span>Send recording by e-mail</span>
<PencilLine className="size-4 text-indigo-600" />
<span>{t('qctx.bulkEdit', { n })}</span>
</button>
</>
)}
@@ -102,7 +134,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
onClick={() => { onExportSelected(menu.ids); onClose(); }}
>
<FileDown className="size-4 text-sky-600" />
<span>Export selected to ADIF ({n})</span>
<span>{t('qctx.exportSelectedAdif', { n })}</span>
</button>
)}
{onExportFiltered && (
@@ -111,7 +143,25 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
onClick={() => { onExportFiltered(); onClose(); }}
>
<FileDown className="size-4 text-violet-600" />
<span>Export filtered view to ADIF (no limit)</span>
<span>{t('qctx.exportFilteredAdif')}</span>
</button>
)}
{onExportCabrilloSelected && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
>
<FileDown className="size-4 text-amber-600" />
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
</button>
)}
{onExportCabrilloFiltered && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
>
<FileDown className="size-4 text-amber-700" />
<span>{t('qctx.exportFilteredCabrillo')}</span>
</button>
)}
</>
@@ -120,18 +170,31 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
{onSendTo && (
<>
<div className="my-1 border-t border-border" />
{UPLOAD_TARGETS.map((t) => (
{UPLOAD_TARGETS.map((target) => (
<button
key={t.service}
key={target.service}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendTo(t.service, menu.ids); onClose(); }}
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
>
<Upload className="size-4 text-emerald-600" />
<span>{t.label}</span>
<span>{t('qctx.sendTo', { name: target.name })}</span>
</button>
))}
</>
)}
{onDelete && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50"
onClick={() => { onDelete(menu.ids); onClose(); }}
>
<Trash2 className="size-4" />
<span>{t('qctx.delete', { n })}</span>
</button>
</>
)}
</div>
);
}
+152 -124
View File
@@ -20,6 +20,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n';
import type { QSOForm } from '@/types';
type QSO = QSOForm;
@@ -37,12 +38,13 @@ function pfxOf(call: string): string {
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
// label holds an i18n key (resolved with t() at render time).
const QSL_STATUSES = [
{ value: '_', label: '' },
{ value: 'Y', label: 'Yes' },
{ value: 'N', label: 'No' },
{ value: 'R', label: 'Requested' },
{ value: 'I', label: 'Ignore' },
{ value: '_', label: 'qedit.qslDash' },
{ value: 'Y', label: 'qedit.qslYes' },
{ value: 'N', label: 'qedit.qslNo' },
{ value: 'R', label: 'qedit.qslRequested' },
{ value: 'I', label: 'qedit.qslIgnore' },
];
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
@@ -63,16 +65,22 @@ const CONFIRMATIONS: ConfDef[] = [
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_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
// (brand names like LoTW/eQSL stay as their literal label in CONFIRMATIONS).
const CONF_LABEL_KEYS: Record<string, string> = {
QSL: 'qedit.confQslPaper',
};
// Colour-coded status cell for the confirmation grid.
function StatusCell({ value }: { value?: string }) {
const { t } = useI18n();
const v = (value || '').toUpperCase();
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
if (v === '') {
return <span className="block text-center text-[11px] text-muted-foreground"></span>;
}
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
: v === 'R' ? 'bg-orange-400 text-white'
: v === 'I' ? 'bg-stone-400 text-white'
@@ -94,6 +102,11 @@ function toLocalISO(d: any): string {
if (!d) return '';
const date = new Date(d);
if (isNaN(date.getTime())) return '';
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
// doesn't apply to a time struct), so a QSO with no end time arrives as a
// year-1 date. Treat anything that old as unset — otherwise the datetime
// field shows a garbage value and fights the user's typing.
if (date.getUTCFullYear() <= 1) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
}
@@ -123,17 +136,19 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
}
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n();
return (
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
</SelectContent>
</Select>
);
}
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
const { t } = useI18n();
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
// fall back to the built-in sets. Always include the QSO's own band/mode so an
// imported/legacy value is never silently dropped from the dropdown.
@@ -159,8 +174,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
const [dateOff, setDateOff] = useState(dateOffISO);
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false);
@@ -209,6 +225,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
// accepts letters (sections/zones), not just numbers.
function setExchange(which: 'srx' | 'stx', raw: string) {
const t = raw.trim();
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
}
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value }));
}
@@ -228,7 +252,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
async function fetchLookup() {
const call = (draft.callsign ?? '').trim().toUpperCase();
if (!call) { setLocalErr('Callsign required'); return; }
if (!call) { setLocalErr(t('qedit.callsignRequired')); return; }
setLooking(true);
setLocalErr('');
try {
@@ -252,14 +276,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
lon: r.lon || d.lon,
}));
} catch (e: any) {
setLocalErr('Lookup: ' + String(e?.message ?? e));
setLocalErr(t('qedit.lookupError', { msg: String(e?.message ?? e) }));
} finally {
setLooking(false);
}
}
function save() {
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
if (!draft.callsign?.trim()) { setLocalErr(t('qedit.callsignRequired')); return; }
setSaving(true);
setLocalErr('');
const out: any = {
@@ -325,23 +349,23 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="flex-row items-baseline gap-2">
<DialogTitle>Edit QSO</DialogTitle>
<DialogTitle>{t('qedit.title')}</DialogTitle>
<span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span>
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
<DialogDescription className="sr-only">{t('qedit.editFieldsFor', { id: draft.id })}</DialogDescription>
</DialogHeader>
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
<TabsList className="px-3 overflow-x-auto">
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
<TabsTrigger value="contact">Contact's details</TabsTrigger>
<TabsTrigger value="awards">Award Refs</TabsTrigger>
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
<TabsTrigger value="contest">Contest</TabsTrigger>
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
<TabsTrigger value="mystation">My Station</TabsTrigger>
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
<TabsTrigger value="qsoinfo">{t('qedit.tabQsoInfo')}</TabsTrigger>
<TabsTrigger value="contact">{t('qedit.tabContact')}</TabsTrigger>
<TabsTrigger value="awards">{t('qedit.tabAwards')}</TabsTrigger>
<TabsTrigger value="qsl">{t('qedit.tabQsl')}</TabsTrigger>
<TabsTrigger value="contest">{t('qedit.tabContest')}</TabsTrigger>
<TabsTrigger value="sat">{t('qedit.tabSat')}</TabsTrigger>
<TabsTrigger value="mystation">{t('qedit.tabMyStation')}</TabsTrigger>
<TabsTrigger value="moreadif">{t('qedit.tabMoreAdif')}</TabsTrigger>
<TabsTrigger value="extras">
ADIF fields
{t('qedit.tabAdifFields')}
{extrasCount > 0 && (
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
)}
@@ -359,7 +383,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
{/* Top: Callsign + RST + Fetch */}
<div className="flex items-end gap-2 mb-3">
<div className="flex flex-col flex-1 min-w-0">
<Label>Callsign</Label>
<Label>{t('qedit.callsign')}</Label>
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div>
@@ -368,39 +392,39 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="flex flex-col w-20"><Label>R</Label>
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
</Button>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
{/* ── Left column ── */}
<div className="flex flex-col gap-2.5">
<div><Label>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Band</Label>
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">RX Band</Label>
<Label className="w-20 shrink-0">{t('qedit.rxBand')}</Label>
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="_"></SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Mode</Label>
<Label className="w-20 shrink-0">{t('qedit.mode')}</Label>
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Country</Label>
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
<Label className="w-20 shrink-0">{t('qedit.country')}</Label>
<Combobox value={draft.country ?? ''} options={countries} placeholder={t('qedit.country')}
onChange={onCountryChange} className="flex-1" />
</div>
<div className="flex items-center gap-2">
@@ -408,16 +432,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
<Label>CQ</Label>
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title="DXCC entity # — set automatically from Country" />
<Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title={t('qedit.dxccTitle')} />
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Freq</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={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">RX Freq</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={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
@@ -425,19 +449,25 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
{/* ── Right column ── */}
<div className="flex flex-col gap-2.5">
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
<div><Label>{t('qedit.qsoStart')}</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
<div>
<Label className="flex items-center gap-2">
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
const on = !!c;
setEndEnabled(on);
// Prefill an empty end with the start time so the user
// only tweaks the minutes instead of typing a full date.
if (on && !dateOff) setDateOff(dateOn);
}} /> {t('qedit.qsoEnd')}
</Label>
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col flex-1"><Label>Grid</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
</div>
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
</div>
</div>
</TabsContent>
@@ -446,10 +476,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
{/* Left column */}
<div className="flex flex-col gap-2.5">
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
<div><Label>{t('qedit.county')}</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
<div><Label>{t('qedit.state')}</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
<div>
<Label>Continent</Label>
<Label>{t('qedit.continent')}</Label>
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
@@ -459,17 +489,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</Select>
</div>
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
</div>
{/* Right column */}
<div className="flex flex-col gap-2.5">
<div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
<div><Label>{t('qedit.email')}</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
<div className="flex items-end gap-2">
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
</div>
<div><Label>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
<div><Label>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
</div>
</div>
</TabsContent>
@@ -483,13 +513,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
{/* Right: computed awards (read-only) derived from this QSO */}
<div className="flex flex-col gap-1.5 min-w-0">
<span className="text-xs font-semibold">Computed (automatic)</span>
<span className="text-xs font-semibold">{t('qedit.computedAuto')}</span>
<p className="text-[11px] text-muted-foreground leading-snug">
Derived from this QSO's fields (DXCC, zones, prefix, notes). Not editable here.
{t('qedit.computedHint')}
</p>
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
{computedRefs.length === 0 ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('qedit.noneYet')}</div>
) : (
computedRefs.map((r) => (
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
@@ -513,27 +543,27 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
{/* Left: edit one confirmation channel at a time */}
<div className="flex-1 max-w-sm space-y-3">
<div>
<Label>Manage Confirmation</Label>
<Label>{t('qedit.manageConf')}</Label>
<Select value={confSel} onValueChange={setConfSel}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</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>)}</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
<div><Label>Received</Label>
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
<div><Label>{t('qedit.received')}</Label>
{def.rcvd
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />}
</div>
<div><Label>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
{def.via && (
<div className="col-span-2"><Label>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></div>
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
)}
</div>
<p className="text-[11px] text-muted-foreground">
Pick a channel, edit it the table on the right updates live. Everything is written when you click <strong>Save changes</strong>.
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
</p>
</div>
@@ -542,15 +572,15 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
<thead>
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-semibold">Type</th>
<th className="font-semibold">Sent</th>
<th className="font-semibold">Received</th>
<th className="text-left font-semibold">{t('qedit.thType')}</th>
<th className="font-semibold">{t('qedit.sent')}</th>
<th className="font-semibold">{t('qedit.received')}</th>
</tr>
</thead>
<tbody>
{CONFIRMATIONS.map((c) => (
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
<td className="font-medium pr-2 py-0.5">{c.label}</td>
<tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
</tr>
@@ -565,90 +595,88 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<TabsContent value="contest" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
<F label={t('qedit.contestId')} span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder={t('qedit.rcvdExchange')} onChange={(e) => setExchange('srx', e.target.value)} /></F>
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder={t('qedit.sentExchange')} onChange={(e) => setExchange('stx', e.target.value)} /></F>
<F label={t('qedit.check')}><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label={t('qedit.precedence')}><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label={t('qedit.arrlSection')}><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="sat" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Propagation mode">
<F label={t('qedit.propMode')}>
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
</Select>
</F>
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
<F label="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
<F label="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
<F label="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
<F label="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
<F label={t('qedit.satName')}><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
<F label={t('qedit.satMode')}><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
<F label={t('qedit.antAz')}><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.antEl')}><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.antPath')}><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="mystation" className="mt-0 space-y-3">
<p className="text-xs text-muted-foreground">These override the active station profile for this QSO only.</p>
<p className="text-xs text-muted-foreground">{t('qedit.myStationHint')}</p>
<div className="grid grid-cols-6 gap-3">
<F label="Station callsign" span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
<F label="Country" span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder="Country" onChange={(v) => set('my_country', v)} /></F>
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
<F label={t('qedit.gridExt')}><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
<F label="CQ zone"><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
<F label="ITU zone"><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.cqZone')}><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.ituZone')}><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
<F label="SOTA ref"><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
<F label="POTA ref"><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
<F label={t('qedit.sotaRef')}><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
<F label={t('qedit.potaRef')}><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
<F label="Street" span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
<F label="City" span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
<F label="Postal" span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
<F label="Rig" span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
<F label="Antenna" span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
</div>
</TabsContent>
<TabsContent value="moreadif" className="mt-0 space-y-4">
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</p>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.specialActivity')}</p>
<div className="grid grid-cols-6 gap-3">
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
<F label="SIG info" span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
<F label="WWFF ref" span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
<F label="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
<F label={t('qedit.sigInfo')} span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
<F label={t('qedit.wwffRef')} span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.region')}><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
</div>
</div>
{/* Power & propagation */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Power &amp; space weather</p>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
<div className="grid grid-cols-6 gap-3">
<F label="RX power (W)"><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
<F label="Distance (km)"><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
<F label="A index"><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
<F label="K index"><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
<F label={t('qedit.kIndex')}><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
</div>
</div>
{/* Identity & clubs */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Identity &amp; clubs</p>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.identityClubs')}</p>
<div className="grid grid-cols-6 gap-3">
<F label="Contacted op" span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
<F label="Former call (EQ_CALL)" span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
<F label="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
<F label={t('qedit.contactedOp')} span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.formerCall')} span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.class')}><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
@@ -658,28 +686,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
{/* Flags & credits */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags &amp; credits</p>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.flagsCredits')}</p>
<div className="grid grid-cols-6 gap-3">
<F label="QSO complete"><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
<F label="QSO random"><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
<F label="Silent key"><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
<F label={t('qedit.qsoComplete')}><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
<F label={t('qedit.qsoRandom')}><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
<F label={t('qedit.silentKey')}><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
<F label="Credit granted" span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
<F label="Credit submitted" span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
<F label={t('qedit.creditGranted')} span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
<F label={t('qedit.creditSubmitted')} span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
</div>
</div>
{/* My station extras */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">My station (ADIF)</p>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.myStationAdif')}</p>
<div className="grid grid-cols-6 gap-3">
<F label="My name" span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
<F label="My WWFF ref" span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
<F label="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
<F label="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
<F label="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
<F label="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
<F label="My VUCC grids" span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.myName')} span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
<F label={t('qedit.myWwffRef')} span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
<F label={t('qedit.myArrlSect')} span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
<F label={t('qedit.mySig')}><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
<F label={t('qedit.mySigInfo')} span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
<F label={t('qedit.myDarcDok')}><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
<F label={t('qedit.myVuccGrids')} span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
</div>
</div>
</TabsContent>
@@ -692,11 +720,11 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<DialogFooter className="!flex-row gap-2">
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
<Trash2 className="size-3.5" /> Delete
<Trash2 className="size-3.5" /> {t('qedit.delete')}
</Button>
<div className="flex-1" />
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={save} disabled={saving}>{saving ? 'Saving' : 'Save changes'}</Button>
<Button variant="outline" onClick={onClose} disabled={saving}>{t('qedit.cancel')}</Button>
<Button onClick={save} disabled={saving}>{saving ? t('qedit.saving') : t('qedit.saveChanges')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+210 -114
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
@@ -13,6 +13,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
// Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
@@ -45,15 +46,26 @@ const hamlogTheme = themeQuartz.withParams({
type Props = {
rows: QSOForm[];
total: number;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (id: number | null) => void;
onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => void;
// One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[];
};
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
@@ -91,123 +103,131 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
// Shared so the Worked-before grid (which now also shows full QSO records)
// can offer the exact same column choices without duplicating the catalog.
export const COL_CATALOG: ColEntry[] = [
// A factory taking the i18n `t()` so headerName/label strings are localized;
// hooks can't run at module level, so the component calls this with its own t.
type TFn = (key: string, vars?: Record<string, string | number>) => string;
export const makeColCatalog = (t: TFn): ColEntry[] => [
// ── QSO basics ──
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.band'), colId: 'band', headerName: t('rqg.c.band'), field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.band_rx'), colId: 'band_rx', headerName: t('rqg.c.band_rx'), field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
{ group: 'QSO', label: t('rqg.c.mode'), colId: 'mode', headerName: t('rqg.c.mode'), field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.submode'), colId: 'submode', headerName: t('rqg.c.submode'), field: 'submode' as any, width: 80, cellClass: 'font-mono' },
{ group: 'QSO', label: t('rqg.c.freq_hz'), colId: 'freq_hz', headerName: t('rqg.h.freq_hz'), field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.freq_rx_hz'), colId: 'freq_rx_hz', headerName: t('rqg.h.freq_rx_hz'), field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
{ group: 'QSO', label: t('rqg.c.rst_sent'), colId: 'rst_sent', headerName: t('rqg.c.rst_sent'), field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.rst_rcvd'), colId: 'rst_rcvd', headerName: t('rqg.c.rst_rcvd'), field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.tx_pwr'), colId: 'tx_pwr', headerName: t('rqg.c.tx_pwr'), field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
// ── Contacted station ──
{ group: 'Contacted', label: 'Name', colId: 'name', headerName: 'Name', field: 'name' as any, width: 170, defaultVisible: true },
{ group: 'Contacted', label: 'QTH', colId: 'qth', headerName: 'QTH', field: 'qth' as any, width: 200, defaultVisible: true },
{ group: 'Contacted', label: 'Address', colId: 'address', headerName: 'Address', field: 'address' as any, width: 200 },
{ group: 'Contacted', label: 'Country', colId: 'country', headerName: 'Country', field: 'country' as any, width: 150, defaultVisible: true },
{ group: 'Contacted', label: 'State', colId: 'state', headerName: 'State', field: 'state' as any, width: 80 },
{ group: 'Contacted', label: 'County', colId: 'cnty', headerName: 'County', field: 'cnty' as any, width: 130 },
{ group: 'Contacted', label: 'Continent',colId: 'cont', headerName: 'Cont', field: 'cont' as any, width: 60 },
{ group: 'Contacted', label: 'Grid', colId: 'grid', headerName: 'Grid', field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'Contacted', label: 'Grid Ext', colId: 'gridsquare_ext', headerName: 'GridExt', field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'VUCC grids',colId: 'vucc_grids', headerName: 'VUCC', field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'DXCC #', colId: 'dxcc', headerName: 'DXCC #', field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'CQZ', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'ITU', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'IOTA', colId: 'iota', headerName: 'IOTA', field: 'iota' as any, width: 80, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'SOTA ref', colId: 'sota_ref', headerName: 'SOTA', field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'POTA ref', colId: 'pota_ref', headerName: 'POTA', field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'Contacted', label: 'Age', colId: 'age', headerName: 'Age', field: 'age' as any, width: 60, type: 'rightAligned' },
{ group: 'Contacted', label: 'Lat', colId: 'lat', headerName: 'Lat', field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'Lon', colId: 'lon', headerName: 'Lon', field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: 'Email', colId: 'email', headerName: 'Email', field: 'email' as any, width: 180 },
{ group: 'Contacted', label: 'Web', colId: 'web', headerName: 'Web', field: 'web' as any, width: 180 },
{ group: 'Contacted', label: t('rqg.c.name'), colId: 'name', headerName: t('rqg.c.name'), field: 'name' as any, width: 170, defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.qth'), colId: 'qth', headerName: t('rqg.c.qth'), field: 'qth' as any, width: 200, defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.address'), colId: 'address', headerName: t('rqg.c.address'), field: 'address' as any, width: 200 },
{ group: 'Contacted', label: t('rqg.c.country'), colId: 'country', headerName: t('rqg.c.country'), field: 'country' as any, width: 150, defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.state'), colId: 'state', headerName: t('rqg.c.state'), field: 'state' as any, width: 80 },
{ group: 'Contacted', label: t('rqg.c.cnty'), colId: 'cnty', headerName: t('rqg.c.cnty'), field: 'cnty' as any, width: 130 },
{ group: 'Contacted', label: t('rqg.c.cont'),colId: 'cont', headerName: t('rqg.h.cont'), field: 'cont' as any, width: 60 },
{ group: 'Contacted', label: t('rqg.c.grid'), colId: 'grid', headerName: t('rqg.c.grid'), field: 'grid' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.gridsquare_ext'), colId: 'gridsquare_ext', headerName: t('rqg.h.gridsquare_ext'), field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.vucc_grids'),colId: 'vucc_grids', headerName: t('rqg.h.vucc_grids'), field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.dxcc'), colId: 'dxcc', headerName: t('rqg.c.dxcc'), field: 'dxcc' as any, width: 70, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.cqz'), colId: 'cqz', headerName: t('rqg.c.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.ituz'), colId: 'ituz', headerName: t('rqg.c.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.iota'), colId: 'iota', headerName: t('rqg.c.iota'), field: 'iota' as any, width: 80, cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.sota_ref'), colId: 'sota_ref', headerName: t('rqg.h.sota_ref'), field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.pota_ref'), colId: 'pota_ref', headerName: t('rqg.h.pota_ref'), field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
// ── QSL ──
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL sent', field: 'qsl_sent' as any, width: 80 },
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL rcvd', field: 'qsl_rcvd' as any, width: 80 },
{ group: 'QSL', label: 'QSL sent date',colId: 'qsl_sent_date', headerName: 'QSL S date', field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: 'QSL rcvd date',colId: 'qsl_rcvd_date', headerName: 'QSL R date', field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: 'QSL via', colId: 'qsl_via', headerName: 'QSL via', field: 'qsl_via' as any, width: 130 },
{ group: 'QSL', label: 'QSL msg', colId: 'qsl_msg', headerName: 'QSL msg', field: 'qsl_msg' as any, width: 200 },
{ group: 'QSL', label: 'QSL msg rcvd', colId: 'qslmsg_rcvd', headerName: 'QSL msg rcvd', field: 'qslmsg_rcvd' as any, width: 200 },
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
// ── LoTW ──
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW sent', field: 'lotw_sent' as any, width: 80 },
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW rcvd', field: 'lotw_rcvd' as any, width: 80 },
{ group: 'LoTW', label: 'LoTW sent date', colId: 'lotw_sent_date', headerName: 'LoTW S date', field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'LoTW', label: 'LoTW rcvd date', colId: 'lotw_rcvd_date', headerName: 'LoTW R date', field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── eQSL ──
{ group: 'eQSL', label: 'eQSL sent', colId: 'eqsl_sent', headerName: 'eQSL sent', field: 'eqsl_sent' as any, width: 80 },
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// 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: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
// ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
// for QRZ only, a "download status/date" (= it came back confirmed). We
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
{ group: 'Uploads', label: 'ClubLog sent', colId: 'clublog_qso_upload_status', headerName: 'ClubLog sent', field: 'clublog_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: 'ClubLog sent date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog S date', field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: 'HRDLog sent date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog S date', field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_status' as any, width: 100 },
{ group: 'Uploads', label: 'QRZ.com sent date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com S date', field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'QRZ.com rcvd date', colId: 'qrzcom_qso_download_date', headerName: 'QRZ.com R date', field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
{ 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 },
{ 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) },
// ── Contest ──
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
{ group: 'Contest', label: 'SRX', colId: 'srx', headerName: 'SRX', field: 'srx' as any, width: 60, type: 'rightAligned' },
{ group: 'Contest', label: 'STX', colId: 'stx', headerName: 'STX', field: 'stx' as any, width: 60, type: 'rightAligned' },
{ group: 'Contest', label: 'SRX string', colId: 'srx_string', headerName: 'SRX str', field: 'srx_string' as any, width: 100 },
{ group: 'Contest', label: 'STX string', colId: 'stx_string', headerName: 'STX str', field: 'stx_string' as any, width: 100 },
{ group: 'Contest', label: 'Check', colId: 'check', headerName: 'Check', field: 'check' as any, width: 70 },
{ group: 'Contest', label: 'Precedence', colId: 'precedence', headerName: 'Precedence', field: 'precedence' as any, width: 90 },
{ group: 'Contest', label: 'ARRL section',colId: 'arrl_sect', headerName: 'ARRL sect', field: 'arrl_sect' as any, width: 90 },
{ 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.srx'), colId: 'srx', headerName: t('rqg.c.srx'), field: 'srx' as any, width: 60, type: 'rightAligned' },
{ group: 'Contest', label: t('rqg.c.stx'), colId: 'stx', headerName: t('rqg.c.stx'), field: 'stx' as any, width: 60, type: 'rightAligned' },
{ group: 'Contest', label: t('rqg.c.srx_string'), colId: 'srx_string', headerName: t('rqg.h.srx_string'), field: 'srx_string' as any, width: 100 },
{ group: 'Contest', label: t('rqg.c.stx_string'), colId: 'stx_string', headerName: t('rqg.h.stx_string'), field: 'stx_string' as any, width: 100 },
{ group: 'Contest', label: t('rqg.c.check'), colId: 'check', headerName: t('rqg.c.check'), field: 'check' as any, width: 70 },
{ group: 'Contest', label: t('rqg.c.precedence'), colId: 'precedence', headerName: t('rqg.c.precedence'), field: 'precedence' as any, width: 90 },
{ group: 'Contest', label: t('rqg.c.arrl_sect'),colId: 'arrl_sect', headerName: t('rqg.h.arrl_sect'), field: 'arrl_sect' as any, width: 90 },
// ── Propagation / antenna ──
{ group: 'Propagation', label: 'Prop mode', colId: 'prop_mode', headerName: 'Prop', field: 'prop_mode' as any, width: 80 },
{ group: 'Propagation', label: 'Sat name', colId: 'sat_name', headerName: 'Sat', field: 'sat_name' as any, width: 110 },
{ group: 'Propagation', label: 'Sat mode', colId: 'sat_mode', headerName: 'Sat mode', field: 'sat_mode' as any, width: 80 },
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
{ group: 'Propagation', label: t('rqg.c.prop_mode'), colId: 'prop_mode', headerName: t('rqg.h.prop_mode'), field: 'prop_mode' as any, width: 80 },
{ group: 'Propagation', label: t('rqg.c.sat_name'), colId: 'sat_name', headerName: t('rqg.h.sat_name'), field: 'sat_name' as any, width: 110 },
{ group: 'Propagation', label: t('rqg.c.sat_mode'), colId: 'sat_mode', headerName: t('rqg.c.sat_mode'), field: 'sat_mode' as any, width: 80 },
{ group: 'Propagation', label: t('rqg.c.ant_az'), colId: 'ant_az', headerName: t('rqg.h.ant_az'), field: 'ant_az' as any, width: 70, type: 'rightAligned' },
{ group: 'Propagation', label: t('rqg.c.ant_el'), colId: 'ant_el', headerName: t('rqg.h.ant_el'), field: 'ant_el' as any, width: 70, type: 'rightAligned' },
{ group: 'Propagation', label: t('rqg.c.ant_path'), colId: 'ant_path', headerName: t('rqg.h.ant_path'), field: 'ant_path' as any, width: 70 },
// ── My station (operator side) ──
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
{ group: 'My station', label: 'Operator', colId: 'operator', headerName: 'Operator',field: 'operator' as any, width: 100, cellClass: 'font-mono' },
{ group: 'My station', label: 'My grid', colId: 'my_grid', headerName: 'My grid', field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
{ group: 'My station', label: 'My country', colId: 'my_country', headerName: 'My ctry', field: 'my_country' as any, width: 130 },
{ group: 'My station', label: 'My state', colId: 'my_state', headerName: 'My state',field: 'my_state' as any, width: 80 },
{ group: 'My station', label: 'My county', colId: 'my_cnty', headerName: 'My cnty', field: 'my_cnty' as any, width: 110 },
{ group: 'My station', label: 'My IOTA', colId: 'my_iota', headerName: 'My IOTA', field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
{ group: 'My station', label: 'My SOTA', colId: 'my_sota_ref', headerName: 'My SOTA', field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'My station', label: 'My POTA', colId: 'my_pota_ref', headerName: 'My POTA', field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'My station', label: 'My DXCC', colId: 'my_dxcc', headerName: 'My DXCC#',field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
{ group: 'My station', label: 'My CQ zone', colId: 'my_cq_zone', headerName: 'My CQZ', field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
{ group: 'My station', label: 'My ITU zone', colId: 'my_itu_zone', headerName: 'My ITU', field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
{ group: 'My station', label: 'My lat', colId: 'my_lat', headerName: 'My lat', field: 'my_lat' as any, width: 90, type: 'rightAligned' },
{ group: 'My station', label: 'My lon', colId: 'my_lon', headerName: 'My lon', field: 'my_lon' as any, width: 90, type: 'rightAligned' },
{ group: 'My station', label: 'My street', colId: 'my_street', headerName: 'Street', field: 'my_street' as any, width: 160 },
{ group: 'My station', label: 'My city', colId: 'my_city', headerName: 'City', field: 'my_city' as any, width: 130 },
{ group: 'My station', label: 'My ZIP', colId: 'my_postal_code', headerName: 'ZIP', field: 'my_postal_code' as any, width: 80 },
{ group: 'My station', label: 'My rig', colId: 'my_rig', headerName: 'My rig', field: 'my_rig' as any, width: 130 },
{ group: 'My station', label: 'My antenna', colId: 'my_antenna', headerName: 'My ant', field: 'my_antenna' as any, width: 130 },
{ group: 'My station', label: t('rqg.c.station_callsign'), colId: 'station_callsign', headerName: t('rqg.h.station_callsign'), field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
{ group: 'My station', label: t('rqg.c.operator'), colId: 'operator', headerName: t('rqg.c.operator'),field: 'operator' as any, width: 100, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_grid'), colId: 'my_grid', headerName: t('rqg.c.my_grid'), field: 'my_grid' as any, width: 85, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_country'), colId: 'my_country', headerName: t('rqg.h.my_country'), field: 'my_country' as any, width: 130 },
{ group: 'My station', label: t('rqg.c.my_state'), colId: 'my_state', headerName: t('rqg.c.my_state'),field: 'my_state' as any, width: 80 },
{ group: 'My station', label: t('rqg.c.my_cnty'), colId: 'my_cnty', headerName: t('rqg.h.my_cnty'), field: 'my_cnty' as any, width: 110 },
{ group: 'My station', label: t('rqg.c.my_iota'), colId: 'my_iota', headerName: t('rqg.c.my_iota'), field: 'my_iota' as any, width: 80, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_sota'), colId: 'my_sota_ref', headerName: t('rqg.c.my_sota'), field: 'my_sota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_pota'), colId: 'my_pota_ref', headerName: t('rqg.c.my_pota'), field: 'my_pota_ref' as any, width: 110, cellClass: 'font-mono' },
{ group: 'My station', label: t('rqg.c.my_dxcc'), colId: 'my_dxcc', headerName: t('rqg.h.my_dxcc'),field: 'my_dxcc' as any, width: 80, type: 'rightAligned' },
{ group: 'My station', label: t('rqg.c.my_cq_zone'), colId: 'my_cq_zone', headerName: t('rqg.h.my_cq_zone'), field: 'my_cq_zone' as any, width: 70, type: 'rightAligned' },
{ group: 'My station', label: t('rqg.c.my_itu_zone'), colId: 'my_itu_zone', headerName: t('rqg.h.my_itu_zone'), field: 'my_itu_zone' as any, width: 70, type: 'rightAligned' },
{ group: 'My station', label: t('rqg.c.my_lat'), colId: 'my_lat', headerName: t('rqg.c.my_lat'), field: 'my_lat' as any, width: 90, type: 'rightAligned' },
{ group: 'My station', label: t('rqg.c.my_lon'), colId: 'my_lon', headerName: t('rqg.c.my_lon'), field: 'my_lon' as any, width: 90, type: 'rightAligned' },
{ group: 'My station', label: t('rqg.c.my_street'), colId: 'my_street', headerName: t('rqg.h.my_street'), field: 'my_street' as any, width: 160 },
{ group: 'My station', label: t('rqg.c.my_city'), colId: 'my_city', headerName: t('rqg.h.my_city'), field: 'my_city' as any, width: 130 },
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
// ── Misc ──
{ group: 'Misc', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
{ group: 'Misc', label: 'Notes', colId: 'notes', headerName: 'Notes', field: 'notes' as any, width: 240 },
{ group: 'Misc', label: 'Created', colId: 'created_at', headerName: 'Created at', field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
{ group: 'Misc', label: t('rqg.c.notes'), colId: 'notes', headerName: t('rqg.c.notes'), field: 'notes' as any, width: 240 },
{ group: 'Misc', label: t('rqg.c.created'), colId: 'created_at', headerName: t('rqg.h.created'), field: 'created_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
{ group: 'Misc', label: t('rqg.c.updated'), colId: 'updated_at', headerName: t('rqg.h.updated'), field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
];
export const GROUP_ORDER = [
@@ -215,11 +235,25 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
// Localized display label for a column group (identifier stays English so it
// keeps working as a filter key).
const GRP_KEYS: Record<string, string> = {
QSO: 'rqg.grpQso', Contacted: 'rqg.grpContacted', QSL: 'rqg.grpQsl',
LoTW: 'rqg.grpLotw', eQSL: 'rqg.grpEqsl', Uploads: 'rqg.grpUploads',
Contest: 'rqg.grpContest', Propagation: 'rqg.grpProp',
'My station': 'rqg.grpMyStation', Misc: 'rqg.grpMisc',
};
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
// Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection.
function onCellContextMenu(e: any) {
@@ -241,10 +275,28 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
// Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
// overrides this so a previously toggled column wins.
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
// While AG Grid rebuilds columns (award columns load async), it fires column
// events that would otherwise trigger a save of the DEFAULT visibility before
// we re-apply the user's saved state — clobbering it. This flag suppresses the
// auto-save during that window. Set in the memo (runs at render, before the
// column events) and cleared by the re-apply effect below.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
});
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110,
cellClass: 'text-[11px]',
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols, COL_CATALOG, t]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true,
@@ -266,17 +318,37 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
// The award columns load asynchronously; when they arrive (or change) the
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
// default — wiping the user's saved visibility (award columns reappear,
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
// every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
}
function onSelectionChanged() {
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
}
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
if (selectAllSignal === undefined) return;
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
// ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
@@ -320,11 +392,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title="Clear all column filters">
<FilterX className="size-3.5" /> Clear filters
title={t('rqg.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns
<Columns3 className="size-3.5" /> {t('rqg.columns')}
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
@@ -361,17 +433,21 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered}
onExportCabrilloSelected={onExportCabrilloSelected}
onExportCabrilloFiltered={onExportCabrilloFiltered}
onDelete={onDelete}
/>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Columns</DialogTitle>
<DialogTitle>{t('rqg.columns')}</DialogTitle>
<DialogDescription>
Pick the columns you want visible in the Recent QSOs table.
Your selection is saved.
{t('rqg.pickerDesc')}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
@@ -381,10 +457,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
return (
<div key={group} className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('rqg.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('rqg.none')}</button>
</div>
</div>
<div className="flex flex-col gap-1">
@@ -401,10 +477,30 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
</div>
);
})}
{awardCols && awardCols.length > 0 && (
<div className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
</div>
</div>
<div className="flex flex-col gap-1">
{awardCols.map((a) => (
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
<span className="font-mono font-semibold">{a.code}</span>
<span className="text-muted-foreground truncate">{a.name}</span>
</label>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('rqg.resetDefaults')}</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('rqg.done')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+31 -3
View File
@@ -18,7 +18,9 @@ const GRATICULE = geoGraticule10();
interface Props {
bearing?: number | null; // short-path azimuth to DX (deg)
headings: number[]; // antenna heading(s) — rotor + Ultrabeam pattern
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
centerLat?: number | null; // operator latitude (projection centre)
centerLon?: number | null; // operator longitude
rotorEnabled?: boolean;
@@ -36,7 +38,7 @@ function pt(az: number, radius: number): [number, number] {
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
@@ -76,6 +78,18 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
{pattern && (
<span
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
: 'bg-emerald-200 text-emerald-900')}
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
: 'Ultrabeam normal'}>
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
</span>
)}
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span>
@@ -123,7 +137,21 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
); })()}
{/* antenna heading needle(s) — green; two when bidirectional */}
{/* mechanical boom (rotor) heading grey dashed needle, shown when the
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
operator sees where the antenna physically points vs where it boom-sits */}
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
const [x, y] = pt(boomHeading, MAP_R - 2);
return (
<g>
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
</g>
);
})()}
{/* radiating heading needle(s) — green; two when bidirectional */}
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
<g key={i}>
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
+14 -12
View File
@@ -6,6 +6,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
export interface RecentSpotQSO {
callsign: string;
@@ -33,6 +34,7 @@ interface Props {
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
// contact into the form.
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
const { t } = useI18n();
const [call, setCall] = useState('');
const [freqKHz, setFreqKHz] = useState('');
const [message, setMessage] = useState('');
@@ -56,8 +58,8 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
async function send() {
const c = call.trim().toUpperCase();
const f = parseFloat(freqKHz);
if (!c) { setError('Callsign required'); return; }
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
if (!c) { setError(t('spm.callRequired')); return; }
if (!f || f <= 0) { setError(t('spm.freqRequired')); return; }
setBusy(true);
setError('');
try {
@@ -84,25 +86,25 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Satellite className="size-4 text-primary" /> Send DX Spot
<Satellite className="size-4 text-primary" /> {t('spm.title')}
</DialogTitle>
</DialogHeader>
<div className="px-5 py-3 space-y-3">
<div className="flex gap-3">
<div className="flex flex-col flex-1">
<Label className="mb-1">Callsign</Label>
<Label className="mb-1">{t('spm.callsign')}</Label>
<Input
ref={callRef}
className="font-mono uppercase font-bold"
value={call}
onChange={(e) => setCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="DX call"
placeholder={t('spm.callPh')}
/>
</div>
<div className="flex flex-col w-32">
<Label className="mb-1">Frequency (kHz)</Label>
<Label className="mb-1">{t('spm.frequency')}</Label>
<Input
className="font-mono"
value={freqKHz}
@@ -113,18 +115,18 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
</div>
</div>
<div className="flex flex-col">
<Label className="mb-1">Message</Label>
<Label className="mb-1">{t('spm.message')}</Label>
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="e.g. CW · TNX QSO"
placeholder={t('spm.messagePh')}
/>
</div>
{recent.length > 0 && (
<div>
<Label className="mb-1 block">Latest QSOs</Label>
<Label className="mb-1 block">{t('spm.latestQsos')}</Label>
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
{recent.map((q, i) => (
<button
@@ -147,12 +149,12 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
<DialogFooter>
<span className="text-[11px] text-muted-foreground mr-auto self-center">
{ok ? 'Spot sent' : targetName ? `${targetName}` : 'Master cluster'}
{ok ? t('spm.spotSent') : targetName ? `${targetName}` : t('spm.masterCluster')}
</span>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={onClose} disabled={busy}>{t('spm.cancel')}</Button>
<Button onClick={send} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
{busy ? 'Sending' : 'Send spot'}
{busy ? t('spm.sending') : t('spm.sendSpot')}
</Button>
</DialogFooter>
</DialogContent>
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Local mirror of the Go struct — we duplicate the type rather than depend
// on the generated Wails model because the inline `as any` casts are
@@ -44,36 +45,36 @@ const SERVICE_TYPES: Array<{
{
id: 'wsjt',
direction: 'inbound',
label: 'WSJT-X / JTDX / MSHV',
hint: 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.',
label: 'udpp.svcWsjtLabel',
hint: 'udpp.svcWsjtHint',
defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' },
},
{
id: 'adif',
direction: 'inbound',
label: 'ADIF message (JTAlert, GridTracker)',
hint: 'Receives a single ADIF record per packet and logs it.',
label: 'udpp.svcAdifLabel',
hint: 'udpp.svcAdifHint',
defaults: { port: 2333, multicast: false },
},
{
id: 'n1mm',
direction: 'inbound',
label: 'N1MM Logger+ (contest XML)',
hint: 'Receives contest QSOs as XML messages.',
label: 'udpp.svcN1mmLabel',
hint: 'udpp.svcN1mmHint',
defaults: { port: 12060, multicast: false },
},
{
id: 'remote_call',
direction: 'inbound',
label: 'Remote callsign (DXHunter, custom)',
hint: 'A short text packet containing just a callsign — fills the entry field.',
label: 'udpp.svcRemoteLabel',
hint: 'udpp.svcRemoteHint',
defaults: { port: 12090, multicast: false },
},
{
id: 'db_updated',
direction: 'outbound',
label: 'DB updated → notify other apps',
hint: 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
label: 'udpp.svcDbLabel',
hint: 'udpp.svcDbHint',
defaults: { port: 2333, destination_ip: '127.0.0.1' },
},
];
@@ -81,6 +82,7 @@ const SERVICE_TYPES: Array<{
type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) {
const { t } = useI18n();
const [items, setItems] = useState<UDPConfig[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<UDPConfig | null>(null);
@@ -123,7 +125,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
}
async function remove(id: number) {
if (!confirm('Delete this UDP connection?')) return;
if (!confirm(t('udpp.deleteConfirm'))) return;
try {
await DeleteUDPIntegration(id);
setItems((prev) => prev.filter((x) => x.id !== id));
@@ -143,7 +145,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
} catch (e: any) { onError(String(e?.message ?? e)); }
}
if (loading) return <div className="text-xs text-muted-foreground italic">Loading</div>;
if (loading) return <div className="text-xs text-muted-foreground italic">{t('udpp.loading')}</div>;
const inbound = items.filter((i) => i.direction === 'inbound');
const outbound = items.filter((i) => i.direction === 'outbound');
@@ -151,15 +153,11 @@ export function UDPIntegrationsPanel({ onError }: Props) {
return (
<div className="space-y-4">
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
UDP connections let OpsLog talk to other ham radio software. Inbound
connections receive QSOs or callsigns and update the logbook live;
outbound connections notify other apps when you log a QSO locally.
Enable multicast to share a port with another listener without
conflict required for the typical WSJT-X 2237 setup.
{t('udpp.intro')}
</div>
<Section
title="Inbound — OpsLog listens"
title={t('udpp.inboundTitle')}
icon={<ArrowDownToLine className="size-4" />}
items={inbound}
onAdd={() => addNew('inbound')}
@@ -168,7 +166,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
onToggle={toggleEnabled}
/>
<Section
title="Outbound — OpsLog sends"
title={t('udpp.outboundTitle')}
icon={<ArrowUpFromLine className="size-4" />}
items={outbound}
onAdd={() => addNew('outbound')}
@@ -179,10 +177,10 @@ export function UDPIntegrationsPanel({ onError }: Props) {
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
<Button size="sm" variant="outline" onClick={reloadServers}>
<RefreshCcw className="size-3.5" /> Reload all
<RefreshCcw className="size-3.5" /> {t('udpp.reloadAll')}
</Button>
<span className="text-[11px] text-muted-foreground">
Restarts every enabled listener after a manual change.
{t('udpp.reloadHint')}
</span>
</div>
@@ -210,6 +208,7 @@ function Section({
onDelete: (id: number) => void;
onToggle: (c: UDPConfig) => void;
}) {
const { t } = useI18n();
return (
<div className="rounded-md border border-border bg-card">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
@@ -217,11 +216,11 @@ function Section({
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
<div className="flex-1" />
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onAdd}>
<Plus className="size-3" /> Add
<Plus className="size-3" /> {t('udpp.add')}
</Button>
</div>
{items.length === 0 ? (
<div className="px-3 py-3 text-xs text-muted-foreground italic">No connection.</div>
<div className="px-3 py-3 text-xs text-muted-foreground italic">{t('udpp.noConnection')}</div>
) : (
<div className="divide-y divide-border/60">
{items.map((c) => {
@@ -231,9 +230,9 @@ function Section({
<Checkbox checked={c.enabled} onCheckedChange={() => onToggle(c)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm truncate">{c.name || '(unnamed)'}</span>
<span className="font-semibold text-sm truncate">{c.name || t('udpp.unnamed')}</span>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{svc?.label ?? c.service_type}
{svc ? t(svc.label) : c.service_type}
</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono">
@@ -269,6 +268,7 @@ function EditDialog({
onCancel: () => void;
onSave: (c: UDPConfig) => void;
}) {
const { t } = useI18n();
const [draft, setDraft] = useState<UDPConfig>(cfg);
// Service-type list filtered to this connection's direction.
const services = SERVICE_TYPES.filter((s) => s.direction === draft.direction);
@@ -292,31 +292,34 @@ function EditDialog({
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>
{cfg.id === 0 ? 'New' : 'Edit'} {draft.direction} connection
{t('udpp.dialogTitle', {
action: cfg.id === 0 ? t('udpp.new') : t('udpp.edit'),
direction: draft.direction === 'inbound' ? t('udpp.directionInbound') : t('udpp.directionOutbound'),
})}
</DialogTitle>
<DialogDescription>
{currentService?.hint}
{currentService ? t(currentService.hint) : ''}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-5 py-4">
<div className="space-y-1">
<Label>Name</Label>
<Label>{t('udpp.name')}</Label>
<Input
autoFocus
placeholder={draft.direction === 'inbound' ? 'WSJT-X log' : 'Cloudlog notify'}
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>Service type</Label>
<Label>{t('udpp.serviceType')}</Label>
<Select value={draft.service_type} onValueChange={(v) => applyPreset(v as UDPConfig['service_type'])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{services.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
<SelectItem key={s.id} value={s.id}>{t(s.label)}</SelectItem>
))}
</SelectContent>
</Select>
@@ -324,7 +327,7 @@ function EditDialog({
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
<div className="space-y-1">
<Label>Port</Label>
<Label>{t('udpp.port')}</Label>
<Input
type="number"
min={1} max={65535}
@@ -341,13 +344,13 @@ function EditDialog({
checked={draft.multicast}
onCheckedChange={(c) => setDraft((d) => ({ ...d, multicast: !!c }))}
/>
<span>Multicast</span>
<span>{t('udpp.multicast')}</span>
</label>
</div>
{draft.multicast && (
<div className="space-y-1">
<Label>Multicast group</Label>
<Label>{t('udpp.multicastGroup')}</Label>
<Input
className="font-mono"
placeholder="224.0.0.1"
@@ -355,14 +358,14 @@ function EditDialog({
onChange={(e) => setDraft((d) => ({ ...d, multicast_group: e.target.value }))}
/>
<div className="text-[10px] text-muted-foreground">
Use the same group address as the sending app. WSJT-X default is 224.0.0.1.
{t('udpp.multicastHint')}
</div>
</div>
)}
{draft.direction === 'outbound' && (
<div className="space-y-1">
<Label>Destination IP</Label>
<Label>{t('udpp.destinationIp')}</Label>
<Input
className="font-mono"
placeholder="127.0.0.1"
@@ -377,17 +380,17 @@ function EditDialog({
checked={draft.enabled}
onCheckedChange={(c) => setDraft((d) => ({ ...d, enabled: !!c }))}
/>
<span>Enabled</span>
<span>{t('udpp.enabled')}</span>
</label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button variant="ghost" onClick={onCancel}>{t('udpp.cancel')}</Button>
<Button
onClick={() => onSave(draft)}
disabled={!draft.name.trim() || !draft.port}
>
Save
{t('udpp.save')}
</Button>
</DialogFooter>
</DialogContent>
+44 -18
View File
@@ -7,6 +7,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export interface WKMacro { label: string; text: string }
export interface WKStatus {
@@ -38,6 +39,10 @@ interface Props {
onToggleSendOnType: (on: boolean) => void;
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
onBackspace: () => void; // remove last not-yet-keyed char
autoCall: boolean; // repeat the clicked macro on a timer
autoCallSecs: number; // gap (s) after the message before repeating
onToggleAutoCall: (on: boolean) => void;
onSetAutoCallSecs: (n: number) => void;
}
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
@@ -48,7 +53,9 @@ export function WinkeyerPanel({
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
}: Props) {
const { t } = useI18n();
const [cwText, setCwText] = useState('');
const [speed, setSpeed] = useState(wpm);
// Step the speed (compact +/- control replaces the old slider).
@@ -91,30 +98,30 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? 'Sending' : `Connected (v${status.version})`) : 'Disconnected'} />
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{!connected ? (
<>
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder="COM port" /></SelectTrigger>
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
<SelectContent>
{ports.length === 0 && <SelectItem value="_" disabled>No ports</SelectItem>}
{ports.length === 0 && <SelectItem value="_" disabled>{t('wkp.noPorts')}</SelectItem>}
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Refresh ports" onClick={onRefreshPorts}>
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.refreshPorts')} onClick={onRefreshPorts}>
<RefreshCw className="size-3.5" />
</Button>
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
<Plug className="size-3.5" /> Connect
<Plug className="size-3.5" /> {t('wkp.connect')}
</Button>
</>
) : (
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
<Power className="size-3.5" /> Disconnect
<Power className="size-3.5" /> {t('wkp.disconnect')}
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Hide / disable WinKeyer" onClick={onClose}>
<Button variant="ghost" size="sm" className="h-7 px-1.5" title={t('wkp.hide')} onClick={onClose}>
<X className="size-3.5" />
</Button>
</div>
@@ -131,13 +138,13 @@ export function WinkeyerPanel({
{status.busy && <span className="ml-0.5 animate-pulse"></span>}
</div>
{/* Speed: number + up/down arrows (replaces the slider, saves height). */}
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="CW speed (WPM)">
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.cwSpeed')}>
<span className="font-mono text-sm font-bold tabular-nums">{speed}</span>
<span className="text-[9px] text-muted-foreground">wpm</span>
<div className="flex flex-col -my-0.5">
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title="Faster"
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title={t('wkp.faster')}
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronUp className="size-3.5" /></button>
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title="Slower"
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title={t('wkp.slower')}
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
</div>
</div>
@@ -147,31 +154,50 @@ export function WinkeyerPanel({
<div className="flex items-end gap-2">
<div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
CW text
{t('wkp.cwText')}
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title="Key each character live as you type (backspace removes un-sent chars)">
title={t('wkp.sendOnTypeHint')}>
<input type="checkbox" className="accent-primary" checked={sendOnType}
onChange={(e) => onToggleSendOnType(e.target.checked)} />
send on type
{t('wkp.sendOnType')}
</label>
</Label>
<Input
value={cwText}
onChange={(e) => onCwChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
placeholder={sendOnType ? 'Type — sent live' : 'Type and press Enter to send…'}
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
disabled={!connected}
className="font-mono uppercase"
/>
</div>
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
<Send className="size-3.5" /> {sendOnType ? 'Clear' : 'Send'}
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
</Button>
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title="Abort (clear keyer buffer)">
<Square className="size-3.5" /> Stop
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
<Square className="size-3.5" /> {t('wkp.stop')}
</Button>
</div>
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
someone answers. The seconds box is the gap AFTER the message. */}
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
title={t('wkp.autoCallHint')}>
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
onChange={(e) => onToggleAutoCall(e.target.checked)} />
{t('wkp.autoCall')}
</label>
<span className="text-[11px] text-muted-foreground">{t('wkp.gap')}</span>
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title={t('wkp.gapHint')}>
<input type="number" min={0} max={120}
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
<span className="text-[9px] text-muted-foreground">sec</span>
</div>
{autoCall && <span className="text-[10px] text-amber-600/80">{t('wkp.loopHint')}</span>}
</div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
<div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => (
@@ -187,7 +213,7 @@ export function WinkeyerPanel({
)}
>
<span className="text-[10px] font-mono text-primary font-semibold shrink-0">F{i + 1}</span>
<span className="text-xs font-medium truncate">{m.label || `Macro ${i + 1}`}</span>
<span className="text-xs font-medium truncate">{m.label || t('wkp.macroN', { n: i + 1 })}</span>
</button>
))}
</div>
+85 -27
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
@@ -12,9 +12,10 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types';
import { COL_CATALOG, GROUP_ORDER } from './RecentQSOsGrid';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -52,6 +53,10 @@ type Props = {
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[];
};
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
@@ -64,11 +69,15 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog (shared with the Recent QSOs grid).
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
}
@@ -92,10 +101,26 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
const count = wb?.count ?? 0;
const entries = wb?.entries ?? [];
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
// Suppress auto-save while AG Grid rebuilds columns (award columns load async),
// so the default visibility doesn't clobber the user's saved state. See the
// matching note in RecentQSOsGrid.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
});
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: t('wbg.awardTip', { name: a.name }),
width: 110,
cellClass: 'text-[11px]',
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols, COL_CATALOG, t]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
@@ -112,10 +137,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
// Re-apply the saved column state after the award columns load (they rebuild
// the column set), so the user's visibility choices win over the defaults.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
@@ -154,14 +190,14 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
if (!hasCall) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
Type a callsign in the entry strip to see prior contacts.
{t('wbg.typeCall')}
</div>
);
}
if (busy && count === 0) {
return (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
checking
{t('wbg.checking')}
</div>
);
}
@@ -169,8 +205,8 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
return (
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
<Star className="size-8 text-primary fill-current" />
<div className="text-2xl font-bold text-primary tracking-wider">NEW</div>
<div>No prior QSO with <span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>.</div>
<div className="text-2xl font-bold text-primary tracking-wider">{t('wbg.new')}</div>
<div>{t('wbg.noPriorPre')}<span className="font-mono font-semibold text-foreground">{currentCall.toUpperCase()}</span>{t('wbg.noPriorPost')}</div>
</div>
);
}
@@ -179,29 +215,29 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
<>
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border/60 bg-muted/30">
<div className="flex items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Worked before</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.workedBefore')}</span>
<span className="font-mono text-sm font-bold text-primary tracking-wider">{currentCall.toUpperCase()}</span>
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">{count}×</Badge>
</div>
<div className="text-[11px] text-muted-foreground">
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
{t('wbg.first')} <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
{t('wbg.last')} <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
</div>
{wb?.dxcc_name && (
<div className="text-[11px] text-muted-foreground">
DXCC: <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
{t('wbg.dxcc')} <strong className="text-foreground font-semibold">{wb.dxcc_name}</strong>
{typeof wb.dxcc_count === 'number' && wb.dxcc_count > 0 && (
<span> · {wb.dxcc_count} entity QSOs</span>
<span> · {t('wbg.entityQsos', { n: wb.dxcc_count })}</span>
)}
</div>
)}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title="Clear all column filters">
<FilterX className="size-3.5" /> Clear filters
title={t('wbg.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('wbg.clearFilters')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns
<Columns3 className="size-3.5" /> {t('wbg.columns')}
</Button>
</div>
@@ -238,20 +274,22 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
onDelete={onDelete}
/>
{count > entries.length && (
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
+ {count - entries.length} older QSOs (not shown capped for performance)
{t('wbg.olderQsos', { n: count - entries.length })}
</div>
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Worked-before columns</DialogTitle>
<DialogTitle>{t('wbg.pickerTitle')}</DialogTitle>
<DialogDescription>
Pick the columns you want visible in the Worked-before table.
{t('wbg.pickerDesc')}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
@@ -261,10 +299,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
return (
<div key={group} className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>none</button>
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('wbg.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('wbg.none')}</button>
</div>
</div>
<div className="flex flex-col gap-1">
@@ -281,10 +319,30 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
</div>
);
})}
{awardCols && awardCols.length > 0 && (
<div className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('wbg.grpAwards')}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('wbg.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('wbg.none')}</button>
</div>
</div>
<div className="flex flex-col gap-1">
{awardCols.map((a) => (
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
<span className="font-mono font-semibold">{a.code}</span>
<span className="text-muted-foreground truncate">{a.name}</span>
</label>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('wbg.resetDefaults')}</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('wbg.done')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+113
View File
@@ -0,0 +1,113 @@
// AutoEQSL drives automatic eQSL sending. The backend decides eligibility on
// log (auto-send on, recipient e-mail known, default template, not already
// sent) and emits "qsl:autosend"; this component does the part Go can't —
// render the card off-screen, rasterize it to JPEG and hand it back to
// SendEQSL. Jobs are processed one at a time. Mounted once, near the app root.
import { useCallback, useEffect, useRef, useState } from 'react';
import { EventsOn } from '../../../wailsjs/runtime/runtime';
import { RenderEQSL, SendEQSL } from '../../../wailsjs/go/main/App';
import type { RenderModel } from './qslTypes';
import { loadCardAssets, type CardAssets } from './qslAssets';
import { CardPreview } from './CardPreview';
import { rasterizeCard } from './rasterize';
interface Job {
qsoId: number;
templateId: number;
callsign: string;
}
interface Props {
onSent?: (callsign: string) => void;
onError?: (message: string) => void;
}
export function AutoEQSL({ onSent, onError }: Props) {
const queue = useRef<Job[]>([]);
const busy = useRef(false);
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
const svgEl = useRef<SVGSVGElement | null>(null);
const sentForRef = useRef<number | null>(null); // qsoId we've already fired SendEQSL for
// Keep the callbacks in refs so they never change the effects' identity — a
// toast/grid re-render from onSent must NOT re-run the send effect (that
// re-sent the same eQSL many times in a row).
const onSentRef = useRef(onSent);
const onErrorRef = useRef(onError);
useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; });
// Pull the next job, fetch its render model + assets, then mount it (the
// effect below rasterizes once the DOM has it).
const pump = useCallback(async () => {
if (busy.current) return;
const job = queue.current.shift();
if (!job) return;
busy.current = true;
try {
const model = JSON.parse(await RenderEQSL(job.qsoId, job.templateId)) as RenderModel;
const assets = await loadCardAssets(model.template, job.templateId);
setCurrent({ job, model, assets });
} catch (e) {
onErrorRef.current?.(`Auto eQSL: ${e}`);
busy.current = false;
void pump();
}
}, []);
useEffect(() => {
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
// Dedupe: ignore a repeat event for a QSO we're already handling/handled.
if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return;
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
void pump();
});
return () => off();
}, [pump]);
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
// Sends exactly once per job (guarded by sentForRef), independent of renders.
useEffect(() => {
if (!current) return;
if (sentForRef.current === current.job.qsoId) return; // already sent this one
let cancelled = false;
void (async () => {
try {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
if (cancelled || !svgEl.current) return;
sentForRef.current = current.job.qsoId;
const card = current.model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
onSentRef.current?.(current.job.callsign);
} catch (e) {
onErrorRef.current?.(`Auto eQSL: ${e}`);
} finally {
if (!cancelled) {
setCurrent(null);
busy.current = false;
void pump();
}
}
})();
return () => { cancelled = true; };
}, [current, pump]);
if (!current) return null;
// Off-screen at full card resolution so the rasterized output matches the
// editor preview exactly.
return (
<div
aria-hidden
style={{ position: 'fixed', left: -100000, top: 0, opacity: 0, pointerEvents: 'none' }}
>
<CardPreview
model={current.model}
assets={current.assets}
width={current.model.template.card.w}
svgRef={(el) => { svgEl.current = el; }}
/>
</div>
);
}
+554
View File
@@ -0,0 +1,554 @@
// CardPreview — the single SVG rendering implementation for the QSL card.
// Proposals, the live editor, thumbnails and final rasterization ALL go
// through this component so the output can never drift from the preview.
// Style presets are layer-stack recipes (see internal/qslcard/presets.go and
// section 4 of the design spec); the gel stack renders the text 7 times,
// back to front: halo, drop shadow, outline, bevel base, bevel light edge,
// gradient face, wavy gloss band clipped to the glyphs.
import { useEffect, useMemo, useRef, useState } from 'react';
import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes';
import { QSO_FIELD_LABELS } from './qslTypes';
import type { CardAssets } from './qslAssets';
import { FONT_WEIGHTS } from './qslAssets';
import { renderTextFx, type TextFxParams, type TextFxKind, type TextFxResult } from './textFx';
// An editor selection: an element index or the QSO box.
export type CardSelection = number | 'box' | null;
// Fraction of the font size between a glyph's em-box top and its cap line.
// Used to make a text element's y align to the visible letter tops.
const CAP_INSET = 0.18;
interface Props {
model: RenderModel;
assets: CardAssets;
width: number; // display width in CSS px
selected?: CardSelection;
onSelect?: (sel: CardSelection) => void;
onMove?: (sel: Exclude<CardSelection, null>, x: number, y: number) => void;
svgRef?: (el: SVGSVGElement | null) => void;
}
// Fallbacks mirroring the preset defaults in presets.go, applied when a
// document omits a knob the stack needs.
const GOLD = ['#FFD83A', '#FFC312', '#EE9400'];
const DEF_SHINE = { coverage: 0.5, opacity: 0.95 };
const DEF_HALO = { color: '#cdd9e4', blur: 6, opacity: 0.4 };
const DEF_SHADOW = { dx: 5, dy: 8, blur: 5, color: '#14243a', opacity: 0.5 };
const DEF_BEVEL = { dx: -2, dy: -4, dark: '#C27500', light: '#FFEFA0' };
// fontSpec maps a template font name to a concrete family + weight.
// "system-bold-sans" is the info-line workhorse: heavy UI sans, no embed.
function fontSpec(font?: string): { family: string; weight: number | 'normal' } {
if (!font || font === 'system-bold-sans') {
return { family: "'Segoe UI', Arial, sans-serif", weight: 800 };
}
return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' };
}
// Presets whose call text is rendered with the ported canvas FX (the only way
// to get the exact glossy/western look). Everything else stays plain SVG text.
const FX_GLOSSY = new Set(['gel_gold', 'gel_silver']);
const FX_WESTERN = new Set(['gel_gold_grunge', 'gel_silver_grunge']);
function fxKindFor(preset?: string): TextFxKind | null {
if (preset && FX_GLOSSY.has(preset)) return 'glossy';
if (preset && FX_WESTERN.has(preset)) return 'western';
return null;
}
function buildFxParams(e: CardElement): TextFxParams | null {
const kind = fxKindFor(e.style_preset);
if (!kind || !e.size) return null;
const fs = fontSpec(e.font);
const sp = e.style_params ?? {};
const fx = sp.fx ?? {};
const grad = sp.gradient ?? (kind === 'western' ? ['#FFC83A', '#F0A21E', '#D87A00'] : ['#ffe22d', '#ffd600', '#ffcc00']);
const silver = e.style_preset?.includes('silver');
return {
kind,
text: e.text ?? '',
weight: String(fs.weight === 'normal' ? 400 : fs.weight),
family: fs.family,
size: e.size,
space: e.size * (kind === 'western' ? 0.08 : 0.04),
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
// Dark inter-letter edge + rim — from the chosen colour palette, else the
// default near-black edge (the navy outline adaptStyle sets is for the old
// SVG stack, not this look).
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
// Per-call overrides from the editor (undefined → renderer default).
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
depth: fx.depth, angle: fx.angle, slant: fx.slant, grunge: fx.grunge,
bevel: fx.bevel, seed: fx.seed,
};
}
// Stable signature of an element's FX-relevant fields, so dragging (x/y only)
// doesn't trigger an expensive re-render of the canvas text.
function fxSignature(e: CardElement): string {
if (!fxKindFor(e.style_preset)) return '';
return [e.text, e.font, e.size, e.style_preset, (e.style_params?.gradient ?? []).join(','), JSON.stringify(e.style_params?.fx ?? {})].join('|');
}
function elementTransform(e: CardElement): string {
const r = e.rotate ? ` rotate(${e.rotate})` : '';
return `translate(${e.x} ${e.y})${r}`;
}
// Shared <text> layer of a style stack. All layers must carry identical
// glyph geometry — only paint/transform/filter differ.
function Layer({
e, content, fill, stroke, strokeWidth, transform, filter, opacity, paintOrder,
}: {
e: CardElement;
content: string;
fill: string;
stroke?: string;
strokeWidth?: number;
transform?: string;
filter?: string;
opacity?: number;
paintOrder?: 'stroke';
}) {
const f = fontSpec(e.font);
return (
<text
x={0}
y={0}
fontFamily={f.family}
fontWeight={f.weight}
fontSize={e.size}
dominantBaseline="text-before-edge"
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinejoin="round"
paintOrder={paintOrder}
transform={transform}
filter={filter}
opacity={opacity}
style={{ userSelect: 'none' }}
>
{content}
</text>
);
}
// glossWavePath builds the gel highlight band: full glyph width, bottom
// edge a gentle quadratic wave (amplitude ≈ 4.5% of the font size, period
// ≈ 25% — i.e. ~10 px and ~55 px at the 220 px design size). The wavy line
// is what produces the "gel" look; the band is clipped to the glyphs.
function glossWavePath(e: CardElement, content: string, coverage: number): string {
const size = e.size ?? 100;
const w = 0.78 * size * Math.max(content.length, 1);
const capTop = 0.16 * size; // cap height starts below the em-box top
const bottom = capTop + coverage * 0.72 * size;
const amp = 0.045 * size;
const period = 0.25 * size;
let d = `M 0 0 H ${w.toFixed(1)} V ${bottom.toFixed(1)}`;
let i = 0;
for (let x = w; x > 0.1; x -= period, i++) {
const x2 = Math.max(0, x - period);
const mid = (x + x2) / 2;
const dy = i % 2 === 0 ? amp : -amp;
d += ` Q ${mid.toFixed(1)} ${(bottom + dy).toFixed(1)} ${x2.toFixed(1)} ${bottom.toFixed(1)}`;
}
return d + ' Z';
}
// TextStack renders one text element through its style preset.
function TextStack({ e, idx, content }: { e: CardElement; idx: number; content: string }) {
const p: StyleParams = e.style_params ?? {};
const preset = e.style_preset ?? 'flat_modern';
const ow = p.outline_width ?? 0;
if (preset === 'flat_modern') {
return <Layer e={e} content={content} fill={p.color ?? '#ffffff'} />;
}
if (preset === 'script_white' || preset === 'outlined_white') {
return (
<>
{p.shadow && (
<Layer
e={e} content={content} fill={p.shadow.color} stroke={p.shadow.color}
strokeWidth={2} transform={`translate(${p.shadow.dx} ${p.shadow.dy})`}
filter={`url(#qsl-blur-sh-${idx})`} opacity={p.shadow.opacity}
/>
)}
<Layer
e={e} content={content} fill={p.color ?? '#ffffff'}
stroke={p.outline_color ?? '#22364e'} strokeWidth={ow || 2} paintOrder="stroke"
/>
</>
);
}
// Gel family + classic white outline: the layered stack.
const gradient = p.gradient ?? GOLD;
const shadow = p.shadow ?? DEF_SHADOW;
const outline = p.outline_color ?? '#2a3f5c';
const outlineW = ow || 9;
const isGrunge = preset === 'gel_gold_grunge' || preset === 'gel_silver_grunge';
const isGel = preset === 'gel_gold' || preset === 'gel_silver' || isGrunge;
const halo = p.halo ?? DEF_HALO;
const bevel = p.bevel_offset ?? DEF_BEVEL;
const shine = p.shine ?? DEF_SHINE;
const faceT = isGel ? `translate(${bevel.dx} ${bevel.dy})` : undefined;
return (
<>
{isGel && (
<Layer
e={e} content={content} fill="none" stroke={halo.color}
strokeWidth={outlineW * 2.5} filter={`url(#qsl-blur-halo-${idx})`} opacity={halo.opacity}
/>
)}
<Layer
e={e} content={content} fill={shadow.color} stroke={shadow.color} strokeWidth={2}
transform={`translate(${shadow.dx} ${shadow.dy})`}
filter={`url(#qsl-blur-sh-${idx})`} opacity={shadow.opacity}
/>
<Layer
e={e} content={content} fill={outline} stroke={outline}
strokeWidth={outlineW} paintOrder="stroke"
/>
{isGel && <Layer e={e} content={content} fill={bevel.dark} />}
{isGel && (
<Layer
e={e} content={content} fill={bevel.light}
transform={`translate(${2 * bevel.dx} ${2 * bevel.dy})`}
/>
)}
<Layer e={e} content={content} fill={`url(#qsl-grad-${idx})`} transform={faceT} />
{isGel && (
<path
d={glossWavePath(e, content, shine.coverage)}
fill={`url(#qsl-gloss-${idx})`}
clipPath={`url(#qsl-clip-${idx})`}
transform={faceT}
opacity={shine.opacity}
/>
)}
{/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */}
{isGrunge && (
<rect
x={0} y={0}
width={0.82 * (e.size ?? 0) * Math.max(content.length, 1)}
height={(e.size ?? 0) * 1.05}
fill="#241405"
filter={`url(#qsl-grunge-${idx})`}
clipPath={`url(#qsl-clip-${idx})`}
transform={faceT}
opacity={0.6}
/>
)}
{/* defs that belong to this stack (unique ids per element index) */}
<defs>
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
{gradient.map((c, i) => (
<stop key={i} offset={`${(i / Math.max(gradient.length - 1, 1)) * 100}%`} stopColor={c} />
))}
</linearGradient>
{isGel && (
<>
<linearGradient id={`qsl-gloss-${idx}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.05" />
</linearGradient>
<clipPath id={`qsl-clip-${idx}`}>
<text
x={0} y={0} fontFamily={fontSpec(e.font).family} fontWeight={fontSpec(e.font).weight}
fontSize={e.size} dominantBaseline="text-before-edge"
>
{content}
</text>
</clipPath>
<filter id={`qsl-blur-halo-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={halo.blur} />
</filter>
</>
)}
{isGrunge && (
<filter id={`qsl-grunge-${idx}`} x="-5%" y="-5%" width="110%" height="110%">
{/* sparse dark specks: fractal noise → threshold into the alpha */}
<feTurbulence type="fractalNoise" baseFrequency="0.14 0.2" numOctaves={2} seed={9} result="n" />
<feColorMatrix in="n" type="matrix"
values="0 0 0 0 0.14 0 0 0 0 0.08 0 0 0 0 0.02 0 0 0 6 -4.2" />
</filter>
)}
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={shadow.blur} />
</filter>
</defs>
</>
);
}
// QSO box field column weights — the date string is far wider than the rest,
// so equal columns let it spill into the next field. Wider weight = more room.
const QSO_FIELD_WEIGHT: Record<string, number> = {
qso_date: 2, time_on: 1, band: 1, mode: 1, rst_sent: 0.9, freq: 1.4, submode: 1.1,
};
// QSOBoxView renders the confirmation box with per-QSO values.
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
const avail = box.w - 56;
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
let cursor = 28;
const cols = box.fields.map((f) => {
const w = (avail * (QSO_FIELD_WEIGHT[f] ?? 1)) / total;
const col = { f, x: cursor, w };
cursor += w;
return col;
});
return (
<>
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{box.title}
</text>
{cols.map(({ f, x }) => (
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
</text>
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{values[f] ?? ''}
</text>
</g>
))}
{box.footer && (
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
fontFamily="'Segoe UI', Arial, sans-serif">
{box.footer}
</text>
)}
</>
);
}
export function CardPreview({ model, assets, width, selected, onSelect, onMove, svgRef }: Props) {
const { template: t, qso_fields } = model;
const card = t.card;
const scale = card.w / width;
const rootRef = useRef<SVGSVGElement | null>(null);
const groupRefs = useRef(new Map<string, SVGGElement>());
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
const measureCanvas = useRef<HTMLCanvasElement | null>(null);
// Embedded fonts may still be loading when the card first mounts; the canvas
// FX needs them for correct metrics, so (re)render once they're ready.
const [fontsReady, setFontsReady] = useState(false);
useEffect(() => {
let alive = true;
const ready = (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve();
void ready.then(() => { if (alive) setFontsReady(true); });
return () => { alive = false; };
}, [assets]);
// Canvas-rendered call text (glossy/western), keyed on the FX-relevant fields
// so dragging doesn't regenerate it. Maps element index → PNG + size.
const fxSig = t.elements.map(fxSignature).join(';');
const fxImages = useMemo(() => {
const map = new Map<number, TextFxResult>();
if (!fontsReady) return map;
t.elements.forEach((e, idx) => {
const params = buildFxParams(e);
if (!params) return;
try { map.set(idx, renderTextFx(params)); } catch { /* leave unrendered */ }
});
return map;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fxSig, fontsReady]);
// Measure the selected group for the dashed selection outline. For plain SVG
// text we use the glyphs' real ink metrics — getBBox would span the font's
// full ascent/descent. FX call images fall through to getBBox (already tight).
useEffect(() => {
if (selected === null || selected === undefined) { setSelBox(null); return; }
const key = String(selected);
const g = groupRefs.current.get(key);
if (!g) { setSelBox(null); return; }
const transform = g.getAttribute('transform') ?? '';
const el = typeof selected === 'number' ? t.elements[selected] : undefined;
const isFx = el ? !!fxKindFor(el.style_preset) : false;
if (!isFx && el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) {
const cv = (measureCanvas.current ??= document.createElement('canvas'));
const ctx = cv.getContext('2d');
if (ctx) {
const f = fontSpec(el.font);
ctx.font = `${f.weight} ${el.size}px ${f.family}`;
const m = ctx.measureText(el.text ?? '');
const ascent = m.fontBoundingBoxAscent ?? el.size * 0.8;
const baselineY = -el.size * CAP_INSET + ascent; // -CAP_INSET matches the render's inner shift
const inkTop = baselineY - (m.actualBoundingBoxAscent ?? el.size * 0.7);
const inkBottom = baselineY + (m.actualBoundingBoxDescent ?? 0);
const left = -(m.actualBoundingBoxLeft ?? 0);
const right = m.actualBoundingBoxRight ?? m.width;
setSelBox({ t: transform, x: left, y: inkTop, w: right - left, h: inkBottom - inkTop });
return;
}
}
const b = g.getBBox();
setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height });
}, [selected, model, assets, width, t.elements, fxImages]);
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
(ev: React.PointerEvent<SVGGElement>) => {
onSelect?.(sel);
if (!onMove) return;
ev.stopPropagation();
drag.current = { sel, sx: ev.clientX, sy: ev.clientY, ox, oy };
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
};
const onPointerMove = (ev: React.PointerEvent<SVGSVGElement>) => {
const d = drag.current;
if (!d || !onMove) return;
const x = Math.round(d.ox + (ev.clientX - d.sx) * scale);
const y = Math.round(d.oy + (ev.clientY - d.sy) * scale);
onMove(d.sel, x, y);
};
const endDrag = () => { drag.current = null; };
const hero = assets.photos[t.hero.photo];
const crop = t.hero.crop;
const setGroupRef = (key: string) => (el: SVGGElement | null) => {
if (el) groupRefs.current.set(key, el);
else groupRefs.current.delete(key);
};
return (
<svg
ref={(el) => { rootRef.current = el; svgRef?.(el); }}
viewBox={`0 0 ${card.w} ${card.h}`}
width={width}
height={Math.round(width * (card.h / card.w))}
xmlns="http://www.w3.org/2000/svg"
style={{ display: 'block', borderRadius: 4 }}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onPointerDown={() => onSelect?.(null)}
>
<defs>
{/* Fonts must live INSIDE the SVG so rasterization (SVG <img>
canvas) sees them; document styles don't apply there. */}
<style>{assets.fontCss}</style>
<filter id="qsl-insert-shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="6" dy="10" stdDeviation="8" floodColor="#0a1422" floodOpacity="0.45" />
</filter>
</defs>
{/* hero photo, source crop mapped onto the full card */}
<rect width={card.w} height={card.h} fill="#1a2433" />
{hero && crop.w > 0 && crop.h > 0 && (
<image
href={hero.url}
x={(-crop.x * card.w) / crop.w}
y={(-crop.y * card.h) / crop.h}
width={(hero.w * card.w) / crop.w}
height={(hero.h * card.h) / crop.h}
preserveAspectRatio="none"
/>
)}
{t.hero.scrim?.enabled && (
<rect width={card.w} height={card.h} fill={t.hero.scrim.color} opacity={t.hero.scrim.opacity} />
)}
{t.elements.map((e, idx) => {
const key = String(idx);
if (e.hidden) return null; // toggled off in the editor
if (e.type === 'insert') {
const ph = assets.photos[e.photo ?? ''];
if (!ph || !e.w) return null;
const h = (e.w * ph.h) / ph.w;
const b = e.border_px ?? 0;
return (
<g
key={key} ref={setGroupRef(key)}
transform={`translate(${e.x} ${e.y})${e.rotate ? ` rotate(${e.rotate} ${e.w / 2} ${h / 2})` : ''}`}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<rect x={-b} y={-b} width={e.w + 2 * b} height={h + 2 * b} fill="#ffffff"
filter={e.shadow ? 'url(#qsl-insert-shadow)' : undefined} />
<image href={ph.url} width={e.w} height={h} preserveAspectRatio="none" />
</g>
);
}
if (e.type === 'country') {
const size = e.size ?? 30;
const flagH = size * 1.4;
const flagW = flagH * (4 / 3);
const hasFlag = !!assets.flagUrl;
return (
<g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
{hasFlag && <image href={assets.flagUrl} width={flagW} height={flagH} />}
<text
x={hasFlag ? flagW + 16 : 0} y={flagH / 2}
fontSize={size} fontWeight={700} fontFamily="'Segoe UI', Arial, sans-serif"
fill="#ffffff" stroke="#22364e" strokeWidth={4} paintOrder="stroke"
strokeLinejoin="round" dominantBaseline="middle"
>
{e.label === 'auto' ? '' : e.label}
</text>
</g>
);
}
// Text elements. Glossy/western calls render through the ported canvas
// FX as an <image> (its trimmed bounds already give a tight frame, so
// no CAP_INSET shim). Everything else is plain SVG text; the inner
// translate pulls the glyphs up to the em-box cap line so the element's
// y means "top of the visible letters".
const fx = fxImages.get(idx);
return (
<g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
{fx ? (
<image href={fx.url} x={0} y={0} width={fx.w} height={fx.h} />
) : (
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
<TextStack e={e} idx={idx} content={e.text ?? ''} />
</g>
)}
</g>
);
})}
{t.qso_box?.enabled && (
<g
ref={setGroupRef('box')}
transform={`translate(${t.qso_box.x} ${t.qso_box.y})`}
onPointerDown={startDrag('box', t.qso_box.x, t.qso_box.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<QSOBoxView box={t.qso_box} values={qso_fields} />
</g>
)}
{/* selection outline (editor only) */}
{selBox && onSelect && (
<g transform={selBox.t} pointerEvents="none">
<rect
x={selBox.x - 8} y={selBox.y - 8} width={selBox.w + 16} height={selBox.h + 16}
fill="none" stroke="#38bdf8" strokeWidth={3 * scale > 3 ? 3 * scale : 3}
strokeDasharray={`${10 * scale} ${6 * scale}`}
/>
</g>
)}
</svg>
);
}
+188
View File
@@ -0,0 +1,188 @@
// EditorPanel — the designer's right-hand controls: card-level options
// (scrim, name, default) and per-selection editing (font, size, rotation,
// style preset + params; insert width/border; QSO box geometry).
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
import type { CardSelection } from './CardPreview';
import { StylePresetPicker, NumberField } from './StylePresetPicker';
interface Props {
template: CardTemplate;
sel: CardSelection;
presets: QSLPresetInfo[];
fontFamilies: string[]; // embedded + detected system faces
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
onPatchBox: (patch: Partial<QSOBox>) => void;
onScrim: (enabled: boolean) => void;
onSelect?: (sel: CardSelection) => void;
}
const TYPE_LABELS: Record<string, string> = {
callsign: 'Callsign',
operator: 'Operator name',
info_line: 'Info line',
country: 'Country + flag',
insert: 'Photo insert',
};
// A readable layer name, disambiguating the two info lines and numbering inserts.
function layerLabel(el: CardElement, i: number, all: CardElement[]): string {
if (el.type === 'info_line') {
return /rig|antenna|ant\b/i.test(el.text ?? '') ? 'Station (rig / ant)' : 'Info line (zones)';
}
if (el.type === 'insert') {
const n = all.slice(0, i + 1).filter((x) => x.type === 'insert').length;
return `Photo insert ${n}`;
}
return TYPE_LABELS[el.type] ?? el.type;
}
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
onPatch: (idx: number, patch: Partial<CardElement>) => void;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Font</Label>
<Select value={e.font} onValueChange={(font) => onPatch(idx, { font })}>
<SelectTrigger className="h-8 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fontFamilies.map((f) => (
<SelectItem key={f} value={f}>{f === 'system-bold-sans' ? 'System bold' : f}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<NumberField label="Size" value={e.size ?? 0} min={8} max={500}
onChange={(size) => onPatch(idx, { size })} />
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-45} max={45}
onChange={(rotate) => onPatch(idx, { rotate })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Text</Label>
<Input className="h-7 w-44 font-mono text-xs" value={e.text ?? ''}
onChange={(ev) => onPatch(idx, { text: ev.target.value })} />
</div>
<Separator className="my-2" />
<StylePresetPicker
presets={presets}
preset={e.style_preset ?? 'flat_modern'}
params={e.style_params ?? {}}
onChange={(style_preset, style_params: StyleParams) =>
onPatch(idx, { style_preset, style_params })}
/>
</div>
);
}
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim, onSelect }: Props) {
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
const box = template.qso_box;
return (
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={!!template.hero.scrim?.enabled}
onCheckedChange={(v) => onScrim(v === true)}
/>
Darken photo behind text (scrim)
</label>
</div>
{/* Layers: show/hide each piece, click a name to edit it. */}
<div className="space-y-1 rounded-md border border-border p-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Show on card</div>
{template.elements.map((el, i) => (
<div key={i} className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === i && 'bg-muted')}>
<Checkbox checked={!el.hidden} onCheckedChange={(v) => onPatchElement(i, { hidden: v !== true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.(i)}>
{layerLabel(el, i, template.elements)}
</button>
</div>
))}
{box && (
<div className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === 'box' && 'bg-muted')}>
<Checkbox checked={box.enabled} onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.('box')}>
QSO info box
</button>
</div>
)}
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
</div>
{!e && sel !== 'box' && (
<p className="text-xs text-muted-foreground">
Click an element on the card to edit it; drag to move it.
</p>
)}
{e && (e.type === 'callsign' || e.type === 'operator' || e.type === 'info_line') && (
<TextControls e={e} idx={sel as number} presets={presets}
fontFamilies={fontFamilies} onPatch={onPatchElement} />
)}
{e && e.type === 'insert' && (
<div className="space-y-2">
<NumberField label="Width" value={e.w ?? 0} min={80} max={1200}
onChange={(w) => onPatchElement(sel as number, { w })} />
<NumberField label="Border" value={e.border_px ?? 0} min={0} max={40}
onChange={(border_px) => onPatchElement(sel as number, { border_px })} />
<NumberField label="Rotation °" value={e.rotate ?? 0} min={-15} max={15}
onChange={(rotate) => onPatchElement(sel as number, { rotate })} />
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={!!e.shadow}
onCheckedChange={(v) => onPatchElement(sel as number, { shadow: v === true })} />
Drop shadow
</label>
</div>
)}
{e && e.type === 'country' && (
<NumberField label="Size" value={e.size ?? 30} min={16} max={80}
onChange={(size) => onPatchElement(sel as number, { size })} />
)}
{sel === 'box' && box && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={box.enabled}
onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
Show QSO box
</label>
<NumberField label="Width" value={box.w} min={300} max={1400}
onChange={(w) => onPatchBox({ w })} />
<NumberField label="Height" value={box.h} min={120} max={500}
onChange={(h) => onPatchBox({ h })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Footer</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
onChange={(ev) => onPatchBox({ footer: ev.target.value })} />
</div>
</div>
)}
</div>
<p className="px-1 text-[11px] leading-snug text-muted-foreground">
Text supports {'{profile.*}'} and {'{qso.*}'} placeholders they fill in
automatically on every card.
</p>
</div>
);
}
@@ -0,0 +1,380 @@
// QslDesignerModal — the QSL card designer. Flow: drop/choose photos →
// "Generate designs" (3 automatic proposals from the placement engine) →
// pick one → live editor (click/drag elements, style controls) → save.
// Saved templates are listed with thumbnails and can be edited, deleted or
// marked default (used by "Send eQSL by e-mail" on the QSO grid).
import { useEffect, useRef, useState } from 'react';
import { Images, Loader2, Sparkles, Star, Trash2, Pencil, ChevronLeft } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
} from '../../../wailsjs/go/main/App';
import type {
CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo,
} from './qslTypes';
import { loadCardAssets, loadFonts, type CardAssets } from './qslAssets';
import { CardPreview, type CardSelection } from './CardPreview';
import { EditorPanel } from './EditorPanel';
import { rasterizeCard } from './rasterize';
interface Props {
open: boolean;
onClose: () => void;
}
interface Proposal {
template: CardTemplate;
model: RenderModel;
assets: CardAssets;
}
interface Editing {
templateId: number; // 0 = new (photos still at their original paths)
template: CardTemplate; // raw document ({placeholders})
model: RenderModel; // resolved copy for display
assets: CardAssets;
}
type View = 'home' | 'proposals' | 'editor';
async function resolveModel(template: CardTemplate): Promise<RenderModel> {
return JSON.parse(await QSLResolvePreview(JSON.stringify(template))) as RenderModel;
}
export function QslDesignerModal({ open, onClose }: Props) {
const [view, setView] = useState<View>('home');
const [photoPaths, setPhotoPaths] = useState<string[]>([]);
const [proposals, setProposals] = useState<Proposal[]>([]);
const [editing, setEditing] = useState<Editing | null>(null);
const [sel, setSel] = useState<CardSelection>(null);
const [name, setName] = useState('');
const [asDefault, setAsDefault] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
const [previews, setPreviews] = useState<Record<number, string>>({});
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
const [deleteArm, setDeleteArm] = useState(0);
const svgEl = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (!open) return;
setView('home');
setError('');
setPhotoPaths([]);
setProposals([]);
setEditing(null);
void refreshSaved();
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
void loadFonts().then(({ fonts }) =>
setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans']));
}, [open]);
async function refreshSaved() {
try {
const list = ((await QSLListTemplates()) ?? []) as QSLTemplateInfo[];
setSaved(list);
const p: Record<number, string> = {};
await Promise.all(list.map(async (t) => {
const url = await QSLPreviewDataURL(t.id);
if (url) p[t.id] = url;
}));
setPreviews(p);
} catch (e) {
setError(String(e));
}
}
async function choosePhotos() {
try {
const paths = ((await QSLPickPhotos()) ?? []) as string[];
if (paths.length) setPhotoPaths(paths.slice(0, 5));
} catch (e) {
setError(String(e));
}
}
async function generate() {
setBusy(true);
setError('');
try {
const docs = (await QSLGenerateProposals(photoPaths)) as string[];
const out: Proposal[] = [];
for (const doc of docs) {
const template = JSON.parse(doc) as CardTemplate;
const model = await resolveModel(template);
const assets = await loadCardAssets(model.template, 0);
out.push({ template, model, assets });
}
setProposals(out);
setView('proposals');
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
function openEditor(templateId: number, template: CardTemplate, model: RenderModel, assets: CardAssets) {
setEditing({ templateId, template, model, assets });
setName(template.name);
setAsDefault(false);
setSel(null);
setError('');
setView('editor');
}
async function editSaved(info: QSLTemplateInfo) {
setBusy(true);
setError('');
try {
const template = JSON.parse(await QSLGetTemplate(info.id)) as CardTemplate;
const model = await resolveModel(template);
const assets = await loadCardAssets(model.template, info.id);
openEditor(info.id, template, model, assets);
setAsDefault(info.is_default);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
// Edits patch the raw template AND the resolved display copy in lock-step;
// only a text change needs a backend re-resolve (placeholders may differ).
function patchBoth(fn: (t: CardTemplate) => void, reResolve = false) {
setEditing((cur) => {
if (!cur) return cur;
const template = structuredClone(cur.template);
const model = structuredClone(cur.model);
fn(template);
fn(model.template);
if (reResolve) {
void resolveModel(template).then((m) =>
setEditing((c) => (c ? { ...c, model: m } : c)));
}
return { ...cur, template, model };
});
}
const patchElement = (idx: number, patch: Partial<CardElement>) =>
patchBoth((t) => Object.assign(t.elements[idx], patch), 'text' in patch);
const patchBox = (patch: Partial<QSOBox>) =>
patchBoth((t) => { if (t.qso_box) Object.assign(t.qso_box, patch); }, 'footer' in patch || 'title' in patch);
const onMove = (target: Exclude<CardSelection, null>, x: number, y: number) =>
patchBoth((t) => {
if (target === 'box') {
if (t.qso_box) { t.qso_box.x = x; t.qso_box.y = y; }
} else {
t.elements[target].x = x;
t.elements[target].y = y;
}
});
const onScrim = (enabled: boolean) =>
patchBoth((t) => {
t.hero.scrim = { enabled, color: t.hero.scrim?.color ?? '#0b1a2e', opacity: t.hero.scrim?.opacity ?? 0.25 };
});
async function save() {
if (!editing) return;
setBusy(true);
setError('');
try {
const id = await QSLSaveTemplate(editing.templateId, name, JSON.stringify(editing.template), true);
if (svgEl.current) {
const card = editing.template.card;
const png = await rasterizeCard(svgEl.current, 480, Math.round(480 * (card.h / card.w)), 'image/png');
await QSLSavePreview(id, png);
}
if (asDefault) await QSLSetDefaultTemplate(id);
await refreshSaved();
setView('home');
setEditing(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
async function removeTemplate(id: number) {
if (deleteArm !== id) { setDeleteArm(id); return; }
setDeleteArm(0);
try {
await QSLDeleteTemplate(id);
await refreshSaved();
} catch (e) {
setError(String(e));
}
}
async function makeDefault(id: number) {
try {
await QSLSetDefaultTemplate(id);
await refreshSaved();
} catch (e) {
setError(String(e));
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[1260px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-5 text-amber-500" />
QSL card designer
{view !== 'home' && (
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
onClick={() => setView(view === 'editor' && proposals.length && editing?.templateId === 0 ? 'proposals' : 'home')}>
<ChevronLeft className="size-3.5" /> Back
</Button>
)}
</DialogTitle>
</DialogHeader>
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
{error && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
)}
{view === 'home' && (
<div className="space-y-5">
<section className="space-y-2">
<h3 className="text-sm font-semibold">New design</h3>
<p className="text-xs text-muted-foreground">
Pick 15 photos OpsLog analyzes them and proposes three card designs
with your callsign, name, zones and country placed automatically.
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={choosePhotos}>
<Images className="mr-1 size-4" /> Choose photos
</Button>
<Button size="sm" disabled={!photoPaths.length || busy} onClick={generate}>
{busy ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
Generate designs
</Button>
{photoPaths.length > 0 && (
<span className="text-xs text-muted-foreground">
{photoPaths.length} photo{photoPaths.length > 1 ? 's' : ''} selected
</span>
)}
</div>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">Saved templates</h3>
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
<div className="grid grid-cols-3 gap-3">
{saved.map((t) => (
<div key={t.id} className="rounded-md border border-border p-2">
{previews[t.id]
? <img src={previews[t.id]} alt={t.name} className="w-full rounded" />
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
<div className="mt-1.5 flex items-center justify-between gap-1">
<span className="truncate text-sm font-medium">
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />}
{t.name}
</span>
<span className="flex shrink-0 gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Edit"
onClick={() => editSaved(t)}>
<Pencil className="size-3.5" />
</Button>
{!t.is_default && (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" title="Set as default"
onClick={() => makeDefault(t.id)}>
<Star className="size-3.5" />
</Button>
)}
<Button variant="ghost" size="sm"
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`}
title="Delete" onClick={() => removeTemplate(t.id)}>
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
</Button>
</span>
</div>
</div>
))}
</div>
</section>
<p className="text-xs text-muted-foreground">
The eQSL e-mail message and the auto-send option are in Settings E-mail (SMTP).
</p>
</div>
)}
{view === 'proposals' && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Pick a design to refine it:</p>
<div className="grid grid-cols-3 gap-3">
{proposals.map((p, i) => (
<button
key={i}
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400"
onClick={() => openEditor(0, p.template, p.model, p.assets)}
>
<CardPreview model={p.model} assets={p.assets} width={352} />
</button>
))}
</div>
</div>
)}
{view === 'editor' && editing && (
<div className="flex gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="rounded-md bg-slate-800/60 p-3">
<CardPreview
model={editing.model}
assets={editing.assets}
width={760}
selected={sel}
onSelect={setSel}
onMove={onMove}
svgRef={(el) => { svgEl.current = el; }}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Name</Label>
<Input className="h-8 w-56" value={name} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Vietnam sunset" />
<label className="flex items-center gap-1.5 text-sm">
<Checkbox checked={asDefault} onCheckedChange={(v) => setAsDefault(v === true)} />
Default for this profile
</label>
<div className="flex-1" />
<Button size="sm" disabled={busy || !name.trim()} onClick={save}>
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Save template
</Button>
</div>
</div>
<EditorPanel
template={editing.template}
sel={sel}
presets={presets}
fontFamilies={fontFamilies}
onPatchElement={patchElement}
onPatchBox={patchBox}
onScrim={onScrim}
onSelect={setSel}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,126 @@
// SendEQSLModal — per-QSO eQSL flow: render the default template with the
// QSO's data, show the exact card, send it as a JPEG e-mail attachment on
// explicit confirmation (never automatic). On success the backend stamps
// eqsl_sent and emits "qsl:sent" so the grid refreshes.
import { useEffect, useRef, useState } from 'react';
import { Loader2, Mail, CheckCircle2 } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
QSLDefaultTemplateID, RenderEQSL, SendEQSL,
} from '../../../wailsjs/go/main/App';
import type { RenderModel } from './qslTypes';
import { loadCardAssets, type CardAssets } from './qslAssets';
import { CardPreview } from './CardPreview';
import { rasterizeCard } from './rasterize';
interface Props {
open: boolean;
qsoId: number | null;
onClose: () => void;
onOpenDesigner: () => void;
}
export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
const [model, setModel] = useState<RenderModel | null>(null);
const [assets, setAssets] = useState<CardAssets | null>(null);
const [templateId, setTemplateId] = useState(0);
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const svgEl = useRef<SVGSVGElement | null>(null);
useEffect(() => {
if (!open || !qsoId) return;
setModel(null);
setAssets(null);
setSent(false);
setError('');
void (async () => {
try {
const tid = await QSLDefaultTemplateID();
setTemplateId(tid);
if (!tid) return; // no template yet — offer the designer below
const m = JSON.parse(await RenderEQSL(qsoId, tid)) as RenderModel;
setModel(m);
setAssets(await loadCardAssets(m.template, tid));
} catch (e) {
setError(String(e));
}
})();
}, [open, qsoId]);
async function send() {
if (!qsoId || !model || !svgEl.current) return;
setBusy(true);
setError('');
try {
const card = model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(qsoId, templateId, jpeg);
setSent(true);
// Auto-close shortly after the success tick (the grid refreshes on the
// qsl:sent event); the user doesn't need to dismiss it manually.
window.setTimeout(onClose, 1100);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[820px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
</DialogTitle>
</DialogHeader>
<div className="space-y-3 px-6 py-5">
{error && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
)}
{open && !templateId && !error && (
<div className="space-y-3 py-2 text-sm">
<p>No QSL template yet design one first (drop a few photos, pick a layout).</p>
<Button size="sm" onClick={() => { onClose(); onOpenDesigner(); }}>Open the designer</Button>
</div>
)}
{templateId > 0 && !model && !error && (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Preparing the card
</div>
)}
{model && assets && (
<div className="rounded-md bg-slate-800/60 p-3">
<CardPreview model={model} assets={assets} width={740}
svgRef={(el) => { svgEl.current = el; }} />
</div>
)}
</div>
<DialogFooter>
{sent ? (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
</div>
) : (
<>
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
<Button size="sm" disabled={!model || busy} onClick={send}>
{busy && <Loader2 className="mr-1 size-4 animate-spin" />} Send
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,211 @@
// StylePresetPicker — preset choice + per-preset parameter controls for the
// selected text element. Only the knobs the preset declares (params) are
// shown, matching the backend whitelist so saving can't fail on a stray key.
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { QSLPresetInfo, StyleParams, FxParams } from './qslTypes';
interface Props {
presets: QSLPresetInfo[];
preset: string;
params: StyleParams;
onChange: (preset: string, params: StyleParams) => void;
}
// Quick colour palettes per FX family (mirrors the reference generators):
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
const GLOSSY_PALETTES: Palette[] = [
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
];
const WESTERN_PALETTES: Palette[] = [
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
];
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">{label}</Label>
<input
type="color"
className="h-7 w-10 cursor-pointer rounded border border-border bg-transparent"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
function SliderRow({ label, value, min, max, step, onChange }: {
label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void;
}) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="w-20 shrink-0 text-xs text-muted-foreground">{label}</Label>
<input type="range" className="min-w-0 flex-1" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
<span className="w-10 shrink-0 text-right text-xs tabular-nums">{value}</span>
</div>
);
}
export function StylePresetPicker({ presets, preset, params, onChange }: Props) {
const info = presets.find((p) => p.name === preset);
const has = (k: string) => info?.params.includes(k) ?? false;
const set = (patch: Partial<StyleParams>) => onChange(preset, { ...params, ...patch });
// gel_* presets are rendered by the canvas FX, not the SVG stack: show the FX
// knobs instead of the old shine/halo/shadow/outline rows.
const isFx = preset.startsWith('gel_');
const fxWestern = preset.includes('grunge');
const fx: FxParams = params.fx ?? {};
const setFx = (patch: Partial<FxParams>) => set({ fx: { ...fx, ...patch } });
return (
<div className="space-y-2">
<Select
value={preset}
onValueChange={(name) => {
const next = presets.find((p) => p.name === name);
// Switching presets resets the params to that preset's defaults —
// stale keys from the previous preset would be rejected on save.
onChange(name, next ? { ...next.defaults } : {});
}}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{presets.map((p) => (
<SelectItem key={p.name} value={p.name}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
{isFx && (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Palette</Label>
<div className="flex flex-wrap gap-1">
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
<button
key={p.name} type="button" title={p.name}
className="h-6 w-6 rounded border border-border"
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
onClick={() => onChange(preset, {
...params,
gradient: [p.top, p.mid, p.bot],
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
})}
/>
))}
</div>
</div>
)}
{has('color') && (
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
)}
{has('gradient') && (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Gradient</Label>
<div className="flex gap-1">
{(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400']).map((c, i) => (
<input
key={i} type="color" value={c}
className="h-7 w-8 cursor-pointer rounded border border-border bg-transparent"
onChange={(e) => {
const g = [...(params.gradient ?? ['#FFD83A', '#FFC312', '#EE9400'])];
g[i] = e.target.value;
set({ gradient: g });
}}
/>
))}
</div>
</div>
)}
{!isFx && has('outline_color') && (
<ColorRow label="Outline" value={params.outline_color ?? '#2a3f5c'}
onChange={(v) => set({ outline_color: v })} />
)}
{!isFx && has('outline_width') && (
<SliderRow label="Outline width" value={params.outline_width ?? 9} min={0} max={24} step={1}
onChange={(v) => set({ outline_width: v })} />
)}
{!isFx && has('shine') && (
<SliderRow label="Gloss" value={params.shine?.coverage ?? 0.5} min={0.2} max={0.8} step={0.05}
onChange={(v) => set({ shine: { coverage: v, opacity: params.shine?.opacity ?? 0.95 } })} />
)}
{!isFx && has('halo') && (
<SliderRow label="Halo" value={params.halo?.opacity ?? 0.4} min={0} max={1} step={0.05}
onChange={(v) => set({
halo: { color: params.halo?.color ?? '#cdd9e4', blur: params.halo?.blur ?? 6, opacity: v },
})} />
)}
{!isFx && has('shadow') && (
<SliderRow label="Shadow" value={params.shadow?.opacity ?? 0.5} min={0} max={1} step={0.05}
onChange={(v) => set({
shadow: {
dx: params.shadow?.dx ?? 5, dy: params.shadow?.dy ?? 8,
blur: params.shadow?.blur ?? 5, color: params.shadow?.color ?? '#14243a', opacity: v,
},
})} />
)}
{isFx && !fxWestern && (
<>
<SliderRow label="Plump" value={fx.plump ?? 0.03} min={0} max={0.06} step={0.005}
onChange={(v) => setFx({ plump: v })} />
<SliderRow label="Gloss" value={fx.gloss ?? 0.85} min={0} max={1} step={0.05}
onChange={(v) => setFx({ gloss: v })} />
<SliderRow label="Gloss height" value={fx.gloss_h ?? 0.45} min={0.2} max={0.8} step={0.05}
onChange={(v) => setFx({ gloss_h: v })} />
<SliderRow label="Inner shadow" value={fx.inner_b ?? 0.7} min={0} max={1} step={0.05}
onChange={(v) => setFx({ inner_b: v })} />
<SliderRow label="Dark edge" value={fx.edge ?? 0.5} min={0} max={1} step={0.05}
onChange={(v) => setFx({ edge: v })} />
<SliderRow label="Silver rim" value={fx.outerw ?? 0.45} min={0} max={1} step={0.05}
onChange={(v) => setFx({ outerw: v })} />
</>
)}
{isFx && fxWestern && (
<>
<SliderRow label="3D depth" value={fx.depth ?? 12} min={0} max={40} step={1}
onChange={(v) => setFx({ depth: v })} />
<SliderRow label="Slant" value={fx.slant ?? 0.08} min={-0.4} max={0.4} step={0.02}
onChange={(v) => setFx({ slant: v })} />
<SliderRow label="Grunge" value={fx.grunge ?? 0.8} min={0} max={1} step={0.05}
onChange={(v) => setFx({ grunge: v })} />
<SliderRow label="Top light" value={fx.bevel ?? 0.45} min={0} max={1} step={0.05}
onChange={(v) => setFx({ bevel: v })} />
</>
)}
</div>
);
}
// Tiny labelled numeric input shared by the designer's right panel.
export function NumberField({ label, value, onChange, min, max }: {
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number;
}) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type="number" className="h-7 w-20 text-right" value={value} min={min} max={max}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
/>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
// Asset loading for the QSL card renderer: designer fonts (registered as
// @font-face AND kept as CSS text so rasterization can inline them into the
// serialized SVG — an SVG drawn through an <img> cannot see document fonts),
// photos with their natural dimensions, and the country flag.
import { QSLFonts, QSLPhotoDataURL, QSLFlagDataURL } from '../../../wailsjs/go/main/App';
import type { CardTemplate, QSLFontInfo } from './qslTypes';
export interface PhotoAsset {
url: string; // data URL
w: number; // natural pixel size
h: number;
}
export interface CardAssets {
photos: Record<string, PhotoAsset>; // keyed by template photo ref
flagUrl: string; // data URL, '' when no flag
fontCss: string; // @font-face rules with data: sources
}
// Weight each embedded family renders at (variable fonts need an explicit
// heavy weight to match the printed-card look).
export const FONT_WEIGHTS: Record<string, number> = {
'Baloo 2': 800,
Oswald: 700,
};
let fontsPromise: Promise<{ css: string; fonts: QSLFontInfo[] }> | null = null;
// loadFonts fetches the embedded fonts once, registers them with the
// document and returns the @font-face CSS for SVG embedding.
export function loadFonts(): Promise<{ css: string; fonts: QSLFontInfo[] }> {
if (!fontsPromise) {
fontsPromise = (async () => {
const fonts = (await QSLFonts()) as QSLFontInfo[];
const rules = fonts.map((f) => {
const weight = f.variable ? '100 900' : 'normal';
return `@font-face{font-family:'${f.family}';src:url(data:font/ttf;base64,${f.data_b64}) format('truetype');font-weight:${weight};}`;
});
const css = rules.join('\n');
const el = document.createElement('style');
el.dataset.qslFonts = '1';
el.textContent = css;
document.head.appendChild(el);
await document.fonts.ready;
return { css, fonts };
})();
}
return fontsPromise;
}
async function imageSize(url: string): Promise<{ w: number; h: number }> {
const img = new Image();
img.src = url;
await img.decode();
return { w: img.naturalWidth, h: img.naturalHeight };
}
// Photos are multi-MB base64 payloads; cache them so the three proposals
// (which share the same photo set) and editor re-renders fetch each once.
const photoCache = new Map<string, Promise<PhotoAsset>>();
function loadPhoto(templateId: number, ref: string): Promise<PhotoAsset> {
const key = `${templateId}:${ref}`;
let p = photoCache.get(key);
if (!p) {
p = (async () => {
const url = await QSLPhotoDataURL(templateId, ref);
const size = await imageSize(url);
return { url, ...size };
})();
photoCache.set(key, p);
}
return p;
}
// loadCardAssets resolves everything a template needs to render: each
// referenced photo as a data URL with its natural size, the flag and the
// font CSS. templateId 0 reads draft photos at their original paths.
export async function loadCardAssets(template: CardTemplate, templateId: number): Promise<CardAssets> {
const refs = new Set<string>([template.hero.photo]);
for (const e of template.elements) {
if (e.type === 'insert' && e.photo) refs.add(e.photo);
}
const photos: Record<string, PhotoAsset> = {};
await Promise.all(
[...refs].map(async (ref) => {
photos[ref] = await loadPhoto(templateId, ref);
}),
);
let flagUrl = '';
const country = template.elements.find((e) => e.type === 'country');
if (country?.flag && country.flag !== 'auto') {
flagUrl = await QSLFlagDataURL(country.flag);
}
const { css } = await loadFonts();
return { photos, flagUrl, fontCss: css };
}
+175
View File
@@ -0,0 +1,175 @@
// TypeScript mirror of the QSL template JSON schema (v1) defined in
// internal/qslcard/template.go. Template documents cross the Wails boundary
// as JSON strings; these types are the frontend's contract with that schema.
export interface CardGeom {
w: number;
h: number;
dpi: number;
bleed_px: number;
}
export interface CropRect {
x: number;
y: number;
w: number;
h: number;
}
export interface Scrim {
enabled: boolean;
color: string;
opacity: number;
}
export interface Hero {
photo: string;
crop: CropRect;
scrim?: Scrim;
}
export interface Shine {
coverage: number;
opacity: number;
}
export interface HaloFx {
color: string;
blur: number;
opacity: number;
}
export interface ShadowFx {
dx: number;
dy: number;
blur: number;
color: string;
opacity: number;
}
export interface BevelFx {
dx: number;
dy: number;
dark: string;
light: string;
}
// FxParams — per-call tuning of the canvas renderer (textFx.ts). All optional;
// omitted values fall back to the renderer's per-preset default.
export interface FxParams {
plump?: number;
edge?: number;
outerw?: number;
gloss?: number;
gloss_h?: number;
gloss_i?: number;
inner_b?: number;
depth?: number;
angle?: number;
slant?: number;
grunge?: number;
bevel?: number;
seed?: number;
dark?: string;
outer?: string;
}
export interface StyleParams {
gradient?: string[];
shine?: Shine;
outline_color?: string;
outline_width?: number;
halo?: HaloFx;
shadow?: ShadowFx;
bevel_offset?: BevelFx;
color?: string;
fx?: FxParams;
}
export type ElementType = 'callsign' | 'operator' | 'info_line' | 'country' | 'insert';
export interface CardElement {
type: ElementType;
x: number;
y: number;
rotate?: number;
hidden?: boolean; // toggled off in the editor
// text elements
text?: string;
font?: string;
size?: number;
style_preset?: string;
style_params?: StyleParams;
// country
flag?: string;
label?: string;
// insert
photo?: string;
w?: number;
border_px?: number;
shadow?: boolean;
}
export interface QSOBox {
enabled: boolean;
x: number;
y: number;
w: number;
h: number;
bg: string;
bg_opacity: number;
radius: number;
title: string;
fields: string[];
footer: string;
}
export interface CardTemplate {
schema: number;
name: string;
card: CardGeom;
hero: Hero;
elements: CardElement[];
qso_box?: QSOBox;
}
// RenderModel mirrors qslcard.RenderModel: a template with placeholders
// resolved plus the values for the QSO box fields.
export interface RenderModel {
template: CardTemplate;
qso_fields: Record<string, string>;
}
// Binding result types (see app_qsl_designer.go).
export interface QSLTemplateInfo {
id: number;
name: string;
profile_id?: number;
is_default: boolean;
updated_at: string;
}
export interface QSLFontInfo {
family: string;
kind: 'display' | 'script' | 'system';
variable: boolean;
data_b64: string;
}
export interface QSLPresetInfo {
name: string;
label: string;
params: string[];
defaults: StyleParams;
}
// Human labels for the QSO box fields the renderer knows.
export const QSO_FIELD_LABELS: Record<string, string> = {
qso_date: 'Date',
time_on: 'UTC',
band: 'Band',
mode: 'Mode',
rst_sent: 'RST',
freq: 'Freq',
submode: 'Submode',
};
+52
View File
@@ -0,0 +1,52 @@
// Rasterizes the CardPreview SVG to bitmap bytes. The SVG is serialized and
// loaded through an <img>, so every resource it needs (fonts, photos, flag)
// must be inline data URLs — CardPreview guarantees that. Fonts are awaited
// before drawing so glyphs never rasterize with a fallback face.
// rasterizeCard returns base64 image bytes (no data: prefix).
// type 'image/jpeg' targets e-mail (maxBytes enforced by stepping quality
// down); 'image/png' is used for template thumbnails.
export async function rasterizeCard(
svgEl: SVGSVGElement,
outW: number,
outH: number,
type: 'image/jpeg' | 'image/png',
maxBytes = 800 * 1024,
): Promise<string> {
await document.fonts.ready;
const clone = svgEl.cloneNode(true) as SVGSVGElement;
clone.setAttribute('width', String(outW));
clone.setAttribute('height', String(outH));
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone);
const url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
try {
const img = new Image();
img.src = url;
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas 2d context unavailable');
if (type === 'image/jpeg') {
ctx.fillStyle = '#ffffff'; // JPEG has no alpha — avoid black background
ctx.fillRect(0, 0, outW, outH);
}
ctx.drawImage(img, 0, 0, outW, outH);
if (type === 'image/png') {
return canvas.toDataURL('image/png').split(',')[1];
}
// Step the JPEG quality down until the e-mail size target is met.
for (const q of [0.9, 0.8, 0.7, 0.6]) {
const b64 = canvas.toDataURL('image/jpeg', q).split(',')[1];
if (b64.length * 0.75 <= maxBytes) return b64;
}
return canvas.toDataURL('image/jpeg', 0.5).split(',')[1];
} finally {
URL.revokeObjectURL(url);
}
}
+576
View File
@@ -0,0 +1,576 @@
// Canvas-2D call-text renderer, ported faithfully from the user's two
// reference generators ("glossy bulle" / XV9Q and "western 3D" / HS0ZLE).
// SVG filters can only approximate these looks; the exact technique is
// canvas — inflated glyphs (fill + stroke to dilate), contour bands via
// boolean compositing, a candy gloss that hugs the outline, an orange inner
// shadow, and organic multi-layer grunge.
//
// renderTextFx() runs the recipe offscreen, trims the transparent padding,
// and returns a PNG data URL + its size in logical (card) pixels. CardPreview
// embeds that as an <image> so the same output drives the editor preview,
// thumbnails and the final e-mail rasterization.
export type TextFxKind = 'glossy' | 'western';
export interface TextFxParams {
kind: TextFxKind;
text: string;
weight: string; // canvas font weight, e.g. "800" or "400"
family: string; // canvas font family, e.g. '"Baloo 2", sans-serif'
size: number; // font size in card px
space: number; // letter spacing in px
cTop: string;
cMid: string;
cBot: string;
cDark: string; // dark edge / outline
cOuter?: string; // silver rim (glossy only)
// glossy
plump?: number; // 0..0.06 — inflation as a fraction of size
edge?: number; // 0..1 — dark edge weight
outerw?: number; // 0..1 — silver rim weight
gloss?: number; // 0..1 — gloss intensity
glossH?: number; // 0..1 — gloss height
glossI?: number; // 0..0.14 — gloss inset
innerB?: number; // 0..1 — bottom inner shadow
// western
depth?: number; // 3D extrusion depth in px
angle?: number; // extrusion direction in radians
slant?: number; // shear, -0.4..0.4
grunge?: number; // 0..1 — distress amount
bevel?: number; // 0..1 — top-edge light
seed?: number;
}
export interface TextFxResult {
url: string; // PNG data URL of the trimmed text
w: number; // logical (card-px) width
h: number; // logical (card-px) height
}
const SS = 2; // supersample for crisp downscaling
function supportsFilter(c: CanvasRenderingContext2D) {
return typeof c.filter === 'string';
}
function shade(hex: string, pct: number) {
const n = parseInt(hex.slice(1), 16);
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v + (255 * pct) / 100)));
return 'rgb(' + f(n >> 16) + ',' + f((n >> 8) & 255) + ',' + f(n & 255) + ')';
}
function deepen(hex: string) {
const n = parseInt(hex.slice(1), 16);
return 'rgb(' + Math.round((n >> 16) * 0.96) + ',' + Math.round(((n >> 8) & 255) * 0.62) + ',' + Math.round((n & 255) * 0.25) + ')';
}
function rustOf(hex: string) {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255, Math.round((n >> 16) * 0.72));
const g = Math.round(((n >> 8) & 255) * 0.49);
const b = Math.min(255, Math.round((n & 255) * 0.3) + 10);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
function mulberry32(seed: number) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashStr(s: string) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// ── glossy bubble (XV9Q) ────────────────────────────────────────────────
function renderGlossy(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'plump' | 'edge' | 'outerw' | 'gloss' | 'glossH' | 'glossI' | 'innerB' | 'cTop' | 'cMid' | 'cBot' | 'cDark' | 'cOuter'>>, scale: number): HTMLCanvasElement {
const probe = document.createElement('canvas').getContext('2d')!;
const font = p.weight + ' ' + p.size + 'px ' + p.family;
probe.letterSpacing = p.space + 'px';
probe.font = font;
const m = probe.measureText(p.text);
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
const capH = asc + desc;
const plumpPx = p.size * p.plump;
const edgeW = p.size * 0.1 * p.edge;
const outerW = edgeW + p.size * 0.22 * p.outerw + 2 * plumpPx;
const pad = Math.ceil(outerW / 2 + p.size * 0.12);
const w = Math.ceil(m.width + pad * 2);
const h = Math.ceil(capH + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const c = canvas.getContext('2d')!;
const x = pad, y = pad + asc;
const top = y - asc - plumpPx, bot = y + desc + plumpPx;
const mk = () => {
const k = document.createElement('canvas');
k.width = canvas.width;
k.height = canvas.height;
return k;
};
const setT = (cx: CanvasRenderingContext2D) => {
cx.setTransform(scale, 0, 0, scale, 0, 0);
cx.letterSpacing = p.space + 'px';
cx.font = font;
cx.textBaseline = 'alphabetic';
cx.lineJoin = 'round';
cx.lineCap = 'round';
};
const glyph = (delta: number, dx?: number, dy?: number) => {
const k = mk();
const g = k.getContext('2d')!;
setT(g);
g.fillStyle = '#fff';
g.strokeStyle = '#fff';
g.fillText(p.text, x + (dx || 0), y + (dy || 0));
if (delta > 0.1) {
g.lineWidth = delta * 2;
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
} else if (delta < -0.1) {
g.globalCompositeOperation = 'destination-out';
g.lineWidth = -delta * 2;
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
}
return k;
};
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-in';
g.drawImage(mask, 0, 0);
g.restore();
return k;
};
const contourBand = (shiftX: number, shiftY: number, insetPx: number) => {
const k = glyph(plumpPx, 0, 0);
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-out';
g.drawImage(glyph(plumpPx, shiftX, shiftY), 0, 0);
g.restore();
return clipTo(k, glyph(plumpPx - insetPx, 0, 0));
};
const vRamp = (k: HTMLCanvasElement, f0: number, f1: number, keepTop: boolean) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(scale, 0, 0, scale, 0, 0);
g.globalCompositeOperation = 'destination-in';
const gr = g.createLinearGradient(0, top, 0, bot);
const a1 = keepTop ? 1 : 0, a2 = keepTop ? 0 : 1;
gr.addColorStop(0, 'rgba(255,255,255,' + a1 + ')');
gr.addColorStop(Math.max(0.001, Math.min(0.998, f0)), 'rgba(255,255,255,' + a1 + ')');
gr.addColorStop(Math.max(0.002, Math.min(0.999, f1)), 'rgba(255,255,255,' + a2 + ')');
gr.addColorStop(1, 'rgba(255,255,255,' + a2 + ')');
g.fillStyle = gr;
g.fillRect(0, 0, k.width / scale, k.height / scale);
g.restore();
return k;
};
const blurred = (src: HTMLCanvasElement, px: number) => {
const k = mk();
const g = k.getContext('2d')!;
if (supportsFilter(g) && px > 0.3) {
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
}
return k;
};
const tint = (k: HTMLCanvasElement, color: string, alpha: number) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color;
g.fillRect(0, 0, k.width, k.height);
g.restore();
if (alpha < 1) {
const k2 = mk();
const g2 = k2.getContext('2d')!;
g2.globalAlpha = alpha;
g2.drawImage(k, 0, 0);
return k2;
}
return k;
};
const paintText = (cx: CanvasRenderingContext2D, paint: string | CanvasGradient) => {
cx.fillStyle = paint;
cx.fillText(p.text, x, y);
if (plumpPx > 0.1) {
cx.strokeStyle = paint;
cx.lineWidth = plumpPx * 2;
cx.strokeText(p.text, x, y);
}
};
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, canvas.width, canvas.height);
setT(c);
// drop shadow
c.save();
c.shadowColor = 'rgba(10,15,25,0.40)';
c.shadowBlur = p.size * 0.045 * scale;
c.shadowOffsetY = p.size * 0.03 * scale;
c.lineWidth = Math.max(outerW, edgeW + 2 * plumpPx, 1);
c.strokeStyle = 'rgba(10,15,25,0.40)';
c.strokeText(p.text, x, y);
c.restore();
const face = mk();
const f = face.getContext('2d')!;
setT(f);
if (p.outerw > 0) {
const gOuter = f.createLinearGradient(0, top - outerW / 2, 0, bot + outerW / 2);
gOuter.addColorStop(0, '#ffffff');
gOuter.addColorStop(0.45, p.cOuter);
gOuter.addColorStop(1, shade(p.cOuter, -22));
f.lineWidth = outerW;
f.strokeStyle = gOuter;
f.strokeText(p.text, x, y);
}
if (p.edge > 0) {
f.lineWidth = edgeW + 2 * plumpPx;
f.strokeStyle = p.cDark;
f.strokeText(p.text, x, y);
}
const gBody = f.createLinearGradient(0, top, 0, bot);
gBody.addColorStop(0, p.cTop);
gBody.addColorStop(0.55, p.cMid);
gBody.addColorStop(1, p.cBot);
paintText(f, gBody);
if (p.innerB > 0) {
let band = contourBand(0, -0.22 * capH, p.size * 0.03);
band = blurred(band, p.size * 0.04);
band = clipTo(band, glyph(plumpPx, 0, 0));
band = vRamp(band, 0.45, 0.8, false);
band = tint(band, deepen(p.cBot), 0.7);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.globalAlpha = Math.min(1, p.innerB);
f.drawImage(band, 0, 0);
f.restore();
}
if (p.gloss > 0) {
const shiftY = p.glossH * capH;
const shiftX = 0.12 * shiftY;
const inset = p.size * p.glossI;
const clip0 = p.glossH * 0.6, clip1 = Math.min(0.95, p.glossH * 1.4);
let band = contourBand(shiftX, shiftY, inset);
band = vRamp(band, clip0, clip1, true);
let halo = blurred(band, p.size * 0.014);
halo = clipTo(halo, glyph(plumpPx - inset * 0.5, 0, 0));
let core = mk();
core.getContext('2d')!.drawImage(band, 0, 0);
core = clipTo(core, glyph(plumpPx - inset * 1.35, 0, 0));
core = blurred(core, p.size * 0.007);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.globalAlpha = Math.min(1, 0.85 * p.gloss);
f.drawImage(halo, 0, 0);
f.globalAlpha = Math.min(1, 1.15 * p.gloss);
f.drawImage(core, 0, 0);
f.restore();
}
c.setTransform(1, 0, 0, 1, 0, 0);
c.drawImage(face, 0, 0);
return canvas;
}
// ── western 3D (HS0ZLE) ─────────────────────────────────────────────────
function noiseMask(W: number, H: number, featurePx: number, thresh: number, seed: number, softenPx: number, stretchY = 1): HTMLCanvasElement {
const sw = Math.max(2, Math.round(W / featurePx));
const sh = Math.max(2, Math.round(H / (featurePx * stretchY)));
const small = document.createElement('canvas');
small.width = sw;
small.height = sh;
const sg = small.getContext('2d')!;
const id = sg.createImageData(sw, sh);
const rnd = mulberry32(seed);
for (let i = 0; i < sw * sh; i++) {
const v = (rnd() * 255) | 0;
id.data[i * 4] = v;
id.data[i * 4 + 1] = v;
id.data[i * 4 + 2] = v;
id.data[i * 4 + 3] = 255;
}
sg.putImageData(id, 0, 0);
const big = document.createElement('canvas');
big.width = W;
big.height = H;
const bg = big.getContext('2d')!;
bg.imageSmoothingEnabled = true;
bg.imageSmoothingQuality = 'high';
if (supportsFilter(bg)) bg.filter = 'blur(' + (featurePx * 0.35).toFixed(1) + 'px)';
bg.drawImage(small, 0, 0, W, H);
if (supportsFilter(bg)) bg.filter = 'none';
const src = bg.getImageData(0, 0, W, H);
const out = bg.createImageData(W, H);
const k = 1 / Math.max(1e-6, (1 - thresh) * 0.35);
for (let i = 0; i < W * H; i++) {
const v = src.data[i * 4] / 255;
let a = (v - thresh) * k;
a = a < 0 ? 0 : a > 1 ? 1 : a;
out.data[i * 4] = 255;
out.data[i * 4 + 1] = 255;
out.data[i * 4 + 2] = 255;
out.data[i * 4 + 3] = (a * 255) | 0;
}
const mask = document.createElement('canvas');
mask.width = W;
mask.height = H;
const mg = mask.getContext('2d')!;
if (supportsFilter(mg) && softenPx > 0.3) mg.filter = 'blur(' + softenPx.toFixed(1) + 'px)';
const tmp = document.createElement('canvas');
tmp.width = W;
tmp.height = H;
tmp.getContext('2d')!.putImageData(out, 0, 0);
mg.drawImage(tmp, 0, 0);
if (supportsFilter(mg)) mg.filter = 'none';
return mask;
}
function renderWestern(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'depth' | 'angle' | 'slant' | 'grunge' | 'bevel' | 'seed' | 'cTop' | 'cMid' | 'cBot' | 'cDark'>>, scale: number): HTMLCanvasElement {
const probe = document.createElement('canvas').getContext('2d')!;
const font = p.weight + ' ' + p.size + 'px ' + p.family;
probe.letterSpacing = p.space + 'px';
probe.font = font;
const m = probe.measureText(p.text);
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
const capH = asc + desc;
const edgeW = p.size * 0.07;
const dx = Math.cos(p.angle) * p.depth, dy = Math.sin(p.angle) * p.depth;
const slantPad = Math.abs(p.slant) * capH;
const pad = Math.ceil(edgeW + p.size * 0.1 + p.depth + slantPad);
const w = Math.ceil(m.width + pad * 2);
const h = Math.ceil(capH + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const c = canvas.getContext('2d')!;
const x = pad - Math.min(0, dx), y = pad + asc - Math.min(0, dy);
const top = y - asc, bot = y + desc;
const mk = () => {
const k = document.createElement('canvas');
k.width = canvas.width;
k.height = canvas.height;
return k;
};
const setT = (cx: CanvasRenderingContext2D) => {
cx.setTransform(scale, 0, 0, scale, 0, 0);
cx.transform(1, 0, -p.slant, 1, p.slant * y, 0);
cx.letterSpacing = p.space + 'px';
cx.font = font;
cx.textBaseline = 'alphabetic';
cx.lineJoin = 'round';
cx.lineCap = 'butt'; // their source set 'miter' here, which browsers ignore on lineCap
};
const glyph = (dxx?: number, dyy?: number) => {
const k = mk();
const g = k.getContext('2d')!;
setT(g);
g.fillStyle = '#fff';
g.fillText(p.text, x + (dxx || 0), y + (dyy || 0));
return k;
};
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-in';
g.drawImage(mask, 0, 0);
g.restore();
return k;
};
const blurred = (src: HTMLCanvasElement, px: number) => {
const k = mk();
const g = k.getContext('2d')!;
if (supportsFilter(g) && px > 0.3) {
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
}
return k;
};
const tintAlpha = (k: HTMLCanvasElement, color: string, alpha: number) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color;
g.fillRect(0, 0, k.width, k.height);
g.restore();
const k2 = mk();
const g2 = k2.getContext('2d')!;
g2.globalAlpha = alpha;
g2.drawImage(k, 0, 0);
return k2;
};
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, canvas.width, canvas.height);
setT(c);
// drop shadow
c.save();
c.shadowColor = 'rgba(15,8,4,0.50)';
c.shadowBlur = p.size * 0.035 * scale;
c.shadowOffsetX = p.size * 0.02 * scale;
c.shadowOffsetY = p.size * 0.035 * scale;
c.lineWidth = edgeW;
c.strokeStyle = 'rgba(15,8,4,0.50)';
c.fillStyle = 'rgba(15,8,4,0.50)';
c.strokeText(p.text, x, y);
c.fillText(p.text, x, y);
c.restore();
// 3D extrusion
if (p.depth > 0) {
const deepCol = shade(p.cDark, -4);
c.lineWidth = edgeW;
const steps = Math.ceil(p.depth);
for (let i = steps; i >= 1; i--) {
const t = i / steps;
c.strokeStyle = deepCol;
c.fillStyle = deepCol;
c.strokeText(p.text, x + dx * t, y + dy * t);
c.fillText(p.text, x + dx * t, y + dy * t);
}
}
const face = mk();
const f = face.getContext('2d')!;
setT(f);
f.lineWidth = edgeW;
f.strokeStyle = p.cDark;
f.strokeText(p.text, x, y);
const gBody = f.createLinearGradient(0, top, 0, bot);
gBody.addColorStop(0, p.cTop);
gBody.addColorStop(0.5, p.cMid);
gBody.addColorStop(1, p.cBot);
f.fillStyle = gBody;
f.fillText(p.text, x, y);
const M = glyph(0, 0);
if (p.bevel > 0) {
let band = glyph(0, 0);
const g = band.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-out';
g.drawImage(glyph(0.02 * p.size, 0.06 * capH), 0, 0);
g.restore();
band = blurred(band, p.size * 0.008);
band = clipTo(band, M);
band = tintAlpha(band, '#ffeba8', p.bevel);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.drawImage(band, 0, 0);
f.restore();
}
if (p.grunge > 0) {
const W = canvas.width, H = canvas.height;
const S = p.size * scale;
const seed = (hashStr(p.text + '|' + p.family) ^ (p.seed * 2654435761)) >>> 0;
const rust = rustOf(p.cBot);
const layers = [
{ mask: noiseMask(W, H, 0.022 * S, 0.72, seed, 0.6 * scale), color: rust, a: 0.8 },
{ mask: noiseMask(W, H, 0.011 * S, 0.7, seed + 11, 0.5 * scale, 4.0), color: rust, a: 0.6 },
{ mask: noiseMask(W, H, 0.0066 * S, 0.74, seed + 7, 0.3 * scale), color: rust, a: 0.75 },
{ mask: noiseMask(W, H, 0.0154 * S, 0.86, seed + 3, 0.4 * scale), color: p.cDark, a: 0.9 },
];
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
for (const L of layers) {
let k = clipTo(L.mask, M);
k = tintAlpha(k, L.color, L.a * p.grunge);
f.drawImage(k, 0, 0);
}
f.restore();
}
c.setTransform(1, 0, 0, 1, 0, 0);
c.drawImage(face, 0, 0);
return canvas;
}
// Trim transparent padding so the element's x/y maps to the visible glyphs.
function trim(canvas: HTMLCanvasElement): { canvas: HTMLCanvasElement; w: number; h: number } {
const ctx = canvas.getContext('2d')!;
const W = canvas.width, H = canvas.height;
const data = ctx.getImageData(0, 0, W, H).data;
let minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (data[(y * W + x) * 4 + 3] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < minX) return { canvas, w: W, h: H };
const cw = maxX - minX + 1, ch = maxY - minY + 1;
const out = document.createElement('canvas');
out.width = cw;
out.height = ch;
out.getContext('2d')!.drawImage(canvas, minX, minY, cw, ch, 0, 0, cw, ch);
return { canvas: out, w: cw, h: ch };
}
export function renderTextFx(p: TextFxParams): TextFxResult {
let raw: HTMLCanvasElement;
if (p.kind === 'western') {
raw = renderWestern(
{
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
depth: p.depth ?? p.size * 0.06, angle: p.angle ?? (112 * Math.PI) / 180,
slant: p.slant ?? 0.08, grunge: p.grunge ?? 0.8, bevel: p.bevel ?? 0.45, seed: p.seed ?? 7,
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark,
},
SS,
);
} else {
raw = renderGlossy(
{
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
plump: p.plump ?? 0.03, edge: p.edge ?? 0.5, outerw: p.outerw ?? 0.45,
gloss: p.gloss ?? 0.85, glossH: p.glossH ?? 0.45, glossI: p.glossI ?? 0.08, innerB: p.innerB ?? 0.7,
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark, cOuter: p.cOuter ?? '#ced3db',
},
SS,
);
}
const t = trim(raw);
return { url: t.canvas.toDataURL('image/png'), w: t.w / SS, h: t.h / SS };
}
+13 -5
View File
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
// only an exact (case-insensitive) match — otherwise it reverts, so the field
// can't hold a typo'd value that isn't in the list.
export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false,
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
}: {
value: string;
onChange: (v: string) => void;
@@ -14,6 +14,10 @@ export function Combobox({
placeholder?: string;
className?: string;
allowFreeText?: boolean;
// Commit each keystroke to the parent immediately (not just on blur). Use for
// fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed.
commitOnType?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
@@ -41,9 +45,13 @@ export function Combobox({
// Defer so a click on an option registers first.
setTimeout(() => {
setOpen(false);
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
if (exact) { onChange(exact); setQuery(exact); }
else if (allowFreeText) { onChange(query.trim()); }
const trimmed = query.trim();
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
// Only fire onChange when the value actually changed — committing an
// unchanged value on a plain tab-through would wrongly flag the field as
// "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default).
if (exact) { if (exact !== value) onChange(exact); setQuery(exact); }
else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); }
else { setQuery(value); } // revert typo
}, 120);
}
@@ -56,7 +64,7 @@ export function Combobox({
// Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
onBlur={onBlur}
onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
+1 -1
View File
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input
type={type}
className={cn(
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
+1 -1
View File
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
+25 -6
View File
@@ -33,12 +33,28 @@ function appendTokens(existing: string | undefined, refs: string): string {
return out;
}
// applyAwardRefs writes picked references onto a QSO payload using each award's
// scanned field. fieldOf maps an award CODE (uppercase) to its field name.
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
// The award engine honours these regardless of how the award matches, so a
// reference can be assigned even for awards that scan a free-text field by
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
// the field wouldn't match.
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
// awards backed by a standard ADIF column also get that column written so the
// data lives in its conventional place and exports correctly. Awards that match
// a free-text field by description/pattern (address/qth/name/custom) rely solely
// on the override — we don't pollute the text field with a code.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
const byCode = parseAwardRefs(awardRefs);
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
const overrides: string[] = [];
for (const [code, ref] of Object.entries(byCode)) {
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
overrides.push(`${code}@${r}`);
}
const field = fieldOf[code] || code.toLowerCase();
switch (field) {
case 'iota': payload.iota = ref; break;
@@ -53,21 +69,24 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
extras['SIG'] = 'WWFF';
extras['SIG_INFO'] = ref;
break;
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM
// scans the note for "D06"). Picking such a reference appends its code(s)
// to that field so the matcher finds it.
// QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
// "D06" in the note): append the code so the in-field matcher finds it too.
case 'note': case 'notes':
payload.notes = appendTokens(payload.notes, ref);
break;
case 'comment':
payload.comment = appendTokens(payload.comment, ref);
break;
// address / qth / name / custom: the override above is authoritative; do
// not write a code into a free-text field the award matches by name.
default:
extras[field.toUpperCase()] = ref;
break;
}
}
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
else delete extras[MANUAL_REFS_KEY];
if (Object.keys(extras).length > 0) payload.extras = extras;
else if (payload.extras) delete payload.extras;
}
// awardRefValue reads a single award's stored reference from a QSO, inverse of
+463
View File
@@ -0,0 +1,463 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { writeUiPref } from './uiPref';
// Lightweight i18n: a flat key→string dictionary per language, a context that
// holds the active language, and a t() lookup with {var} interpolation. English
// is the source/fallback; a missing French key falls back to English, then to
// the key itself. Language is persisted (localStorage + portable UI pref) and
// chosen once at first run via the flag gate below.
export type Lang = 'en' | 'fr';
const LS_KEY = 'opslog.lang';
type Dict = Record<string, string>;
const en: Dict = {
// Menu bar
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
'edit.editSel': 'Edit selected QSO…', 'edit.prefs': 'Preferences…',
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…',
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
// Duplicates modal
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
'dup.hint': 'Each group is the same contact logged more than once (callsign + band + mode). The first (oldest) is left unchecked; tick the ones to delete.',
'dup.window': 'Within', 'dup.minutes': 'min', 'dup.sameDay': 'same day', 'dup.summary': '{groups} groups · {qsos} duplicate QSOs · {sel} to delete',
'dup.selectExtras': 'Select all but the first', 'dup.deselectAll': 'Deselect all', 'dup.deleteSel': 'Delete {n} selected', 'dup.deleteConfirm': 'Delete {n} QSO(s)? This cannot be undone.', 'dup.deleted': '{n} duplicate(s) deleted', 'dup.keep': 'oldest', 'dup.close': 'Close',
'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name',
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
// Tabs
'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net',
// Entry form
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'TX Freq (MHz)', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band',
'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
// Settings navigation (sidebar groups + section names)
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
// General panel
'gen.hint': 'App behaviour (saved instantly).',
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
'gen.showBeam': 'Show the antenna beam heading on the Main map',
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)',
'gen.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
'email.title': 'E-mail',
// Station panel
'station.loading': 'Loading profile…',
'station.hint': 'Editing the active profile: {name}. Switch profiles in the Profiles section to edit a different one.',
'station.stationCall': 'Station callsign', 'station.stationCallHint': "What's transmitted (ADIF STATION_CALLSIGN).",
'station.opCall': 'Operator callsign', 'station.opCallHint': "Who's at the radio (ADIF OPERATOR).",
'station.ownerCall': 'Owner callsign', 'station.ownerBlank': '(leave blank if same as station)',
'station.ownerHint': 'Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).',
'station.opName': 'Operator name', 'station.opNameHint': 'Your first name — used as the signature on QSL cards.',
'station.autofill': 'Auto-filled from the callsign — editable (stamped as MY_* on each QSO)',
'station.myGrid': 'My grid', 'station.myCountry': 'My country', 'station.dxcc': 'DXCC #', 'station.cqz': 'CQ zone', 'station.ituz': 'ITU zone',
'station.lat': 'Latitude', 'station.lon': 'Longitude', 'station.state': 'State / pref', 'station.county': 'County',
'station.street': 'Street address', 'station.postal': 'Postal code', 'station.city': 'City', 'station.sota': 'SOTA ref', 'station.pota': 'POTA ref',
// Confirmations panel
'conf.hint': 'Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty.',
'conf.sent': 'Sent', 'conf.rcvd': 'Rcvd', 'conf.paperQsl': 'Paper QSL',
'conf.note': '"Sent" = the QSO\'s upload status to the service; "N" is typical so OpsLog tracks which QSOs still need uploading. Club Log & HRDLog are upload-only (no "rcvd" in ADIF); QRZ.com also has "Rcvd" = confirmed back.',
'conf.blank': '— leave blank —', 'conf.y': 'Y (yes)', 'conf.n': 'N (no)', 'conf.r': 'R (requested)', 'conf.q': 'Q (queued)', 'conf.i': 'I (ignore)',
'udp.hint': 'Listen for QSO logs from WSJT-X / JTDX / MSHV, ADIF messages from JTAlert/GridTracker, or simple callsign packets from external tools. Outbound connections forward every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
'operating.hint': "Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on.",
'op.intro1': 'Define your rigs (stations) and the antennas connected to each one. For every antenna, tick the bands it covers. ',
'op.intro2': ' marks the default antenna for that band — when you change the band in the entry strip, the matching rig + antenna auto-fill the MY_RIG and MY_ANTENNA ADIF fields. Only one antenna can be the default per band; setting one clears the previous default.',
'op.addRig': 'Add rig', 'op.noRig': 'No rig configured yet. Click "Add rig" to get started.',
'op.rigNamePh': 'Rig name (also stamped as MY_RIG)', 'op.powerW': 'Power (W)',
'op.antenna': 'Antenna', 'op.deleteRig': 'Delete rig', 'op.noAntenna': 'No antenna yet — click "Antenna" above to add one.',
'op.antNamePh': 'Antenna name (also stamped as MY_ANTENNA)', 'op.default': 'Default', 'op.set': 'Set',
'op.defaultOn': 'Default antenna for this band — click to unset', 'op.defaultOff': 'Click to make this antenna the default for this band',
'op.collapse': 'Collapse', 'op.expand': 'Expand', 'op.deleteAntConfirm': 'Delete this antenna?', 'op.edit': 'Edit',
// Lookup panel
'lk.hint': 'Pick a Primary provider and an optional Failsafe (queried only when Primary returns no data). Click Test to verify credentials without saving.',
'lk.provider': 'Provider', 'lk.primary': 'Primary', 'lk.failsafe': 'Failsafe', 'lk.user': 'User', 'lk.password': 'Password', 'lk.result': 'Result',
'lk.testing': 'Testing…', 'lk.test': 'Test', 'lk.testTitle': "Run a sample lookup against the active profile's callsign to verify credentials",
'lk.failsafeNote': 'Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.',
'lk.display': 'Display', 'lk.showPics': 'Show QRZ profile pictures',
'lk.showPicsHint': 'Display the photo from QRZ.com next to the worked-before matrix. May noticeably slow lookups during busy contest days; turn off if you operate fast.',
'lk.cache': 'Cache', 'lk.cacheHint': "Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.",
'lk.ttl': 'TTL (days)', 'lk.clearing': 'Clearing…', 'lk.clearCache': 'Clear cache now',
// Bands panel
'bnd.hint': "Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order.",
'bnd.available': 'Available', 'bnd.allSelected': 'All catalog bands selected.', 'bnd.customPh': 'Custom band (e.g. 4m)',
'bnd.selected': 'Selected ({n})', 'bnd.none': 'No band selected — pick from the left.',
// Modes panel
'mds.hint': 'Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you\'ve typed something).',
'mds.allSelected': 'All catalog modes selected.', 'mds.customPh': 'Custom mode', 'mds.order': 'Order', 'mds.mode': 'Mode',
'mds.rstSnt': 'RST snt', 'mds.rstRcv': 'RST rcv', 'mds.none': 'No mode selected — pick from the left.', 'mds.addBlank': 'Add blank row',
'mds.defaultRstTitle': 'Default RST: {sent} / {rcvd}',
'mds.rstLists': 'RST report lists', 'mds.rstListsHint': "The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.",
'mds.phone': 'Phone (SSB/AM/FM)', 'mds.cw': 'CW / RTTY / PSK', 'mds.digital': 'Digital (FT8/FT4/JT…) — dB',
// Cluster panel
'clu.hint': 'Connect to one or several DX cluster nodes (telnet). The first enabled server is the master — typed commands and init commands go through it.',
'clu.name': 'Name', 'clu.hostPort': 'Host:port', 'clu.status': 'Status', 'clu.actions': 'Actions',
'clu.moveUp': 'Move up', 'clu.moveDown': 'Move down', 'clu.edit': 'Edit', 'clu.delete': 'Delete', 'clu.none': 'No cluster nodes saved yet.', 'clu.connect': 'Connect', 'clu.disconnect': 'Disconnect',
'clu.add': 'Add cluster', 'clu.connectAll': 'Connect all', 'clu.disconnectAll': 'Disconnect all', 'clu.autoConnect': 'Auto-connect all enabled on app start',
'clu.freeNodes': 'Free public nodes:',
// Backup panel
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
'bk.auto': 'Automatic backup when closing OpsLog (max once per day)', 'bk.folder': 'Backup folder', 'bk.folderPh': 'leave empty for default', 'bk.browse': 'Browse…',
'bk.effective': 'Effective folder:', 'bk.defaultUse': 'If empty, OpsLog uses the default:', 'bk.rotation': 'Rotation (copies to keep)', 'bk.zip': 'ZIP backup (smaller file)',
'bk.lastRun': 'Last run:', 'bk.never': 'never', 'bk.backupNow': 'Back up now', 'bk.backingUp': 'Backing up…', 'bk.writtenTo': 'Backup written to',
// Section hints (hardware/software panel headers)
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).',
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)",
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
'cat.flexHint': 'Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in real time from the radio (no polling, no second-click mode bug). Use Detect radios or enter the IP. Default digital mode is what OpsLog logs when the slice is in a digital mode (DIGU/DIGL).',
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.',
// External services (repeated labels)
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (12 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
// Profiles panel
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
'prof.hint': 'Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile.', 'prof.active': 'ACTIVE', 'prof.duplicate': 'Duplicate', 'prof.delete': 'Delete', 'prof.profileName': 'Profile name',
'prof.configId': 'Configuration ID', 'prof.description': 'Description', 'prof.new': 'New', 'prof.newTitle': 'Create a new empty profile', 'prof.dupTitle': 'Clone the selected profile (keeps all its fields)', 'prof.setActive': 'Set active', 'prof.setActiveTitle': 'Activate the selected profile — new QSOs will use its MY_* fields', 'prof.deleteTitle': 'Delete the selected profile', 'prof.cantDeleteLast': 'Cannot delete the last profile', 'prof.activeSuffix': ' (active)', 'prof.viewingNote': "You're viewing {name}. The active profile is {active} — its values are stamped on new QSOs. Click Set active to switch.",
// Database panel
'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
'db.activeBackend': 'Active backend:', 'db.logbook': 'Logbook:', 'db.config': 'Config:', 'db.mysqlConnected': 'shared MySQL connected ✓', 'db.localSqlite': 'local SQLite',
'db.fallback': 'MySQL is enabled but the connection failed at startup — OpsLog is running on the local SQLite database.',
'db.mysqlNote': "Only QSOs go to MySQL; your settings, profiles, rigs and cluster stay local (and fast). Existing local QSOs aren't copied — import them into the shared log if you want your history there.",
// Email panel
'em.none': 'None', 'em.smtpAuth': 'SMTP requires authorization', 'em.username': 'Username', 'em.fromAddr': 'From address', 'em.replyTo': 'Reply-To address', 'em.replyToPh': '(optional — where replies go)', 'em.replyToHint': 'Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.',
'em.sendTest': 'Send test e-mail', 'em.sendingTest': 'Sending test…', 'em.testSent': 'Test e-mail sent ✓', 'em.testFailed': 'Test failed: ',
'em.qslCardEmail': 'OpsLog QSL card e-mail', 'em.qslVarsHint': 'Message sent with the QSL card. Variables:', 'em.subject': 'Subject', 'em.body': 'Body', 'em.autoSend': 'Auto-send OpsLog QSL when a QSO is logged', 'em.autoSendHint': 'Sends automatically only when the contact has an e-mail address and a default QSL template exists.',
'settings.title': 'Preferences',
'btn.cancel': 'Cancel', 'btn.save': 'Save', 'btn.saveClose': 'Save and close', 'btn.savingLong': 'Saving…',
// Component keys (chat / call history / band map / first-run / contest / adif extras)
'chatp.chat': 'Chat', 'chatp.online': 'Online', 'chatp.close': 'Close', 'chatp.noMessages': 'No messages yet.', 'chatp.messagePh': 'Message…',
'chp.workedBefore': 'Worked before', 'chp.typeCallsign': 'Type a callsign to see prior contacts.', 'chp.checking': 'checking…', 'chp.new': 'NEW',
'chp.noPriorQso': 'No prior QSO with this callsign.', 'chp.first': 'First', 'chp.last': 'Last', 'chp.dateUtc': 'Date UTC', 'chp.band': 'Band', 'chp.mode': 'Mode',
'chp.lotwRcvd': 'LoTW rcvd', 'chp.bureauRcvd': 'Bureau rcvd', 'chp.olderQsos': '+ {n} older QSOs',
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)',
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging',
'ctp.contestSetup': 'Contest setup', 'ctp.live': 'Live', 'ctp.chooseContest': '— choose a contest —', 'ctp.serial': 'Serial', 'ctp.fixedExchange': 'Fixed exchange', 'ctp.nextSerial': 'Next serial', 'ctp.yourExchangePh': 'your exchange', 'ctp.startUtc': 'Start (UTC)', 'ctp.endUtc': 'End (UTC)', 'ctp.windowHint': 'empty end = open · leave blank to count all',
'ctp.stopContest': 'Stop contest', 'ctp.startContest': 'Start contest', 'ctp.activeHint': 'The entry form shows Snt/Rcv and a DUPE badge; QSOs are stamped with this contest.', 'ctp.inactiveHint': 'Pick a contest, set the window, then Start.', 'ctp.scoreboard': 'Scoreboard', 'ctp.estimate': 'estimate', 'ctp.qsos': 'QSOs', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Last 60 min', 'ctp.band': 'Band',
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter',
'rst.clickToFill': 'Click to set RST tx from the signal',
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'DB updated → notify other apps', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
// Awards (ref picker / ref selector / awards panel / award editor)
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Additional searches', 'awed.orAlsoMatch': '— also match the reference if any of these hit', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}',
'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…',
// Grids column headers (recent / worked-before / cluster)
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date off', 'rqg.c.callsign': 'Callsign', 'rqg.c.band': 'Band', 'rqg.c.band_rx': 'Band RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Submode', 'rqg.c.freq_hz': 'Freq (TX)', 'rqg.h.freq_hz': 'Freq', 'rqg.c.freq_rx_hz': 'Freq (RX)', 'rqg.h.freq_rx_hz': 'Freq RX', 'rqg.c.rst_sent': 'RST sent', 'rqg.c.rst_rcvd': 'RST rcvd', 'rqg.c.tx_pwr': 'TX Power', 'rqg.c.name': 'Name', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Address', 'rqg.c.country': 'Country', 'rqg.c.state': 'State', 'rqg.c.cnty': 'County', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Grid', 'rqg.c.gridsquare_ext': 'Grid Ext', 'rqg.h.gridsquare_ext': 'GridExt', 'rqg.c.vucc_grids': 'VUCC grids', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'SOTA ref', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'POTA ref', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Age', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'Email', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL sent', 'rqg.c.qsl_rcvd': 'QSL rcvd', 'rqg.c.qsl_sent_date': 'QSL sent date', 'rqg.h.qsl_sent_date': 'QSL S date', 'rqg.c.qsl_rcvd_date': 'QSL rcvd date', 'rqg.h.qsl_rcvd_date': 'QSL R date', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'QSL msg', 'rqg.c.qslmsg_rcvd': 'QSL msg rcvd', 'rqg.c.lotw_sent': 'LoTW sent', 'rqg.c.lotw_rcvd': 'LoTW rcvd', 'rqg.c.lotw_sent_date': 'LoTW sent date', 'rqg.h.lotw_sent_date': 'LoTW S date', 'rqg.c.lotw_rcvd_date': 'LoTW rcvd date', 'rqg.h.lotw_rcvd_date': 'LoTW R date', 'rqg.c.eqsl_sent': 'eQSL sent', 'rqg.c.eqsl_rcvd': 'eQSL rcvd', 'rqg.c.eqsl_sent_date': 'eQSL sent date', 'rqg.h.eqsl_sent_date': 'eQSL S date', 'rqg.c.eqsl_rcvd_date': 'eQSL rcvd date', 'rqg.h.eqsl_rcvd_date': 'eQSL R date', 'rqg.c.opslog_qsl_card_sent': 'OpsLog QSL', 'rqg.c.opslog_recording_sent': 'Recording sent', 'rqg.h.opslog_recording_sent': 'Rec sent', 'rqg.c.clublog_sent': 'ClubLog sent', 'rqg.c.clublog_sent_date': 'ClubLog sent date', 'rqg.h.clublog_sent_date': 'ClubLog S date', 'rqg.c.hrdlog_sent': 'HRDLog sent', 'rqg.c.hrdlog_sent_date': 'HRDLog sent date', 'rqg.h.hrdlog_sent_date': 'HRDLog S date', 'rqg.c.qrz_sent': 'QRZ.com sent', 'rqg.c.qrz_rcvd': 'QRZ.com rcvd', 'rqg.c.qrz_sent_date': 'QRZ.com sent date', 'rqg.h.qrz_sent_date': 'QRZ.com S date', 'rqg.c.qrz_rcvd_date': 'QRZ.com rcvd date', 'rqg.h.qrz_rcvd_date': 'QRZ.com R date', 'rqg.c.contest_id': 'Contest ID', 'rqg.h.contest_id': 'Contest', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'SRX string', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'STX string', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Precedence', 'rqg.c.arrl_sect': 'ARRL section', 'rqg.h.arrl_sect': 'ARRL sect', 'rqg.c.prop_mode': 'Prop mode', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Sat name', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Sat mode', 'rqg.c.ant_az': 'Ant az', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Ant el', 'rqg.h.ant_el': 'El', 'rqg.c.ant_path': 'Ant path', 'rqg.h.ant_path': 'Path', 'rqg.c.station_callsign': 'Station call', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Operator', 'rqg.c.my_grid': 'My grid', 'rqg.c.my_country': 'My country', 'rqg.h.my_country': 'My ctry', 'rqg.c.my_state': 'My state', 'rqg.c.my_cnty': 'My county', 'rqg.h.my_cnty': 'My cnty', 'rqg.c.my_iota': 'My IOTA', 'rqg.c.my_sota': 'My SOTA', 'rqg.c.my_pota': 'My POTA', 'rqg.c.my_dxcc': 'My DXCC', 'rqg.h.my_dxcc': 'My DXCC#', 'rqg.c.my_cq_zone': 'My CQ zone', 'rqg.h.my_cq_zone': 'My CQZ', 'rqg.c.my_itu_zone': 'My ITU zone', 'rqg.h.my_itu_zone': 'My ITU', 'rqg.c.my_lat': 'My lat', 'rqg.c.my_lon': 'My lon', 'rqg.c.my_street': 'My street', 'rqg.h.my_street': 'Street', 'rqg.c.my_city': 'My city', 'rqg.h.my_city': 'City', 'rqg.c.my_zip': 'My ZIP', 'rqg.h.my_zip': 'ZIP', 'rqg.c.my_rig': 'My rig', 'rqg.c.my_antenna': 'My antenna', 'rqg.h.my_antenna': 'My ant', 'rqg.c.comment': 'Comment', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Created', 'rqg.h.created': 'Created at', 'rqg.c.updated': 'Updated', 'rqg.h.updated': 'Updated at', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Contacted', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Uploads', 'rqg.grpContest': 'Contest', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'My station', 'rqg.grpMisc': 'Misc', 'rqg.grpAwards': 'Awards', 'rqg.awardTip': '{name} — reference this QSO counts for', 'rqg.clearFiltersTitle': 'Clear all column filters', 'rqg.clearFilters': 'Clear filters', 'rqg.columns': 'Columns', 'rqg.pickerDesc': 'Pick the columns you want visible in the Recent QSOs table. Your selection is saved.', 'rqg.all': 'all', 'rqg.none': 'none', 'rqg.resetDefaults': 'Reset to defaults', 'rqg.done': 'Done',
'wbg.awardTip': '{name} — reference this QSO counts for', 'wbg.typeCall': 'Type a callsign in the entry strip to see prior contacts.', 'wbg.checking': 'checking…', 'wbg.new': 'NEW', 'wbg.noPriorPre': 'No prior QSO with ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Worked before', 'wbg.first': 'First:', 'wbg.last': 'Last:', 'wbg.dxcc': 'DXCC:', 'wbg.entityQsos': '{n} entity QSOs', 'wbg.clearFiltersTitle': 'Clear all column filters', 'wbg.clearFilters': 'Clear filters', 'wbg.columns': 'Columns', 'wbg.olderQsos': '+ {n} older QSOs (not shown — capped for performance)', 'wbg.pickerTitle': 'Worked-before columns', 'wbg.pickerDesc': 'Pick the columns you want visible in the Worked-before table.', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done',
'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator', 'clg2.h.locator': 'Loc', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.wkdCall': 'WKD CALL', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
};
const fr: Dict = {
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
'edit.editSel': 'Éditer le QSO sélectionné…', 'edit.prefs': 'Préférences…',
'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…',
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
'dup.window': 'En moins de', 'dup.minutes': 'min', 'dup.sameDay': 'même jour', 'dup.summary': '{groups} groupes · {qsos} QSO en double · {sel} à supprimer',
'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer',
'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom',
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'Fréq TX (MHz)', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX',
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)',
'gen.checkUpdates': 'Vérifier les mises à jour au démarrage', 'gen.checkUpdatesHint': '(prévient quand une version plus récente est publiée)',
'email.title': 'E-mail',
'station.loading': 'Chargement du profil…',
'station.hint': 'Édition du profil actif : {name}. Change de profil dans la section Profils pour en éditer un autre.',
'station.stationCall': 'Indicatif station', 'station.stationCallHint': "Ce qui est émis (ADIF STATION_CALLSIGN).",
'station.opCall': 'Indicatif opérateur', 'station.opCallHint': "Qui est à la radio (ADIF OPERATOR).",
'station.ownerCall': 'Indicatif propriétaire', 'station.ownerBlank': '(vide si identique à la station)',
'station.ownerHint': 'Propriétaire légal — ne diffère que pour les stations de club ou setups distants (ADIF STATION_OWNER).',
'station.opName': "Nom de l'opérateur", 'station.opNameHint': 'Ton prénom — utilisé comme signature sur les cartes QSL.',
'station.autofill': "Auto-rempli depuis l'indicatif — éditable (inscrit en MY_* sur chaque QSO)",
'station.myGrid': 'Mon locator', 'station.myCountry': 'Mon pays', 'station.dxcc': 'DXCC #', 'station.cqz': 'Zone CQ', 'station.ituz': 'Zone ITU',
'station.lat': 'Latitude', 'station.lon': 'Longitude', 'station.state': 'État / préf', 'station.county': 'Comté',
'station.street': 'Adresse', 'station.postal': 'Code postal', 'station.city': 'Ville', 'station.sota': 'Réf SOTA', 'station.pota': 'Réf POTA',
'conf.hint': 'Statut QSL / eQSL / LoTW / upload par défaut appliqué à chaque QSO enregistré — manuellement ou via auto-log UDP depuis WSJT-X / JTDX / MSHV. Laisse un champ vide pour garder la colonne du QSO vide.',
'conf.sent': 'Envoyée', 'conf.rcvd': 'Reçue', 'conf.paperQsl': 'QSL papier',
'conf.note': '« Envoyée » = statut d\'upload du QSO vers le service ; « N » est typique pour qu\'OpsLog suive les QSO restant à uploader. Club Log & HRDLog sont upload-only (pas de « reçue » en ADIF) ; QRZ.com a aussi « Reçue » = confirmé en retour.',
'conf.blank': '— laisser vide —', 'conf.y': 'Y (oui)', 'conf.n': 'N (non)', 'conf.r': 'R (demandée)', 'conf.q': 'Q (en file)', 'conf.i': 'I (ignorer)',
'udp.hint': 'Écoute les logs QSO de WSJT-X / JTDX / MSHV, les messages ADIF de JTAlert/GridTracker, ou de simples paquets d\'indicatif d\'outils externes. Les connexions sortantes transmettent chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).',
'operating.hint': "Définis tes stations et les antennes utilisées sur chaque bande. Le bandeau de saisie remplit auto MY_RIG et MY_ANTENNA selon l'antenne par défaut de la bande.",
'op.intro1': 'Définis tes stations et les antennes connectées à chacune. Pour chaque antenne, coche les bandes qu\'elle couvre. ',
'op.intro2': ' marque l\'antenne par défaut de cette bande — quand tu changes de bande dans le bandeau, la station + antenne correspondantes remplissent auto les champs MY_RIG et MY_ANTENNA. Une seule antenne peut être par défaut par bande ; en définir une retire la précédente.',
'op.addRig': 'Ajouter station', 'op.noRig': 'Aucune station configurée. Clique « Ajouter station » pour commencer.',
'op.rigNamePh': 'Nom de la station (inscrit en MY_RIG)', 'op.powerW': 'Puissance (W)',
'op.antenna': 'Antenne', 'op.deleteRig': 'Supprimer la station', 'op.noAntenna': 'Aucune antenne — clique « Antenne » ci-dessus pour en ajouter.',
'op.antNamePh': "Nom de l'antenne (inscrit en MY_ANTENNA)", 'op.default': 'Défaut', 'op.set': 'Définir',
'op.defaultOn': 'Antenne par défaut pour cette bande — clic pour retirer', 'op.defaultOff': 'Clic pour définir comme antenne par défaut pour cette bande',
'op.collapse': 'Réduire', 'op.expand': 'Développer', 'op.deleteAntConfirm': 'Supprimer cette antenne ?', 'op.edit': 'Éditer',
'lk.hint': 'Choisis un fournisseur Principal et un Secours optionnel (interrogé seulement si le Principal ne renvoie rien). Clique Test pour vérifier les identifiants sans enregistrer.',
'lk.provider': 'Fournisseur', 'lk.primary': 'Principal', 'lk.failsafe': 'Secours', 'lk.user': 'Utilisateur', 'lk.password': 'Mot de passe', 'lk.result': 'Résultat',
'lk.testing': 'Test…', 'lk.test': 'Test', 'lk.testTitle': "Lance une recherche test sur l'indicatif du profil actif pour vérifier les identifiants",
'lk.failsafeNote': 'Le Secours n\'est consulté que si le Principal ne trouve rien ou échoue. Décoche les deux en contest pour éviter tout accès réseau.',
'lk.display': 'Affichage', 'lk.showPics': 'Afficher les photos de profil QRZ',
'lk.showPicsHint': 'Affiche la photo de QRZ.com à côté de la matrice « déjà contacté ». Peut ralentir les recherches en contest ; désactive si tu opères vite.',
'lk.cache': 'Cache', 'lk.cacheHint': "Les recherches réussies sont mises en cache localement pour ne pas re-interroger le même indicatif. Le TTL contrôle la durée avant une nouvelle requête.",
'lk.ttl': 'TTL (jours)', 'lk.clearing': 'Effacement…', 'lk.clearCache': 'Vider le cache',
'bnd.hint': "Choisis les bandes que tu utilises vraiment. Le bandeau de saisie, la grille de bandes et le sélecteur de carte n'affichent que celles de droite. L'ordre à droite = ordre d'affichage.",
'bnd.available': 'Disponibles', 'bnd.allSelected': 'Toutes les bandes du catalogue sont sélectionnées.', 'bnd.customPh': 'Bande perso (ex. 4m)',
'bnd.selected': 'Sélectionnés ({n})', 'bnd.none': 'Aucune bande — choisis à gauche.',
'mds.hint': 'Choisis à droite les modes que tu utilises. Partout où l\'interface propose un mode, elle parcourt la colonne de droite. En sélectionnant un mode dans le formulaire, RST envoyé/reçu se remplissent avec les valeurs par défaut ci-dessous (sauf si tu as tapé quelque chose).',
'mds.allSelected': 'Tous les modes du catalogue sont sélectionnés.', 'mds.customPh': 'Mode perso', 'mds.order': 'Ordre', 'mds.mode': 'Mode',
'mds.rstSnt': 'RST env', 'mds.rstRcv': 'RST reç', 'mds.none': 'Aucun mode — choisis à gauche.', 'mds.addBlank': 'Ajouter une ligne',
'mds.defaultRstTitle': 'RST par défaut : {sent} / {rcvd}',
'mds.rstLists': 'Listes de report RST', 'mds.rstListsHint': "Les choix proposés dans les menus RST du formulaire, par famille de mode. Une valeur par ligne (ou séparées par espaces). La première est en haut de la liste.",
'mds.phone': 'Phonie (SSB/AM/FM)', 'mds.cw': 'CW / RTTY / PSK', 'mds.digital': 'Numérique (FT8/FT4/JT…) — dB',
'clu.hint': 'Connecte-toi à un ou plusieurs nœuds DX cluster (telnet). Le premier serveur activé est le maître — les commandes tapées et d\'init passent par lui.',
'clu.name': 'Nom', 'clu.hostPort': 'Hôte:port', 'clu.status': 'Statut', 'clu.actions': 'Actions',
'clu.moveUp': 'Monter', 'clu.moveDown': 'Descendre', 'clu.edit': 'Éditer', 'clu.delete': 'Supprimer', 'clu.none': 'Aucun nœud cluster enregistré.', 'clu.connect': 'Connecter', 'clu.disconnect': 'Déconnecter',
'clu.add': 'Ajouter cluster', 'clu.connectAll': 'Tout connecter', 'clu.disconnectAll': 'Tout déconnecter', 'clu.autoConnect': 'Connexion auto de tous les activés au démarrage',
'clu.freeNodes': 'Nœuds publics gratuits :',
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
'bk.effective': 'Dossier effectif :', 'bk.defaultUse': 'Si vide, OpsLog utilise le défaut :', 'bk.rotation': 'Rotation (copies à garder)', 'bk.zip': 'Sauvegarde ZIP (fichier plus petit)',
'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans',
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).",
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).",
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)",
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
'cat.flexHint': "API SmartSDR native — pas besoin d'OmniRig. Fréquence, mode et split sont lus en temps réel depuis la radio (sans poll, sans le bug du mode au 2ᵉ clic). Utilise Détecter les radios ou saisis l'IP. Le mode numérique par défaut est ce qu'OpsLog logge quand la slice est en mode numérique (DIGU/DIGL).",
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.",
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (12 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
'db.activeBackend': 'Backend actif :', 'db.logbook': 'Journal :', 'db.config': 'Config :', 'db.mysqlConnected': 'MySQL partagé connecté ✓', 'db.localSqlite': 'SQLite local',
'db.fallback': "MySQL est activé mais la connexion a échoué au démarrage — OpsLog tourne sur la base SQLite locale.",
'db.mysqlNote': "Seuls les QSO vont dans MySQL ; tes réglages, profils, stations et cluster restent locaux (et rapides). Les QSO locaux existants ne sont pas copiés — importe-les dans le journal partagé si tu veux ton historique là-bas.",
'em.none': 'Aucun', 'em.smtpAuth': 'Le SMTP requiert une authentification', 'em.username': 'Utilisateur', 'em.fromAddr': 'Adresse expéditeur', 'em.replyTo': 'Adresse de réponse', 'em.replyToPh': '(optionnel — où vont les réponses)', 'em.replyToHint': "Vide = utilise l'adresse expéditeur. Renseigne-la pour que les correspondants répondent p. ex. sur ta boîte perso.",
'em.sendTest': 'Envoyer un e-mail test', 'em.sendingTest': 'Envoi du test…', 'em.testSent': 'E-mail test envoyé ✓', 'em.testFailed': 'Échec du test : ',
'em.qslCardEmail': 'E-mail de carte QSL OpsLog', 'em.qslVarsHint': 'Message envoyé avec la carte QSL. Variables :', 'em.subject': 'Objet', 'em.body': 'Corps', 'em.autoSend': "Envoyer auto la QSL OpsLog à l'enregistrement d'un QSO", 'em.autoSendHint': "Envoi automatique uniquement si le contact a une adresse e-mail et qu'un modèle QSL par défaut existe.",
'settings.title': 'Préférences',
'btn.cancel': 'Annuler', 'btn.save': 'Enregistrer', 'btn.saveClose': 'Enregistrer et fermer', 'btn.savingLong': 'Enregistrement…',
'chatp.chat': 'Chat', 'chatp.online': 'En ligne', 'chatp.close': 'Fermer', 'chatp.noMessages': 'Aucun message pour le moment.', 'chatp.messagePh': 'Message…',
'chp.workedBefore': 'Déjà contacté', 'chp.typeCallsign': 'Saisis un indicatif pour voir les contacts précédents.', 'chp.checking': 'vérification…', 'chp.new': 'NOUVEAU',
'chp.noPriorQso': 'Aucun QSO antérieur avec cet indicatif.', 'chp.first': 'Premier', 'chp.last': 'Dernier', 'chp.dateUtc': 'Date UTC', 'chp.band': 'Bande', 'chp.mode': 'Mode',
'chp.lotwRcvd': 'LoTW reçue', 'chp.bureauRcvd': 'Bureau reçue', 'chp.olderQsos': '+ {n} QSO plus anciens',
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)',
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger',
'ctp.contestSetup': 'Configuration du contest', 'ctp.live': 'En direct', 'ctp.chooseContest': '— choisir un contest —', 'ctp.serial': 'Numéro de série', 'ctp.fixedExchange': 'Échange fixe', 'ctp.nextSerial': 'Prochain numéro', 'ctp.yourExchangePh': 'ton échange', 'ctp.startUtc': 'Début (UTC)', 'ctp.endUtc': 'Fin (UTC)', 'ctp.windowHint': 'fin vide = ouvert · laisser vide pour tout compter',
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "Base mise à jour → notifier d'autres apps", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches supplémentaires', 'awed.orAlsoMatch': "— correspond aussi à la référence si l'une d'elles est trouvée", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé',
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator', 'clg2.h.locator': 'Loc', 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.wkdCall': 'DÉJÀ CTC', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
};
const dicts: Record<Lang, Dict> = { en, fr };
function translate(lang: Lang, key: string, vars?: Record<string, string | number>): string {
let s = dicts[lang][key] ?? dicts.en[key] ?? key;
if (vars) for (const k of Object.keys(vars)) s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(vars[k]));
return s;
}
type Ctx = { lang: Lang; setLang: (l: Lang) => void; t: (key: string, vars?: Record<string, string | number>) => string; chosen: boolean };
const I18nContext = createContext<Ctx>({ lang: 'en', setLang: () => {}, t: (k) => k, chosen: true });
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(() => ((localStorage.getItem(LS_KEY) as Lang) || 'en'));
const [chosen, setChosen] = useState<boolean>(() => !!localStorage.getItem(LS_KEY));
const setLang = (l: Lang) => {
setLangState(l);
setChosen(true);
localStorage.setItem(LS_KEY, l);
writeUiPref(LS_KEY, l);
};
const t = useCallback((key: string, vars?: Record<string, string | number>) => translate(lang, key, vars), [lang]);
return (
<I18nContext.Provider value={{ lang, setLang, t, chosen }}>
{children}
{!chosen && <LanguageGate onPick={setLang} />}
</I18nContext.Provider>
);
}
export const useI18n = () => useContext(I18nContext);
// ── Flags (inline SVG — Windows doesn't render flag emoji) ──────────────────
export function FlagFR({ className }: { className?: string }) {
return (
<svg viewBox="0 0 3 2" className={className} aria-hidden>
<rect width="1" height="2" x="0" fill="#0055A4" />
<rect width="1" height="2" x="1" fill="#fff" />
<rect width="1" height="2" x="2" fill="#EF4135" />
</svg>
);
}
export function FlagGB({ className }: { className?: string }) {
return (
<svg viewBox="0 0 60 30" className={className} aria-hidden>
<clipPath id="ujs"><path d="M0,0 v30 h60 v-30 z" /></clipPath>
<clipPath id="ujt"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z" /></clipPath>
<g clipPath="url(#ujs)">
<path d="M0,0 v30 h60 v-30 z" fill="#012169" />
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
<path d="M0,0 L60,30 M60,0 L0,30" clipPath="url(#ujt)" stroke="#C8102E" strokeWidth="4" />
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10" />
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6" />
</g>
</svg>
);
}
// LanguageGate — first-run modal to pick the interface language.
function LanguageGate({ onPick }: { onPick: (l: Lang) => void }) {
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="rounded-2xl border border-border bg-card shadow-2xl p-8 w-[420px] max-w-[90vw] text-center">
<div className="text-lg font-bold mb-1">Choose your language</div>
<div className="text-sm text-muted-foreground mb-6">Choisissez votre langue</div>
<div className="grid grid-cols-2 gap-4">
<button type="button" onClick={() => onPick('en')}
className="flex flex-col items-center gap-3 rounded-xl border-2 border-border p-5 hover:border-primary hover:bg-muted transition-colors">
<FlagGB className="w-16 rounded shadow-sm" />
<span className="font-bold">English</span>
</button>
<button type="button" onClick={() => onPick('fr')}
className="flex flex-col items-center gap-3 rounded-xl border-2 border-border p-5 hover:border-primary hover:bg-muted transition-colors">
<FlagFR className="w-16 rounded shadow-sm border border-border/40" />
<span className="font-bold">Français</span>
</button>
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
// sMeterRST turns an S-meter reading into the RST report you SEND to the other
// station (RST tx / rst_sent) — because your S-meter is how strong THEY are.
//
// s : S-unit 0-9 (used when below S9)
// overDb : dB over S9 (0 when at/below S9)
// mode : operating mode — CW/digital get a 3-digit RST, phone a 2-digit RS
//
// For S9+ the dB is rounded UP to the nearest 5 (a 9+3 reading reports 59+5,
// 9+12 reports 59+15, …), matching how operators give strong-signal reports.
export function sMeterRST(s: number, overDb: number, mode?: string): string {
const cw = /CW|RTTY|PSK|FT[0-9]|JT|JS8|MFSK|OLIVIA|DATA|DIG/i.test(mode || '');
const strength = Math.max(1, Math.min(9, Math.round(s)));
if (cw) return overDb > 0 ? '599' : `5${strength}9`;
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
return `5${strength}`;
}
+4
View File
@@ -12,6 +12,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
const PORTABLE_KEYS = [
'hamlog.qsoLimit', // QSO list page size
'bandmap.side', // band map docked left / right
'bandmap.show', // band map open / closed
'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers
@@ -21,6 +22,9 @@ const PORTABLE_KEYS = [
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite)
];
// syncPortablePrefs reconciles the DB with the local cache at startup:
+4 -1
View File
@@ -3,6 +3,7 @@ import {createRoot} from 'react-dom/client'
import './style.css'
import App from './App'
import { syncPortablePrefs } from './lib/uiPref'
import { I18nProvider } from './lib/i18n'
const container = document.getElementById('root')
@@ -14,7 +15,9 @@ const root = createRoot(container!)
syncPortablePrefs().finally(() => {
root.render(
<React.StrictMode>
<App/>
<I18nProvider>
<App/>
</I18nProvider>
</React.StrictMode>
)
})
+6
View File
@@ -0,0 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.16.5';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+332 -3
View File
@@ -3,17 +3,22 @@
import {adif} from '../models';
import {qso} from '../models';
import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {antgenius} from '../models';
import {award} from '../models';
import {awardref} from '../models';
import {cat} from '../models';
import {cluster} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {winkeyer} from '../models';
import {alerts} from '../models';
import {audio} from '../models';
import {contest} from '../models';
import {operating} from '../models';
import {udp} from '../models';
import {lookup} from '../models';
import {netctl} from '../models';
export function ADIFFields():Promise<Array<adif.FieldDef>>;
@@ -23,16 +28,34 @@ export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
export function AntGeniusDeselect(arg1:number):Promise<void>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>;
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
@@ -45,6 +68,10 @@ export function ConnectAllClusters():Promise<void>;
export function ConnectClusterServer(arg1:number):Promise<void>;
export function ContestDupe(arg1:string,arg2:string,arg3:string,arg4:string,arg5:string):Promise<boolean>;
export function ContestStats(arg1:string,arg2:string,arg3:string,arg4:string):Promise<main.ContestStatsResult>;
export function CountQSO():Promise<number>;
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
@@ -67,6 +94,8 @@ export function DXCCForCountry(arg1:string):Promise<number>;
export function DXCCName(arg1:number):Promise<string>;
export function DeleteAlertRule(arg1:string):Promise<void>;
export function DeleteAllQSO():Promise<number>;
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
@@ -81,12 +110,18 @@ export function DeleteProfile(arg1:number):Promise<void>;
export function DeleteQSO(arg1:number):Promise<void>;
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
export function DeleteUDPIntegration(arg1:number):Promise<void>;
export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DownloadAllReferenceLists():Promise<string>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
@@ -101,14 +136,108 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
export function ExportCabrilloFiltered(arg1:string,arg2:qso.QueryFilter):Promise<main.CabrilloResult>;
export function ExportCabrilloSelected(arg1:string,arg2:Array<number>):Promise<main.CabrilloResult>;
export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
export function FindDuplicates(arg1:number):Promise<Array<main.DuplicateGroup>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
export function FlexATUBypass():Promise<void>;
export function FlexATUStart():Promise<void>;
export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>;
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
export function FlexSetANF(arg1:boolean):Promise<void>;
export function FlexSetANFLevel(arg1:number):Promise<void>;
export function FlexSetAPF(arg1:boolean):Promise<void>;
export function FlexSetAPFLevel(arg1:number):Promise<void>;
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
export function FlexSetAudioLevel(arg1:number):Promise<void>;
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
export function FlexSetCWFilter(arg1:number):Promise<void>;
export function FlexSetCWPitch(arg1:number):Promise<void>;
export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>;
export function FlexSetMute(arg1:boolean):Promise<void>;
export function FlexSetNB(arg1:boolean):Promise<void>;
export function FlexSetNBLevel(arg1:number):Promise<void>;
export function FlexSetNR(arg1:boolean):Promise<void>;
export function FlexSetNRLevel(arg1:number):Promise<void>;
export function FlexSetPower(arg1:number):Promise<void>;
export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>;
export function FlexSetVoxDelay(arg1:number):Promise<void>;
export function FlexSetVoxLevel(arg1:number):Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>;
export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>;
@@ -127,6 +256,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClusterAutoConnect():Promise<boolean>;
@@ -135,6 +268,10 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
export function GetDBConnectionInfo():Promise<main.DBConnectionInfo>;
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>;
@@ -147,12 +284,30 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>;
export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLogFilePath():Promise<string>;
export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
export function GetPGXLSettings():Promise<main.PGXLSettings>;
export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>;
export function GetQSLDefaults():Promise<main.QSLDefaults>;
@@ -163,10 +318,14 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationSettings():Promise<main.StationSettings>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
@@ -179,12 +338,58 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
export function IcomRefresh():Promise<void>;
export function IcomScopeData():Promise<cat.ScopeSweep>;
export function IcomSetAFGain(arg1:number):Promise<void>;
export function IcomSetAGC(arg1:string):Promise<void>;
export function IcomSetANF(arg1:boolean):Promise<void>;
export function IcomSetAtt(arg1:number):Promise<void>;
export function IcomSetFilter(arg1:number):Promise<void>;
export function IcomSetMicGain(arg1:number):Promise<void>;
export function IcomSetNB(arg1:boolean):Promise<void>;
export function IcomSetNBLevel(arg1:number):Promise<void>;
export function IcomSetNR(arg1:boolean):Promise<void>;
export function IcomSetNRLevel(arg1:number):Promise<void>;
export function IcomSetPTT(arg1:boolean):Promise<void>;
export function IcomSetPreamp(arg1:number):Promise<void>;
export function IcomSetRFGain(arg1:number):Promise<void>;
export function IcomSetRFPower(arg1:number):Promise<void>;
export function IcomSetScope(arg1:boolean):Promise<void>;
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
export function IcomSetSplit(arg1:boolean):Promise<void>;
export function IcomTune():Promise<void>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
export function ImportAwards():Promise<main.AwardImportResult>;
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
export function ListAlertRules():Promise<Array<alerts.Rule>>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
@@ -193,6 +398,8 @@ export function ListAwardReferences(arg1:string):Promise<Array<awardref.Ref>>;
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
export function ListContests():Promise<Array<contest.Def>>;
export function ListCountries():Promise<Array<string>>;
export function ListOperatingTree():Promise<Array<operating.Station>>;
@@ -215,6 +422,40 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function MoveDatabase(arg1:string):Promise<void>;
export function NetActivate(arg1:string):Promise<qso.QSO>;
export function NetActiveList():Promise<Array<qso.QSO>>;
export function NetClose():Promise<void>;
export function NetCreate(arg1:string):Promise<netctl.Net>;
export function NetDeactivate(arg1:number):Promise<number>;
export function NetDelete(arg1:string):Promise<void>;
export function NetDiscardActive(arg1:number):Promise<void>;
export function NetList():Promise<Array<netctl.Net>>;
export function NetLookup(arg1:string):Promise<netctl.Station>;
export function NetOpen(arg1:string):Promise<void>;
export function NetOpenID():Promise<string>;
export function NetRename(arg1:string,arg2:string):Promise<void>;
export function NetRoster(arg1:string):Promise<Array<netctl.Station>>;
export function NetRosterRemove(arg1:string,arg2:string):Promise<void>;
export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function OpenADIFFile():Promise<string>;
export function OpenDatabase(arg1:string):Promise<void>;
@@ -223,6 +464,8 @@ export function OpenExternalURL(arg1:string):Promise<void>;
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PickAudioFolder():Promise<string>;
export function PickBackupFolder():Promise<string>;
@@ -233,10 +476,46 @@ export function PickSaveDatabase():Promise<string>;
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function QSLDefaultTemplateID():Promise<number>;
export function QSLDeleteTemplate(arg1:number):Promise<void>;
export function QSLFlagDataURL(arg1:string):Promise<string>;
export function QSLFonts():Promise<Array<main.QSLFontInfo>>;
export function QSLGenerateProposals(arg1:Array<string>):Promise<Array<string>>;
export function QSLGetEmailTemplates():Promise<main.QSLEmailTemplates>;
export function QSLGetTemplate(arg1:number):Promise<string>;
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
export function QSLPickPhotos():Promise<Array<string>>;
export function QSLPreviewDataURL(arg1:number):Promise<string>;
export function QSLResolvePreview(arg1:string):Promise<string>;
export function QSLSaveEmailTemplates(arg1:main.QSLEmailTemplates):Promise<void>;
export function QSLSavePreview(arg1:number,arg2:string):Promise<void>;
export function QSLSaveTemplate(arg1:number,arg2:string,arg3:string,arg4:boolean):Promise<number>;
export function QSLSetDefaultTemplate(arg1:number):Promise<void>;
export function QSLStylePresets():Promise<Array<main.QSLPresetInfo>>;
export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>;
export function QSOAudioResetClock():Promise<boolean>;
export function QSOAudioRestart():Promise<boolean>;
export function QuitApp():Promise<void>;
@@ -245,8 +524,16 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
export function RescanAwards():Promise<void>;
export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
@@ -263,8 +550,14 @@ export function RunBackupNow():Promise<string>;
export function SaveADIFFile():Promise<string>;
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
@@ -273,20 +566,28 @@ export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveCabrilloFile():Promise<string>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
export function SavePOTAToken(arg1:string):Promise<void>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
@@ -305,16 +606,24 @@ export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
@@ -323,22 +632,38 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TestClublogUpload():Promise<string>;
export function TestEQSLUpload():Promise<string>;
export function TestEmail(arg1:string):Promise<void>;
export function TestHRDLogUpload():Promise<string>;
export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
export function TestMySQLConnection(arg1:main.MySQLSettings):Promise<void>;
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
export function TestQRZUpload():Promise<string>;
@@ -349,6 +674,8 @@ export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function UltrabeamRetract():Promise<void>;
export function UnlockSecrets(arg1:string):Promise<void>;
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
@@ -359,6 +686,8 @@ export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WinkeyerBackspace():Promise<void>;
+650 -2
View File
@@ -18,10 +18,22 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
export function AntGeniusActivate(arg1, arg2) {
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
}
export function AntGeniusDeselect(arg1) {
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
}
export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
}
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
}
export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
}
@@ -34,10 +46,34 @@ export function AwardMissingQSOs(arg1) {
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
}
export function AwardRefsForQSOs(arg1) {
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
}
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
export function BulkUpdateField(arg1, arg2, arg3) {
return window['go']['main']['App']['BulkUpdateField'](arg1, arg2, arg3);
}
export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
}
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
export function CheckForUpdate() {
return window['go']['main']['App']['CheckForUpdate']();
}
export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache']();
}
@@ -62,6 +98,14 @@ export function ConnectClusterServer(arg1) {
return window['go']['main']['App']['ConnectClusterServer'](arg1);
}
export function ContestDupe(arg1, arg2, arg3, arg4, arg5) {
return window['go']['main']['App']['ContestDupe'](arg1, arg2, arg3, arg4, arg5);
}
export function ContestStats(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ContestStats'](arg1, arg2, arg3, arg4);
}
export function CountQSO() {
return window['go']['main']['App']['CountQSO']();
}
@@ -106,6 +150,10 @@ export function DXCCName(arg1) {
return window['go']['main']['App']['DXCCName'](arg1);
}
export function DeleteAlertRule(arg1) {
return window['go']['main']['App']['DeleteAlertRule'](arg1);
}
export function DeleteAllQSO() {
return window['go']['main']['App']['DeleteAllQSO']();
}
@@ -134,6 +182,10 @@ export function DeleteQSO(arg1) {
return window['go']['main']['App']['DeleteQSO'](arg1);
}
export function DeleteQSOs(arg1) {
return window['go']['main']['App']['DeleteQSOs'](arg1);
}
export function DeleteUDPIntegration(arg1) {
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
}
@@ -146,6 +198,14 @@ export function DisconnectClusterServer(arg1) {
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
}
export function DiscoverFlexRadios() {
return window['go']['main']['App']['DiscoverFlexRadios']();
}
export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists']();
}
export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty']();
}
@@ -174,22 +234,210 @@ export function ExportAwards() {
return window['go']['main']['App']['ExportAwards']();
}
export function ExportCabrillo(arg1) {
return window['go']['main']['App']['ExportCabrillo'](arg1);
}
export function ExportCabrilloFiltered(arg1, arg2) {
return window['go']['main']['App']['ExportCabrilloFiltered'](arg1, arg2);
}
export function ExportCabrilloSelected(arg1, arg2) {
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2);
}
export function FilterFields() {
return window['go']['main']['App']['FilterFields']();
}
export function FindDuplicates(arg1) {
return window['go']['main']['App']['FindDuplicates'](arg1);
}
export function FindQSOsForUpload(arg1, arg2) {
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
}
export function FlexATUBypass() {
return window['go']['main']['App']['FlexATUBypass']();
}
export function FlexATUStart() {
return window['go']['main']['App']['FlexATUStart']();
}
export function FlexAmpOperate(arg1) {
return window['go']['main']['App']['FlexAmpOperate'](arg1);
}
export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
}
export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1);
}
export function FlexSetAGCMode(arg1) {
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
}
export function FlexSetAGCThreshold(arg1) {
return window['go']['main']['App']['FlexSetAGCThreshold'](arg1);
}
export function FlexSetANF(arg1) {
return window['go']['main']['App']['FlexSetANF'](arg1);
}
export function FlexSetANFLevel(arg1) {
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
}
export function FlexSetAPF(arg1) {
return window['go']['main']['App']['FlexSetAPF'](arg1);
}
export function FlexSetAPFLevel(arg1) {
return window['go']['main']['App']['FlexSetAPFLevel'](arg1);
}
export function FlexSetATUMemories(arg1) {
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
}
export function FlexSetAudioLevel(arg1) {
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
}
export function FlexSetCWBreakInDelay(arg1) {
return window['go']['main']['App']['FlexSetCWBreakInDelay'](arg1);
}
export function FlexSetCWFilter(arg1) {
return window['go']['main']['App']['FlexSetCWFilter'](arg1);
}
export function FlexSetCWPitch(arg1) {
return window['go']['main']['App']['FlexSetCWPitch'](arg1);
}
export function FlexSetCWSidetone(arg1) {
return window['go']['main']['App']['FlexSetCWSidetone'](arg1);
}
export function FlexSetCWSpeed(arg1) {
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
}
export function FlexSetFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
}
export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1);
}
export function FlexSetMon(arg1) {
return window['go']['main']['App']['FlexSetMon'](arg1);
}
export function FlexSetMonLevel(arg1) {
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
}
export function FlexSetMute(arg1) {
return window['go']['main']['App']['FlexSetMute'](arg1);
}
export function FlexSetNB(arg1) {
return window['go']['main']['App']['FlexSetNB'](arg1);
}
export function FlexSetNBLevel(arg1) {
return window['go']['main']['App']['FlexSetNBLevel'](arg1);
}
export function FlexSetNR(arg1) {
return window['go']['main']['App']['FlexSetNR'](arg1);
}
export function FlexSetNRLevel(arg1) {
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
}
export function FlexSetPower(arg1) {
return window['go']['main']['App']['FlexSetPower'](arg1);
}
export function FlexSetProcessor(arg1) {
return window['go']['main']['App']['FlexSetProcessor'](arg1);
}
export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
}
export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
}
export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
}
export function FlexSetSplit(arg1) {
return window['go']['main']['App']['FlexSetSplit'](arg1);
}
export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
}
export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1);
}
export function FlexSetVox(arg1) {
return window['go']['main']['App']['FlexSetVox'](arg1);
}
export function FlexSetVoxDelay(arg1) {
return window['go']['main']['App']['FlexSetVoxDelay'](arg1);
}
export function FlexSetVoxLevel(arg1) {
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo']();
}
export function GetAntGeniusSettings() {
return window['go']['main']['App']['GetAntGeniusSettings']();
}
export function GetAntGeniusStatus() {
return window['go']['main']['App']['GetAntGeniusStatus']();
}
export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings']();
}
export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms']();
}
export function GetAward(arg1) {
return window['go']['main']['App']['GetAward'](arg1);
}
@@ -226,6 +474,14 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState']();
}
export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo']();
}
@@ -242,6 +498,14 @@ export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo']();
}
export function GetDBBackendStatus() {
return window['go']['main']['App']['GetDBBackendStatus']();
}
export function GetDBConnectionInfo() {
return window['go']['main']['App']['GetDBConnectionInfo']();
}
export function GetDVKMessages() {
return window['go']['main']['App']['GetDVKMessages']();
}
@@ -266,18 +530,54 @@ export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices']();
}
export function GetFlexBandAntennas() {
return window['go']['main']['App']['GetFlexBandAntennas']();
}
export function GetFlexState() {
return window['go']['main']['App']['GetFlexState']();
}
export function GetIcomState() {
return window['go']['main']['App']['GetIcomState']();
}
export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings']();
}
export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled']();
}
export function GetLogFilePath() {
return window['go']['main']['App']['GetLogFilePath']();
}
export function GetLogbookRevision() {
return window['go']['main']['App']['GetLogbookRevision']();
}
export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings']();
}
export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings']();
}
export function GetOnlineOperators() {
return window['go']['main']['App']['GetOnlineOperators']();
}
export function GetPGXLSettings() {
return window['go']['main']['App']['GetPGXLSettings']();
}
export function GetPGXLStatus() {
return window['go']['main']['App']['GetPGXLStatus']();
}
export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken']();
}
@@ -298,6 +598,10 @@ export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings']();
}
export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
export function GetStartupStatus() {
return window['go']['main']['App']['GetStartupStatus']();
}
@@ -306,6 +610,10 @@ export function GetStationSettings() {
return window['go']['main']['App']['GetStationSettings']();
}
export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1);
}
@@ -330,8 +638,88 @@ export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
export function ImportADIF(arg1, arg2, arg3) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
export function IcomRefresh() {
return window['go']['main']['App']['IcomRefresh']();
}
export function IcomScopeData() {
return window['go']['main']['App']['IcomScopeData']();
}
export function IcomSetAFGain(arg1) {
return window['go']['main']['App']['IcomSetAFGain'](arg1);
}
export function IcomSetAGC(arg1) {
return window['go']['main']['App']['IcomSetAGC'](arg1);
}
export function IcomSetANF(arg1) {
return window['go']['main']['App']['IcomSetANF'](arg1);
}
export function IcomSetAtt(arg1) {
return window['go']['main']['App']['IcomSetAtt'](arg1);
}
export function IcomSetFilter(arg1) {
return window['go']['main']['App']['IcomSetFilter'](arg1);
}
export function IcomSetMicGain(arg1) {
return window['go']['main']['App']['IcomSetMicGain'](arg1);
}
export function IcomSetNB(arg1) {
return window['go']['main']['App']['IcomSetNB'](arg1);
}
export function IcomSetNBLevel(arg1) {
return window['go']['main']['App']['IcomSetNBLevel'](arg1);
}
export function IcomSetNR(arg1) {
return window['go']['main']['App']['IcomSetNR'](arg1);
}
export function IcomSetNRLevel(arg1) {
return window['go']['main']['App']['IcomSetNRLevel'](arg1);
}
export function IcomSetPTT(arg1) {
return window['go']['main']['App']['IcomSetPTT'](arg1);
}
export function IcomSetPreamp(arg1) {
return window['go']['main']['App']['IcomSetPreamp'](arg1);
}
export function IcomSetRFGain(arg1) {
return window['go']['main']['App']['IcomSetRFGain'](arg1);
}
export function IcomSetRFPower(arg1) {
return window['go']['main']['App']['IcomSetRFPower'](arg1);
}
export function IcomSetScope(arg1) {
return window['go']['main']['App']['IcomSetScope'](arg1);
}
export function IcomSetScopeMode(arg1) {
return window['go']['main']['App']['IcomSetScopeMode'](arg1);
}
export function IcomSetSplit(arg1) {
return window['go']['main']['App']['IcomSetSplit'](arg1);
}
export function IcomTune() {
return window['go']['main']['App']['IcomTune']();
}
export function ImportADIF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
}
export function ImportAwardReferencesText(arg1, arg2) {
@@ -342,6 +730,18 @@ export function ImportAwards() {
return window['go']['main']['App']['ImportAwards']();
}
export function LaunchAutostartProgram(arg1) {
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
}
export function LaunchAutostartPrograms() {
return window['go']['main']['App']['LaunchAutostartPrograms']();
}
export function ListAlertRules() {
return window['go']['main']['App']['ListAlertRules']();
}
export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices']();
}
@@ -358,6 +758,10 @@ export function ListClusterServers() {
return window['go']['main']['App']['ListClusterServers']();
}
export function ListContests() {
return window['go']['main']['App']['ListContests']();
}
export function ListCountries() {
return window['go']['main']['App']['ListCountries']();
}
@@ -402,6 +806,74 @@ export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1);
}
export function NetActivate(arg1) {
return window['go']['main']['App']['NetActivate'](arg1);
}
export function NetActiveList() {
return window['go']['main']['App']['NetActiveList']();
}
export function NetClose() {
return window['go']['main']['App']['NetClose']();
}
export function NetCreate(arg1) {
return window['go']['main']['App']['NetCreate'](arg1);
}
export function NetDeactivate(arg1) {
return window['go']['main']['App']['NetDeactivate'](arg1);
}
export function NetDelete(arg1) {
return window['go']['main']['App']['NetDelete'](arg1);
}
export function NetDiscardActive(arg1) {
return window['go']['main']['App']['NetDiscardActive'](arg1);
}
export function NetList() {
return window['go']['main']['App']['NetList']();
}
export function NetLookup(arg1) {
return window['go']['main']['App']['NetLookup'](arg1);
}
export function NetOpen(arg1) {
return window['go']['main']['App']['NetOpen'](arg1);
}
export function NetOpenID() {
return window['go']['main']['App']['NetOpenID']();
}
export function NetRename(arg1, arg2) {
return window['go']['main']['App']['NetRename'](arg1, arg2);
}
export function NetRoster(arg1) {
return window['go']['main']['App']['NetRoster'](arg1);
}
export function NetRosterRemove(arg1, arg2) {
return window['go']['main']['App']['NetRosterRemove'](arg1, arg2);
}
export function NetRosterUpsert(arg1, arg2) {
return window['go']['main']['App']['NetRosterUpsert'](arg1, arg2);
}
export function NetSetDefaults(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['NetSetDefaults'](arg1, arg2, arg3, arg4);
}
export function NetUpdateActive(arg1) {
return window['go']['main']['App']['NetUpdateActive'](arg1);
}
export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile']();
}
@@ -418,6 +890,10 @@ export function OperatingDefaultForBand(arg1) {
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
}
export function PGXLSetFanMode(arg1) {
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
}
export function PickAudioFolder() {
return window['go']['main']['App']['PickAudioFolder']();
}
@@ -438,6 +914,74 @@ export function PopulateBuiltinReferences(arg1) {
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
}
export function QSLDefaultTemplateID() {
return window['go']['main']['App']['QSLDefaultTemplateID']();
}
export function QSLDeleteTemplate(arg1) {
return window['go']['main']['App']['QSLDeleteTemplate'](arg1);
}
export function QSLFlagDataURL(arg1) {
return window['go']['main']['App']['QSLFlagDataURL'](arg1);
}
export function QSLFonts() {
return window['go']['main']['App']['QSLFonts']();
}
export function QSLGenerateProposals(arg1) {
return window['go']['main']['App']['QSLGenerateProposals'](arg1);
}
export function QSLGetEmailTemplates() {
return window['go']['main']['App']['QSLGetEmailTemplates']();
}
export function QSLGetTemplate(arg1) {
return window['go']['main']['App']['QSLGetTemplate'](arg1);
}
export function QSLListTemplates() {
return window['go']['main']['App']['QSLListTemplates']();
}
export function QSLPhotoDataURL(arg1, arg2) {
return window['go']['main']['App']['QSLPhotoDataURL'](arg1, arg2);
}
export function QSLPickPhotos() {
return window['go']['main']['App']['QSLPickPhotos']();
}
export function QSLPreviewDataURL(arg1) {
return window['go']['main']['App']['QSLPreviewDataURL'](arg1);
}
export function QSLResolvePreview(arg1) {
return window['go']['main']['App']['QSLResolvePreview'](arg1);
}
export function QSLSaveEmailTemplates(arg1) {
return window['go']['main']['App']['QSLSaveEmailTemplates'](arg1);
}
export function QSLSavePreview(arg1, arg2) {
return window['go']['main']['App']['QSLSavePreview'](arg1, arg2);
}
export function QSLSaveTemplate(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['QSLSaveTemplate'](arg1, arg2, arg3, arg4);
}
export function QSLSetDefaultTemplate(arg1) {
return window['go']['main']['App']['QSLSetDefaultTemplate'](arg1);
}
export function QSLStylePresets() {
return window['go']['main']['App']['QSLStylePresets']();
}
export function QSOAudioBegin() {
return window['go']['main']['App']['QSOAudioBegin']();
}
@@ -446,6 +990,10 @@ export function QSOAudioCancel() {
return window['go']['main']['App']['QSOAudioCancel']();
}
export function QSOAudioResetClock() {
return window['go']['main']['App']['QSOAudioResetClock']();
}
export function QSOAudioRestart() {
return window['go']['main']['App']['QSOAudioRestart']();
}
@@ -462,10 +1010,26 @@ export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations']();
}
export function RemovePassphrase(arg1) {
return window['go']['main']['App']['RemovePassphrase'](arg1);
}
export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
}
export function ReplaceAwardReferences(arg1, arg2) {
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
}
export function ReportLiveActivity(arg1, arg2, arg3) {
return window['go']['main']['App']['ReportLiveActivity'](arg1, arg2, arg3);
}
export function RescanAwards() {
return window['go']['main']['App']['RescanAwards']();
}
export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs']();
}
@@ -498,10 +1062,22 @@ export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
export function SaveAlertRule(arg1) {
return window['go']['main']['App']['SaveAlertRule'](arg1);
}
export function SaveAntGeniusSettings(arg1) {
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
}
export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1);
}
export function SaveAutostartPrograms(arg1) {
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
}
export function SaveAwardDefs(arg1) {
return window['go']['main']['App']['SaveAwardDefs'](arg1);
}
@@ -518,6 +1094,10 @@ export function SaveCATSettings(arg1) {
return window['go']['main']['App']['SaveCATSettings'](arg1);
}
export function SaveCabrilloFile() {
return window['go']['main']['App']['SaveCabrilloFile']();
}
export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1);
}
@@ -530,6 +1110,10 @@ export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1);
}
export function SaveFlexBandAntennas(arg1) {
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
}
export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1);
}
@@ -538,6 +1122,10 @@ export function SaveLookupSettings(arg1) {
return window['go']['main']['App']['SaveLookupSettings'](arg1);
}
export function SaveMySQLSettings(arg1) {
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
}
export function SaveOperatingAntenna(arg1) {
return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
}
@@ -546,6 +1134,10 @@ export function SaveOperatingStation(arg1) {
return window['go']['main']['App']['SaveOperatingStation'](arg1);
}
export function SavePGXLSettings(arg1) {
return window['go']['main']['App']['SavePGXLSettings'](arg1);
}
export function SavePOTAToken(arg1) {
return window['go']['main']['App']['SavePOTAToken'](arg1);
}
@@ -582,6 +1174,10 @@ export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
}
export function SendChatMessage(arg1) {
return window['go']['main']['App']['SendChatMessage'](arg1);
}
export function SendClusterCommand(arg1) {
return window['go']['main']['App']['SendClusterCommand'](arg1);
}
@@ -590,10 +1186,18 @@ export function SendClusterSpot(arg1, arg2, arg3) {
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
}
export function SendEQSL(arg1, arg2, arg3) {
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
}
export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetAlertEmailTo(arg1) {
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
}
export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1);
}
@@ -602,6 +1206,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1);
}
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
}
@@ -618,6 +1226,18 @@ export function SetDVKLabel(arg1, arg2) {
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
}
export function SetLiveStatusEnabled(arg1) {
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
}
export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
}
export function SetUIPref(arg1, arg2) {
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
}
@@ -626,6 +1246,14 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
@@ -638,10 +1266,18 @@ export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload']();
}
export function TestEQSLUpload() {
return window['go']['main']['App']['TestEQSLUpload']();
}
export function TestEmail(arg1) {
return window['go']['main']['App']['TestEmail'](arg1);
}
export function TestHRDLogUpload() {
return window['go']['main']['App']['TestHRDLogUpload']();
}
export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload']();
}
@@ -650,6 +1286,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
}
export function TestMySQLConnection(arg1) {
return window['go']['main']['App']['TestMySQLConnection'](arg1);
}
export function TestPTT(arg1) {
return window['go']['main']['App']['TestPTT'](arg1);
}
@@ -670,6 +1310,10 @@ export function UltrabeamRetract() {
return window['go']['main']['App']['UltrabeamRetract']();
}
export function UnlockSecrets(arg1) {
return window['go']['main']['App']['UnlockSecrets'](arg1);
}
export function UpdateAwardReferenceList(arg1) {
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
}
@@ -690,6 +1334,10 @@ export function UpdateQSOsFromQRZ(arg1) {
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
}
export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1);
}
export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -5,6 +5,8 @@ go 1.25.0
require (
github.com/braheezy/shine-mp3 v0.1.0
github.com/go-ole/go-ole v1.3.0
github.com/go-sql-driver/mysql v1.10.0
github.com/gorilla/websocket v1.5.3
github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3
@@ -16,11 +18,11 @@ require (
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
+4
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
@@ -9,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+19
View File
@@ -120,6 +120,25 @@ func SingleRecordADIF(q qso.QSO) string {
return b.String()
}
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
func BatchRecordsADIF(records []string) string {
var b strings.Builder
now := time.Now().UTC().Format("20060102 150405")
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
len(adifVersion), adifVersion, now)
for _, r := range records {
r = strings.TrimRight(r, "\r\n")
if r == "" {
continue
}
b.WriteString(r)
b.WriteString("\n")
}
return b.String()
}
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical
+309
View File
@@ -0,0 +1,309 @@
// Package alerts evaluates incoming DX-cluster spots against user-defined rules
// and reports which rules fire, so the app can notify the operator (sound /
// visual / e-mail) when a wanted station is spotted — like Log4OM's Alert
// Management. Rules are persisted as JSON (global, machine-local).
package alerts
import (
"encoding/json"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Rule is one alert definition. Every filter dimension is optional: an empty
// list/string matches ANY value, and the dimensions are ANDed together, so a
// rule with Countries=[France] and Bands=[20m] fires only for French stations
// on 20m. Calls / SpotterCall accept wildcards (IW3*, */P).
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
// DX filters.
Calls []string `json:"calls,omitempty"` // wildcard patterns on the DX call
Countries []string `json:"countries,omitempty"` // DXCC entity names
Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA
// Band / mode filters.
Bands []string `json:"bands,omitempty"`
Modes []string `json:"modes,omitempty"`
// Spotter (origin) filters.
SpotterCall string `json:"spotter_call,omitempty"` // wildcard
SpotterContinents []string `json:"spotter_continents,omitempty"`
SpotterCountries []string `json:"spotter_countries,omitempty"`
// Actions.
Sound bool `json:"sound"`
Visual bool `json:"visual"`
Email bool `json:"email"`
// Throttling: minutes to wait before re-alerting the same callsign. 0 = only
// once per session, -1 = always, >0 = that many minutes.
AgainAfterMin int `json:"again_after_min"`
// Skip a spot whose DX call is already worked on this band+mode.
SkipWorked bool `json:"skip_worked"`
}
// Spot is the subset of a cluster spot the engine matches against.
type Spot struct {
DXCall string
Band string
Mode string // inferred (see InferMode)
Country string // DXCC entity name
Continent string
Spotter string
SpotterCountry string
SpotterContinent string
}
// Store persists the rule set as a JSON file.
type Store struct {
mu sync.Mutex
path string
rules []Rule
// last fired time per (ruleID|call), for the AgainAfter throttle.
lastFired map[string]time.Time
}
// Open loads the store (empty when the file is absent).
func Open(path string) (*Store, error) {
s := &Store{path: path, lastFired: map[string]time.Time{}}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, err
}
if len(b) > 0 {
_ = json.Unmarshal(b, &s.rules) // corrupt file → start empty
}
return s, nil
}
func (s *Store) save() error {
b, err := json.MarshalIndent(s.rules, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// List returns a copy of all rules.
func (s *Store) List() []Rule {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Rule, len(s.rules))
copy(out, s.rules)
return out
}
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
// Save upserts a rule (creating an id when empty) and returns it.
func (s *Store) Save(r Rule) (Rule, error) {
s.mu.Lock()
defer s.mu.Unlock()
if strings.TrimSpace(r.ID) == "" {
r.ID = newID()
s.rules = append(s.rules, r)
} else {
found := false
for i := range s.rules {
if s.rules[i].ID == r.ID {
s.rules[i] = r
found = true
break
}
}
if !found {
s.rules = append(s.rules, r)
}
}
return r, s.save()
}
// Delete removes a rule by id.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.rules {
if s.rules[i].ID == id {
s.rules = append(s.rules[:i], s.rules[i+1:]...)
return s.save()
}
}
return nil
}
// Match is a rule that fired for a spot (returned by Evaluate).
type Match struct {
Rule Rule
Spot Spot
}
// Evaluate returns every enabled rule that matches the spot AND isn't currently
// throttled. It records the fire time for matched rules so the AgainAfter window
// is honoured. workedFn (may be nil) reports whether the DX call is already
// worked on this band+mode — used by rules with SkipWorked.
func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match {
s.mu.Lock()
defer s.mu.Unlock()
var out []Match
for _, r := range s.rules {
if !r.Enabled || !ruleMatches(r, sp) {
continue
}
if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) {
continue
}
key := r.ID + "|" + strings.ToUpper(sp.DXCall)
if r.AgainAfterMin >= 0 {
last, seen := s.lastFired[key]
if seen {
if r.AgainAfterMin == 0 {
continue // once per session
}
if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute {
continue
}
}
}
s.lastFired[key] = now
out = append(out, Match{Rule: r, Spot: sp})
}
return out
}
// ruleMatches reports whether every specified filter dimension matches the spot.
func ruleMatches(r Rule, sp Spot) bool {
if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) {
return false
}
if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) {
return false
}
if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) {
return false
}
if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) {
return false
}
if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) {
return false
}
if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) {
return false
}
if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) {
return false
}
if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) {
return false
}
return true
}
func containsFold(list []string, v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, x := range list {
if strings.EqualFold(strings.TrimSpace(x), v) {
return true
}
}
return false
}
func anyWildcard(patterns []string, v string) bool {
for _, p := range patterns {
if matchWildcard(p, v) {
return true
}
}
return false
}
// matchWildcard does a case-insensitive full-string match where '*' matches any
// run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO").
func matchWildcard(pattern, v string) bool {
pattern = strings.TrimSpace(pattern)
v = strings.TrimSpace(v)
if pattern == "" {
return true
}
var b strings.Builder
b.WriteString("(?i)^")
for _, r := range pattern {
switch r {
case '*':
b.WriteString(".*")
case '?':
b.WriteString(".")
default:
b.WriteString(regexp.QuoteMeta(string(r)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return strings.EqualFold(pattern, v)
}
return re.MatchString(v)
}
// InferMode guesses a spot's mode from its comment and frequency. Cluster spots
// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall
// back to the digital watering holes and the band-plan CW/phone split.
func InferMode(comment string, freqHz int64) string {
c := strings.ToUpper(comment)
switch {
case strings.Contains(c, "FT8"):
return "FT8"
case strings.Contains(c, "FT4"):
return "FT4"
case strings.Contains(c, "RTTY"):
return "RTTY"
case strings.Contains(c, "PSK"):
return "PSK"
case strings.Contains(c, "JS8"):
return "JS8"
case strings.Contains(c, "CW"):
return "CW"
case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"):
return "SSB"
}
khz := float64(freqHz) / 1000
// FT8 watering holes (…074) and FT4 (…080/…140) as a fallback.
for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} {
if khz >= f-1 && khz <= f+3 {
return "FT8"
}
}
// Band-plan CW segments (bottom of each band).
switch {
case khz >= 1810 && khz <= 1840,
khz >= 3500 && khz <= 3570,
khz >= 7000 && khz <= 7040,
khz >= 10100 && khz <= 10130,
khz >= 14000 && khz <= 14070,
khz >= 18068 && khz <= 18095,
khz >= 21000 && khz <= 21070,
khz >= 24890 && khz <= 24910,
khz >= 28000 && khz <= 28070:
return "CW"
}
return "SSB"
}
+369
View File
@@ -0,0 +1,369 @@
// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP
// text API (default port 9007). On connect the device sends a banner line
// (e.g. "V4.1.16 AG"); commands are "C<seq>|<command>\r" and the device replies
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
// "S<0>|<message>" status pushes once you subscribe with "sub port/antenna".
//
// (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used
// by pre-v4 firmware and is NOT what v4 speaks.)
package antgenius
import (
"bufio"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
defaultPort = 9007
dialTimeout = 5 * time.Second
writeTimeout = 3 * time.Second
readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead
keepaliveEvery = 3 * time.Second // periodic "port get" refreshes state + keeps the link alive
reconnectDelay = 2 * time.Second
)
// Antenna is one configured antenna (index + name as stored on the device).
type Antenna struct {
Index int `json:"index"`
Name string `json:"name"`
}
// Status is the snapshot the UI renders.
type Status struct {
Connected bool `json:"connected"`
Host string `json:"host,omitempty"`
LastError string `json:"last_error,omitempty"`
PortA int `json:"port_a"` // active antenna index on port A (0 = none)
PortB int `json:"port_b"` // active antenna index on port B
TxA bool `json:"tx_a"` // port A is transmitting
TxB bool `json:"tx_b"` // port B is transmitting
Antennas []Antenna `json:"antennas"`
}
type Client struct {
host string
port int
mu sync.Mutex // guards conn + writes
conn net.Conn
statusMu sync.RWMutex
status Status
antennas map[int]string // index → name (rebuilt into status.Antennas)
stop chan struct{}
running bool
}
func New(host string, port int) *Client {
if port <= 0 || port > 65535 {
port = defaultPort
}
return &Client{
host: host,
port: port,
stop: make(chan struct{}),
antennas: map[int]string{},
status: Status{Host: host},
}
}
func (c *Client) Start() error {
c.running = true
go c.runLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setStatus(fn func(*Status)) {
c.statusMu.Lock()
fn(&c.status)
c.statusMu.Unlock()
}
// Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets
// the port to "None"). We set both RX and TX antennas and force manual mode so
// the choice sticks regardless of the device's auto band-following.
func (c *Client) Activate(port, antenna int) error {
if port != 1 && port != 2 {
return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port)
}
if antenna < 0 {
return fmt.Errorf("antgenius: invalid antenna %d", antenna)
}
// Set only rxant (like the reference ShackMaster client): the AG mirrors it
// to the TX antenna automatically. Forcing txant too can be rejected on the
// 8x2 (an antenna can't be TX on both ports at once), which broke port-B
// selection and deselection.
if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil {
return err
}
// Ask for the new port state so the snapshot reflects it promptly (the
// subscription also pushes it, but this makes the change deterministic).
_ = c.send(fmt.Sprintf("port get %d", port))
return nil
}
func (c *Client) runLoop() {
for {
if !c.running {
return
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
if err != nil {
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
if c.sleep(reconnectDelay) {
return
}
continue
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
// Subscribe to live updates and pull the initial state. Command set and
// order mirror a known-working Node-RED v4 client (WA9WUD).
_ = c.send("antenna list")
_ = c.send("sub port all")
_ = c.send("port get 1")
_ = c.send("port get 2")
done := make(chan struct{})
go c.keepalive(conn, done)
err = c.readLoop(conn) // blocks until the link errors
close(done)
c.mu.Lock()
if c.conn == conn {
c.conn = nil
}
c.mu.Unlock()
conn.Close()
c.setStatus(func(s *Status) {
s.Connected = false
if err != nil {
s.LastError = "read: " + err.Error()
}
})
if c.sleep(reconnectDelay) {
return
}
}
}
// keepalive periodically re-reads a port so an idle-but-dead link is detected
// (the read loop's idle timeout fires if these stop producing replies).
func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
t := time.NewTicker(keepaliveEvery)
defer t.Stop()
for {
select {
case <-done:
return
case <-c.stop:
return
case <-t.C:
_ = c.send("port get 1")
_ = c.send("port get 2")
}
}
}
func (c *Client) readLoop(conn net.Conn) error {
r := bufio.NewReader(conn)
var sb strings.Builder
for {
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
b, err := r.ReadByte()
if err != nil {
return err
}
if b == '\r' || b == '\n' {
if sb.Len() > 0 {
c.handleLine(sb.String())
sb.Reset()
}
continue
}
sb.WriteByte(b)
}
}
// send writes a "C<seq>|<command>\r" line to the device.
func (c *Client) send(command string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("antgenius: not connected")
}
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
// The device only accepts the constant "C1|" sequence prefix for every
// command (using incrementing sequence numbers makes it drop the link);
// commands are LF-terminated.
_, err := fmt.Fprintf(c.conn, "C1|%s\n", command)
return err
}
// handleLine parses one response/status/banner line and updates the snapshot.
func (c *Client) handleLine(line string) {
line = strings.TrimSpace(line)
if line == "" {
return
}
// Banner like "V4.1.16 AG" — just confirms the link is up.
if line[0] == 'V' && strings.Contains(line, "AG") {
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
return
}
// R<seq>|<hex>|<message> or S<seq>|<message>
var msg string
switch {
case strings.HasPrefix(line, "R"):
p := strings.SplitN(line, "|", 3)
if len(p) == 3 {
msg = p[2]
}
case strings.HasPrefix(line, "S"):
p := strings.SplitN(line, "|", 2)
if len(p) == 2 {
msg = p[1]
}
}
msg = strings.TrimSpace(msg)
switch {
case strings.HasPrefix(msg, "antenna "):
c.parseAntenna(msg)
case strings.HasPrefix(msg, "port "):
c.parsePort(msg)
}
}
// parseAntenna handles "antenna <id> name=<name> tx=.. rx=.. inband=..".
// The name may contain spaces, so it's extracted up to the " tx=" field.
func (c *Client) parseAntenna(msg string) {
fields := strings.Fields(msg)
if len(fields) < 2 {
return
}
id, err := strconv.Atoi(fields[1])
if err != nil {
return
}
name := ""
if i := strings.Index(msg, "name="); i >= 0 {
name = msg[i+len("name="):]
if j := strings.Index(name, " tx="); j >= 0 {
name = name[:j]
}
// The device stores spaces as underscores in names.
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
}
c.statusMu.Lock()
if name != "" && !isPlaceholderName(name) {
c.antennas[id] = name
} else {
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
}
c.status.Antennas = sortedAntennas(c.antennas)
c.status.Connected = true
c.statusMu.Unlock()
}
// parsePort handles "port <id> ... rxant=<n> txant=<n> ...". The active antenna
// shown is the TX antenna, falling back to the RX antenna when TX is none.
func (c *Client) parsePort(msg string) {
fields := strings.Fields(msg)
if len(fields) < 2 {
return
}
id, err := strconv.Atoi(fields[1])
if err != nil || (id != 1 && id != 2) {
return
}
tx := kvInt(msg, "txant")
rx := kvInt(msg, "rxant")
active := tx
if active == 0 {
active = rx
}
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
c.setStatus(func(s *Status) {
s.Connected = true
if id == 1 {
s.PortA, s.TxA = active, txOn
} else {
s.PortB, s.TxB = active, txOn
}
})
}
func (c *Client) sleep(d time.Duration) (stopped bool) {
select {
case <-c.stop:
return true
case <-time.After(d):
return false
}
}
// kvInt extracts the integer value of a "key=<int>" token from a space-
// separated string (returns 0 if absent).
func kvInt(s, key string) int {
i := strings.Index(s, key+"=")
if i < 0 {
return 0
}
v := s[i+len(key)+1:]
if sp := strings.IndexByte(v, ' '); sp >= 0 {
v = v[:sp]
}
n, _ := strconv.Atoi(strings.TrimSpace(v))
return n
}
// isPlaceholderName reports whether name is an unconfigured-slot default like
// "Antenna 4" / "antenna_5" (after underscores become spaces): the word
// "antenna" followed by a number, which the UI shouldn't list.
func isPlaceholderName(name string) bool {
f := strings.Fields(strings.ToLower(name))
if len(f) != 2 || f[0] != "antenna" {
return false
}
_, err := strconv.Atoi(f[1])
return err == nil
}
func sortedAntennas(m map[int]string) []Antenna {
out := make([]Antenna, 0, len(m))
for idx, name := range m {
out = append(out, Antenna{Index: idx, Name: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
return out
}
+71
View File
@@ -0,0 +1,71 @@
package antgenius
import "testing"
func TestHandleAntennaList(t *testing.T) {
c := New("x", 9007)
// Names may contain spaces — must be captured up to " tx=".
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
st := c.GetStatus()
if len(st.Antennas) != 2 {
t.Fatalf("got %d antennas, want 2: %+v", len(st.Antennas), st.Antennas)
}
if st.Antennas[0].Index != 1 || st.Antennas[0].Name != "UB VL2.3" {
t.Errorf("antenna 1 = %+v", st.Antennas[0])
}
if st.Antennas[1].Name != "DX Commander" {
t.Errorf("antenna 2 name = %q", st.Antennas[1].Name)
}
}
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
c := New("x", 9007)
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
st := c.GetStatus()
if len(st.Antennas) != 1 || st.Antennas[0].Name != "Hex Beam" {
t.Fatalf("want only [Hex Beam], got %+v", st.Antennas)
}
}
func TestHandlePortStatus(t *testing.T) {
c := New("x", 9007)
// Async push after "sub port all": active antenna is txant (fallback rxant).
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
st := c.GetStatus()
if st.PortA != 2 {
t.Errorf("PortA = %d, want 2", st.PortA)
}
if st.PortB != 0 {
t.Errorf("PortB = %d, want 0 (none)", st.PortB)
}
// A "port get" reply (R-line) must parse the same way.
c.handleLine("R15|0|port 2 source=MANUAL band=3 rxant=5 txant=5 inband=0 inhibit=0")
if st = c.GetStatus(); st.PortB != 5 {
t.Errorf("PortB after port get = %d, want 5", st.PortB)
}
}
func TestPortTxFallbackToRx(t *testing.T) {
c := New("x", 9007)
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
if st := c.GetStatus(); st.PortA != 3 {
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
}
}
func TestKvInt(t *testing.T) {
s := "port 1 source=MANUAL band=6 rxant=2 txant=7 inhibit=0"
if v := kvInt(s, "txant"); v != 7 {
t.Errorf("txant = %d, want 7", v)
}
if v := kvInt(s, "rxant"); v != 2 {
t.Errorf("rxant = %d, want 2", v)
}
if v := kvInt(s, "missing"); v != 0 {
t.Errorf("missing = %d, want 0", v)
}
}
+16 -3
View File
@@ -11,14 +11,16 @@ import (
"log"
"os"
"path/filepath"
"runtime/debug"
"sync"
"time"
)
var (
mu sync.Mutex
file *os.File
path string
mu sync.Mutex
file *os.File
path string
crashFile *os.File // kept open so the runtime can write a crash traceback to it
)
// Init opens (creates) the log file in dataDir. On rotation we truncate
@@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) {
file = f
path = logPath
// Capture a full traceback on a FATAL crash (a Go panic that escapes our
// recover()s, or a runtime-fatal error like a concurrent map write, or a
// Windows access violation routed through the Go signal handler) into a
// dedicated file the runtime writes directly — so otherwise-silent process
// deaths leave a stack we can read.
if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
crashFile = cf
_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
}
// Redirect log.Print* and the standard logger to the file too, so
// any third-party output stays consistent.
log.SetOutput(io.MultiWriter(file, os.Stderr))
+17
View File
@@ -0,0 +1,17 @@
package audio
// SampleRate is the fixed capture rate (mono 16-bit PCM). Exported so consumers
// like the CW decoder can configure their DSP to match.
const SampleRate = sampleRate
// StreamCapture captures from deviceID and calls onSamples with mono 16-bit PCM
// frames (as int16) until stop closes. A thin wrapper over the internal capture
// loop for live consumers (the CW decoder) that want samples, not raw bytes.
func StreamCapture(deviceID string, stop <-chan struct{}, onSamples func([]int16)) error {
return captureStream(deviceID, stop, func(chunk []byte) {
if len(chunk) == 0 {
return
}
onSamples(bytesToInt16(chunk))
})
}
+14
View File
@@ -32,6 +32,20 @@ func writeMP3(path string, pcm []byte) error {
for i, v := range mono32 {
stereo[2*i], stereo[2*i+1] = v, v
}
// Shine's Write() reads a WHOLE frame (samplesPerPass × channels = 1152 × 2 =
// 2304 interleaved samples for MPEG-1) per pass via unsafe pointer arithmetic,
// regardless of how short the trailing chunk is. If the buffer isn't an exact
// multiple of a frame, the final pass reads past the slice and the process
// dies with an access violation (0xc0000005) inside windowFilterSubband.
// Pad with trailing silence to a whole number of frames so no partial pass
// exists. (~36 ms of silence at most — inaudible.)
const frameInterleaved = 1152 * 2 // samplesPerPass(MPEG-1) × 2 channels
if rem := len(stereo) % frameInterleaved; rem != 0 {
stereo = append(stereo, make([]int16, frameInterleaved-rem)...)
}
if len(stereo) == 0 {
return nil // nothing to encode (empty recording)
}
enc := mp3.NewEncoder(mp3Rate, 2)
return enc.Write(f, stereo)
}
+56 -6
View File
@@ -5,11 +5,26 @@ package audio
import (
"encoding/binary"
"fmt"
"runtime/debug"
"strings"
"sync"
"time"
)
// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
// Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal
// — but it catches any Go-level panic in capture/mix.)
func recoverGoroutine(what string) {
if r := recover(); r != nil {
LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
}
}
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
// recording can begin a few seconds BEFORE the operator entered the callsign.
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
@@ -108,6 +123,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (radio)")
_ = captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
@@ -119,6 +135,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (mic)")
_ = captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
@@ -132,6 +149,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder mixer")
t := time.NewTicker(40 * time.Millisecond)
defer t.Stop()
for {
@@ -221,27 +239,59 @@ func (r *Recorder) RestartQSO() {
r.active = true
}
// SaveQSO writes the accumulated recording to path as a WAV and stops
// accumulating. Returns an error if no recording was active.
func (r *Recorder) SaveQSO(path string) error {
// ResetQSOClock restarts the active accumulation from ZERO — discarding
// everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which
// re-seeds from the pre-roll ring), this keeps nothing: the saved file will
// contain only audio from this moment onward. Used when the contact you entered
// was already in a long QSO and you want to record just your own exchange.
// No-op if not running; if no take is active it begins one (empty).
func (r *Recorder) ResetQSOClock() {
r.mu.Lock()
defer r.mu.Unlock()
if !r.running {
return
}
r.acc = nil
r.active = true
}
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
// new take immediately. Pair with WritePCM to encode/write off the hot path so
// a long recording doesn't delay logging.
func (r *Recorder) TakeQSO() ([]byte, error) {
r.mu.Lock()
if !r.active {
r.mu.Unlock()
return fmt.Errorf("no active recording")
return nil, fmt.Errorf("no active recording")
}
samples := r.acc
r.acc, r.active = nil, false
r.mu.Unlock()
if len(samples) == 0 {
return fmt.Errorf("recording was empty")
return nil, fmt.Errorf("recording was empty")
}
data := int16sToBytes(samples)
return int16sToBytes(samples), nil
}
// WritePCM encodes raw 16 kHz mono PCM to path (WAV, or MP3 when path ends in
// .mp3). Slow for MP3 — call off the logging path.
func WritePCM(path string, data []byte) error {
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
return writeMP3(path, data)
}
return writeWAV(path, data)
}
// SaveQSO snapshots and writes the recording in one call (synchronous).
func (r *Recorder) SaveQSO(path string) error {
data, err := r.TakeQSO()
if err != nil {
return err
}
return WritePCM(path, data)
}
// DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() {
r.mu.Lock()
+120 -7
View File
@@ -68,6 +68,12 @@ type Def struct {
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
// earns a reference if the primary match OR any of these match. Lets a
// French department (DDFM) be found from "D74" in the note AND from a postal
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
@@ -84,6 +90,20 @@ type Def struct {
Builtin bool `json:"builtin"` // shipped default (informational)
}
// OrRule is one additional search OR'd with the award's primary matching rule.
// Same knobs as the primary (field + how to match), plus Prefix which is
// prepended to each reference it finds so a captured value can be normalised to
// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74").
type OrRule struct {
Field string `json:"field"` // QSO field to scan
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
Pattern string `json:"pattern,omitempty"` // Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
}
// Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def {
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
@@ -414,7 +434,73 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
}
}
rl := NewRefList(metas)
return candidates(&d, re, q, rl, len(metas) > 0)
found := candidates(&d, re, q, rl, len(metas) > 0)
// Merge operator-assigned references (manual override). These let the
// operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
// province NAME, so a contact whose address doesn't spell it out needs the
// province picked by hand. For a predefined award the override is still
// validated against its reference list.
if man := manualRefs(q, d.Code); len(man) > 0 {
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
}
return found
}
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
// comma list). Honoured by MatchQSO regardless of how the award matches.
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"
// manualRefs returns the reference codes the operator assigned to award `code`
// on this QSO (from the ManualRefsKey extra).
func manualRefs(q *qso.QSO, code string) []string {
if q == nil || q.Extras == nil {
return nil
}
raw := strings.TrimSpace(q.Extras[ManualRefsKey])
if raw == "" {
return nil
}
code = strings.ToUpper(strings.TrimSpace(code))
var out []string
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
at := strings.IndexByte(entry, '@')
if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
continue
}
for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
if r = strings.TrimSpace(r); r != "" {
out = append(out, r)
}
}
}
return out
}
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
// When the award is predefined, only references present and valid in its list
// are kept (so a typo can't invent a reference).
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
seen := map[string]struct{}{}
for _, c := range found {
seen[normalizeRef(c)] = struct{}{}
}
for _, c := range manual {
c = normalizeRef(c)
if _, dup := seen[c]; dup {
continue
}
if predefined {
if m, ok := rl.byCode[c]; !ok || !m.Valid {
continue
}
}
seen[c] = struct{}{}
found = append(found, c)
}
return found
}
// Confirmed reports whether a QSO satisfies any of the given confirmation
@@ -445,13 +531,15 @@ func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf Nam
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
// a predefined list when one applies.
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
// searchOne runs one matching rule (the primary or an OR rule) over a QSO and
// returns the reference codes it finds, each prefixed with `prefix` (so a
// captured "74" becomes "D74"). predefined enables list-aware matching.
func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing))
if raw == "" {
return nil
}
predefined := hasList && !d.Dynamic
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
var found []string
switch {
@@ -464,7 +552,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// the field equals the name; otherwise the name is a substring of it.
up := strings.ToUpper(raw)
for _, nc := range rl.names {
if d.ExactMatch {
if exact {
if up == nc.name {
found = append(found, nc.code)
}
@@ -472,7 +560,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
found = append(found, nc.code)
}
}
case predefined && !d.ExactMatch:
case predefined && !exact:
// "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few
// references that declare a regex.
@@ -492,6 +580,31 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// counts each reference separately.
found = splitRefs(raw)
}
if prefix != "" {
for i := range found {
found[i] = prefix + found[i]
}
}
return found
}
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
predefined := hasList && !d.Dynamic
// Primary search, then each OR rule — a QSO earns a reference if any matches.
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
for i := range d.OrRules {
r := &d.OrRules[i]
var rre *regexp.Regexp
if p := strings.TrimSpace(r.Pattern); p != "" {
c, err := regexp.Compile(p)
if err != nil {
continue // skip a rule with a bad regex rather than failing the award
}
rre = c
}
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
}
if !predefined {
return dedupe(found)
+4
View File
@@ -2,6 +2,10 @@ package award
import "strings"
// WPXPrefix is the exported entry point for deriving a callsign's CQ WPX prefix
// (used by contest-mode multiplier counting).
func WPXPrefix(call string) string { return wpxPrefix(call) }
// wpxPrefix derives the CQ WPX prefix from a callsign. This is an approximation
// of the official WPX rules — good enough to count distinct prefixes worked:
// - standard call: letters+digits up to and including the LAST digit of the
+2 -2
View File
@@ -94,7 +94,7 @@ func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (in
return 0, fmt.Errorf("clear refs: %w", err)
}
stmt, err := tx.PrepareContext(ctx,
`INSERT OR REPLACE INTO award_references
`REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
@@ -257,7 +257,7 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
return fmt.Errorf("empty award or reference code")
}
_, err := r.db.ExecContext(ctx,
`INSERT OR REPLACE INTO award_references
`REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
+82 -10
View File
@@ -99,6 +99,54 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
return dstPath, nil
}
// RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
// ADIF export is a portable, re-importable backup of the actual contacts.
// writeADIF must write the full ADIF to the path it's handed.
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) {
if rotation <= 0 {
rotation = 5
}
if folder == "" {
return "", fmt.Errorf("backup folder not set")
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
base := "opslog-log-" + time.Now().Format("2006-01-02")
tmp := filepath.Join(folder, base+".adi.tmp")
_ = os.Remove(tmp)
if err := writeADIF(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("export adif: %w", err)
}
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".adi.zip")
if err := copyZipped(tmp, dstPath, base+".adi"); err != nil {
_ = os.Remove(tmp)
return "", err
}
_ = os.Remove(tmp)
} else {
dstPath = filepath.Join(folder, base+".adi")
_ = os.Remove(dstPath)
if err := os.Rename(tmp, dstPath); err != nil {
if cerr := copyFile(tmp, dstPath); cerr != nil {
_ = os.Remove(tmp)
return "", cerr
}
_ = os.Remove(tmp)
}
}
if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil {
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
}
return dstPath, nil
}
// copyFile performs a plain file copy. We don't use os.Rename because
// the source is the live database; we want a fresh standalone file.
func copyFile(src, dst string) error {
@@ -155,10 +203,18 @@ func copyZipped(src, dst, innerName string) error {
return out.Close()
}
// rotate keeps the most recent `keep` backups in folder and deletes the
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
// are touched — never user files that happen to live in the same folder.
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
// opslog-*.db.zip) and deletes the rest.
func rotate(folder string, keep int) error {
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
}
// rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix
// filter keeps the .db family and the .adi family from rotating each other even
// though both share the "opslog-" prefix.
func rotateMatch(folder string, keep int, prefix string, suffixes ...string) error {
entries, err := os.ReadDir(folder)
if err != nil {
return err
@@ -173,10 +229,17 @@ func rotate(folder string, keep int) error {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "opslog-") {
if !strings.HasPrefix(name, prefix) {
continue
}
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
ok := false
for _, sfx := range suffixes {
if strings.HasSuffix(name, sfx) {
ok = true
break
}
}
if !ok {
continue
}
info, err := e.Info()
@@ -198,16 +261,25 @@ func rotate(folder string, keep int) error {
return firstErr
}
// HasBackupToday returns true if a backup for today's date already exists
// in folder. Used by the startup auto-backup to skip when the user has
// already restarted the app once today.
// HasBackupToday returns true if a SQLite backup for today's date already
// exists in folder. Used by the auto-backup to skip when the user has already
// restarted the app once today.
func HasBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-", ".db", ".db.zip")
}
// HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode).
func HasADIFBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip")
}
func hasBackupToday(folder, prefix string, exts ...string) bool {
if folder == "" {
return false
}
stamp := time.Now().Format("2006-01-02")
for _, ext := range []string{".db", ".db.zip"} {
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
for _, ext := range exts {
if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
return true
}
}
+164
View File
@@ -0,0 +1,164 @@
// Package cabrillo writes a Cabrillo v3 contest log from OpsLog QSOs — the
// format most contest sponsors require for log submission (a header of
// CATEGORY-* / CONTEST / CALLSIGN tags followed by one "QSO:" line each).
package cabrillo
import (
"fmt"
"io"
"strings"
"hamlog/internal/qso"
)
// Header holds the Cabrillo header fields. Only Callsign and Contest really
// matter; the rest carry sensible defaults the operator can edit in the file
// before submitting (categories vary per contest).
type Header struct {
Callsign string
Contest string
Operators string
CatOperator string // SINGLE-OP | MULTI-OP | CHECKLOG
CatBand string // ALL | 20M | …
CatMode string // CW | SSB | MIXED | …
CatPower string // HIGH | LOW | QRP
GridLocator string
Name string
Email string
Club string
}
func def(v, d string) string {
if strings.TrimSpace(v) == "" {
return d
}
return v
}
// Generate writes the Cabrillo document for qsos and returns the number of QSO
// lines written (rows without a usable frequency/callsign are skipped).
func Generate(w io.Writer, h Header, qsos []qso.QSO) (int, error) {
myCall := strings.ToUpper(strings.TrimSpace(h.Callsign))
fmt.Fprintln(w, "START-OF-LOG: 3.0")
fmt.Fprintln(w, "CREATED-BY: OpsLog")
fmt.Fprintf(w, "CONTEST: %s\n", strings.ToUpper(def(h.Contest, "UNKNOWN")))
fmt.Fprintf(w, "CALLSIGN: %s\n", myCall)
fmt.Fprintf(w, "CATEGORY-OPERATOR: %s\n", strings.ToUpper(def(h.CatOperator, "SINGLE-OP")))
fmt.Fprintf(w, "CATEGORY-BAND: %s\n", strings.ToUpper(def(h.CatBand, "ALL")))
fmt.Fprintf(w, "CATEGORY-MODE: %s\n", strings.ToUpper(def(h.CatMode, "MIXED")))
fmt.Fprintf(w, "CATEGORY-POWER: %s\n", strings.ToUpper(def(h.CatPower, "HIGH")))
fmt.Fprintln(w, "CLAIMED-SCORE: 0")
fmt.Fprintf(w, "OPERATORS: %s\n", strings.ToUpper(def(h.Operators, myCall)))
if h.GridLocator != "" {
fmt.Fprintf(w, "GRID-LOCATOR: %s\n", strings.ToUpper(h.GridLocator))
}
if h.Name != "" {
fmt.Fprintf(w, "NAME: %s\n", h.Name)
}
if h.Club != "" {
fmt.Fprintf(w, "CLUB: %s\n", h.Club)
}
if h.Email != "" {
fmt.Fprintf(w, "EMAIL: %s\n", h.Email)
}
n := 0
for _, q := range qsos {
call := strings.ToUpper(strings.TrimSpace(q.Callsign))
if call == "" {
continue
}
khz := freqKHz(q)
mode := cabrilloMode(q.Mode)
myRST := def(q.RSTSent, defaultRST(mode))
hisRST := def(q.RSTRcvd, defaultRST(mode))
myExch := exchange(q.STX, q.STXString)
hisExch := exchange(q.SRX, q.SRXString)
// QSO: <freq> <mo> <date> <time> <mycall> <myrst> <myexch> <call> <rst> <exch>
fmt.Fprintf(w, "QSO: %5d %-2s %s %s %-13s %-3s %-6s %-13s %-3s %-6s\n",
khz, mode,
q.QSODate.UTC().Format("2006-01-02"), q.QSODate.UTC().Format("1504"),
myCall, myRST, myExch, call, hisRST, hisExch)
n++
}
fmt.Fprintln(w, "END-OF-LOG:")
return n, nil
}
// freqKHz returns the QSO frequency in kHz, falling back to a nominal frequency
// for the band when the exact frequency wasn't logged.
func freqKHz(q qso.QSO) int {
if q.FreqHz != nil && *q.FreqHz > 0 {
return int((*q.FreqHz + 500) / 1000)
}
return bandNominalKHz(q.Band)
}
func bandNominalKHz(band string) int {
switch strings.ToLower(strings.TrimSpace(band)) {
case "160m":
return 1800
case "80m":
return 3500
case "60m":
return 5350
case "40m":
return 7000
case "30m":
return 10100
case "20m":
return 14000
case "17m":
return 18068
case "15m":
return 21000
case "12m":
return 24890
case "10m":
return 28000
case "6m":
return 50000
case "2m":
return 144000
case "70cm":
return 432000
}
return 0
}
// cabrilloMode maps an ADIF mode to the two-letter Cabrillo mode code.
func cabrilloMode(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
return "CW"
case "RTTY":
return "RY"
case "FM":
return "FM"
case "SSB", "USB", "LSB", "AM":
return "PH"
case "":
return "PH"
default:
return "DG" // FT8/FT4/PSK/JT/DATA and other digital modes
}
}
func defaultRST(cabMode string) string {
if cabMode == "CW" || cabMode == "RY" || cabMode == "DG" {
return "599"
}
return "59"
}
// exchange renders the contest exchange: the string field if set, else the
// numeric serial, else empty.
func exchange(num *int, str string) string {
if s := strings.TrimSpace(str); s != "" {
return s
}
if num != nil && *num > 0 {
return fmt.Sprintf("%d", *num)
}
return ""
}
+365 -36
View File
@@ -39,24 +39,25 @@ type Backend interface {
// and RxFreqHz is the active VFO (where they listen). When not split,
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
type RigState struct {
Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort)
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any
Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort)
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// Manager owns the active backend and runs the polling loop.
type Manager struct {
mu sync.RWMutex
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
state RigState
emit func(RigState)
backend Backend
@@ -115,7 +116,13 @@ func (m *Manager) State() RigState {
// state.Error rather than returned, so the UI can keep retrying via the
// poll loop on next reconnect attempt.
func (m *Manager) Start(b Backend) {
m.Stop()
// Serialize the whole stop-old-then-start-new sequence. Two concurrent
// Start (or Start+Stop) calls could otherwise interleave and leave the
// previous poll goroutine alive — two pollers then fight, e.g. flipping
// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
@@ -134,6 +141,18 @@ func (m *Manager) Start(b Backend) {
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
func (m *Manager) Stop() {
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// stopLocked tears down any running poller and blocks until it exits. The
// caller must hold startMu so it can't race a concurrent Start.
func (m *Manager) stopLocked() {
m.mu.Lock()
stop := m.stopCh
done := m.doneCh
@@ -148,10 +167,6 @@ func (m *Manager) Stop() {
if done != nil {
<-done
}
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
@@ -169,6 +184,295 @@ func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) })
}
// SpotInfo is one cluster spot to render on a backend that supports a spot
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
// the backend picks a default when it's empty. (Status-based colouring can be
// driven later by setting Color per spot.)
type SpotInfo struct {
FreqHz int64
Callsign string
Mode string
Color string
Comment string
}
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
// (FlexRadio panadapter). Backends that don't implement it are simply skipped.
type Spotter interface {
SendSpot(SpotInfo) error
}
// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on
// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a
// missed spot on the panadapter is harmless.
func (m *Manager) SendSpot(s SpotInfo) {
m.mu.RLock()
cmds := m.cmdCh
b := m.backend
m.mu.RUnlock()
if cmds == nil || b == nil {
return
}
if _, ok := b.(Spotter); !ok {
return
}
select {
case cmds <- func() {
if sp, ok := b.(Spotter); ok {
_ = sp.SendSpot(s)
}
}:
default: // queue busy → drop this spot
}
}
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
// the radio over TCP; live meters arrive over a separate UDP stream later.)
type FlexTXState struct {
Available bool `json:"available"` // backend is Flex and handshaked
Model string `json:"model,omitempty"`
RFPower int `json:"rf_power"`
TunePower int `json:"tune_power"`
Tune bool `json:"tune"` // tune carrier active
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
VoxEnable bool `json:"vox_enable"`
VoxLevel int `json:"vox_level"`
VoxDelay int `json:"vox_delay"`
ProcEnable bool `json:"proc_enable"`
ProcLevel int `json:"proc_level"`
Mon bool `json:"mon"`
MonLevel int `json:"mon_level"`
MicLevel int `json:"mic_level"`
ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls.
RXAvail bool `json:"rx_avail"` // an RX slice exists
Split bool `json:"split"` // RX/TX on separate slices
RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split
TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split
AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"`
Mute bool `json:"mute"`
RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna
TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna
AntList []string `json:"ant_list,omitempty"` // antennas selectable for RX
TXAntList []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX
NB bool `json:"nb"`
NBLevel int `json:"nb_level"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"`
// CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"`
CWPitch int `json:"cw_pitch"`
CWBreakInDelay int `json:"cw_break_in_delay"`
CWSidetone bool `json:"cw_sidetone"`
CWMonLevel int `json:"cw_mon_level"` // sidetone level
APF bool `json:"apf"`
APFLevel int `json:"apf_level"`
FilterLo int `json:"filter_lo"`
FilterHi int `json:"filter_hi"`
// External amplifier (PowerGenius XL).
AmpAvailable bool `json:"amp_available"`
AmpModel string `json:"amp_model,omitempty"`
AmpOperate bool `json:"amp_operate"`
AmpFault string `json:"amp_fault,omitempty"`
// Live meters streamed over UDP (S-meter, PWR, SWR, temp, voltage…).
Meters []FlexMeter `json:"meters,omitempty"`
}
// FlexMeter is one live meter value (already scaled to real units).
type FlexMeter struct {
ID int `json:"id"`
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
Unit string `json:"unit,omitempty"`
Value float64 `json:"value"`
Lo float64 `json:"lo"`
Hi float64 `json:"hi"`
}
// FlexController is an OPTIONAL backend capability (the FlexRadio backend): the
// SmartSDR-style transmit controls. Backends that don't implement it are skipped
// by the FlexRadio tab. FlexState() is mutex-guarded in the backend so it's safe
// to read off the CAT goroutine; the setters are dispatched onto it via FlexDo.
type FlexController interface {
FlexState() FlexTXState
SetRFPower(int) error
SetTunePower(int) error
SetTune(bool) error
SetVOX(bool) error
SetVOXLevel(int) error
SetVOXDelay(int) error
SetProcessor(bool) error
SetProcessorLevel(int) error
SetMon(bool) error
SetMonLevel(int) error
SetMic(int) error
ATUStart() error
ATUBypass() error
SetATUMemories(bool) error
// RX slice DSP controls (target the active receive slice).
SetAGCMode(string) error
SetAGCThreshold(int) error
SetAudioLevel(int) error
SetMute(bool) error
SetRXAntenna(string) error
SetTXAntenna(string) error
SetSplit(bool) error
SetNB(bool) error
SetNBLevel(int) error
SetNR(bool) error
SetNRLevel(int) error
SetANF(bool) error
SetANFLevel(int) error
SetAPF(bool) error
SetAPFLevel(int) error
// CW keyer + mode-specific controls.
SetCWSpeed(int) error
SetCWPitch(int) error
SetCWBreakInDelay(int) error
SetCWSidetone(bool) error
SetSidetoneLevel(int) error
SetCWFilter(int) error
SetFilter(lo, hi int) error
// External amplifier (PowerGenius XL) operate/standby.
SetAmpOperate(bool) error
}
// FlexState returns the current FlexRadio transmit state, or (zero, false) when
// the active backend isn't a Flex. Safe to call from any goroutine.
func (m *Manager) FlexState() (FlexTXState, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if fc, ok := b.(FlexController); ok {
return fc.FlexState(), true
}
return FlexTXState{}, false
}
// FlexDo dispatches a FlexRadio control onto the CAT goroutine. Errors if the
// active backend isn't a Flex.
func (m *Manager) FlexDo(fn func(FlexController) error) error {
return m.exec(func(b Backend) error {
fc, ok := b.(FlexController)
if !ok {
return fmt.Errorf("active CAT backend is not a FlexRadio")
}
return fn(fc)
})
}
// IcomTXState is the Icom receive-DSP state surfaced to the dedicated Icom
// control tab. Levels are 0-100 (scaled from the rig's 0-255). Unlike Flex,
// the Icom doesn't push changes, so these reflect the last RefreshIcom() read
// plus the optimistic updates each setter applies.
type IcomTXState struct {
Available bool `json:"available"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
// Transmit + live status (polled).
Transmitting bool `json:"transmitting"`
Split bool `json:"split"`
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
// Set controls.
RFPower int `json:"rf_power"` // 0-100 (TX output)
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"`
RFGain int `json:"rf_gain"`
NB bool `json:"nb"`
NBLevel int `json:"nb_level"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
Att int `json:"att"` // dB attenuation, 0=off
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
}
// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
// receive-DSP controls shown on the Icom tab. IcomState() is mutex-guarded in
// the backend so it's safe off the CAT goroutine; setters dispatch via IcomDo.
type IcomController interface {
IcomState() IcomTXState
RefreshIcom() error // re-read all DSP state from the rig
SetAFGain(int) error
SetRFGain(int) error
SetNB(bool) error
SetNBLevel(int) error
SetNR(bool) error
SetNRLevel(int) error
SetANF(bool) error
SetAGC(string) error
SetPreamp(int) error
SetAtt(int) error
SetIcomFilter(int) error
SetRFPower(int) error
SetMicGain(int) error
SetIcomSplit(bool) error
TuneATU() error
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
}
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
// divided 0x27 waveform frames. Amp holds one amplitude byte per pixel (raw rig
// scale, typically 0-160). Seq increments on every completed sweep so the UI can
// tell fresh data from a repeated poll.
type ScopeSweep struct {
Amp []int `json:"amp"` // []int (not []byte) so it marshals as a JSON number array
Seq int `json:"seq"`
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
}
// IcomState returns the current Icom DSP state, or (zero, false) when the active
// backend isn't an Icom. Safe to call from any goroutine.
func (m *Manager) IcomState() (IcomTXState, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if ic, ok := b.(IcomController); ok {
return ic.IcomState(), true
}
return IcomTXState{}, false
}
// IcomScope returns the latest spectrum-scope sweep, or (zero, false) when the
// active backend isn't an Icom. The sweep is mutex-guarded in the backend, so
// this reads it directly (no CAT-goroutine round trip) — cheap enough to poll.
func (m *Manager) IcomScope() (ScopeSweep, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if ic, ok := b.(IcomController); ok {
return ic.ScopeData(), true
}
return ScopeSweep{}, false
}
// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
// active backend isn't an Icom.
func (m *Manager) IcomDo(fn func(IcomController) error) error {
return m.exec(func(b Backend) error {
ic, ok := b.(IcomController)
if !ok {
return fmt.Errorf("active CAT backend is not an Icom")
}
return fn(ic)
})
}
// exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error {
@@ -195,23 +499,27 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
defer runtime.UnlockOSThread()
defer close(done)
if err := b.Connect(); err != nil {
m.update(RigState{
Enabled: true, Backend: b.Name(), Connected: false,
Error: err.Error(), UpdatedAt: time.Now(),
})
// Stay idle until Stop is called — let the user fix config and re-Start.
for {
select {
case <-stop:
return
case fn := <-cmds:
fn()
}
}
}
defer b.Disconnect()
// Connection is (re)established lazily and retried with a backoff, so a rig
// that's off at startup — or a FlexRadio that reboots/drops its TCP link —
// reconnects on its own instead of staying dead until the user toggles CAT.
const reconnectEvery = 5 * time.Second
connected := false
var lastAttempt time.Time
tryConnect := func() {
if connected || time.Since(lastAttempt) < reconnectEvery {
return
}
lastAttempt = time.Now()
if err := b.Connect(); err != nil {
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
return
}
connected = true
}
tryConnect()
ticker := time.NewTicker(pollEvery)
defer ticker.Stop()
@@ -223,12 +531,33 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
fn()
m.applyCommandDelay()
case <-ticker.C:
// Drain any queued commands before polling. A serial backend reads
// many registers per ReadState, so without this the shared select's
// fairness lets polls repeatedly win and a user's Set* can lag by
// seconds. Servicing commands first bounds that latency to a single
// ReadState.
for {
select {
case fn := <-cmds:
fn()
m.applyCommandDelay()
continue
default:
}
break
}
if !connected {
tryConnect()
continue
}
ns, err := b.ReadState()
if err != nil {
m.update(RigState{
Enabled: true, Backend: b.Name(), Connected: false,
Error: err.Error(), UpdatedAt: time.Now(),
})
// Lost the rig — drop the backend so the next attempt reconnects
// cleanly, then back off before retrying.
connected = false
lastAttempt = time.Now()
b.Disconnect()
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
continue
}
ns.Enabled = true
+267
View File
@@ -0,0 +1,267 @@
// Package civ implements the Icom CI-V protocol independently of the transport
// carrying it. The exact same frames travel over a USB/serial port (local
// control) and, wrapped in Icom's UDP "serial" stream, over the network
// (remote control). Keeping the wire format in one place means the USB backend
// (icomserial) and a future network backend (icomnet) share all of it — only
// the transport differs.
//
// Frame layout: FE FE <to> <from> <cmd> [sub] [data…] FD
package civ
import (
"bytes"
"fmt"
)
// Protocol bytes.
const (
Pre = 0xFE // preamble (sent twice at the start of every frame)
End = 0xFD // end-of-message
OK = 0xFB // rig acknowledged a set command
NG = 0xFA // rig rejected a set command
// AddrController is the conventional address software uses for itself.
AddrController = 0xE0
)
// Commands (the few Phase-1 control needs; more get added with the panel).
const (
CmdTransceiveFreq = 0x00 // unsolicited freq update (dial turned)
CmdTransceiveMode = 0x01 // unsolicited mode update
CmdReadFreq = 0x03
CmdReadMode = 0x04
CmdSetFreq = 0x05
CmdSetMode = 0x06
CmdSplit = 0x0F // read/set split & duplex (0x00 off, 0x01 split on)
CmdVfoFreq = 0x25 // read a specific VFO's freq (sub 0x00 selected, 0x01 unselected)
CmdPTT = 0x1C // sub 0x00 = PTT
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
SubDataMode = 0x06
SubPTT = 0x00
SubVfoSelected = 0x00 // CmdVfoFreq: the active/RX VFO
SubVfoUnselected = 0x01 // CmdVfoFreq: the other VFO (TX in split)
// CmdLevel sub-commands.
SubLevelAF = 0x01 // AF (volume)
SubLevelRF = 0x02 // RF gain
SubLevelNR = 0x06 // noise-reduction depth
SubLevelNB = 0x12 // noise-blanker depth
SubLevelRFPower = 0x0A // TX RF output power
SubLevelMic = 0x0B // mic gain
// CmdMeter sub-commands.
SubMeterS = 0x02 // S-meter (RX)
SubMeterPo = 0x11 // power output (TX)
SubMeterSWR = 0x12 // SWR (TX)
// CmdATU / CmdPTT sub-commands.
SubATU = 0x01 // antenna tuner (data 0x02 = start tune)
// CmdScope sub-commands.
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed) — VERIFY on rig
SubScopeSpan = 0x15 // span in center mode — VERIFY on rig
SubScopeEdge = 0x16 // fixed-mode edge frequencies — VERIFY on rig
// CmdSwitch sub-commands.
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off
)
// Icom mode codes (used by CmdReadMode / CmdSetMode).
const (
ModeLSB = 0x00
ModeUSB = 0x01
ModeAM = 0x02
ModeCW = 0x03
ModeRTTY = 0x04
ModeFM = 0x05
ModeCWR = 0x07
ModeRTTYR = 0x08
)
// Frame builds a complete CI-V frame (preamble … end) for payload, which is the
// command byte followed by any sub-command/data bytes.
func Frame(to, from byte, payload ...byte) []byte {
f := make([]byte, 0, len(payload)+5)
f = append(f, Pre, Pre, to, from)
f = append(f, payload...)
f = append(f, End)
return f
}
// FreqToBCD encodes a frequency in Hz as the 5 little-endian BCD bytes Icom
// expects (10 digits, 2 per byte, least-significant byte first).
func FreqToBCD(hz int64) []byte {
if hz < 0 {
hz = 0
}
b := make([]byte, 5)
for i := 0; i < 5; i++ {
lo := hz % 10
hz /= 10
hi := hz % 10
hz /= 10
b[i] = byte(lo) | byte(hi)<<4
}
return b
}
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
func BCDToFreq(b []byte) int64 {
var hz int64
mult := int64(1)
for i := 0; i < len(b) && i < 5; i++ {
hz += int64(b[i]&0x0F) * mult
mult *= 10
hz += int64(b[i]>>4) * mult
mult *= 10
}
return hz
}
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
// CmdLevel commands use (e.g. 128 → 0x01 0x28, 255 → 0x02 0x55).
func LevelToBCD(v int) []byte {
if v < 0 {
v = 0
}
if v > 255 {
v = 255
}
return []byte{byte(v / 100), byte(((v/10)%10)<<4 | v%10)}
}
// BCDToLevel decodes the 2 BCD bytes of a CmdLevel response back to 0-255.
func BCDToLevel(b []byte) int {
if len(b) < 2 {
return 0
}
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
}
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
func ByteToBCD(v int) byte {
if v < 0 {
v = 0
}
if v > 99 {
v = 99
}
return byte((v/10)<<4 | v%10)
}
func BCDToByte(b byte) int { return int(b>>4)*10 + int(b&0x0F) }
// ModeToADIF maps an Icom mode byte (plus the data-mode flag) to an ADIF mode
// string. Data mode on USB/LSB is surfaced as "DATA" so the app can substitute
// the user's preferred digital mode (FT8/RTTY/…), matching the OmniRig backend.
func ModeToADIF(m byte, data bool) string {
switch m {
case ModeCW, ModeCWR:
return "CW"
case ModeRTTY, ModeRTTYR:
return "RTTY"
case ModeAM:
return "AM"
case ModeFM:
return "FM"
case ModeLSB, ModeUSB:
if data {
return "DATA"
}
return "SSB"
}
return ""
}
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
// model. Unknown addresses fall back to a hex label.
func ModelName(addr byte) string {
switch addr {
case 0x94:
return "IC-7300"
case 0x98:
return "IC-7610"
case 0xA2:
return "IC-9700"
case 0xA4:
return "IC-705"
case 0x88:
return "IC-7700"
case 0x80:
return "IC-7800"
}
return fmt.Sprintf("Icom (0x%02X)", addr)
}
// Decoded is one parsed CI-V frame. Data is everything after the command byte
// (so it still carries the sub-command for multi-byte commands like 1A 06).
type Decoded struct {
To byte
From byte
Cmd byte
Data []byte
}
// Scan extracts every complete frame from buf and reports how many leading
// bytes the caller may now discard. A trailing partial frame (or a lone
// preamble byte) is left unconsumed so it can be completed by the next read.
func Scan(buf []byte) (frames []Decoded, consumed int) {
pos := 0
for {
p := indexPreamble(buf, pos)
if p < 0 {
// No further preamble. Keep a trailing FE (possible start of the
// next preamble); otherwise everything seen is consumable.
if len(buf) > 0 && buf[len(buf)-1] == Pre {
return frames, len(buf) - 1
}
return frames, len(buf)
}
start := p + 2
for start < len(buf) && buf[start] == Pre { // tolerate padding FEs
start++
}
end := bytes.IndexByte(buf[start:], End)
if end < 0 {
return frames, p // incomplete frame — keep from its preamble
}
end += start
if body := buf[start:end]; len(body) >= 3 {
frames = append(frames, Decoded{
To: body[0],
From: body[1],
Cmd: body[2],
Data: append([]byte(nil), body[3:]...),
})
}
pos = end + 1
consumed = pos
}
}
// indexPreamble returns the index of the next FE FE pair at or after from.
func indexPreamble(buf []byte, from int) int {
for i := from; i+1 < len(buf); i++ {
if buf[i] == Pre && buf[i+1] == Pre {
return i
}
}
return -1
}
+132
View File
@@ -0,0 +1,132 @@
package civ
import (
"bytes"
"testing"
)
func TestFreqBCDRoundTrip(t *testing.T) {
cases := []int64{0, 1, 7074000, 14250000, 28074000, 50313000, 144174000, 1296000000}
for _, hz := range cases {
b := FreqToBCD(hz)
if len(b) != 5 {
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
}
if got := BCDToFreq(b); got != hz {
t.Errorf("round trip %d → % X → %d", hz, b, got)
}
}
}
func TestFreqBCDKnownEncoding(t *testing.T) {
// 14.250.000 Hz → little-endian BCD 00 00 25 14 00.
want := []byte{0x00, 0x00, 0x25, 0x14, 0x00}
if got := FreqToBCD(14250000); !bytes.Equal(got, want) {
t.Errorf("FreqToBCD(14250000) = % X, want % X", got, want)
}
}
func TestFrame(t *testing.T) {
// Read-frequency request to a 7610 (0x98) from the controller (0xE0).
got := Frame(0x98, AddrController, CmdReadFreq)
want := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
if !bytes.Equal(got, want) {
t.Errorf("Frame = % X, want % X", got, want)
}
}
func TestScanSingleFreqResponse(t *testing.T) {
// Rig (0x98) → controller (0xE0): freq read response for 14.250 MHz.
in := Frame(AddrController, 0x98, CmdReadFreq, 0x00, 0x00, 0x25, 0x14, 0x00)
frames, consumed := Scan(in)
if consumed != len(in) {
t.Fatalf("consumed=%d, want %d", consumed, len(in))
}
if len(frames) != 1 {
t.Fatalf("got %d frames, want 1", len(frames))
}
f := frames[0]
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
t.Errorf("addrs/cmd wrong: %+v", f)
}
if hz := BCDToFreq(f.Data); hz != 14250000 {
t.Errorf("decoded freq %d, want 14250000", hz)
}
}
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
buf := append(append([]byte{}, echo...), resp...)
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
frames, consumed := Scan(buf)
if len(frames) != 2 {
t.Fatalf("got %d frames, want 2", len(frames))
}
// The partial frame must be left unconsumed so the next read can finish it.
if consumed != len(echo)+len(resp) {
t.Errorf("consumed=%d, want %d (partial frame retained)", consumed, len(echo)+len(resp))
}
if frames[1].Cmd != CmdReadMode || len(frames[1].Data) < 1 || frames[1].Data[0] != ModeCW {
t.Errorf("second frame wrong: %+v", frames[1])
}
}
func TestModeToADIF(t *testing.T) {
cases := []struct {
m byte
data bool
want string
}{
{ModeUSB, false, "SSB"},
{ModeLSB, false, "SSB"},
{ModeUSB, true, "DATA"},
{ModeCW, false, "CW"},
{ModeCWR, false, "CW"},
{ModeRTTY, false, "RTTY"},
{ModeAM, false, "AM"},
{ModeFM, false, "FM"},
}
for _, c := range cases {
if got := ModeToADIF(c.m, c.data); got != c.want {
t.Errorf("ModeToADIF(0x%02X, %v) = %q, want %q", c.m, c.data, got, c.want)
}
}
}
func TestLevelBCDRoundTrip(t *testing.T) {
for _, v := range []int{0, 1, 50, 99, 100, 128, 200, 255} {
b := LevelToBCD(v)
if len(b) != 2 {
t.Fatalf("LevelToBCD(%d) len=%d", v, len(b))
}
if got := BCDToLevel(b); got != v {
t.Errorf("level round trip %d → % X → %d", v, b, got)
}
}
// Known encodings from the Icom CI-V reference.
if got := LevelToBCD(128); !bytes.Equal(got, []byte{0x01, 0x28}) {
t.Errorf("LevelToBCD(128) = % X, want 01 28", got)
}
if got := LevelToBCD(255); !bytes.Equal(got, []byte{0x02, 0x55}) {
t.Errorf("LevelToBCD(255) = % X, want 02 55", got)
}
}
func TestByteBCDRoundTrip(t *testing.T) {
for _, v := range []int{0, 6, 12, 18, 21} {
if got := BCDToByte(ByteToBCD(v)); got != v {
t.Errorf("byte BCD round trip %d → %d", v, got)
}
}
}
func TestModelName(t *testing.T) {
if got := ModelName(0x98); got != "IC-7610" {
t.Errorf("ModelName(0x98) = %q, want IC-7610", got)
}
if got := ModelName(0x12); got != "Icom (0x12)" {
t.Errorf("ModelName(0x12) = %q, want fallback", got)
}
}
+1742
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
//go:build windows
package cat
import (
"context"
"net"
"regexp"
"strconv"
"syscall"
"time"
"golang.org/x/sys/windows"
)
// FlexRadio is one radio found by discovery.
type FlexRadio struct {
IP string `json:"ip"`
Port int `json:"port"`
Model string `json:"model"`
Nickname string `json:"nickname"`
Serial string `json:"serial"`
Callsign string `json:"callsign"`
}
// FlexRadios on the LAN broadcast a discovery datagram to UDP :4992 about once a
// second. DiscoverFlex listens for that broadcast for the given duration and
// returns the unique radios seen. Best effort: if the port can't be bound
// (SmartSDR running, firewall…), it returns what it has (often nothing) and the
// user falls back to entering the IP by hand.
func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) {
if timeout <= 0 {
timeout = 2 * time.Second
}
// Bind :4992 with SO_REUSEADDR so we coexist with SmartSDR, which also
// listens for the same broadcast.
lc := net.ListenConfig{
Control: func(_, _ string, c syscall.RawConn) error {
var serr error
_ = c.Control(func(fd uintptr) {
serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
})
return serr
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pc, err := lc.ListenPacket(ctx, "udp4", ":4992")
if err != nil {
return nil, err
}
defer pc.Close()
_ = pc.SetReadDeadline(time.Now().Add(timeout))
found := map[string]FlexRadio{}
buf := make([]byte, 2048)
for {
n, _, err := pc.ReadFrom(buf)
if err != nil {
break // deadline reached or socket closed
}
if r, ok := parseFlexDiscovery(buf[:n]); ok && r.IP != "" {
if _, dup := found[r.IP]; !dup {
found[r.IP] = r
}
}
}
out := make([]FlexRadio, 0, len(found))
for _, r := range found {
out = append(out, r)
}
return out, nil
}
var (
reFlexModel = regexp.MustCompile(`model=(\S+)`)
reFlexIP = regexp.MustCompile(`ip=(\S+)`)
reFlexPort = regexp.MustCompile(`port=(\d+)`)
reFlexSerial = regexp.MustCompile(`serial=(\S+)`)
reFlexNickname = regexp.MustCompile(`nickname=(\S+)`)
reFlexCallsign = regexp.MustCompile(`callsign=(\S+)`)
)
// parseFlexDiscovery extracts radio fields from a VITA-49 discovery datagram.
// The payload carries a space-separated key=value ASCII blob after a binary
// header, so we scan the whole packet text for the keys we need.
func parseFlexDiscovery(pkt []byte) (FlexRadio, bool) {
s := string(pkt)
m := reFlexIP.FindStringSubmatch(s)
if m == nil {
return FlexRadio{}, false
}
r := FlexRadio{IP: m[1], Port: 4992}
if mm := reFlexPort.FindStringSubmatch(s); mm != nil {
if p, err := strconv.Atoi(mm[1]); err == nil && p > 0 {
r.Port = p
}
}
if mm := reFlexModel.FindStringSubmatch(s); mm != nil {
r.Model = mm[1]
}
if mm := reFlexSerial.FindStringSubmatch(s); mm != nil {
r.Serial = mm[1]
}
if mm := reFlexNickname.FindStringSubmatch(s); mm != nil {
r.Nickname = mm[1]
}
if mm := reFlexCallsign.FindStringSubmatch(s); mm != nil {
r.Callsign = mm[1]
}
return r, true
}
+980
View File
@@ -0,0 +1,980 @@
package cat
import (
"fmt"
"strings"
"sync"
"time"
"hamlog/internal/applog"
"hamlog/internal/cat/civ"
"go.bug.st/serial"
)
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local
// control). It speaks the shared civ protocol, so when the network backend
// (icomnet) is added it will reuse the same encode/decode — only the transport
// changes. Implements Backend; all methods run on the Manager's CAT goroutine,
// so the port is accessed single-threaded (no locking needed).
type IcomSerial struct {
portName string
baud int
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
digital string // mode to command for DATA (FT8/RTTY/…)
port serial.Port
model string
// I/O routing. A single reader goroutine owns port.Read and dispatches every
// decoded rig frame: control replies go to respCh (drained by recv), while
// spectrum-scope frames (0x27) go to specCh for the panadapter. This decouples
// the continuous scope stream from the request/response control path — without
// it, scope frames would flood recv() and stall polling.
respCh chan civ.Decoded
specCh chan civ.Decoded
readerDone chan struct{}
// Spectrum scope (0x27). dualScope marks rigs whose waveform frames carry a
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time
// dsp caches the receive-DSP state for the Icom control tab. Read off the
// CAT goroutine via IcomState(), written on the CAT goroutine (RefreshIcom
// / setters) — hence the mutex.
dspMu sync.Mutex
dsp IcomTXState
}
const (
icomReadTimeout = 350 * time.Millisecond // wait for a poll response
icomCmdTimeout = 400 * time.Millisecond // wait for a set ack (FB/FA)
)
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
// 115200, rig address to the IC-7610's 0x98 when out of range.
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
if baud <= 0 {
baud = 115200
}
if civAddr <= 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610
}
if digitalDefault == "" {
digitalDefault = "FT8"
}
return &IcomSerial{
portName: portName,
baud: baud,
rigAddr: byte(civAddr),
digital: strings.ToUpper(digitalDefault),
model: "Icom",
scopeFixed: true, // rigs default to a fixed-span scope
}
}
func (b *IcomSerial) Name() string { return "icom" }
func (b *IcomSerial) Connect() error {
if b.portName == "" {
return fmt.Errorf("no serial port configured")
}
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
if err != nil {
return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
}
// Short read timeout so recv() polls in a tight loop without blocking the
// CAT goroutine when the rig is silent.
_ = port.SetReadTimeout(60 * time.Millisecond)
// Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and
// "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with
// those asserted, the rig keys into TRANSMIT. PTT here is CI-V only, so both
// hardware lines must stay low.
_ = port.SetDTR(false)
_ = port.SetRTS(false)
b.port = port
b.model = civ.ModelName(b.rigAddr)
// Start the reader before any request: recv() now waits on respCh, which only
// the reader feeds. respCh is buffered so a burst (or the scope stream) never
// blocks the reader; specCh holds the latest scope frames for the panadapter.
b.respCh = make(chan civ.Decoded, 64)
b.specCh = make(chan civ.Decoded, 32)
b.readerDone = make(chan struct{})
go b.reader(port, b.readerDone)
go b.scopeLoop(b.specCh, b.readerDone)
// Best-effort model identification: ask the rig for its own CI-V address.
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
}); err == nil {
b.model = civ.ModelName(f.Data[1])
}
}
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
// selector byte; single-scope rigs (IC-7300…) do not.
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2
b.readDSP() // best-effort initial snapshot for the control tab
return nil
}
func (b *IcomSerial) Disconnect() {
if b.port != nil {
_ = b.port.Close() // unblocks the reader's pending Read
b.port = nil
}
if b.readerDone != nil {
<-b.readerDone // wait for the reader goroutine to exit cleanly
b.readerDone = nil
}
}
// ReadState polls the rig for frequency and mode. A failed frequency read is
// treated as "lost the rig" so the Manager reconnects.
func (b *IcomSerial) ReadState() (RigState, error) {
if b.port == nil {
return RigState{}, fmt.Errorf("not connected")
}
s := RigState{Backend: b.Name(), Connected: true, Rig: b.model}
hz, err := b.readFreq()
if err != nil {
// The rig briefly stops answering CI-V while it switches band/VFO. Treat a
// few consecutive misses as transient — keep the connection and report the
// last known state — so a band change doesn't trigger a full disconnect +
// 5 s reconnect (which showed the new frequency ~10 s late). Only after
// several failures do we declare the rig lost so the Manager reconnects.
b.readFails++
if b.readFails <= 6 && b.curFreq > 0 {
s.FreqHz = b.curFreq
if b.curModeByte != 0 {
s.Mode = civ.ModeToADIF(b.curModeByte, false)
if s.Mode == "DATA" {
s.Mode = b.digital
}
}
return s, nil
}
return RigState{}, err
}
b.readFails = 0
s.FreqHz = hz
b.curFreq = hz
if m, ok := b.readMode(); ok {
b.curModeByte = m
data := b.readDataMode() // best-effort; ignored on failure
s.Mode = civ.ModeToADIF(m, data)
if s.Mode == "DATA" {
s.Mode = b.digital
}
b.dspMu.Lock()
b.dsp.Mode = s.Mode
b.dspMu.Unlock()
}
b.pollN++
// Split: the selected VFO (read above) is RX; the unselected VFO is TX. ADIF
// convention → FreqHz = TX, RxFreqHz = RX. Split changes rarely and its read
// (0x0F + 0x25, each with a 350 ms timeout) is the costliest part of a poll,
// so refresh it only every 4th cycle and reuse the cached value between —
// this keeps the CAT thread free for the freq/mode/meter reads and, above
// all, for the user's Set* commands.
if b.pollN%4 == 1 {
b.splitOn, b.splitTXFreq = false, 0
if on, ok := b.readSplit(); ok && on {
if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 {
b.splitOn, b.splitTXFreq = true, txHz
}
}
}
if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz {
s.Split = true
s.RxFreqHz = s.FreqHz // selected VFO = RX
s.FreqHz = b.splitTXFreq // unselected VFO = TX
}
// Live meters + TX state for the Icom panel (the rig doesn't push these).
tx := b.readTX()
sm, _ := b.readMeter(civ.SubMeterS)
po, swr := 0, 0
if tx {
if v, ok := b.readMeter(civ.SubMeterPo); ok {
po = v
}
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
swr = v
}
}
b.dspMu.Lock()
b.dsp.Available = true
b.dsp.Model = b.model
b.dsp.Transmitting = tx
b.dsp.Split = s.Split
b.dsp.SMeter = sm
b.dsp.PowerMeter = po
b.dsp.SWRMeter = swr
b.dspMu.Unlock()
return s, nil
}
func (b *IcomSerial) SetFrequency(hz int64) error {
if hz <= 0 {
return fmt.Errorf("invalid frequency")
}
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
}
func (b *IcomSerial) SetMode(mode string) error {
code, data, err := b.modeCode(mode)
if err != nil {
return err
}
// Set the base mode (keeping the rig's current filter by sending only the
// mode byte), then set the data-mode flag for digital modes.
if err := b.exec(civ.CmdSetMode, code); err != nil {
return err
}
dataByte := byte(0)
if data {
dataByte = 1
}
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
return nil
}
func (b *IcomSerial) SetPTT(on bool) error {
state := byte(0)
if on {
state = 1
}
return b.exec(civ.CmdPTT, civ.SubPTT, state)
}
// ── helpers ───────────────────────────────────────────────────────────────
func (b *IcomSerial) write(payload ...byte) error {
// Drop any stale/unsolicited frames buffered from before this command so
// recv() only sees the reply to THIS request (avoids a previous command's ack
// or an unsolicited dial-turn update being mistaken for our response).
b.drainResp()
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
return err
}
// recv waits for a frame the reader routed to respCh that satisfies match, or
// times out. The reader has already discarded echoes and split off scope frames,
// so recv only ever sees candidate control replies.
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
deadline := time.After(timeout)
for {
select {
case f := <-b.respCh:
if match(f) {
return f, nil
}
case <-deadline:
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
}
}
}
// reader is the sole owner of port.Read. It decodes the CI-V byte stream into
// frames and routes each: our own echoes are dropped, spectrum-scope frames
// (0x27) go to specCh, everything else (control replies, acks, unsolicited
// transceive updates) goes to respCh. It exits when the port is closed.
func (b *IcomSerial) reader(port serial.Port, done chan struct{}) {
defer close(done)
tmp := make([]byte, 512)
var rx []byte
for {
n, err := port.Read(tmp)
if err != nil {
return // port closed or failed — Disconnect/reconnect handles it
}
if n == 0 {
continue // read timeout with no data
}
rx = append(rx, tmp[:n]...)
frames, consumed := civ.Scan(rx)
if consumed > 0 {
rx = append(rx[:0], rx[consumed:]...)
}
for _, f := range frames {
if f.From != b.rigAddr {
continue // echo of our own command
}
if f.Cmd == civ.CmdScope {
b.route(b.specCh, f)
continue
}
b.route(b.respCh, f)
}
}
}
// route delivers a frame without ever blocking the reader: if the channel is
// full it drops the oldest entry to make room for the newest.
func (b *IcomSerial) route(ch chan civ.Decoded, f civ.Decoded) {
select {
case ch <- f:
default:
select { // buffer full — discard oldest, then enqueue newest
case <-ch:
default:
}
select {
case ch <- f:
default:
}
}
}
// drainResp empties any pending control frames (non-blocking).
func (b *IcomSerial) drainResp() {
for {
select {
case <-b.respCh:
default:
return
}
}
}
// ── spectrum scope (0x27) ───────────────────────────────────────────────────
// scopeLoop reassembles the Icom's divided waveform frames into complete sweeps.
// Frame layout (verified on an IC-7610): Data = [00, main/sub, seq, total, …].
// The first frame (seq==1) is a HEADER — [info, low-edge 5-BCD, high-edge 5-BCD]
// — and carries NO waveform bytes; frames 2..total each carry a block of
// amplitude bytes. So we parse the edges from frame 1 and concatenate frames
// 2..total for the trace.
func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
regions := make(map[byte][]byte)
var total byte
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
for {
select {
case <-done:
return
case f := <-spec:
if len(f.Data) < 1 {
continue
}
if f.Data[0] != civ.SubScopeData {
// Non-waveform 0x27 frame = a config read response (mode/span/edge).
// Log each subcommand once so we can confirm its exact byte layout.
if !loggedCfg[f.Data[0]] {
loggedCfg[f.Data[0]] = true
applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data)
}
continue
}
if rawN < 4 {
rawN++
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
}
idx := 1
if b.dualScope {
if len(f.Data) < 2 || f.Data[1] != 0x00 {
continue // only the MAIN scope
}
idx = 2
}
if len(f.Data) < idx+2 {
continue
}
seq, tot := f.Data[idx], f.Data[idx+1]
region := f.Data[idx+2:]
if seq == 0 || tot == 0 {
continue
}
if seq == 1 { // header frame — begins a new sweep, no waveform data
regions = make(map[byte][]byte)
total = tot
if len(region) >= 11 { // [info][low 5][high 5]
low := civ.BCDToFreq(region[1:6])
high := civ.BCDToFreq(region[6:11])
b.scopeMu.Lock()
b.scopeLow, b.scopeHigh = low, high
b.scopeMu.Unlock()
}
continue
}
if total == 0 || tot != total {
continue // stray frame from a sweep whose header we missed
}
regions[seq] = append([]byte(nil), region...)
if seq == total { // last data frame — assemble in sequence order
b.assembleSweep(regions, total)
}
}
}
}
func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
var amp []byte
for s := byte(2); s <= total; s++ {
amp = append(amp, regions[s]...)
}
b.scopeMu.Lock()
b.scopeAmp = amp
b.scopeSeq++
firstLog := !b.scopeSeen
b.scopeSeen = true
low, high := b.scopeLow, b.scopeHigh
b.scopeMu.Unlock()
if firstLog {
applog.Printf("icom scope: first sweep — model=%s total=%d points=%d edges=%d..%d Hz",
b.model, total, len(amp), low, high)
}
}
// SetScope enables or disables the spectrum scope. Two commands are needed and
// RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig
// streams nothing — the case when we're remote and can't touch the front panel),
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
// routes every 0x27 frame to scopeLoop.
func (b *IcomSerial) SetScope(on bool) error {
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
// continue rather than abort the second command.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
applog.Printf("icom scope: display on=%v ack: %v", on, err)
}
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
applog.Printf("icom scope: output on=%v ack: %v", on, err)
}
b.scopeMu.Lock()
b.scopeOn = on
if !on {
b.scopeAmp = nil
}
b.scopeMu.Unlock()
if on {
// Fire read requests for the mode/span/edge settings; their 0x27 responses
// route to scopeLoop, which logs each once so we can confirm the layout.
// Best-effort (fire-and-forget) — responses are 0x27, not FB/FA acks.
b.scopeReadCfg()
}
return nil
}
// scopeReadCfg requests the scope's mode/span/edge settings for the diagnostic
// log. Sent both with and without the leading main/sub selector byte so we
// capture whichever form the rig answers.
func (b *IcomSerial) scopeReadCfg() {
for _, sub := range []byte{civ.SubScopeMode, civ.SubScopeSpan, civ.SubScopeEdge} {
_ = b.write(civ.CmdScope, sub)
if b.dualScope {
_ = b.write(civ.CmdScope, sub, 0x00)
}
}
}
// SetScopeMode selects fixed-span (true) or center-on-VFO (false). Center mode
// makes the scope follow the VFO, so tuning pans the view left/right.
func (b *IcomSerial) SetScopeMode(fixed bool) error {
mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
var payload []byte
if b.dualScope {
payload = []byte{civ.CmdScope, civ.SubScopeMode, 0x00, mode}
} else {
payload = []byte{civ.CmdScope, civ.SubScopeMode, mode}
}
if err := b.exec(payload...); err != nil {
applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
}
b.scopeMu.Lock()
b.scopeFixed = fixed
b.scopeMu.Unlock()
return nil
}
// ScopeData returns a copy of the latest reassembled sweep as a number array.
func (b *IcomSerial) ScopeData() ScopeSweep {
b.scopeMu.Lock()
defer b.scopeMu.Unlock()
amp := make([]int, len(b.scopeAmp))
for i, v := range b.scopeAmp {
amp[i] = int(v)
}
return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed}
}
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
func (b *IcomSerial) exec(payload ...byte) error {
if err := b.write(payload...); err != nil {
return err
}
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.OK || d.Cmd == civ.NG
})
if err != nil {
return err
}
if f.Cmd == civ.NG {
return fmt.Errorf("icom: rig rejected command 0x%02X", payload[0])
}
return nil
}
func (b *IcomSerial) readFreq() (int64, error) {
if err := b.write(civ.CmdReadFreq); err != nil {
return 0, err
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadFreq || d.Cmd == civ.CmdTransceiveFreq
})
if err != nil {
return 0, err
}
return civ.BCDToFreq(f.Data), nil
}
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
// are repeater duplex (not split) and 0x00 is off.
func (b *IcomSerial) readSplit() (on bool, ok bool) {
if err := b.write(civ.CmdSplit); err != nil {
return false, false
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdSplit && len(d.Data) >= 1
})
if err != nil {
return false, false
}
return f.Data[0] == 0x01, true
}
// readTXFreq reads the UNSELECTED VFO's frequency (CI-V 0x25/01) — the TX VFO
// when the rig is in split. Supported on IC-7610/7300/7851/705/9700 and similar.
func (b *IcomSerial) readTXFreq() (int64, bool) {
if err := b.write(civ.CmdVfoFreq, civ.SubVfoUnselected); err != nil {
return 0, false
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdVfoFreq && len(d.Data) >= 6 && d.Data[0] == civ.SubVfoUnselected
})
if err != nil {
return 0, false
}
return civ.BCDToFreq(f.Data[1:]), true
}
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
func (b *IcomSerial) readTX() bool {
if err := b.write(civ.CmdPTT, civ.SubPTT); err != nil {
return false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdPTT && len(d.Data) >= 2 && d.Data[0] == civ.SubPTT
})
if err != nil {
return false
}
return f.Data[1] != 0
}
// readMeter reads a meter (CI-V 0x15) and returns it scaled to 0-100.
func (b *IcomSerial) readMeter(sub byte) (int, bool) {
if err := b.write(civ.CmdMeter, sub); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdMeter && len(d.Data) >= 3 && d.Data[0] == sub
})
if err != nil {
return 0, false
}
return from255(civ.BCDToLevel(f.Data[1:3])), true
}
func (b *IcomSerial) readMode() (byte, bool) {
if err := b.write(civ.CmdReadMode); err != nil {
return 0, false
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return (d.Cmd == civ.CmdReadMode || d.Cmd == civ.CmdTransceiveMode) && len(d.Data) >= 1
})
if err != nil {
return 0, false
}
return f.Data[0], true
}
func (b *IcomSerial) readDataMode() bool {
if err := b.write(civ.CmdExtra, civ.SubDataMode); err != nil {
return false
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubDataMode
})
if err != nil {
return false
}
return f.Data[1] != 0
}
// modeCode maps an ADIF mode to an Icom mode byte plus whether the data-mode
// flag should be set. SSB sideband follows the usual convention (LSB below
// 10 MHz, USB above); the frequency just commanded is preferred over the last
// poll so a clicked spot (freq then mode) picks the right sideband immediately.
func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
freq := b.curFreq
if b.lastSetFreq > 0 && time.Since(b.lastSetFreqAt) < 5*time.Second {
freq = b.lastSetFreq
}
usb := byte(civ.ModeUSB)
if freq > 0 && freq < 10_000_000 {
usb = civ.ModeLSB
}
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
return civ.ModeCW, false, nil
case "SSB":
return usb, false, nil
case "AM":
return civ.ModeAM, false, nil
case "FM":
return civ.ModeFM, false, nil
case "RTTY", "FSK":
return civ.ModeRTTY, false, nil
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
// Digital data modes ride on USB with the data flag set (FT8 etc.).
return civ.ModeUSB, true, nil
}
return 0, false, fmt.Errorf("icom: unsupported mode %q", mode)
}
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
func (b *IcomSerial) IcomState() IcomTXState {
b.dspMu.Lock()
defer b.dspMu.Unlock()
return b.dsp
}
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
// goroutine (dispatched via IcomDo).
func (b *IcomSerial) RefreshIcom() error {
if b.port == nil {
return fmt.Errorf("not connected")
}
b.readDSP()
return nil
}
// readDSP polls every DSP value once and replaces the cache. Best-effort: a
// value the rig doesn't answer keeps its previous cached value rather than
// stalling (each read has a short timeout).
func (b *IcomSerial) readDSP() {
st := IcomTXState{Available: true, Model: b.model}
b.dspMu.Lock()
// Preserve the live fields ReadState polls (mode, TX/split, meters) — readDSP
// only refreshes the set-once DSP values.
st.Mode = b.dsp.Mode
st.Transmitting = b.dsp.Transmitting
st.Split = b.dsp.Split
st.SMeter = b.dsp.SMeter
st.PowerMeter = b.dsp.PowerMeter
st.SWRMeter = b.dsp.SWRMeter
b.dspMu.Unlock()
if v, ok := b.readLevel(civ.SubLevelAF); ok {
st.AFGain = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelRF); ok {
st.RFGain = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelRFPower); ok {
st.RFPower = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelMic); ok {
st.MicGain = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelNR); ok {
st.NRLevel = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelNB); ok {
st.NBLevel = from255(v)
}
if v, ok := b.readSwitch(civ.SubSwNB); ok {
st.NB = v != 0
}
if v, ok := b.readSwitch(civ.SubSwNR); ok {
st.NR = v != 0
}
if v, ok := b.readSwitch(civ.SubSwANF); ok {
st.ANF = v != 0
}
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
st.AGC = agcName(v)
}
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
st.Preamp = int(v)
}
if v, ok := b.readAtt(); ok {
st.Att = v
}
if _, f, ok := b.readModeFilter(); ok {
st.Filter = int(f)
}
b.dspMu.Lock()
b.dsp = st
b.dspMu.Unlock()
}
const icomDSPTimeout = 150 * time.Millisecond // shorter: unsupported reads mustn't stall the poll
func (b *IcomSerial) readLevel(sub byte) (int, bool) {
if err := b.write(civ.CmdLevel, sub); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdLevel && len(d.Data) >= 3 && d.Data[0] == sub
})
if err != nil {
return 0, false
}
return civ.BCDToLevel(f.Data[1:3]), true
}
func (b *IcomSerial) readSwitch(sub byte) (byte, bool) {
if err := b.write(civ.CmdSwitch, sub); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdSwitch && len(d.Data) >= 2 && d.Data[0] == sub
})
if err != nil {
return 0, false
}
return f.Data[1], true
}
func (b *IcomSerial) readAtt() (int, bool) {
if err := b.write(civ.CmdAtt); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdAtt && len(d.Data) >= 1
})
if err != nil {
return 0, false
}
return civ.BCDToByte(f.Data[0]), true
}
func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
if err := b.write(civ.CmdReadMode); err != nil {
return 0, 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadMode && len(d.Data) >= 2
})
if err != nil {
return 0, 0, false
}
return f.Data[0], f.Data[1], true
}
func (b *IcomSerial) SetAFGain(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAF}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.AFGain = clampPct(p) })
return nil
}
func (b *IcomSerial) SetRFGain(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRF}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.RFGain = clampPct(p) })
return nil
}
func (b *IcomSerial) SetNB(on bool) error {
if err := b.exec(civ.CmdSwitch, civ.SubSwNB, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.NB = on })
return nil
}
func (b *IcomSerial) SetNBLevel(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNB}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.NBLevel = clampPct(p) })
return nil
}
func (b *IcomSerial) SetNR(on bool) error {
if err := b.exec(civ.CmdSwitch, civ.SubSwNR, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.NR = on })
return nil
}
func (b *IcomSerial) SetNRLevel(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNR}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.NRLevel = clampPct(p) })
return nil
}
func (b *IcomSerial) SetANF(on bool) error {
if err := b.exec(civ.CmdSwitch, civ.SubSwANF, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.ANF = on })
return nil
}
func (b *IcomSerial) SetAGC(name string) error {
v := agcValue(name)
if v == 0 {
return fmt.Errorf("icom: invalid AGC %q", name)
}
if err := b.exec(civ.CmdSwitch, civ.SubSwAGC, v); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.AGC = strings.ToUpper(name) })
return nil
}
func (b *IcomSerial) SetPreamp(n int) error {
if n < 0 || n > 2 {
return fmt.Errorf("icom: invalid preamp %d", n)
}
if err := b.exec(civ.CmdSwitch, civ.SubSwPreamp, byte(n)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Preamp = n })
return nil
}
func (b *IcomSerial) SetAtt(db int) error {
if err := b.exec(civ.CmdAtt, civ.ByteToBCD(db)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Att = db })
return nil
}
func (b *IcomSerial) SetIcomFilter(n int) error {
if n < 1 || n > 3 {
return fmt.Errorf("icom: invalid filter %d", n)
}
if b.curModeByte == 0 {
// Need the current mode to re-send with the chosen filter.
if m, _, ok := b.readModeFilter(); ok {
b.curModeByte = m
}
}
if err := b.exec(civ.CmdSetMode, b.curModeByte, byte(n)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Filter = n })
return nil
}
func (b *IcomSerial) SetRFPower(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRFPower}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.RFPower = clampPct(p) })
return nil
}
func (b *IcomSerial) SetMicGain(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMic}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.MicGain = clampPct(p) })
return nil
}
func (b *IcomSerial) SetIcomSplit(on bool) error {
if err := b.exec(civ.CmdSplit, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Split = on })
return nil
}
// TuneATU triggers a one-shot antenna-tuner tune (CI-V 0x1C 0x01 0x02).
func (b *IcomSerial) TuneATU() error {
return b.exec(civ.CmdATU, civ.SubATU, 0x02)
}
func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
b.dspMu.Lock()
fn(&b.dsp)
b.dspMu.Unlock()
}
// ── small helpers ──────────────────────────────────────────────────────────
func to255(p int) int { return clampPct(p) * 255 / 100 }
func from255(v int) int { return (v*100 + 127) / 255 }
func clampPct(p int) int { return min(100, max(0, p)) }
func boolByte(on bool) byte {
if on {
return 1
}
return 0
}
func agcName(v byte) string {
switch v {
case 1:
return "FAST"
case 2:
return "MID"
case 3:
return "SLOW"
}
return ""
}
func agcValue(name string) byte {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "FAST":
return 1
case "MID":
return 2
case "SLOW":
return 3
}
return 0
}
+106 -42
View File
@@ -3,11 +3,21 @@ package cat
import (
"fmt"
"strings"
"time"
"github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil"
)
// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
// split ON = 0x8000, split OFF = 0x10000.
const (
pmSplitOn = 0x8000 // PM_SPLITON
pmSplitOff = 0x10000 // PM_SPLITOFF
)
// OmniRig talks to the user's installed OmniRig server over COM.
//
// All methods MUST be called from the same OS thread (the one Manager.run
@@ -21,6 +31,15 @@ type OmniRig struct {
omnirig *ole.IDispatch
rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two
// after a QSY — that lag is why a clicked spot needed a second click to fix
// the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64
lastSetFreqAt time.Time
}
// NewOmniRig creates a non-connected backend. Call Connect before use.
@@ -107,13 +126,19 @@ func (o *OmniRig) ReadState() (RigState, error) {
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
s.Mode = omniRigMode(modeVar.Val)
}
rawVfo := int64(0)
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
rawVfo = vfoVar.Val
s.Vfo = omniRigVfo(vfoVar.Val)
}
// Read both VFO frequencies separately so we can expose split TX/RX.
// Fall back to generic Freq if the rig only exposes the merged property.
freqA, freqB := int64(0), int64(0)
// Read the active/displayed frequency (generic Freq) AND both VFOs. The
// generic Freq is what the rig is operating on — the reliable source for the
// main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
freqMain, freqA, freqB := int64(0), int64(0), int64(0)
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freqMain = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
freqA = v.Val
}
@@ -121,38 +146,65 @@ func (o *OmniRig) ReadState() (RigState, error) {
freqB = v.Val
}
// Split detection: trust the explicit Split property when it's set,
// BUT only call it a real split if both VFO frequencies are non-zero
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
// slice model doesn't map to VFO A/B — that would yield a useless
// permanent SPLIT badge.
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
s.Split = true
}
if s.Split && (freqB == 0 || freqA == freqB) {
s.Split = false
s.RxFreqHz = 0
// Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
// compared to PM_SPLITON, not "!= 0".
splitRaw := int64(0)
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
splitRaw = v.Val
}
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
switch s.Vfo {
case "AB":
s.FreqHz = freqB // TX
s.RxFreqHz = freqA // RX
case "BA":
s.FreqHz = freqA // TX
s.RxFreqHz = freqB // RX
case "B", "BB":
s.FreqHz = freqB
default: // "A", "AA", "" — single VFO on A or unknown
s.FreqHz = freqA
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
// normal operation stays quiet but toggling split on the radio is captured —
// needed to pin down this rig's PM_SPLITON value.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
return v.Val
}
return -1
}())
}
if s.FreqHz == 0 {
// Last resort — some rigs only update generic Freq.
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
s.FreqHz = v.Val
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
// distinct and non-zero, AND they're in the same band. The same-band test
// kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
if genuineSplit {
// ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
// ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
// RX = the active VFO, TX = the other one. This is far more reliable than
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
// report inverted — the split then showed TX/RX swapped.
s.Split = true
switch {
case freqMain != 0 && freqMain == freqA:
s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
case freqMain != 0 && freqMain == freqB:
s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
case s.Vfo == "BA":
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
default:
s.FreqHz, s.RxFreqHz = freqB, freqA
}
} else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
s.Split = false
s.RxFreqHz = 0
s.FreqHz = freqMain
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB
} else {
s.FreqHz = freqA
}
}
}
return s, nil
@@ -169,6 +221,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
return fmt.Errorf("frequency out of OmniRig int32 range")
}
hz32 := int32(hz)
// Remember the commanded frequency so a mode change moments later (a clicked
// spot sets freq then mode) picks the sideband from the TARGET band, not the
// not-yet-updated OmniRig Freq property.
o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()
// Log the rig's writable-params, status and VFO state up front so a
// friend's session shows exactly what OmniRig reports for their rig.
@@ -274,9 +330,15 @@ func (o *OmniRig) SetMode(mode string) error {
case "CW":
bit, bitName = pmCWU, "PM_CW_U"
case "SSB":
// Read current freq to decide USB vs LSB.
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
// property still reports the OLD band for a poll or two after a QSY, so
// reading it here picked the wrong sideband and the user had to click a
// second time. Fall back to the live read for a standalone mode change.
var freq int64
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
freq = o.lastSetFreq
} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freq = freqVar.Val
}
if freq > 0 && freq < 10_000_000 {
@@ -396,20 +458,22 @@ func omniRigMode(m int64) string {
return ""
}
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
func omniRigVfo(v int64) string {
switch {
case v&1024 != 0:
return "A"
case v&2048 != 0:
return "B"
case v&64 != 0:
case v&0x40 != 0: // PM_VFOAA
return "AA"
case v&128 != 0:
case v&0x80 != 0: // PM_VFOAB
return "AB"
case v&256 != 0:
case v&0x100 != 0: // PM_VFOBA
return "BA"
case v&512 != 0:
case v&0x200 != 0: // PM_VFOBB
return "BB"
case v&0x400 != 0: // PM_VFOA
return "A"
case v&0x800 != 0: // PM_VFOB
return "B"
}
return ""
}
+321
View File
@@ -0,0 +1,321 @@
//go:build windows
package cat
import (
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// TCI is a native backend for Expert Electronics' TCI protocol (SunSDR2/MB1/
// ColibriNANO via ExpertSDR2/EESDR, and TCI-compatible apps). TCI is a text
// protocol over a WebSocket: the server streams state ("vfo:0,0,14100000;",
// "modulation:0,cw;", "trx:0,true;") and accepts the same commands to control
// the rig. We keep the pushed state cached so ReadState is instant, like Flex.
//
// Pure Go (gorilla/websocket, no CGO). Default port 40001.
type TCI struct {
host string
port int
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
spotsEnabled bool // mirror cluster spots onto the TCI panorama
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
ready bool
// Cached state pushed by the radio.
device string
freqA int64 // VFO A (RX) frequency, Hz (vfo:0,0)
freqB int64 // VFO B (TX in split), Hz (vfo:0,1)
mode string
split bool
tx bool
lastSig string // last logged state signature (log only on change)
}
const tciDefaultPort = 40001
// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
// mode surfaced when the radio reports a generic digital modulation; spots turns
// on mirroring OpsLog's cluster spots onto the TCI panorama.
func NewTCI(host string, port int, digitalDefault string, spots bool) *TCI {
if port <= 0 || port > 65535 {
port = tciDefaultPort
}
return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault), spotsEnabled: spots}
}
func (t *TCI) Name() string { return "tci" }
// Connect opens the WebSocket and starts the reader goroutine. The reader keeps
// our cached state current from the radio's push messages.
func (t *TCI) Connect() error {
t.mu.Lock()
already := t.conn != nil
host, port := t.host, t.port
t.mu.Unlock()
if already {
return nil
}
if host == "" {
return fmt.Errorf("tci: no host configured")
}
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, _, err := dialer.Dial(url, nil)
if err != nil {
return fmt.Errorf("tci: connect %s: %w", url, err)
}
t.mu.Lock()
t.conn = conn
t.ready = false
t.mu.Unlock()
debugLog.Printf("TCI: connected to %s", url)
go t.reader(conn)
if t.spotsEnabled {
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
}
return nil
}
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
// The radio replaces a spot that has the same callsign, so re-spotting updates
// it in place. No-op when spot mirroring is disabled.
func (t *TCI) SendSpot(s SpotInfo) error {
if !t.spotsEnabled {
return nil
}
call := strings.TrimSpace(s.Callsign)
if call == "" || s.FreqHz <= 0 {
return nil
}
color := strings.TrimSpace(s.Color)
if color == "" {
color = "#FFFFA500" // opaque orange default
}
color = "0x" + strings.TrimPrefix(color, "#")
mode := strings.ToLower(strings.TrimSpace(s.Mode))
// Commas/semicolons would break TCI's comma-separated argument parsing.
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
}
// Disconnect closes the WebSocket; the reader goroutine then exits.
func (t *TCI) Disconnect() {
t.mu.Lock()
c := t.conn
t.conn = nil
t.ready = false
t.mu.Unlock()
if c != nil {
_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
_ = c.Close()
}
}
// ReadState returns the cached state pushed by the radio.
func (t *TCI) ReadState() (RigState, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.conn == nil {
return RigState{}, fmt.Errorf("tci: not connected")
}
st := RigState{Connected: t.ready, Rig: t.device}
if !t.ready {
return st, nil
}
// ADIF convention: FreqHz is the TX freq. In split, TX is VFO B.
if t.split && t.freqB > 0 {
st.FreqHz = t.freqB
st.RxFreqHz = t.freqA
st.Split = true
} else {
st.FreqHz = t.freqA
}
st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
if st.FreqHz > 0 {
st.Band = BandFromHz(st.FreqHz)
}
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
if sig != t.lastSig {
t.lastSig = sig
debugLog.Printf("TCI: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
}
return st, nil
}
// SetFrequency tunes VFO A (the main/RX VFO).
func (t *TCI) SetFrequency(hz int64) error {
return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
}
// SetMode maps an ADIF mode to a TCI modulation and sets it. USB vs LSB is
// chosen from the current VFO-A frequency (< 10 MHz → LSB).
func (t *TCI) SetMode(mode string) error {
t.mu.Lock()
freq := t.freqA
t.mu.Unlock()
m := adifToTCIMode(mode, freq)
if m == "" {
return nil
}
return t.send(fmt.Sprintf("modulation:0,%s;", m))
}
// SetPTT keys or unkeys the transmitter (VFO 0).
func (t *TCI) SetPTT(on bool) error {
return t.send(fmt.Sprintf("trx:0,%t;", on))
}
// send writes a command to the WebSocket (one writer at a time).
func (t *TCI) send(cmd string) error {
t.mu.Lock()
c := t.conn
t.mu.Unlock()
if c == nil {
return fmt.Errorf("tci: not connected")
}
_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte(cmd)); err != nil {
debugLog.Printf("TCI: send %q failed: %v", cmd, err)
return err
}
debugLog.Printf("TCI: → %s", cmd)
return nil
}
// reader drains push messages and keeps the cached state current until the
// connection closes.
func (t *TCI) reader(conn *websocket.Conn) {
for {
_, data, err := conn.ReadMessage()
if err != nil {
break
}
// A frame may carry several ";"-terminated commands.
for _, cmd := range strings.Split(string(data), ";") {
t.handle(strings.TrimSpace(cmd))
}
}
t.mu.Lock()
if t.conn == conn {
t.conn = nil
t.ready = false
}
t.mu.Unlock()
debugLog.Printf("TCI: reader ended")
}
// handle parses one "command:args" message and updates the cache.
func (t *TCI) handle(msg string) {
if msg == "" {
return
}
name, args := msg, ""
if i := strings.IndexByte(msg, ':'); i >= 0 {
name, args = msg[:i], msg[i+1:]
}
f := strings.Split(args, ",")
get := func(i int) string {
if i < len(f) {
return strings.TrimSpace(f[i])
}
return ""
}
t.mu.Lock()
defer t.mu.Unlock()
switch strings.ToLower(name) {
case "device":
t.device = strings.TrimSpace(args)
case "ready", "start":
t.ready = true
case "stop":
t.ready = false
case "vfo":
// vfo:<rx>,<channel>,<freq>
if get(0) == "0" {
hz, _ := strconv.ParseInt(get(2), 10, 64)
if hz > 0 {
t.ready = true // receiving live state → treat as ready even without an explicit "ready;"
switch get(1) {
case "0":
t.freqA = hz
case "1":
t.freqB = hz
}
}
}
case "modulation":
if get(0) == "0" {
t.mode = strings.ToLower(get(1))
}
case "split_enable":
if get(0) == "0" {
t.split = get(1) == "true"
}
case "trx":
if get(0) == "0" {
t.tx = get(1) == "true"
}
}
}
// tciModeToADIF converts a TCI modulation to an ADIF mode. Generic digital
// modulations surface the operator's chosen digital default (FT8/FT4/RTTY…).
func tciModeToADIF(m, digitalDefault string) string {
switch strings.ToLower(strings.TrimSpace(m)) {
case "usb", "lsb", "dsb":
return "SSB"
case "cw":
return "CW"
case "am", "sam":
return "AM"
case "nfm", "wfm", "fm":
return "FM"
case "digu", "digl":
if digitalDefault != "" {
return strings.ToUpper(digitalDefault)
}
return "DATA"
case "drm":
return "DIGITALVOICE"
case "":
return ""
default:
return strings.ToUpper(m)
}
}
// adifToTCIMode maps an ADIF mode to a TCI modulation. USB/LSB is chosen from
// the frequency (< 10 MHz → LSB) as usual. Digital modes → digu.
func adifToTCIMode(mode string, freqHz int64) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB", "LSB":
if freqHz > 0 && freqHz < 10_000_000 {
return "lsb"
}
return "usb"
case "CW", "CWR", "CW-R":
return "cw"
case "AM":
return "am"
case "FM", "NFM":
return "nfm"
case "RTTY":
return "digl"
case "":
return ""
default:
// FT8/FT4/PSK/DATA/JT… → upper-sideband digital.
return "digu"
}
}
+24
View File
@@ -18,6 +18,8 @@ import (
"strings"
"sync"
"time"
"hamlog/internal/applog"
)
// ServerConfig is the persisted shape of one cluster node. Mirrors the
@@ -106,6 +108,7 @@ type session struct {
doneCh chan struct{}
stopped bool // guards against double-stop on the same session
spotsCnt int
dbgN int // diagnostic: how many raw lines logged this connection
}
// Manager owns N sessions, one per enabled server. Safe for concurrent
@@ -392,10 +395,31 @@ func (s *session) runOnce() (time.Time, error) {
return connectedAt, fmt.Errorf("read: %w", err)
}
line = strings.TrimRight(line, "\r\n")
// Strip terminal BELLs (\a) and other control bytes that some nodes
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
// would otherwise break the parser's end-of-line anchor. Tabs → spaces.
line = strings.Map(func(r rune) rune {
if r == '\t' {
return ' '
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, line)
if line == "" {
continue
}
// Safety net: warn (rate-limited) about DX lines the parser still can't
// read, so a future format drift is visible in the log.
if strings.Contains(line, "DX de") && s.dbgN < 10 {
if _, ok := parseSpot(line); !ok {
s.dbgN++
applog.Printf("cluster[%s] UNPARSED spot: %q", s.cfg.Name, line)
}
}
// Login on explicit prompt.
if !loginSent && s.login != "" && isLoginPrompt(line) {
_, _ = conn.Write([]byte(s.login + "\r\n"))
+85
View File
@@ -0,0 +1,85 @@
// Package contest holds a curated catalogue of the major amateur-radio contests
// with their official ADIF CONTEST_ID codes and a default exchange kind. It is
// deliberately a built-in static list (not an online fetch): the ADIF code is
// what must be written to each logged QSO's CONTEST_ID, and those codes don't
// change — bundling them means correct codes, offline, no fragile scraping.
//
// The Exchange field is only a UI hint for what a station typically sends; the
// operator can always override it (serial vs fixed, and the fixed kind) when
// activating the contest.
package contest
import "sort"
// Exchange kinds (UI prefill for the sent/received exchange).
const (
ExSerial = "serial" // RST + running serial number
ExCQZone = "cq-zone" // RST + CQ zone
ExITUZone = "itu-zone" // RST + ITU zone
ExState = "state" // RST + state / province
ExDept = "dept" // RST + département (REF)
ExGrid = "grid" // RST + Maidenhead grid
ExName = "name" // name + state (NAQP-style)
ExClassSection = "class-section" // Field Day: class + ARRL/RAC section
ExRST = "rst" // RST only
)
// Def is one contest: its ADIF CONTEST_ID, a readable name, and the exchange a
// station typically sends.
type Def struct {
Code string `json:"code"`
Name string `json:"name"`
Exchange string `json:"exchange"`
}
// catalogue is the built-in list. Codes are ADIF CONTEST_ID values.
var catalogue = []Def{
{"CQ-WW-CW", "CQ WW DX Contest (CW)", ExCQZone},
{"CQ-WW-SSB", "CQ WW DX Contest (SSB)", ExCQZone},
{"CQ-WW-RTTY", "CQ WW DX RTTY Contest", ExCQZone},
{"CQ-WPX-CW", "CQ WPX Contest (CW)", ExSerial},
{"CQ-WPX-SSB", "CQ WPX Contest (SSB)", ExSerial},
{"CQ-WPX-RTTY", "CQ WPX RTTY Contest", ExSerial},
{"CQ-160-CW", "CQ 160-Meter Contest (CW)", ExState},
{"CQ-160-SSB", "CQ 160-Meter Contest (SSB)", ExState},
{"CQ-VHF", "CQ WW VHF Contest", ExGrid},
{"ARRL-DX-CW", "ARRL International DX Contest (CW)", ExState},
{"ARRL-DX-SSB", "ARRL International DX Contest (SSB)", ExState},
{"ARRL-10", "ARRL 10-Meter Contest", ExState},
{"ARRL-160", "ARRL 160-Meter Contest", ExState},
{"ARRL-SS-CW", "ARRL Sweepstakes (CW)", ExSerial},
{"ARRL-SS-SSB", "ARRL Sweepstakes (SSB)", ExSerial},
{"ARRL-FIELD-DAY", "ARRL Field Day", ExClassSection},
{"ARRL-RTTY", "ARRL RTTY Roundup", ExState},
{"ARRL-DIGI", "ARRL Digital Contest", ExGrid},
{"ARRL-VHF-JAN", "ARRL January VHF Contest", ExGrid},
{"ARRL-VHF-JUN", "ARRL June VHF Contest", ExGrid},
{"ARRL-VHF-SEP", "ARRL September VHF Contest", ExGrid},
{"ARRL-UHF-AUG", "ARRL August UHF Contest", ExGrid},
{"IARU-HF", "IARU HF World Championship", ExITUZone},
{"WAE-DX-CW", "WAE DX Contest (CW)", ExSerial},
{"WAE-DX-SSB", "WAE DX Contest (SSB)", ExSerial},
{"WAE-DX-RTTY", "WAE DX Contest (RTTY)", ExSerial},
{"NAQP-CW", "North American QSO Party (CW)", ExName},
{"NAQP-SSB", "North American QSO Party (SSB)", ExName},
{"NAQP-RTTY", "North American QSO Party (RTTY)", ExName},
{"STEW-PERRY", "Stew Perry Topband Challenge", ExGrid},
{"REF-CW", "REF Contest (CW)", ExDept},
{"REF-SSB", "REF Contest (SSB)", ExDept},
{"REF-160M", "REF 160m Contest", ExDept},
{"OCEANIA-DX-CW", "Oceania DX Contest (CW)", ExSerial},
{"OCEANIA-DX-SSB", "Oceania DX Contest (SSB)", ExSerial},
{"HELVETIA", "Helvetia Contest", ExSerial},
{"UKRAINIAN-DX", "Ukrainian DX Contest", ExSerial},
{"WW-DIGI", "World Wide Digi DX Contest", ExGrid},
{"EU-HF-C", "EU HF Championship", ExSerial},
{"DARC-WAEDC", "WAEDC", ExSerial},
}
// List returns the catalogue sorted by name (copy — callers must not mutate).
func List() []Def {
out := make([]Def, len(catalogue))
copy(out, catalogue)
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
+370
View File
@@ -0,0 +1,370 @@
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
// tone (level-independent, so weak or strong signals both key cleanly), an
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
// marks/spaces to Morse and then to characters.
//
// It is deliberately self-contained and dependency-free so it can be unit
// tested with synthetic signals. As with every audio CW decoder, weak signals
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
// out of the decode.
package cwdecode
import (
"math"
"sort"
"sync/atomic"
)
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
type Status struct {
WPM int `json:"wpm"`
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
Active bool `json:"active"` // a tone is currently keyed down
}
// Decoder consumes PCM and emits decoded characters via onChar (one or more
// characters at a time, including " " for word gaps) and periodic onStatus.
type Decoder struct {
fs int
hop int // samples between updates
win int // Goertzel window length
freqs []float64
coeffs []float64 // precomputed 2*cos(w) per freq
ring []float64 // last win samples
acc int // samples since last hop
mags []float64 // per-bin magnitude this hop
nbuf []float64 // scratch for the noise percentile
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
// another goroutine, so it's atomic.
targetHz atomic.Int32
// Pitch lock.
lockIdx int // index of the locked tone bin, -1 = unlocked
candIdx int // current argmax candidate while unlocked
candHops int // consecutive hops the candidate has been dominant
quietHops int // consecutive key-up hops while locked
noise float64 // broadband noise estimate (percentile of bins)
relockHops int // quiet hops before the lock is released
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
// Adaptive keying envelope, on the LOCKED bin's magnitude.
peak, floor float64
state bool // true = mark (key down)
stateHops int
dotHops float64 // adaptive dot length, in hops
elem []byte // current "." / "-" run for the in-progress character
charEmitted bool
wordEmitted bool
lastPitch float64
lastRMS float64
statusEvery int
sinceStatus int
onChar func(string)
onStatus func(Status)
}
var morse = map[string]byte{
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
"-.--": 'Y', "--..": 'Z',
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
}
// New builds a decoder for the given sample rate. onChar receives decoded text
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
if sampleRate <= 0 {
sampleRate = 16000
}
d := &Decoder{
fs: sampleRate,
hop: sampleRate / 250, // ~4 ms resolution
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
dotHops: 15, // ~20 WPM seed
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
lockIdx: -1,
candIdx: -1,
statusEvery: 25, // ~10 Hz
onChar: onChar,
onStatus: onStatus,
}
if d.hop < 1 {
d.hop = 1
}
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
// Candidate CW tones: 4001000 Hz every 25 Hz. Deliberately NOT lower: strong
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
for f := 400.0; f <= 1000.0; f += 25 {
d.freqs = append(d.freqs, f)
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
}
d.mags = make([]float64, len(d.freqs))
d.nbuf = make([]float64, len(d.freqs))
return d
}
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
// nearestBin returns the bin index closest to hz.
func (d *Decoder) nearestBin(hz float64) int {
best, bestD := 0, math.Inf(1)
for i, f := range d.freqs {
if dd := math.Abs(f - hz); dd < bestD {
bestD, best = dd, i
}
}
return best
}
// Reset clears decode state (e.g. when the user re-arms the decoder).
func (d *Decoder) Reset() {
d.ring = d.ring[:0]
d.acc = 0
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
d.peak, d.floor = 0, 0
d.state = false
d.stateHops = 0
d.dotHops = 15
d.elem = d.elem[:0]
d.charEmitted, d.wordEmitted = false, false
}
// Process feeds a block of mono samples through the decoder.
func (d *Decoder) Process(samples []int16) {
for _, s := range samples {
d.ring = append(d.ring, float64(s))
if len(d.ring) > d.win {
d.ring = d.ring[len(d.ring)-d.win:]
}
d.acc++
if d.acc >= d.hop && len(d.ring) >= d.win {
d.acc = 0
d.analyze()
d.step()
}
}
}
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
// pitch lock (which tone the envelope detector then follows).
func (d *Decoder) analyze() {
n := float64(len(d.ring))
var sumSq float64
maxIdx, maxMag := 0, -1.0
for i, coeff := range d.coeffs {
var s1, s2 float64
for _, x := range d.ring {
s0 := x + coeff*s1 - s2
s2 = s1
s1 = s0
}
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
d.mags[i] = m
if m > maxMag {
maxMag = m
maxIdx = i
}
}
for _, x := range d.ring {
sumSq += x * x
}
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
if th := int(d.targetHz.Load()); th > 0 {
d.lockIdx = d.nearestBin(float64(th))
d.lastPitch = d.freqs[d.lockIdx]
return
}
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
if d.lockIdx < 0 {
if maxIdx == d.candIdx {
d.candHops++
} else {
d.candIdx, d.candHops = maxIdx, 1
}
snr := maxMag / (d.noise + 1e-9)
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
// don't eat the first element of a strong signal), a marginal/weak tone
// locks after a couple of stable hops (so we don't lock onto pure noise).
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
d.lockIdx = maxIdx
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
d.quietHops = 0
}
}
if d.lockIdx >= 0 {
d.lastPitch = d.freqs[d.lockIdx]
} else {
d.lastPitch = 0
}
}
// step runs the adaptive envelope on the locked bin and the timing state
// machine, one hop. The envelope adapts to the signal level (not an absolute
// threshold), so weak and strong signals both key correctly.
func (d *Decoder) step() {
on := false
if d.lockIdx >= 0 {
m := d.mags[d.lockIdx]
// Peak: fast attack, slow release.
if m > d.peak {
d.peak += (m - d.peak) * 0.4
} else {
d.peak += (m - d.peak) * 0.02
}
// Floor: drops fast toward the signal, but only RISES between marks (when
// keyed up). Letting the floor rise during a long dash would shrink the
// span until the dash drops below the threshold and fragments into dots —
// the cause of the "all dots" garbage on a strong clean signal.
if m < d.floor {
d.floor += (m - d.floor) * 0.4
} else if !d.state {
d.floor += (m - d.floor) * 0.02
}
span := d.peak - d.floor
// The frozen floor already stops dashes fragmenting, so keep balanced
// thresholds: low enough that short inter-element GAPS are still seen
// (otherwise elements merge into >7-symbol runs that decode to nothing).
if span > d.floor*0.3+1e-9 {
onTh := d.floor + 0.55*span
offTh := d.floor + 0.35*span
if d.state {
on = m > offTh
} else {
on = m > onTh
}
}
// Release the lock after a long quiet so we can retune to a new signal.
if on {
d.quietHops = 0
} else {
d.quietHops++
if d.quietHops > d.relockHops {
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
}
}
}
if on == d.state {
d.stateHops++
if !d.state {
d.spaceProgress()
}
} else {
if d.state {
d.endMark(d.stateHops)
}
d.state = on
d.stateHops = 1
if on {
d.charEmitted, d.wordEmitted = false, false
}
}
d.emitStatus(on)
}
// endMark classifies a finished key-down run as a dot or dash and adapts the
// dot-length estimate. Runs shorter than a third of a dot are rejected as
// clicks/noise.
func (d *Decoder) endMark(hops int) {
h := float64(hops)
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
if h < d.dotHops*0.35 || h < 4 {
return
}
if h > d.dotHops*2 {
d.elem = append(d.elem, '-')
d.adaptDot(h / 3)
} else {
d.elem = append(d.elem, '.')
d.adaptDot(h)
}
}
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
// to ~5100 WPM).
func (d *Decoder) adaptDot(obs float64) {
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
d.dotHops = 5
}
if d.dotHops > 55 {
d.dotHops = 55
}
}
// spaceProgress flushes the current character once the gap exceeds a character
// gap, and a word space once it exceeds a word gap.
func (d *Decoder) spaceProgress() {
g := float64(d.stateHops)
if !d.charEmitted && g > d.dotHops*2 {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && g > d.dotHops*5 {
if d.onChar != nil {
d.onChar(" ")
}
d.wordEmitted = true
}
}
// flushChar looks up the accumulated element string and emits the character.
func (d *Decoder) flushChar() {
if len(d.elem) == 0 {
return
}
if c, ok := morse[string(d.elem)]; ok {
if d.onChar != nil {
d.onChar(string(c))
}
} else if d.onChar != nil && len(d.elem) <= 7 {
// Only flag a genuinely Morse-shaped but unknown char with "?". An
// over-long element run is noise — drop it silently rather than spam "?".
d.onChar("?")
}
d.elem = d.elem[:0]
}
func (d *Decoder) emitStatus(on bool) {
d.sinceStatus++
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
return
}
d.sinceStatus = 0
hopMs := float64(d.hop) / float64(d.fs) * 1000
wpm := 0
if d.dotHops > 0 {
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
}
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
}
+201
View File
@@ -0,0 +1,201 @@
package cwdecode
import (
"math"
"strings"
"testing"
)
// reverse Morse map for the synthesizer.
func charToMorse() map[byte]string {
m := map[byte]string{}
for code, ch := range morse {
m[ch] = code
}
return m
}
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
}
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
dot := fs * 1200 / (wpm * 1000) // samples per dot
c2m := charToMorse()
var out []int16
phase := 0.0
dphi := 2 * math.Pi * pitch / float64(fs)
tone := func(n int) {
for i := 0; i < n; i++ {
out = append(out, int16(amp*math.Sin(phase)))
phase += dphi
}
}
silence := func(n int) {
for i := 0; i < n; i++ {
out = append(out, 0)
}
}
silence(fs / 4) // 250 ms lead-in for AGC warmup
for i := 0; i < len(msg); i++ {
ch := msg[i]
if ch == ' ' {
silence(7 * dot)
continue
}
code := c2m[ch]
for j := 0; j < len(code); j++ {
if code[j] == '.' {
tone(dot)
} else {
tone(3 * dot)
}
silence(dot) // inter-element gap
}
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
}
silence(fs / 4)
return out
}
func TestDecodeCleanSignal(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
// Repeat so AGC warm-up only costs the first word.
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
// Feed in small chunks like the live capture would.
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "PARIS") {
t.Fatalf("decoded %q, want it to contain PARIS", got)
}
}
func TestDecodeWithQRM(t *testing.T) {
const fs = 16000
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
// quieter, sending different text. The pitch lock should hold on the target.
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
mix := make([]int16, len(target))
for i := range target {
v := int(target[i])
if i < len(qrm) {
v += int(qrm[i])
}
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
mix[i] = int16(v)
}
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
for i := 0; i < len(mix); i += 256 {
end := i + 256
if end > len(mix) {
end = len(mix)
}
d.Process(mix[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "PARIS") {
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
}
}
func TestDecodeFirstCharStrong(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
// Strong signal: the very first element (T = a dash) must not be eaten by
// lock acquisition. Output should begin with the first character.
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(strings.TrimSpace(sb.String()))
if !strings.HasPrefix(got, "TEST") {
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
}
}
func TestDecodeWithAmplitudeRipple(t *testing.T) {
const fs = 16000
// A real signal's tone amplitude wobbles within a mark; if the floor chases
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
rp := 0.0
for i := range samples {
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
}
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "TEST DE OM") {
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
}
}
func TestDecodeCQFixedPitch(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
d.SetTarget(700) // fixed pitch like the user's manual override
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if n := strings.Count(got, "CQ"); n < 2 {
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
}
}
func TestDecodeNumbersAndProsign(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "599") {
t.Fatalf("decoded %q, want it to contain 599", got)
}
}
+164 -8
View File
@@ -7,13 +7,143 @@ import (
"fmt"
"sort"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
)
// MySQLConfig targets a shared MySQL database for multi-operator logging
// (multiple OpsLog instances on one logbook, à la Log4OM).
type MySQLConfig struct {
Host string
Port int
User string
Password string
Database string
}
func (c MySQLConfig) dsn() string {
port := c.Port
if port == 0 {
port = 3306
}
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
// Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
// or wrong-port server fails fast with a real error instead of hanging
// startup (which would surface only as "db not initialized"); read/write
// timeouts cap a stuck statement (generous, so normal migrations/imports
// never trip them).
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
c.User, c.Password, c.Host, port, c.Database)
}
// validDBIdent guards a database name we splice into DDL (CREATE DATABASE can't
// use a placeholder). Only plain identifiers allowed.
func validDBIdent(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r != '_' && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') {
return false
}
}
return true
}
// PingMySQL verifies a shared-database connection and creates the logbook
// database if it doesn't exist yet. It connects at server level first (no
// database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
// database is actually usable. A restricted user (common on shared hosting)
// may lack the CREATE DATABASE privilege but still have full rights on a
// pre-created database — so a denied CREATE is not fatal as long as the
// database already exists and we can connect to it. Backs the "Test
// connection" button.
func PingMySQL(c MySQLConfig) error {
if strings.TrimSpace(c.Host) == "" {
return fmt.Errorf("host is required")
}
server := c
server.Database = "" // connect to the server, not a specific DB
conn, err := sql.Open("mysql", server.dsn())
if err != nil {
return fmt.Errorf("open mysql: %w", err)
}
defer conn.Close()
conn.SetConnMaxLifetime(15 * time.Second)
if err := conn.Ping(); err != nil {
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
}
name := strings.TrimSpace(c.Database)
if name == "" {
return nil
}
if !validDBIdent(name) {
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
createErr := error(nil)
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
if !isAccessDenied(err) {
return fmt.Errorf("create database %q: %w", name, err)
}
createErr = err // remember it in case the DB also turns out to be unreachable
}
// Confirm the database is usable (it may have been pre-created by an admin
// even though this user can't CREATE one).
dbConn, err := sql.Open("mysql", c.dsn())
if err != nil {
return fmt.Errorf("open mysql db: %w", err)
}
defer dbConn.Close()
dbConn.SetConnMaxLifetime(15 * time.Second)
if err := dbConn.Ping(); err != nil {
if createErr != nil {
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
}
return fmt.Errorf("connect to database %q: %w", name, err)
}
return nil
}
//go:embed migrations/*.sql
var migrationsFS embed.FS
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
// dialect; run through the dialect translator for MySQL.
const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`
// Dialect names the backend opened at startup. Repos consult it for the few
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
// Timestamps are generated in Go (NowISO) for both, so most queries are shared.
var Dialect = "sqlite"
// IsMySQL reports whether the active LOGBOOK backend is the shared MySQL
// server. Config tables always live in local SQLite, so this only governs
// qso-table SQL (see columnExpr).
func IsMySQL() bool { return Dialect == "mysql" }
// SetDialect pins the logbook dialect ("mysql" | "sqlite"). Called once at
// startup after the logbook backend is chosen, so it doesn't depend on the
// order in which the local and logbook connections were opened.
func SetDialect(d string) {
if d == "mysql" {
Dialect = "mysql"
} else {
Dialect = "sqlite"
}
}
// NowISO returns the current UTC time as the ISO-8601 string OpsLog stores in
// *_at / date columns — millisecond precision, matching the old SQLite
// strftime('%Y-%m-%dT%H:%M:%fZ','now') default. Bind this as a parameter so the
// same INSERT/UPDATE works on both backends.
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
// Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) {
@@ -30,7 +160,8 @@ func Open(path string) (*sql.DB, error) {
_ = conn.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := migrate(conn); err != nil {
Dialect = "sqlite"
if err := migrate(conn, nil); err != nil {
_ = conn.Close()
return nil, err
}
@@ -39,12 +170,18 @@ func Open(path string) (*sql.DB, error) {
// migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system
// (no external dependency).
func migrate(conn *sql.DB) error {
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`); err != nil {
// (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string) error {
// A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
mysqlPath := translate != nil
if translate == nil {
translate = func(s string) string { return s }
}
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
@@ -74,11 +211,30 @@ func migrate(conn *sql.DB) error {
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
sqlText := translate(string(content))
// MySQL implicitly commits each DDL statement, so a wrapping transaction
// gives no atomicity — a mid-file failure would leave columns/tables
// behind, unrecorded, and every restart would re-run and choke on
// "duplicate column". Instead apply statement-by-statement and tolerate
// "already exists" errors, making migrations idempotent (and self-healing
// for a previously half-applied database).
if mysqlPath {
if err := applyMySQLMigration(conn, sqlText); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
continue
}
// SQLite supports DDL inside a transaction, so keep it atomic there.
tx, err := conn.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
if _, err := tx.Exec(sqlText); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
@@ -0,0 +1,14 @@
-- QSL card designer templates. The json column holds the full template
-- document (schema v1, internal/qslcard). Photos referenced by a template
-- are copied into <dataDir>/qsl/templates/<id>/ — never referenced at their
-- original path. profile_id scopes the "default template" choice; a NULL
-- profile keeps the template usable by everyone.
CREATE TABLE qsl_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
profile_id INTEGER REFERENCES station_profiles(id) ON DELETE SET NULL,
json TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
@@ -0,0 +1,4 @@
-- Operator's personal name (e.g. "Greg"), distinct from the ADIF OPERATOR
-- callsign. Used by the QSL card designer for the signature/script line; may
-- feed correspondence and exports later. Optional, blank by default.
ALTER TABLE station_profiles ADD COLUMN op_name TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,6 @@
-- Per-profile logbook database. Each profile can target its own logbook:
-- the local SQLite file, or a specific shared MySQL database. Switching the
-- active profile switches the logbook accordingly. Stored as a small JSON
-- document {backend, host, port, user, password, database}; empty = inherit
-- the default (local SQLite).
ALTER TABLE station_profiles ADD COLUMN db_config TEXT NOT NULL DEFAULT '';
+459
View File
@@ -0,0 +1,459 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
)
// ── SQLite → MySQL schema translation ──────────────────────────────────
//
// OpsLog's migrations are authored in SQLite dialect (the default backend).
// For the shared-MySQL backend we translate that DDL on the fly rather than
// maintaining a second set of migration files, so the two backends can never
// drift apart. The translation is deliberately narrow — it only rewrites the
// SQLite-isms that actually appear in our migrations:
//
// - INTEGER PRIMARY KEY [AUTOINCREMENT] → BIGINT AUTO_INCREMENT PRIMARY KEY
// - bare INTEGER → BIGINT (so FK child/parent types match)
// - DEFAULT (strftime(... 'now')) → DEFAULT '' (timestamps come from Go)
// - TEXT → VARCHAR(255), except a few free-text
// columns that stay TEXT/LONGTEXT
// - reserved column `key` → backticked
// - CREATE INDEX IF NOT EXISTS → CREATE INDEX (MySQL has no IF NOT EXISTS)
//
// REAL is left as-is (MySQL accepts it as an alias for DOUBLE). CREATE/DROP
// TABLE IF [NOT] EXISTS, RENAME TO, INSERT…SELECT, CHECK(...) and foreign keys
// are all valid MySQL as written.
var (
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
// uses backticks. Our schema has no double-quoted string literals, so it's
// safe to convert every "ident" to `ident`.
reDoubleQuoteIdent = regexp.MustCompile(`"([^"\n]*)"`)
)
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
// The qso table has ~150 columns, so making them all VARCHAR(255) (1020 bytes
// each in utf8mb4) overflows the row. We therefore emit TEXT (stored off-page,
// ~20 bytes in-row) for the bulk of columns, and VARCHAR only where a column
// actually needs in-row storage: it's indexed, a primary key, or carries a
// DEFAULT (TEXT can't hold a literal default in MySQL).
// varcharColumns must be VARCHAR because they're indexed or part of a primary
// key declared on a separate line (so the DEFAULT/PRIMARY-KEY line heuristic
// below can't catch them). MySQL can't index a TEXT column without a prefix
// length. Keyed by column name (a name may repeat across tables — harmless).
var varcharColumns = map[string]bool{
// qso indexes (0001, 0003, 0019)
"callsign": true, "qso_date": true, "band": true, "mode": true,
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
// integrations_udp index (0011)
"direction": true,
// award_references composite primary key (0017)
"award_code": true, "ref_code": true,
}
// longTextColumns override the default TEXT with a bigger type where the
// content can be large (a full QSL-template document).
var longTextColumns = map[string]string{
"json": "LONGTEXT",
}
// mysqlDDL rewrites a SQLite DDL/DML statement (or multi-statement migration
// file) into the MySQL dialect. See the package note above for the rules.
func mysqlDDL(stmt string) string {
s := stmt
// SQLite double-quoted identifiers → MySQL backticks (renamed tables in the
// baseline dump, etc.).
s = reDoubleQuoteIdent.ReplaceAllString(s, "`$1`")
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
// "INTEGER PRIMARY KEY" (SQLite rowid alias) must become AUTO_INCREMENT so
// inserts that omit the id still get one.
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT AUTO_INCREMENT PRIMARY KEY")
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY", "BIGINT AUTO_INCREMENT PRIMARY KEY")
// Timestamp defaults: Go supplies ISO strings, so drop the SQLite function
// default (TEXT/VARCHAR can't carry it in MySQL anyway).
s = reStrftimeDefault.ReplaceAllString(s, "''")
// Remaining INTEGER columns → BIGINT so foreign-key parent/child types match.
s = reBareInteger.ReplaceAllString(s, "BIGINT")
// TEXT columns → TEXT (off-page) by default, VARCHAR(255) only when indexed,
// a primary key, or default-bearing — keeping the row under MySQL's limit.
s = translateTextColumns(s)
// `key` is a MySQL reserved word — backtick the settings column declaration.
s = reKeyColumn.ReplaceAllString(s, "$1`key`")
return s
}
// translateTextColumns rewrites each "<col> TEXT" column declaration to the
// right MySQL type, deciding per line so it can see whether the line carries a
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
func translateTextColumns(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
m := reColText.FindStringSubmatchIndex(line)
if m == nil {
continue
}
col := line[m[2]:m[3]]
var typ string
switch {
case longTextColumns[col] != "":
typ = longTextColumns[col]
case varcharColumns[col],
strings.Contains(line, "DEFAULT"),
strings.Contains(line, "PRIMARY KEY"):
typ = "VARCHAR(255)"
default:
typ = "TEXT"
}
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
}
return strings.Join(lines, "\n")
}
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
// then applies the (translated) embedded migrations. multiStatements is enabled
// so multi-statement migration files run in a single Exec.
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
if strings.TrimSpace(c.Host) == "" {
return nil, fmt.Errorf("host is required")
}
name := strings.TrimSpace(c.Database)
if !validDBIdent(name) {
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
// Ensure the database exists (connect server-level first).
if err := PingMySQL(c); err != nil {
return nil, err
}
conn, err := sql.Open("mysql", c.dsn())
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
// The UI fires bursts of concurrent queries (the Preferences dialog alone
// loads ~8 settings in parallel, plus the grid and live cluster status). A
// low cap turns such a burst into a deadlock-like stall against a remote
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
// No max lifetime: a slow server's first migration can run for minutes on a
// single connection, and reaping it mid-migration drops the selected database
// (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
}
// Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path.
Dialect = "mysql"
fresh, err := isFreshMySQL(conn)
if err != nil {
_ = conn.Close()
Dialect = "sqlite"
return nil, fmt.Errorf("inspect database: %w", err)
}
if fresh {
// Empty database: build the whole final schema in one pass (one CREATE
// per table, no ALTERs) — far faster than replaying 21 migrations,
// especially on a server with slow DDL.
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL)
}
if err != nil {
_ = conn.Close()
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err
}
return conn, nil
}
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator.
func isFreshMySQL(conn *sql.DB) (bool, error) {
var name string
err := conn.QueryRow("SHOW TABLES LIKE 'qso'").Scan(&name)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
return false, nil
}
// applyMySQLBaseline creates the entire current schema on a fresh MySQL database
// in a single pass, then records every migration as already applied. The schema
// is derived from the migrations themselves (replayed on a throwaway in-memory
// SQLite, whose sqlite_master holds each table's FINAL CREATE statement with all
// ALTER-added columns folded in) and translated to MySQL — so there's no second
// schema to maintain and the two backends can't drift. Future migrations apply
// incrementally on top.
func applyMySQLBaseline(conn *sql.DB) error {
mem, err := sql.Open("sqlite", "file:opslog_baseline?mode=memory&cache=shared")
if err != nil {
return fmt.Errorf("open baseline sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil); err != nil {
return fmt.Errorf("build baseline schema: %w", err)
}
var migNames []string
mrows, err := mem.Query("SELECT name FROM schema_migrations ORDER BY name")
if err != nil {
return fmt.Errorf("read baseline migrations: %w", err)
}
for mrows.Next() {
var n string
if err := mrows.Scan(&n); err != nil {
mrows.Close()
return err
}
migNames = append(migNames, n)
}
mrows.Close()
var tables, indexes []string
srows, err := mem.Query("SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'")
if err != nil {
return fmt.Errorf("read baseline schema: %w", err)
}
for srows.Next() {
var typ, s string
if err := srows.Scan(&typ, &s); err != nil {
srows.Close()
return err
}
switch typ {
case "table":
tables = append(tables, s)
case "index":
indexes = append(indexes, s)
}
}
srows.Close()
// Apply on one connection with FK checks off so table order doesn't matter.
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
if _, err := c.ExecContext(ctx, mysqlDDL(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, tb := range tables {
if _, err := c.ExecContext(ctx, mysqlDDL(tb)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline table: %w", err)
}
}
for _, ix := range indexes {
if _, err := c.ExecContext(ctx, mysqlDDL(ix)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline index: %w", err)
}
}
for _, n := range migNames {
if _, err := conn.Exec("INSERT INTO schema_migrations(name) VALUES(?)", n); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("record migration %s: %w", n, err)
}
}
return nil
}
// applyMySQLMigration executes a translated migration one statement at a time.
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
// so running statements individually (and tolerating "already exists" errors)
// keeps migrations idempotent and lets a half-applied database self-heal.
//
// It runs on a single dedicated connection with FOREIGN_KEY_CHECKS=0, because a
// few migrations recreate tables by DROP/RENAME in an order that trips MySQL's
// foreign-key enforcement (SQLite is laxer). The flag is reset to 1 before the
// connection returns to the pool, so runtime queries keep FK enforcement.
func applyMySQLMigration(conn *sql.DB, sqlText string) error {
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
for _, stmt := range coalesceAddColumns(splitStatements(sqlText)) {
if _, err := c.ExecContext(ctx, stmt); err != nil {
if isIgnorableDDLError(err) {
// A coalesced multi-column ALTER fails wholesale if even one
// column already exists (partial prior apply). Re-run it column
// by column so the missing ones still get added.
if cols := decomposeAddColumns(stmt); len(cols) > 1 {
for _, one := range cols {
if _, e := c.ExecContext(ctx, one); e != nil && !isIgnorableDDLError(e) {
return fmt.Errorf("statement %q: %w", firstLine(one), e)
}
}
}
continue
}
return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
}
}
return nil
}
var reAddColumn = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+COLUMN\s+(.+)$`)
// coalesceAddColumns merges consecutive "ALTER TABLE t ADD COLUMN …" statements
// on the same table into a single multi-column ALTER. SQLite needs one ALTER per
// column, but each is a full table operation in MySQL — and on a slow server
// that's seconds apiece, turning a ~50-column migration into minutes (long
// enough to trip connection lifetimes and stall startup). One combined ALTER is
// a single table rebuild instead of fifty.
func coalesceAddColumns(stmts []string) []string {
out := make([]string, 0, len(stmts))
for i := 0; i < len(stmts); {
m := reAddColumn.FindStringSubmatch(stmts[i])
if m == nil {
out = append(out, stmts[i])
i++
continue
}
table := m[1]
cols := []string{strings.TrimSpace(m[2])}
j := i + 1
for j < len(stmts) {
m2 := reAddColumn.FindStringSubmatch(stmts[j])
if m2 == nil || m2[1] != table {
break
}
cols = append(cols, strings.TrimSpace(m2[2]))
j++
}
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.Join(cols, ", ADD COLUMN "))
i = j
}
return out
}
// decomposeAddColumns splits a (possibly coalesced) ADD COLUMN ALTER back into
// one ALTER per column, for the idempotent fallback path.
func decomposeAddColumns(stmt string) []string {
m := reAddColumn.FindStringSubmatch(stmt)
if m == nil {
return nil
}
table := m[1]
var out []string
for _, col := range strings.Split(m[2], ", ADD COLUMN ") {
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.TrimSpace(col))
}
return out
}
// splitStatements breaks a migration file into individual SQL statements,
// dropping comments (full-line and inline "-- …") and blank fragments. Our
// migrations never embed a ';' or '--' inside a string literal, so this is safe.
func splitStatements(sqlText string) []string {
var b strings.Builder
for _, line := range strings.Split(sqlText, "\n") {
if i := strings.Index(line, "--"); i >= 0 {
line = line[:i] // strip inline / full-line comment
}
if strings.TrimSpace(line) == "" {
continue
}
b.WriteString(line)
b.WriteByte('\n')
}
var out []string
for _, part := range strings.Split(b.String(), ";") {
if strings.TrimSpace(part) != "" {
out = append(out, part)
}
}
return out
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// isAccessDenied reports whether a MySQL error is a privilege denial — e.g. a
// restricted user trying to CREATE DATABASE. Such users can still operate on a
// pre-existing database they've been granted, so the caller treats this as
// non-fatal and verifies database access separately.
func isAccessDenied(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1044, // ER_DBACCESS_DENIED_ERROR — access denied to database
1045, // ER_ACCESS_DENIED_ERROR — access denied for user
1142: // ER_TABLEACCESS_DENIED_ERROR — command denied to user
return true
}
return false
}
// isIgnorableDDLError reports whether a MySQL error means the object the
// statement creates/drops is already in the intended state — safe to skip when
// re-applying a forward-only migration.
func isIgnorableDDLError(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1050, // ER_TABLE_EXISTS_ERROR — CREATE TABLE on an existing table
1051, // ER_BAD_TABLE_ERROR — DROP TABLE on a missing table
1060, // ER_DUP_FIELDNAME — ADD COLUMN already present
1061, // ER_DUP_KEYNAME — CREATE INDEX already present
1091: // ER_CANT_DROP_FIELD_OR_KEY — DROP of something that doesn't exist
return true
}
return false
}

Some files were not shown because too many files have changed in this diff Show More