Compare commits
79
Commits
806b39970b
...
v0.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
572e8ca538 | ||
|
|
6ac9783f7c | ||
|
|
725600c341 | ||
|
|
5d9765be09 | ||
|
|
b302d4d87b | ||
|
|
8b7c42ec9b | ||
|
|
cde0add5e0 | ||
|
|
0e2ef317c3 | ||
|
|
a9f2e515e1 | ||
|
|
260172cd6d | ||
|
|
4b5e2e0b72 | ||
|
|
4a6ea45665 | ||
|
|
95d37da3bb | ||
|
|
e1b3f0faf3 | ||
|
|
6379e2cd1f | ||
|
|
2228816057 | ||
|
|
32878c17be | ||
|
|
079d0c32df | ||
|
|
45d081ac0c | ||
|
|
183db7ac2b | ||
|
|
4d074de27e | ||
|
|
679e8f8d39 | ||
|
|
dd2deee939 | ||
|
|
cdd71b17c8 | ||
|
|
e8eedcc1dc | ||
|
|
3c47366f56 | ||
|
|
bd11bb4763 | ||
|
|
40e95e6a16 | ||
|
|
cc0f9ffc64 | ||
|
|
e1f1ab4922 | ||
|
|
b6d991b799 | ||
|
|
59f1775fcd | ||
|
|
b2a8b1946f | ||
|
|
8b1609f5ce | ||
|
|
bde1195b34 | ||
|
|
abdab22010 | ||
|
|
16dc864dbd | ||
|
|
01235624ee | ||
|
|
a7bbc53c35 | ||
|
|
3d15f20c7f | ||
|
|
e5c6bddb29 | ||
|
|
75ee5344a4 | ||
|
|
957182611d | ||
|
|
69d0780bac | ||
|
|
33af122964 | ||
|
|
22e3bb4a18 | ||
|
|
29fd832bcd | ||
|
|
67203cd4a8 | ||
|
|
08162fa126 | ||
|
|
81e505e040 | ||
|
|
0b3e22c97e | ||
|
|
d3ba7c71f4 | ||
|
|
00cab6b204 | ||
|
|
ff53831be4 | ||
|
|
3cb2e466d8 | ||
|
|
408b29896c | ||
|
|
6150498a9e | ||
|
|
42b5c6247d | ||
|
|
6542504a4b | ||
|
|
3dd9620cca | ||
|
|
7d80d26bbd | ||
|
|
9189f54df5 | ||
|
|
eb64b8f2f9 | ||
|
|
d0526c39f5 | ||
|
|
8040a37315 | ||
|
|
16c04fc12b | ||
|
|
17f7a00bd7 | ||
|
|
f91f9ff3b8 | ||
|
|
176cc0e62b | ||
|
|
b4e104f5a2 | ||
|
|
922a185208 | ||
|
|
51d3a734e8 | ||
|
|
88623f55df | ||
|
|
cf9dbf26f3 | ||
|
|
95fdc1ccd1 | ||
|
|
a2a29c66d2 | ||
|
|
1a425a1b0d | ||
|
|
2b4326b553 | ||
|
|
2eb77370e4 |
+19
-1
@@ -4,7 +4,25 @@
|
|||||||
"Bash(go get *)",
|
"Bash(go get *)",
|
||||||
"Bash(go build *)",
|
"Bash(go build *)",
|
||||||
"Bash(wails generate *)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ desktop.ini
|
|||||||
hamlog.db*
|
hamlog.db*
|
||||||
cty.dat
|
cty.dat
|
||||||
cat.log
|
cat.log
|
||||||
|
*.adi
|
||||||
|
|
||||||
# --- Secrets ---
|
# --- Secrets ---
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -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
|
## Building / developing
|
||||||
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
|
- **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 1–6 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 (1–5)",
|
||||||
|
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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+121
-1
@@ -23,16 +23,23 @@
|
|||||||
"ag-grid-react": "^35.3.0",
|
"ag-grid-react": "^35.3.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"topojson-client": "^3.1.0",
|
||||||
|
"world-atlas": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^18.0.17",
|
"@types/react": "^18.0.17",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -2577,6 +2584,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-geo": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2584,6 +2601,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.1",
|
"version": "25.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
@@ -2622,6 +2656,27 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/topojson-client": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*",
|
||||||
|
"@types/topojson-specification": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/topojson-specification": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -2773,6 +2828,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -2787,6 +2848,30 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.5.0 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2953,6 +3038,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2995,6 +3089,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -3634,6 +3734,20 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/topojson-client": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"topo2geo": "bin/topo2geo",
|
||||||
|
"topomerge": "bin/topomerge",
|
||||||
|
"topoquantize": "bin/topoquantize"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -3820,6 +3934,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/world-atlas": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -24,16 +24,23 @@
|
|||||||
"ag-grid-react": "^35.3.0",
|
"ag-grid-react": "^35.3.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"topojson-client": "^3.1.0",
|
||||||
|
"world-atlas": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^18.0.17",
|
"@types/react": "^18.0.17",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
687705a933fcf09f20bdb5083955a417
|
f9b41e192918fa2511f68cd1b361fcd3
|
||||||
+2538
-662
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { X, Plus } from 'lucide-react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
type FieldDef = {
|
||||||
|
name: string; kind: string; category: string;
|
||||||
|
promoted: boolean; deprecated: boolean; intl: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// The QSO's extras map (uppercase ADIF tag → raw value).
|
||||||
|
value: Record<string, string> | undefined;
|
||||||
|
onChange: (next: Record<string, string> | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdifExtrasEditor — a dictionary-driven editor for every ADIF field that is
|
||||||
|
// NOT promoted to a first-class column. Backed by the QSO's extras map, so any
|
||||||
|
// 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 [dict, setDict] = useState<FieldDef[]>([]);
|
||||||
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ADIFFields().then((f) => setDict((f ?? []) as any)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Entries currently set, sorted for stable display.
|
||||||
|
const entries = useMemo(
|
||||||
|
() => Object.entries(value ?? {}).sort((a, b) => a[0].localeCompare(b[0])),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Addable fields: standard non-promoted ADIF fields not already present.
|
||||||
|
// Deprecated (import-only) fields are hidden unless the toggle is on.
|
||||||
|
const addable = useMemo(() => {
|
||||||
|
const have = new Set(Object.keys(value ?? {}));
|
||||||
|
return dict
|
||||||
|
.filter((f) => !f.promoted && !have.has(f.name) && (showDeprecated || !f.deprecated))
|
||||||
|
.map((f) => f.name);
|
||||||
|
}, [dict, value, showDeprecated]);
|
||||||
|
|
||||||
|
const meta = useMemo(() => {
|
||||||
|
const m: Record<string, FieldDef> = {};
|
||||||
|
for (const f of dict) m[f.name] = f;
|
||||||
|
return m;
|
||||||
|
}, [dict]);
|
||||||
|
|
||||||
|
function setKV(key: string, val: string) {
|
||||||
|
const next = { ...(value ?? {}) };
|
||||||
|
next[key] = val;
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
function remove(key: string) {
|
||||||
|
const next = { ...(value ?? {}) };
|
||||||
|
delete next[key];
|
||||||
|
onChange(Object.keys(next).length ? next : undefined);
|
||||||
|
}
|
||||||
|
function addField(name: string) {
|
||||||
|
const key = name.trim().toUpperCase();
|
||||||
|
if (!key || (value && key in value)) return;
|
||||||
|
setKV(key, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Add a field */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 max-w-xs">
|
||||||
|
<Combobox
|
||||||
|
value=""
|
||||||
|
options={addable}
|
||||||
|
placeholder="Add ADIF field…"
|
||||||
|
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
|
||||||
|
</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.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{entries.map(([k, v]) => {
|
||||||
|
const def = meta[k];
|
||||||
|
return (
|
||||||
|
<div key={k} className="flex items-center gap-2">
|
||||||
|
<div className="w-48 shrink-0">
|
||||||
|
<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 && ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!def && <span className="block text-[10px] text-amber-600 leading-tight">non-standard</span>}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
className="flex-1 h-8 text-xs font-mono"
|
||||||
|
value={v}
|
||||||
|
onChange={(e) => setKV(k, e.target.value)}
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Antenna, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
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 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 ? `Port ${letter} — click to deselect` : `Select on port ${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 ? 'online' : 'offline'}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title="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">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">No antennas configured.</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } 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 { Textarea } from '@/components/ui/textarea';
|
||||||
|
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 {
|
||||||
|
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||||
|
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||||
|
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
|
||||||
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
|
ExportAwards, ImportAwards,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// Above this many references the editor stops loading the whole list and
|
||||||
|
// switches to search-only (mirrors Log4OM's "Too many items" behaviour).
|
||||||
|
const REF_LIST_CAP = 1000;
|
||||||
|
|
||||||
|
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
|
||||||
|
|
||||||
|
export type AwardDef = {
|
||||||
|
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||||
|
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;
|
||||||
|
score?: number; bonus?: number; gridsquare?: string; alias?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
|
||||||
|
|
||||||
|
// Award types mirror Log4OM: REFERENCE (we supply a list), QSOFIELDS (scan any
|
||||||
|
// QSO field — handles state/grid4/zones/etc.), CALLSIGN. The type is
|
||||||
|
// organizational only; matching is driven by the field/pattern/dynamic options,
|
||||||
|
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
|
||||||
|
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
|
||||||
|
const CONFIRM_SRC = [
|
||||||
|
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||||
|
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||||
|
];
|
||||||
|
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
||||||
|
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||||
|
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||||
|
|
||||||
|
const emptyAward = (): AwardDef => ({
|
||||||
|
code: 'NEW', name: 'New award', description: '', valid: true,
|
||||||
|
type: 'QSOFIELDS', field: 'note', match_by: 'code', exact_match: true, pattern: '',
|
||||||
|
dxcc_filter: null, confirm: ['lotw', 'qsl'], validate: ['lotw', 'qsl'], total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props { open: boolean; onClose: () => void; onSaved: () => void; }
|
||||||
|
|
||||||
|
// Small reusable multi-toggle chip group.
|
||||||
|
function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onToggle: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{all.map((v) => {
|
||||||
|
const on = value.includes(v);
|
||||||
|
return (
|
||||||
|
<button key={v} type="button" onClick={() => onToggle(v)}
|
||||||
|
className={cn('px-2 py-0.5 rounded text-[11px] border', on
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background border-border text-muted-foreground hover:bg-accent')}>
|
||||||
|
{v}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 [names, setNames] = useState<Record<number, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
(async () => {
|
||||||
|
const miss = value.filter((n) => names[n] === undefined);
|
||||||
|
if (miss.length === 0) return;
|
||||||
|
const got: Record<number, string> = {};
|
||||||
|
for (const n of miss) { try { got[n] = await DXCCName(n); } catch { got[n] = ''; } }
|
||||||
|
if (live) setNames((m) => ({ ...m, ...got }));
|
||||||
|
})();
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function addCountry(name: string) {
|
||||||
|
const n = await DXCCForCountry(name);
|
||||||
|
if (n && n > 0 && !value.includes(n)) {
|
||||||
|
setNames((m) => ({ ...m, [n]: name }));
|
||||||
|
onChange([...value, n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Combobox value="" options={countries} placeholder="Add country…" onChange={addCountry} className="h-8 w-full" />
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{value.map((n) => (
|
||||||
|
<span key={n} className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-accent border border-border">
|
||||||
|
<span className="font-mono">#{n}</span>
|
||||||
|
<span className="text-muted-foreground">{names[n] || '…'}</span>
|
||||||
|
<button className="hover:text-destructive" onClick={() => onChange(value.filter((x) => x !== n))}>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||||
|
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||||
|
const [fields, setFields] = useState<string[]>([]);
|
||||||
|
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
|
||||||
|
const [presets, setPresets] = useState<Preset[]>([]);
|
||||||
|
const [countries, setCountries] = useState<string[]>([]);
|
||||||
|
const [sel, setSel] = useState(0);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
// The err banner doubles as a success/notice area (export path, import counts,
|
||||||
|
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||||
|
// forever; the longer text (export path) gets a bit more time.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!err) return;
|
||||||
|
const t = window.setTimeout(() => setErr(''), 8000);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [err]);
|
||||||
|
|
||||||
|
const loadMeta = () => GetAwardReferenceMeta()
|
||||||
|
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setErr('');
|
||||||
|
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
||||||
|
.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]);
|
||||||
|
|
||||||
|
const cur = defs[sel];
|
||||||
|
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
|
const arr = ((cur?.[key] as string[]) ?? []);
|
||||||
|
patch({ [key]: arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v] } as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
function addAward() {
|
||||||
|
setDefs((ds) => [...ds, emptyAward()]);
|
||||||
|
setSel(defs.length);
|
||||||
|
}
|
||||||
|
function removeAward(i: number) {
|
||||||
|
setDefs((ds) => ds.filter((_, j) => j !== i));
|
||||||
|
setSel((s) => Math.max(0, s >= i ? s - 1 : s));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const clean = defs.filter((d) => d.code.trim())
|
||||||
|
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [], validate: d.validate ?? [] }));
|
||||||
|
await SaveAwardDefs(clean as any);
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function reset() {
|
||||||
|
try { setDefs((await ResetAwardDefs()) as any); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Export every award definition + reference list to a JSON bundle (backup
|
||||||
|
// that survives a reinstall / PC change, independent of the database).
|
||||||
|
async function exportAwards() {
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const p = await ExportAwards();
|
||||||
|
if (p) setErr(`Awards exported to:\n${p}`);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Import an award bundle: definitions are upserted by code, reference lists
|
||||||
|
// replaced. Reloads the editor afterwards.
|
||||||
|
async function importAwards() {
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const r: any = await ImportAwards();
|
||||||
|
if (!r || (!r.awards && !r.references)) return; // cancelled
|
||||||
|
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).`);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function updateList(code: string) {
|
||||||
|
setUpdating(code); setErr('');
|
||||||
|
try { await UpdateAwardReferenceList(code); await loadMeta(); }
|
||||||
|
catch (e: any) { setErr(`${code}: ${String(e?.message ?? e)}`); }
|
||||||
|
finally { setUpdating(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toUpperCase();
|
||||||
|
// Keep the original index `i` (used for selection/patch) but display the
|
||||||
|
// list sorted alphabetically by code — scales as the catalogue grows.
|
||||||
|
return defs.map((d, i) => ({ d, i }))
|
||||||
|
.filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q))
|
||||||
|
.sort((a, b) => a.d.code.localeCompare(b.d.code));
|
||||||
|
}, [defs, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||||
|
{/* Left: award list */}
|
||||||
|
<div className="border-r flex flex-col min-h-0">
|
||||||
|
<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)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{filtered.map(({ d, i }) => (
|
||||||
|
<button key={i} onClick={() => setSel(i)}
|
||||||
|
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||||
|
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||||
|
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} />
|
||||||
|
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||||
|
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> New award
|
||||||
|
</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>}
|
||||||
|
{!cur ? (
|
||||||
|
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</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>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{/* ── Award info ── */}
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground pt-1.5">DXCC filter</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>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Award type ── */}
|
||||||
|
<TabsContent value="type" className="mt-0 space-y-2.5">
|
||||||
|
<Field2 label="Award type">
|
||||||
|
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{
|
||||||
|
// Keep a legacy type (DXCC/GRID) selectable if an existing
|
||||||
|
// award still uses it, so it isn't silently changed.
|
||||||
|
(cur.type && !AWARD_TYPES.includes(cur.type) ? [cur.type, ...AWARD_TYPES] : AWARD_TYPES)
|
||||||
|
.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)
|
||||||
|
}</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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<input type="radio" name="matchby" checked={(cur.match_by || 'code') === m} onChange={() => patch({ match_by: m })} className="accent-primary" /> {m}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</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">Additional searches <span className="font-semibold">(OR)</span> — also match the reference if any of these hit</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" /> Add OR
|
||||||
|
</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">OR — search in</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 })} /> exact</label>
|
||||||
|
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><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="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
|
||||||
|
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Confirmation ── */}
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
{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>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── References ── */}
|
||||||
|
<TabsContent value="refs" className="mt-0">
|
||||||
|
<ReferencesPanel
|
||||||
|
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||||
|
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||||
|
onChanged={loadMeta} setErr={setErr}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<Button variant="outline" onClick={importAwards} title="Import an award bundle (definitions + reference lists)">
|
||||||
|
<Upload className="size-3.5 mr-1" /> 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>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||||
|
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||||
|
// the online updater (POTA/SOTA/WWFF).
|
||||||
|
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
||||||
|
code: string; presets: Preset[]; meta?: RefMeta;
|
||||||
|
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||||
|
}) {
|
||||||
|
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [selCode, setSelCode] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [bulk, setBulk] = useState('');
|
||||||
|
const [showBulk, setShowBulk] = useState(false);
|
||||||
|
const [hasBuiltin, setHasBuiltin] = useState(false);
|
||||||
|
|
||||||
|
const total = meta?.count ?? 0;
|
||||||
|
const large = total > REF_LIST_CAP;
|
||||||
|
|
||||||
|
// Small lists are loaded whole and filtered client-side; large lists (POTA,
|
||||||
|
// 85k parks) are search-only to stay responsive.
|
||||||
|
const load = () => {
|
||||||
|
if (!code) return;
|
||||||
|
if (total > REF_LIST_CAP) { setRefs([]); return; }
|
||||||
|
setBusy(true);
|
||||||
|
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
|
||||||
|
};
|
||||||
|
useEffect(load, [code, total]);
|
||||||
|
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
|
||||||
|
|
||||||
|
// Server-side search for large lists (debounced, min 2 chars).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!large) return;
|
||||||
|
const s = q.trim();
|
||||||
|
if (s.length < 2) { setRefs([]); return; }
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
setBusy(true);
|
||||||
|
SearchAwardReferences(code, s, 0, 200).then((r) => setRefs((r ?? []) as any)).catch(() => setRefs([])).finally(() => setBusy(false));
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [code, q, large]);
|
||||||
|
|
||||||
|
async function populateBuiltin() {
|
||||||
|
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sel = refs.find((r) => r.code === selCode) || null;
|
||||||
|
// Large lists are already filtered by the server; small lists filter locally.
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (large) return refs;
|
||||||
|
const s = q.trim().toUpperCase();
|
||||||
|
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
|
||||||
|
}, [refs, q, large]);
|
||||||
|
|
||||||
|
const patchSel = (p: Partial<AwardRef>) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r)));
|
||||||
|
|
||||||
|
async function saveRef(r: AwardRef) {
|
||||||
|
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function addRef() {
|
||||||
|
const c = prompt('New reference code:')?.trim().toUpperCase();
|
||||||
|
if (!c) return;
|
||||||
|
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
|
||||||
|
await saveRef(r); setSelCode(c);
|
||||||
|
}
|
||||||
|
async function delRef(c: string) {
|
||||||
|
try { await DeleteAwardReference(code, c); setSelCode(null); load(); onChanged(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function applyPreset(key: string) {
|
||||||
|
if (!key) return;
|
||||||
|
try { await ApplyAwardPreset(code, key); load(); onChanged(); }
|
||||||
|
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.`); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Select value="" onValueChange={applyPreset}>
|
||||||
|
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></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>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{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
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> 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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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="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 && 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).
|
||||||
|
</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>}
|
||||||
|
{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')}>
|
||||||
|
<span className="font-mono font-semibold shrink-0">{r.code}</span>
|
||||||
|
<span className="text-muted-foreground truncate">{r.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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="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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { X, Loader2 } from 'lucide-react';
|
||||||
|
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string; // award code, e.g. "POTA"
|
||||||
|
label: string; // display label
|
||||||
|
dxcc?: number; // contacted-station DXCC; used when "this country" is on
|
||||||
|
countryOnly: boolean;
|
||||||
|
value: string; // currently assigned reference
|
||||||
|
onChange: (ref: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwardRefPicker — type-ahead search over an award's reference list (POTA parks,
|
||||||
|
// 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 [q, setQ] = useState('');
|
||||||
|
const [results, setResults] = useState<Ref[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await SearchAwardReferences(code, q, countryOnly ? (dxcc ?? 0) : 0, 30);
|
||||||
|
setResults((r ?? []) as any);
|
||||||
|
} catch { setResults([]); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [q, open, code, dxcc, countryOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
window.addEventListener('mousedown', close);
|
||||||
|
return () => window.removeEventListener('mousedown', close);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[60px_1fr] items-center gap-2">
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
||||||
|
<div className="relative" ref={boxRef}>
|
||||||
|
{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>
|
||||||
|
</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}…`}
|
||||||
|
value={q}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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 && results.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-muted-foreground">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
|
||||||
|
)}
|
||||||
|
{results.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.code}
|
||||||
|
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onChange(r.code); setOpen(false); setQ(''); }}
|
||||||
|
>
|
||||||
|
<span className="font-mono font-semibold shrink-0">{r.code}</span>
|
||||||
|
<span className="text-muted-foreground truncate">{r.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { X, Plus, Loader2 } from 'lucide-react';
|
||||||
|
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../../wailsjs/go/main/App';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
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 };
|
||||||
|
type Meta = { code: string; count: number; can_update: boolean };
|
||||||
|
|
||||||
|
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
||||||
|
// are computed, never manually picked, so they don't belong in this picker.
|
||||||
|
// Fields purely derived from the callsign / cty.dat — their awards are computed,
|
||||||
|
// never picked. NB: 'state' and 'cnty' are NOT here — they're operator-settable
|
||||||
|
// QSO fields driving predefined-list awards (WAS/RAC/WAJA/JCC), so they ARE
|
||||||
|
// pickable (a lookup rarely fills the JA prefecture or VE province).
|
||||||
|
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid', 'grid4']);
|
||||||
|
|
||||||
|
// If DXCC-filtered auto-results exceed this, require the user to type instead.
|
||||||
|
const AUTO_SHOW_MAX = 100;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dxcc?: number;
|
||||||
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
// Current QSO field values (state, cnty, …). When a predefined award reads one
|
||||||
|
// 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, heightClass = 'h-[210px]' }: Props) {
|
||||||
|
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||||
|
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||||
|
const [awardCode, setAwardCode] = useState('POTA');
|
||||||
|
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
// autoResults: loaded immediately when award/dxcc changes (empty query, DXCC-filtered).
|
||||||
|
// Shown when q is short and count ≤ AUTO_SHOW_MAX (e.g. 5 IOTA refs for France).
|
||||||
|
const [autoResults, setAutoResults] = useState<AwardRef[]>([]);
|
||||||
|
// searchResults: loaded when user types 2+ chars.
|
||||||
|
const [searchResults, setSearchResults] = useState<AwardRef[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [selectedRef, setSelectedRef] = useState<AwardRef | null>(null);
|
||||||
|
const [selectedEntry, setSelectedEntry] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
||||||
|
.then(([d, m]) => {
|
||||||
|
setDefs((d ?? []) as any);
|
||||||
|
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// An award is offered when its DXCC scope matches the contacted entity (or it
|
||||||
|
// has no scope) AND it has references to pick from (a loaded list, an online
|
||||||
|
// list, or dynamic references like POTA). This is why DDFM (scope 227) shows
|
||||||
|
// for a French call but not for others.
|
||||||
|
const awards = useMemo(() => {
|
||||||
|
return defs.filter((d) => {
|
||||||
|
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||||
|
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) 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));
|
||||||
|
}, [defs, metas, dxcc]);
|
||||||
|
|
||||||
|
// Keep the selected award valid as the offered list changes with the call.
|
||||||
|
useEffect(() => {
|
||||||
|
if (awards.length === 0) return;
|
||||||
|
if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code);
|
||||||
|
}, [awards, awardCode]);
|
||||||
|
|
||||||
|
// Is the selected award a giant dynamic list (POTA/SOTA/IOTA)? Those carry a
|
||||||
|
// per-reference DXCC so we filter by entity; predefined lists (WAS/RAC/WAJA)
|
||||||
|
// are small and their refs may lack a per-ref DXCC, so we load them whole.
|
||||||
|
const isDynamic = useMemo(
|
||||||
|
() => !!defs.find((d) => d.code === awardCode)?.dynamic,
|
||||||
|
[defs, awardCode],
|
||||||
|
);
|
||||||
|
// For dynamic lists, restrict to the contacted entity; otherwise load all.
|
||||||
|
const refDxcc = isDynamic ? (dxcc ?? 0) : 0;
|
||||||
|
|
||||||
|
// The field the selected award reads (state / cnty / note / …).
|
||||||
|
const selField = useMemo(
|
||||||
|
() => String(defs.find((d) => d.code === awardCode)?.field ?? '').toLowerCase(),
|
||||||
|
[defs, awardCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search helper with a DXCC fallback: try the entity-scoped query first, but
|
||||||
|
// if it finds nothing AND we were filtering by DXCC, retry unfiltered. This
|
||||||
|
// fixes awards whose references carry no per-ref DXCC (e.g. SOTA summits,
|
||||||
|
// where the country is in the summit prefix F/AB-001, not a DXCC column) —
|
||||||
|
// otherwise filtering by entity returns zero. POTA/IOTA keep entity filtering
|
||||||
|
// when their refs do match.
|
||||||
|
const searchRefs = async (query: string, limit: number): Promise<AwardRef[]> => {
|
||||||
|
let r = (await SearchAwardReferences(awardCode, query, refDxcc, limit)) as any as AwardRef[];
|
||||||
|
if ((!r || r.length === 0) && refDxcc > 0) {
|
||||||
|
r = (await SearchAwardReferences(awardCode, query, 0, limit)) as any as AwardRef[];
|
||||||
|
}
|
||||||
|
return r ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-load refs on award/dxcc change with empty query. Fetches AUTO_SHOW_MAX+1
|
||||||
|
// so we can distinguish "all results shown" from "too many to list".
|
||||||
|
useEffect(() => {
|
||||||
|
setAutoResults([]);
|
||||||
|
// Dynamic lists need an entity to scope to; predefined lists load regardless.
|
||||||
|
if (isDynamic && !dxcc) return;
|
||||||
|
searchRefs('', AUTO_SHOW_MAX + 1)
|
||||||
|
.then((r) => setAutoResults(r))
|
||||||
|
.catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [awardCode, dxcc, isDynamic, refDxcc]);
|
||||||
|
|
||||||
|
// Typed search (2+ chars).
|
||||||
|
useEffect(() => {
|
||||||
|
if (q.length < 2) { setSearchResults([]); return; }
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setSearchResults(await searchRefs(q, 50));
|
||||||
|
} catch { setSearchResults([]); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [awardCode, q, refDxcc]);
|
||||||
|
|
||||||
|
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
|
||||||
|
// When q is empty/short: show auto-results (if ≤ AUTO_SHOW_MAX); when typing: show search results.
|
||||||
|
const results: AwardRef[] = q.length >= 2 ? searchResults : (tooManyAuto ? [] : autoResults);
|
||||||
|
|
||||||
|
// Auto-match: when the selected award reads a QSO field that's already filled
|
||||||
|
// (e.g. RAC reads `state`, and the call resolved to state NB), find the matching
|
||||||
|
// reference so the operator can add it in one click — the award already counts
|
||||||
|
// it from the field, but this makes it explicit and confirms it.
|
||||||
|
const autoMatch = useMemo(() => {
|
||||||
|
if (!selField || !fieldValues) return null;
|
||||||
|
const v = String(fieldValues[selField] ?? '').trim().toUpperCase();
|
||||||
|
if (!v) return null;
|
||||||
|
const pool = (autoResults.length ? autoResults : searchResults);
|
||||||
|
const m = pool.find((r) => r.code.toUpperCase() === v);
|
||||||
|
return m ? { code: m.code, name: m.name } : null;
|
||||||
|
}, [selField, fieldValues, autoResults, searchResults]);
|
||||||
|
const autoAlreadyAdded = autoMatch ? entries.includes(`${awardCode}@${autoMatch.code}`) : false;
|
||||||
|
|
||||||
|
function addRef(ref: AwardRef) {
|
||||||
|
const entry = `${awardCode}@${ref.code}`;
|
||||||
|
if (!entries.includes(entry)) {
|
||||||
|
onChange([...entries, entry].join(';'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry(entry: string) {
|
||||||
|
const next = entries.filter((e) => e !== entry).join(';');
|
||||||
|
onChange(next);
|
||||||
|
if (selectedEntry === entry) setSelectedEntry(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-2 ${heightClass}`}>
|
||||||
|
{/* Left panel */}
|
||||||
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={awardCode}
|
||||||
|
onValueChange={(v) => { setAwardCode(v); setSelectedRef(null); setQ(''); setSearchResults([]); }}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{awards.map((a) => (
|
||||||
|
<SelectItem key={a.code} value={a.code}>{a.code}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 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="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
|
||||||
|
<span className="text-muted-foreground text-[11px]">Sub</span>
|
||||||
|
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected ref chip */}
|
||||||
|
{selectedRef ? (
|
||||||
|
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0">
|
||||||
|
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||||
|
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span>
|
||||||
|
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</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
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add — references are always scoped to the contacted DXCC */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
disabled={!selectedRef}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border shrink-0" />
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry}
|
||||||
|
className={`flex items-center gap-1.5 px-2 py-0.5 rounded text-xs cursor-pointer select-none ${
|
||||||
|
selectedEntry === entry ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
|
||||||
|
>
|
||||||
|
<span className="font-mono font-semibold flex-1 truncate">{entry}</span>
|
||||||
|
<button
|
||||||
|
className="shrink-0 hover:text-destructive"
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
{/* Auto-match from the QSO field (e.g. State NB → RAC@NB). */}
|
||||||
|
{autoMatch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={autoAlreadyAdded}
|
||||||
|
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
||||||
|
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
||||||
|
autoAlreadyAdded
|
||||||
|
? '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`}
|
||||||
|
>
|
||||||
|
{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>}
|
||||||
|
</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…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
{/* Add an UNLISTED reference: type a new code (e.g. a brand-new POTA park
|
||||||
|
not yet in the list) and add it directly. */}
|
||||||
|
{q.trim().length >= 2 && !results.some((r) => r.code.toUpperCase() === q.trim().toUpperCase()) && (
|
||||||
|
<button
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
+ Add <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground"> (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…
|
||||||
|
</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
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
||||||
|
note the list is empty (predefined awards with no references). */}
|
||||||
|
{!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.'}
|
||||||
|
</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.
|
||||||
|
<br />
|
||||||
|
<span className="text-[10px]">Download reference lists in the Awards panel → Import data.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.code}
|
||||||
|
className={`px-2 py-1 cursor-pointer border-b border-border/30 last:border-0 ${
|
||||||
|
selectedRef?.code === r.code ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedRef(r)}
|
||||||
|
onDoubleClick={() => { setSelectedRef(r); addRef(r); }}
|
||||||
|
>
|
||||||
|
<div className="font-mono font-semibold leading-tight text-[11px]">{r.code}</div>
|
||||||
|
{r.name && (
|
||||||
|
<div className="text-[10px] text-muted-foreground leading-tight truncate">{r.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
type BandCount = { band: string; worked: number; confirmed: number };
|
||||||
|
type AwardRef = {
|
||||||
|
ref: string; name?: string; group?: string; subgrp?: string;
|
||||||
|
worked: boolean; confirmed: boolean; validated: boolean;
|
||||||
|
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||||
|
};
|
||||||
|
type AwardResult = {
|
||||||
|
code: string; name: string; dimension: string;
|
||||||
|
worked: number; confirmed: number; validated: number; total: number;
|
||||||
|
bands: BandCount[]; refs: AwardRef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
||||||
|
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
|
||||||
|
|
||||||
|
// Fixed band columns for the matrix view (Log4OM-style).
|
||||||
|
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
|
||||||
|
|
||||||
|
// Per-band status for a reference, highest first.
|
||||||
|
type CellStatus = 'validated' | 'confirmed' | 'worked' | 'none';
|
||||||
|
function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||||
|
if (r.validated_bands?.includes(band)) return 'validated';
|
||||||
|
if (r.confirmed_bands?.includes(band)) return 'confirmed';
|
||||||
|
if (r.bands?.includes(band)) return 'worked';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
const CELL_STYLE: Record<CellStatus, string> = {
|
||||||
|
validated: 'bg-emerald-500 text-white',
|
||||||
|
confirmed: 'bg-amber-400 text-amber-950',
|
||||||
|
worked: 'bg-stone-400 text-white',
|
||||||
|
none: '',
|
||||||
|
};
|
||||||
|
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
|
||||||
|
|
||||||
|
function pct(n: number, total: number): number {
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
return Math.min(100, Math.round((n / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-segment progress: confirmed (solid green) over worked (light amber).
|
||||||
|
function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed: number; total: number }) {
|
||||||
|
if (total <= 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
||||||
|
<div className="bg-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||||
|
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||||
|
|
||||||
|
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
|
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).
|
||||||
|
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [selected, setSelected] = useState<string>('');
|
||||||
|
const [refSearch, setRefSearch] = useState('');
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||||
|
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||||
|
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||||
|
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(() => {
|
||||||
|
if (view !== 'stats' || !selected) return;
|
||||||
|
setStatsLoading(true);
|
||||||
|
GetAwardStats(selected)
|
||||||
|
.then((s) => setStats(s as any))
|
||||||
|
.catch(() => setStats(null))
|
||||||
|
.finally(() => setStatsLoading(false));
|
||||||
|
}, [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) {
|
||||||
|
if (!code) return;
|
||||||
|
if (!force && byCode[code]) { setSelected(code); return; }
|
||||||
|
setSelected(code);
|
||||||
|
setLoading(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const r = (await GetAward(code)) as any as AwardResult;
|
||||||
|
setByCode((m) => ({ ...m, [code]: r }));
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the award list (no QSO scan), then compute only the first award.
|
||||||
|
async function loadList() {
|
||||||
|
try {
|
||||||
|
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||||
|
const list: AwardListItem[] = defs
|
||||||
|
.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];
|
||||||
|
if (first) compute(first.code);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { loadList(); }, []);
|
||||||
|
|
||||||
|
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 160m–6m 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();
|
||||||
|
return (current.refs ?? []).filter((r) => {
|
||||||
|
if (refFilter === 'worked' && !r.worked) return false;
|
||||||
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
|
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [current, refSearch, refFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
{/* Award list */}
|
||||||
|
<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>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||||
|
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
|
||||||
|
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
|
Rescan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
||||||
|
{/* Quick selector — scales when there are many awards. */}
|
||||||
|
{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>
|
||||||
|
<SelectContent className="max-h-80">
|
||||||
|
{awardList.map((a) => (
|
||||||
|
<SelectItem key={a.code} value={a.code} className="text-xs">
|
||||||
|
<span className="font-semibold">{a.code}</span>
|
||||||
|
<span className="text-muted-foreground"> — {a.name}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||||
|
{awardList.map((a) => {
|
||||||
|
const r = byCode[a.code];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={a.code}
|
||||||
|
onClick={() => { setRefSearch(''); compute(a.code); }}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
|
||||||
|
selected === a.code && 'bg-accent/60',
|
||||||
|
a.valid === false && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-semibold text-sm">{a.code}</span>
|
||||||
|
{r ? (
|
||||||
|
<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>}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground truncate mb-1">{a.name}</div>
|
||||||
|
{r && <ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail */}
|
||||||
|
<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'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="px-4 py-3 border-b border-border/60">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h3 className="text-lg font-bold">{current.code}</h3>
|
||||||
|
<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>
|
||||||
|
{current.total > 0 && (
|
||||||
|
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Band breakdown */}
|
||||||
|
{(current.bands ?? []).length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-b border-border/60">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</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.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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* References toolbar */}
|
||||||
|
<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-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||||
|
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] 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-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMissing(true)}
|
||||||
|
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="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" /> Missing refs
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-stone-400" />W</span>
|
||||||
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics matrix: status × band, by mode category */}
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<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.5 pr-3 font-medium border-b border-border">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">Total</th>
|
||||||
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||||
|
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : view === 'grid' ? (
|
||||||
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
|
<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.5 pr-2 font-medium border-b border-border w-24">Ref</th>
|
||||||
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">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-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>
|
||||||
|
{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-11 h-7" /> : (
|
||||||
|
<button
|
||||||
|
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||||
|
title={`${r.ref} · ${b} — click to view QSOs`}
|
||||||
|
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||||
|
>{CELL_LABEL[s]}</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
|
<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.5 pr-2 font-medium w-24">Ref</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium">Name</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
||||||
|
<th className="py-1.5 font-medium">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-[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>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 font-mono text-muted-foreground">
|
||||||
|
{r.bands.map((b) => (
|
||||||
|
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cell && current && (
|
||||||
|
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
||||||
|
)}
|
||||||
|
{showMissing && current && (
|
||||||
|
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 [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]);
|
||||||
|
// 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(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
||||||
|
} 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-[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>
|
||||||
|
{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
|
||||||
|
</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.
|
||||||
|
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
||||||
|
</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">{sel.size} selected →</span>
|
||||||
|
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||||
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></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} Assign to {sel.size} selected
|
||||||
|
</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>
|
||||||
|
) : 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.)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<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="Date (UTC)" className="pl-0" />
|
||||||
|
<SortTh k="callsign" label="Callsign" />
|
||||||
|
<SortTh k="band" label="Band" />
|
||||||
|
<SortTh k="mode" label="Mode" />
|
||||||
|
<SortTh k="country" label="Country" />
|
||||||
|
<SortTh k="qth" label="QTH / Note" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 [qsos, setQsos] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
AwardCellQSOs(code, cell.ref, cell.band)
|
||||||
|
.then((r) => setQsos((r ?? []) as any))
|
||||||
|
.catch(() => setQsos([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [code, cell.ref, cell.band]);
|
||||||
|
|
||||||
|
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||||
|
|
||||||
|
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-[640px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||||
|
<span className="font-semibold text-sm">{code} · <span className="font-mono">{cell.ref}</span> · {cell.band}</span>
|
||||||
|
{cell.name && <span className="text-xs text-muted-foreground truncate">{cell.name}</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||||
|
</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>
|
||||||
|
) : qsos.length === 0 ? (
|
||||||
|
<div className="p-4 text-xs text-muted-foreground">No QSOs.</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>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{qsos.map((q, i) => (
|
||||||
|
<tr key={q.id ?? i} className="border-b border-border/30">
|
||||||
|
<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-3 text-muted-foreground">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">{qsos.length} QSO{qsos.length > 1 ? 's' : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { spotStatusKey, inferSpotMode } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||||
@@ -32,6 +32,8 @@ interface Props {
|
|||||||
currentFreqHz: number;
|
currentFreqHz: number;
|
||||||
onSpotClick: (s: Spot) => void;
|
onSpotClick: (s: Spot) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
side?: 'left' | 'right';
|
||||||
|
onToggleSide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
const BAND_RANGES: Record<string, [number, number]> = {
|
||||||
@@ -136,15 +138,15 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
|
|||||||
const LABEL_W = 200;
|
const LABEL_W = 200;
|
||||||
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
const TOP_PAD = 14; // px of breathing room above/below the band edges so
|
||||||
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||||
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single
|
// Max DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once.
|
||||||
// watering-hole frequency and otherwise spawn hundreds of spots that fan out
|
// These pile up on the watering-hole frequencies and otherwise spawn
|
||||||
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other
|
// hundreds of spots that fan out and cover the whole map. ONLY digital is
|
||||||
// digital spots are always shown in full. When more than this FT spots are in
|
// capped — CW and SSB are always shown in full. When more than this digital
|
||||||
// band we keep the most useful (new entities first, worked last; ties broken
|
// spots are in band we keep the most useful (new entities first, worked
|
||||||
// by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
|
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) {
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
const [zoomIdx, setZoomIdx] = useState(0);
|
const [zoomIdx, setZoomIdx] = useState(0);
|
||||||
@@ -189,14 +191,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
inBand.push(s);
|
inBand.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single
|
// Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…)
|
||||||
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is
|
// are capped — they're what floods the watering-hole frequencies. We key
|
||||||
// always shown in full.
|
// off the mode CATEGORY (not a literal "FT8" string) because many FT8
|
||||||
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz));
|
// spots carry no mode word and the band-plan fallback labels them the
|
||||||
|
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
||||||
|
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
||||||
const ftSpots = inBand.filter(isFlood);
|
const ftSpots = inBand.filter(isFlood);
|
||||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||||
|
|
||||||
// Rank an FT spot by usefulness (new entity → unworked → worked); ties
|
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
||||||
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
|
||||||
const rank = (s: Spot) => {
|
const rank = (s: Spot) => {
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
@@ -295,6 +299,20 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi]);
|
}, [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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -363,6 +381,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
title="Scroll to current rig frequency">
|
title="Scroll to current rig frequency">
|
||||||
<Crosshair className="size-3" />
|
<Crosshair className="size-3" />
|
||||||
</button>
|
</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'}>
|
||||||
|
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button type="button" onClick={onClose}
|
<button type="button" onClick={onClose}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||||
@@ -497,7 +522,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<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
|
scroll · ctrl+wheel = zoom · ◎ = jump to rig
|
||||||
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>}
|
{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>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,10 +11,22 @@ interface Props {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentBand: string;
|
currentBand: string;
|
||||||
currentMode: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 13-column band layout — no 4m, no SHF (per user preference).
|
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||||
const BANDS: { tag: string; label: string }[] = [
|
// strip the trailing "m" for meter bands (160m→160), and shorten cm bands
|
||||||
|
// (13cm→13c) so the column stays narrow.
|
||||||
|
function bandColLabel(tag: string): string {
|
||||||
|
if (tag === '2m') return 'V';
|
||||||
|
if (tag === '70cm') return 'U';
|
||||||
|
if (tag.endsWith('cm')) return tag.replace('cm', 'c');
|
||||||
|
return tag.replace(/m$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default 13-column band layout, used when the operator hasn't configured bands.
|
||||||
|
const DEFAULT_BANDS: { tag: string; label: string }[] = [
|
||||||
{ tag: '160m', label: '160' },
|
{ tag: '160m', label: '160' },
|
||||||
{ tag: '80m', label: '80' },
|
{ tag: '80m', label: '80' },
|
||||||
{ tag: '60m', label: '60' },
|
{ tag: '60m', label: '60' },
|
||||||
@@ -46,6 +58,17 @@ const STATUS_CLASSES: Record<string, string> = {
|
|||||||
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Legend entries, in the same colour order as the cells. swatch = the
|
||||||
|
// background class (or a special ring marker for the current-entry cell).
|
||||||
|
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
||||||
|
{ swatch: 'bg-emerald-700', label: 'Call confirmed' },
|
||||||
|
{ swatch: 'bg-emerald-300', label: 'Call worked' },
|
||||||
|
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
|
||||||
|
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
|
||||||
|
{ swatch: 'bg-stone-200', label: 'Not worked' },
|
||||||
|
{ swatch: 'bg-stone-200', ring: true, label: 'Current entry' },
|
||||||
|
];
|
||||||
|
|
||||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
||||||
const desc =
|
const desc =
|
||||||
status === 'call_c' ? 'This callsign confirmed' :
|
status === 'call_c' ? 'This callsign confirmed' :
|
||||||
@@ -56,10 +79,17 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
|||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode }: 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(
|
||||||
|
() => (bands && bands.length ? bands.map((tag) => ({ tag, label: bandColLabel(tag) })) : DEFAULT_BANDS),
|
||||||
|
[bands],
|
||||||
|
);
|
||||||
const dxcc = wb?.dxcc ?? 0;
|
const dxcc = wb?.dxcc ?? 0;
|
||||||
const dxccName = wb?.dxcc_name ?? '';
|
const dxccName = wb?.dxcc_name ?? '';
|
||||||
const dxccCount = wb?.dxcc_count ?? 0;
|
const dxccCount = wb?.dxcc_count ?? 0;
|
||||||
|
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
||||||
const hasDxcc = dxcc > 0;
|
const hasDxcc = dxcc > 0;
|
||||||
const newOne = hasDxcc && dxccCount === 0;
|
const newOne = hasDxcc && dxccCount === 0;
|
||||||
|
|
||||||
@@ -71,11 +101,34 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
return m;
|
return m;
|
||||||
}, [wb]);
|
}, [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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4 px-3 py-2 bg-card border-b border-border flex-wrap shrink-0',
|
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
||||||
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 border-amber-300',
|
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-[220px]">
|
<div className="flex items-center gap-2 min-w-[220px]">
|
||||||
@@ -98,7 +151,22 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||||
|
{callCount > 0 && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<strong className="text-foreground font-semibold">{callCount}</strong>{' '}
|
||||||
|
with this call
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</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 & 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 ? (
|
) : busy ? (
|
||||||
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
||||||
@@ -112,15 +180,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table className="border-separate" style={{ borderSpacing: 2 }}>
|
<div className="flex flex-col gap-2">
|
||||||
|
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-[22px]" />
|
<th className="w-[26px]" />
|
||||||
{BANDS.map((b) => (
|
{cols.map((b) => (
|
||||||
<th
|
<th
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[10px] font-semibold px-1 text-center',
|
'font-mono text-[11px] font-semibold px-1 text-center',
|
||||||
b.tag === currentBand ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
b.tag === currentBand ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -136,21 +205,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
<tr key={cls}>
|
<tr key={cls}>
|
||||||
<th
|
<th
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[10px] font-semibold pr-1.5 text-right w-[22px]',
|
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
|
||||||
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cls}
|
{cls}
|
||||||
</th>
|
</th>
|
||||||
{BANDS.map((b) => {
|
{cols.map((b) => {
|
||||||
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
const isCurrent = b.tag === currentBand && classCurrent;
|
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[22px] h-[18px] rounded transition-colors p-0',
|
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
||||||
isCurrent && 'ring-2 ring-amber-500 ring-inset',
|
isCurrent && 'ring-2 ring-amber-500 ring-inset',
|
||||||
)}
|
)}
|
||||||
@@ -162,6 +231,23 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* Colour legend — sits in the spare room under the matrix. */}
|
||||||
|
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap pl-[26px]">
|
||||||
|
{LEGEND.map((l) => (
|
||||||
|
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block size-3 rounded shrink-0',
|
||||||
|
l.swatch,
|
||||||
|
l.ring && 'ring-2 ring-amber-500 ring-inset',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{l.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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.
|
||||||
|
const FIELDS: FieldDef[] = [
|
||||||
|
// QSL / upload status
|
||||||
|
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
|
||||||
|
// My station / operator
|
||||||
|
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
|
||||||
|
// Misc
|
||||||
|
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_VALUES: { v: string; label: string }[] = [
|
||||||
|
{ v: 'Y', label: 'Y — Yes / uploaded' },
|
||||||
|
{ v: 'N', label: 'N — No' },
|
||||||
|
{ v: 'R', label: 'R — Requested' },
|
||||||
|
{ v: 'I', label: 'I — Ignore' },
|
||||||
|
{ v: '_', label: '(blank — clear)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
||||||
|
|
||||||
|
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 [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>Bulk edit field</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
|
||||||
|
This overwrites the current value — there is no undo.
|
||||||
|
</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">Field</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">{g}</div>
|
||||||
|
{FIELDS.filter((f) => f.group === g).map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Label className="text-sm">Value</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}>{v.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={textValue}
|
||||||
|
placeholder="leave empty to clear the field"
|
||||||
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Will set <span className="font-semibold">{def.label}</span> ={' '}
|
||||||
|
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-rose-700">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
Apply to {ids.length}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
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 [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">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">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="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">No messages yet.</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="Message…"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3 } from 'lucide-react';
|
import { Columns3, FilterX } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ export type ClusterSpot = {
|
|||||||
received_at: string;
|
received_at: string;
|
||||||
raw: string;
|
raw: string;
|
||||||
repeats?: number;
|
repeats?: number;
|
||||||
|
pota_ref?: string;
|
||||||
|
pota_name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SpotStatusEntry = {
|
export type SpotStatusEntry = {
|
||||||
@@ -102,6 +105,27 @@ function fmtDateTimeUTC(s: any): string {
|
|||||||
|
|
||||||
type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVisible?: boolean };
|
type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVisible?: boolean };
|
||||||
|
|
||||||
|
// statusFor resolves the precomputed spot status (new / new-band / new-slot /
|
||||||
|
// worked-call) for an ag-Grid cell's row.
|
||||||
|
function statusFor(p: any): SpotStatusEntry | undefined {
|
||||||
|
return p?.context?.spotStatus?.[
|
||||||
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||||
|
switch (s?.status) {
|
||||||
|
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||||
|
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||||
|
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||||
|
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const COL_CATALOG: ColEntry[] = [
|
const COL_CATALOG: ColEntry[] = [
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Time', colId: 'time',
|
group: 'Spot', label: 'Time', colId: 'time',
|
||||||
@@ -114,29 +138,56 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
group: 'Spot', label: 'Call', colId: 'call',
|
group: 'Spot', label: 'Call', colId: 'call',
|
||||||
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
headerName: 'Call', field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellRenderer: (p: any) => {
|
cellClass: 'font-mono',
|
||||||
if (!p.value) return '';
|
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
|
||||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
// aligned with non-new rows). Text colour also flags worked-call vs new-call.
|
||||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
cellStyle: (p: any): any => (statusFor(p)?.status === 'new'
|
||||||
];
|
? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }
|
||||||
const isNew = status?.status === 'new';
|
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
||||||
const workedCall = !!status?.worked_call;
|
tooltipValueGetter: (p: any) => {
|
||||||
const style: any = {
|
const s = statusFor(p);
|
||||||
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
|
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
||||||
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
|
|
||||||
};
|
|
||||||
if (isNew) {
|
|
||||||
style.backgroundColor = '#ffe4e6';
|
|
||||||
style.color = '#9f1239';
|
|
||||||
style.border = '1px solid #fda4af';
|
|
||||||
} else if (workedCall) {
|
|
||||||
style.color = '#0369a1';
|
|
||||||
} else {
|
|
||||||
style.color = '#b8410c';
|
|
||||||
}
|
|
||||||
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Spot', label: 'Status', colId: 'status',
|
||||||
|
headerName: 'Status', width: 96, sortable: true,
|
||||||
|
defaultVisible: true,
|
||||||
|
// 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 'NEW DXCC';
|
||||||
|
if (s?.status === 'new-band') return 'NEW BAND';
|
||||||
|
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||||
|
return s?.worked_call ? 'WKD CALL' : '';
|
||||||
|
},
|
||||||
|
cellRenderer: (p: any) => {
|
||||||
|
const b = statusBadge(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 `NEW DXCC: ${s?.country ?? ''}`;
|
||||||
|
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
||||||
|
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
||||||
|
if (s?.worked_call) return 'Already worked this call';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Spot', label: 'POTA', colId: 'pota',
|
||||||
|
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
|
defaultVisible: true,
|
||||||
|
cellStyle: { color: '#166534' },
|
||||||
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Freq', colId: 'freq',
|
group: 'Spot', label: 'Freq', colId: 'freq',
|
||||||
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
@@ -148,52 +199,25 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
group: 'Spot', label: 'Band', colId: 'band',
|
group: 'Spot', label: 'Band', colId: 'band',
|
||||||
headerName: 'Band', field: 'band' as any, width: 75,
|
headerName: 'Band', field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'flex items-center',
|
cellClass: 'font-mono',
|
||||||
cellRenderer: (p: any) => {
|
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
||||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
||||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
||||||
];
|
: undefined),
|
||||||
const newBand = status?.status === 'new-band';
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined),
|
||||||
const bg = newBand ? '#fde68a' : '#f0d9a8';
|
|
||||||
const fg = newBand ? '#92400e' : '#7a4a14';
|
|
||||||
return p.value
|
|
||||||
? <span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
|
||||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
|
||||||
border: newBand ? '1px solid #f59e0b' : undefined,
|
|
||||||
}}
|
|
||||||
title={newBand ? 'NEW BAND for this entity' : undefined}
|
|
||||||
>{p.value}</span>
|
|
||||||
: '';
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Mode', colId: 'mode',
|
group: 'Spot', label: 'Mode', colId: 'mode',
|
||||||
headerName: 'Mode', colSpan: undefined, width: 80,
|
headerName: 'Mode', colSpan: undefined, width: 80,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'flex items-center',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
cellRenderer: (p: any) => {
|
// NEW SLOT (mode not yet worked on this band) → fill the cell.
|
||||||
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot'
|
||||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }
|
||||||
];
|
: undefined),
|
||||||
const newSlot = status?.status === 'new-slot';
|
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||||
const bg = newSlot ? '#fef08a' : '#d1fae5';
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined),
|
||||||
const fg = newSlot ? '#854d0e' : '#047857';
|
|
||||||
return p.value
|
|
||||||
? <span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
|
||||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
|
||||||
border: newSlot ? '1px solid #eab308' : undefined,
|
|
||||||
}}
|
|
||||||
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
|
|
||||||
>{p.value}</span>
|
|
||||||
: <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
||||||
@@ -304,19 +328,18 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
try {
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
if (raw) {
|
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||||
const state = JSON.parse(raw) as ColumnState[];
|
if (remote && !local) {
|
||||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
} catch {}
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
try {
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
||||||
@@ -360,6 +383,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<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
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||||
<Columns3 className="size-3.5" /> Columns
|
<Columns3 className="size-3.5" /> Columns
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Construction } from 'lucide-react';
|
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { pathBetween } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||||
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
|
|
||||||
export interface DetailsState {
|
export interface DetailsState {
|
||||||
state: string;
|
state: string;
|
||||||
@@ -37,6 +38,10 @@ export interface DetailsState {
|
|||||||
srx?: number;
|
srx?: number;
|
||||||
stx?: number;
|
stx?: number;
|
||||||
email: 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".
|
||||||
|
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||||
|
award_refs?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -51,13 +56,52 @@ interface Props {
|
|||||||
wbBusy?: boolean;
|
wbBusy?: boolean;
|
||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
onOpenImage?: () => void;
|
onOpenImage?: () => void;
|
||||||
|
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||||
|
tab?: TabName;
|
||||||
|
onTab?: (t: TabName) => void;
|
||||||
|
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
||||||
|
// shown as Ctrl+F1…F5 instead of F1…F5.
|
||||||
|
keyerActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||||
|
|
||||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
// 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: '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: '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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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' },
|
||||||
|
];
|
||||||
|
|
||||||
function numOrUndef(v: string): number | undefined {
|
function numOrUndef(v: string): number | undefined {
|
||||||
if (v === '') return undefined;
|
if (v === '') return undefined;
|
||||||
@@ -66,28 +110,70 @@ function numOrUndef(v: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compact field helper to keep the JSX dense.
|
// Compact field helper to keep the JSX dense.
|
||||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
|
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
|
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
||||||
<Label className="mb-1">{label}</Label>
|
<Label className="mb-1">{label}</Label>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||||
const [open, setOpen] = useState<TabName>('stats');
|
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.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
const path = useMemo(
|
const path = useMemo(() => {
|
||||||
() => pathBetween(operatorGrid, remoteGrid),
|
const byGrid = pathBetween(operatorGrid, remoteGrid);
|
||||||
[operatorGrid, remoteGrid],
|
if (byGrid) return byGrid;
|
||||||
);
|
// Fall back to lat/lon when the DX has coordinates but no grid (e.g. a
|
||||||
|
// cty.dat-only entity like Svalbard: no QRZ grid, but cty.dat coordinates).
|
||||||
|
const myLL = gridToLatLon(operatorGrid);
|
||||||
|
if (myLL && details.lat != null && details.lon != null) {
|
||||||
|
return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||||
|
|
||||||
function toggle(t: TabName) { setOpen(t); }
|
function toggle(t: TabName) { onTab ? onTab(t) : setInternalOpen(t); }
|
||||||
|
const fk = keyerActive ? 'Ctrl+F' : 'F';
|
||||||
|
|
||||||
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
||||||
function setSatellite(on: boolean) {
|
function setSatellite(on: boolean) {
|
||||||
@@ -102,15 +188,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { key: TabName; label: string }[] = [
|
const tabs: { key: TabName; label: string }[] = [
|
||||||
{ key: 'stats', label: 'Stats (F1)' },
|
{ key: 'stats', label: `Stats (${fk}1)` },
|
||||||
{ key: 'info', label: 'Info (F2)' },
|
{ key: 'info', label: `Info (${fk}2)` },
|
||||||
{ key: 'awards', label: 'Awards (F3)' },
|
{ key: 'awards', label: `Awards (${fk}3)` },
|
||||||
{ key: 'my', label: 'My (F4)' },
|
{ key: 'my', label: `My (${fk}4)` },
|
||||||
{ key: 'extended', label: 'Extended (F5)' },
|
{ key: 'extended', label: `Extended (${fk}5)` },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border border-border rounded-lg bg-card flex flex-col flex-1 min-h-0 overflow-hidden">
|
<section className="bg-card shadow-sm border border-border rounded-lg flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
@@ -128,10 +214,10 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</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' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} />
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -146,9 +232,26 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
<Field label="Prefix">
|
<Field label="Prefix">
|
||||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||||
</Field>
|
</Field>
|
||||||
{/* DXCC #, CQ zone, ITU zone, Continent and Azimuth SP live in the
|
<Field label="CQ zone">
|
||||||
main entry strip — visible without opening F2. F2 keeps the
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.cqz ?? ''} placeholder="—"
|
||||||
less-needed long-path bearing and both distances. */}
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
|
</Field>
|
||||||
|
<Field label="ITU zone">
|
||||||
|
<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 # 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="DXCC #">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
className="font-mono bg-muted/40 cursor-default"
|
||||||
|
value={details.dxcc ?? ''}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Azimuth LP">
|
<Field label="Azimuth LP">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
@@ -189,40 +292,60 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'awards' && (
|
{open === 'awards' && (
|
||||||
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
|
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
|
||||||
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
|
<AwardRefSelector
|
||||||
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div>
|
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">Detected — this contact will count for:</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'my' && (
|
{open === 'my' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||||
<Field label="Ant. azimuth (°)">
|
<Field label="Azimuth (°)">
|
||||||
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ant. elevation (°)">
|
<Field label="Elevation (°)">
|
||||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ant. path">
|
|
||||||
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Propagation">
|
|
||||||
<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={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="TX power (W)">
|
<Field label="TX power (W)">
|
||||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="flex items-end pb-1.5">
|
<div className="col-span-3 flex items-end pb-1.5">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||||
Satellite mode
|
Satellite mode
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<Field label="Ant. path" 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>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="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>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
<Field label="Rig" span={3}>
|
<Field label="Rig" span={3}>
|
||||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Mic, Square, X, Radio } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||||
|
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
messages: DVKMsg[];
|
||||||
|
status: DVKStat;
|
||||||
|
onPlay: (slot: number) => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||||||
|
// 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 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={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">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
|
||||||
|
</Button>
|
||||||
|
<button className="text-muted-foreground hover:text-foreground" title="Disable voice keyer" onClick={onClose}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-2">
|
||||||
|
{!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 & voice keyer</strong> to record F1–F6.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-1">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.slot}
|
||||||
|
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`}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||||
|
m.has_audio
|
||||||
|
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
||||||
|
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
|
||||||
|
// adds field/operator/value conditions, joins them with AND or OR, and can
|
||||||
|
// save/recall named presets. Closing the dialog applies the filter.
|
||||||
|
|
||||||
|
export type FilterOp =
|
||||||
|
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
|
||||||
|
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty';
|
||||||
|
|
||||||
|
export interface FilterCondition { field: string; op: FilterOp; value: string }
|
||||||
|
export interface QueryFilter {
|
||||||
|
quick_callsign?: string;
|
||||||
|
conditions: FilterCondition[];
|
||||||
|
match: 'AND' | 'OR';
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
||||||
|
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||||
|
type FieldType = 'text' | 'number' | 'date';
|
||||||
|
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: 'wwff_ref', label: 'WWFF 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: 'hrdlog_qso_upload_status', label: 'HRDLog 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: 'my_state', label: 'My state', type: 'text' },
|
||||||
|
{ value: 'my_cnty', label: 'My county', type: 'text' },
|
||||||
|
{ value: 'my_iota', label: 'My IOTA', type: 'text' },
|
||||||
|
{ value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' },
|
||||||
|
{ value: 'my_pota_ref', label: 'My POTA ref', type: 'text' },
|
||||||
|
{ value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' },
|
||||||
|
{ value: 'my_street', label: 'My street', type: 'text' },
|
||||||
|
{ value: 'my_city', label: 'My city', type: 'text' },
|
||||||
|
{ value: 'my_postal_code', label: 'My postal code', type: 'text' },
|
||||||
|
{ value: 'my_rig', label: 'My rig', type: 'text' },
|
||||||
|
{ value: 'my_antenna', label: 'My antenna', type: 'text' },
|
||||||
|
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
||||||
|
{ value: 'comment', label: 'Comment', type: 'text' },
|
||||||
|
{ value: 'notes', label: 'Notes', 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
|
||||||
|
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
|
||||||
|
|
||||||
|
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||||
|
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
||||||
|
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
||||||
|
return OPS.filter((o) => allow.includes(o.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESETS_KEY = 'hamlog.filterPresets';
|
||||||
|
function loadPresets(): Record<string, QueryFilter> {
|
||||||
|
try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
function savePresets(p: Record<string, QueryFilter>) {
|
||||||
|
// Write-through to the portable DB so presets travel with the data/ folder.
|
||||||
|
writeUiPref(PRESETS_KEY, JSON.stringify(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
initial: QueryFilter;
|
||||||
|
onApply: (f: QueryFilter) => void; // applies and closes
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||||
|
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||||
|
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
||||||
|
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
||||||
|
const [presetName, setPresetName] = useState('');
|
||||||
|
|
||||||
|
// Seed from the active filter each time the dialog opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setConditions(initial.conditions?.length ? initial.conditions.map((c) => ({ ...c })) : []);
|
||||||
|
setMatch(initial.match === 'OR' ? 'OR' : 'AND');
|
||||||
|
setPresets(loadPresets());
|
||||||
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const setCond = (i: number, patch: Partial<FilterCondition>) =>
|
||||||
|
setConditions((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c)));
|
||||||
|
const addCond = () => setConditions((cs) => [...cs, { field: 'callsign', op: 'contains', value: '' }]);
|
||||||
|
const removeCond = (i: number) => setConditions((cs) => cs.filter((_, j) => j !== i));
|
||||||
|
|
||||||
|
function buildFilter(): QueryFilter {
|
||||||
|
const clean = conditions.filter((c) => c.field && c.op);
|
||||||
|
return { ...initial, conditions: clean, match };
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() { onApply(buildFilter()); }
|
||||||
|
|
||||||
|
function saveCurrentPreset() {
|
||||||
|
const name = presetName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const next = { ...presets, [name]: buildFilter() };
|
||||||
|
savePresets(next);
|
||||||
|
setPresets(next);
|
||||||
|
setPresetName('');
|
||||||
|
}
|
||||||
|
function loadPreset(name: string) {
|
||||||
|
const f = presets[name];
|
||||||
|
if (!f) return;
|
||||||
|
setConditions(f.conditions?.map((c) => ({ ...c })) ?? []);
|
||||||
|
setMatch(f.match === 'OR' ? 'OR' : 'AND');
|
||||||
|
}
|
||||||
|
function deletePreset(name: string) {
|
||||||
|
const next = { ...presets };
|
||||||
|
delete next[name];
|
||||||
|
savePresets(next);
|
||||||
|
setPresets(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetNames = Object.keys(presets).sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader className="px-6 py-4">
|
||||||
|
<DialogTitle>QSO filter</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>
|
||||||
|
<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)'}
|
||||||
|
</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>
|
||||||
|
<SelectContent>
|
||||||
|
{presetNames.map((n) => (
|
||||||
|
<SelectItem key={n} value={n}>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{n}
|
||||||
|
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
<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);
|
||||||
|
setCond(i, { field: v, op: allowed.includes(c.op) ? c.op : allowed[0] });
|
||||||
|
}}>
|
||||||
|
<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>)}
|
||||||
|
</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>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||||
|
className="h-8 flex-1 text-xs"
|
||||||
|
disabled={!needsValue}
|
||||||
|
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
|
||||||
|
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">
|
||||||
|
<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
|
||||||
|
</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}
|
||||||
|
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
|
||||||
|
</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 & close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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 { 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 [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">Welcome to OpsLog</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||||
|
<Label className="text-sm">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">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">Operator</Label>
|
||||||
|
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Owner</Label>
|
||||||
|
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Name</Label>
|
||||||
|
<Input className="h-9" placeholder="your first name" 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">Award reference lists</div>
|
||||||
|
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
||||||
|
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : '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">Callsign and locator are required.</span>}
|
||||||
|
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
|
FlexMox, FlexAmpOperate,
|
||||||
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
||||||
|
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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
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,
|
||||||
|
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('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
|
||||||
|
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{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 }: {
|
||||||
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
|
segColor?: (frac: number) => 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 className="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">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexPanel() {
|
||||||
|
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, 2800, 3000, 4000, 6000];
|
||||||
|
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
||||||
|
|
||||||
|
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">SmartSDR remote control</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 ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{off && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground py-6">
|
||||||
|
Waiting for the FlexRadio… (set CAT to FlexRadio and connect)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TX + RX columns */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{/* TRANSMIT */}
|
||||||
|
<Card icon={Zap} title="Transmit" accent="#dc2626">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">RF Power</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">Tune Pwr</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>
|
||||||
|
|
||||||
|
{!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">VOX Dly</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">Speed</span>
|
||||||
|
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(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">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">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="Receive (active slice)" accent="#0891b2">
|
||||||
|
<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">Thresh</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="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||||
|
<Slider value={st.audio_level} disabled={rxOff} 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.audio_level}</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">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">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',
|
||||||
|
Math.abs(curBW - bw) <= 50 ? '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={`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 ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
||||||
|
</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 ? 'PowerGenius connected' : (pg.last_error || 'PowerGenius offline')}>
|
||||||
|
<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">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">Standard</option>
|
||||||
|
<option value="CONTEST">Contest</option>
|
||||||
|
<option value="BROADCAST">Broadcast</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">FAULT: {st.amp_fault}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live meters (UDP VITA-49 stream) */}
|
||||||
|
<Card icon={Gauge} title="Meters">
|
||||||
|
{(() => {
|
||||||
|
const meters = st.meters || [];
|
||||||
|
if (off || meters.length === 0) {
|
||||||
|
return <p className="text-[11px] text-muted-foreground text-center py-2">No meters yet — waiting for the radio's UDP stream…</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 s = (dbm + 127) / 6; // S0 = -127 dBm
|
||||||
|
if (s >= 9) {
|
||||||
|
const over = Math.round(dbm + 73); // dB over S9
|
||||||
|
return { display: over > 0 ? `S9+${over}` : 'S9', bar: s };
|
||||||
|
}
|
||||||
|
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
|
||||||
|
};
|
||||||
|
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`}
|
||||||
|
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 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">AMPLIFIER</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, AudioLines, RefreshCw } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetIcomState, IcomRefresh,
|
||||||
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
|
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type IcomState = {
|
||||||
|
available: boolean; model?: string; mode?: string;
|
||||||
|
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, 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' }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend.
|
||||||
|
// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects
|
||||||
|
// the last refresh plus optimistic updates. Front-panel knob changes show after
|
||||||
|
// the next ↻ Refresh.
|
||||||
|
export function IcomPanel() {
|
||||||
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
|
const [busy, setBusy] = useState(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, 1500); // cheap cache poll (mode + optimistic state)
|
||||||
|
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(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!st.available) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||||
|
Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</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')} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card icon={Radio} title="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="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="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="Noise / Notch" 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">Auto notch filter</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
// Persisted free-pan view of the world map (when auto-zoom is off).
|
||||||
|
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
||||||
|
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
function saveMapView(m: L.Map) {
|
||||||
|
const c = m.getCenter();
|
||||||
|
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||||
|
const out: [number, number][] = [];
|
||||||
|
let prev = NaN;
|
||||||
|
for (const [la, lo] of ring) {
|
||||||
|
let lon = lo;
|
||||||
|
if (!Number.isNaN(prev)) {
|
||||||
|
while (lon - prev > 180) lon -= 360;
|
||||||
|
while (lon - prev < -180) lon += 360;
|
||||||
|
}
|
||||||
|
prev = lon;
|
||||||
|
out.push([la, lon]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||||
|
const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||||
|
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||||
|
const OSM_ATTR = '© 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 © 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: '',
|
||||||
|
html: `<span style="display:block;width:12px;height:12px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.4)"></span>`,
|
||||||
|
iconSize: [12, 12],
|
||||||
|
iconAnchor: [6, 6],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||||
|
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
||||||
|
const worldRef = useRef<HTMLDivElement>(null);
|
||||||
|
const worldMap = useRef<L.Map | null>(null);
|
||||||
|
const worldOverlay = 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
|
||||||
|
// across restarts. Default on.
|
||||||
|
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||||
|
const autoZoomRef = useRef(autoZoom);
|
||||||
|
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||||
|
|
||||||
|
// One-time map creation.
|
||||||
|
useEffect(() => {
|
||||||
|
if (worldRef.current && !worldMap.current) {
|
||||||
|
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
||||||
|
.setView([20, 0], 1);
|
||||||
|
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;
|
||||||
|
const sv = loadMapView();
|
||||||
|
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (from && to) {
|
||||||
|
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||||
|
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(wo);
|
||||||
|
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||||
|
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(wo);
|
||||||
|
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||||
|
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
|
||||||
|
// line, which makes a smooth arc look angular/bumpy).
|
||||||
|
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
||||||
|
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).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 FAN of translucent great-circle radials, not a
|
||||||
|
// filled polygon: a polygon breaks badly near the poles on Mercator
|
||||||
|
// (its edges run off toward ±90° and the fill smears across the map),
|
||||||
|
// while each radial LINE stays clean. The overlapping lines read as a
|
||||||
|
// lobe — solid near the antenna, fanning out toward the front. Works
|
||||||
|
// for any azimuth, north/south included.
|
||||||
|
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
||||||
|
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||||
|
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, 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 (esp. dark satellite imagery). Same dashArray
|
||||||
|
// as the red line so the casing tracks each dash — otherwise the wide
|
||||||
|
// casing peeks through the gaps and the line looks bumpy.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoZoom) {
|
||||||
|
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||||
|
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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 QTH→DX 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);
|
||||||
|
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
||||||
|
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||||
|
locatorMap.current = m;
|
||||||
|
}
|
||||||
|
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lm = locatorMap.current, lo = locatorOverlay.current;
|
||||||
|
if (!lm || !lo) return;
|
||||||
|
lo.clearLayers();
|
||||||
|
const to = gridToLatLon(toGrid);
|
||||||
|
if (to) {
|
||||||
|
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||||
|
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(lo);
|
||||||
|
const b = gridSquareBounds(toGrid);
|
||||||
|
if (b) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
setTimeout(() => { lm.invalidateSize(); }, 0);
|
||||||
|
}, [toGrid, toLabel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,42 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations } 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 { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
|
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 = {
|
type UploadRow = {
|
||||||
id: number; qso_date: string; callsign: string;
|
id: number; qso_date: string; callsign: string;
|
||||||
band: string; mode: string; country: string; status: 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 = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
||||||
@@ -22,9 +45,29 @@ type Confirmation = {
|
|||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
{ v: 'qrz', label: 'QRZ.com' },
|
{ v: 'qrz', label: 'QRZ.com' },
|
||||||
{ v: 'clublog', label: 'Club Log' },
|
{ v: 'clublog', label: 'Club Log' },
|
||||||
|
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||||
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const QSL_STATUSES = [
|
||||||
|
{ v: '_', label: '— leave —' },
|
||||||
|
{ v: 'Y', label: 'Yes' },
|
||||||
|
{ v: 'N', label: 'No' },
|
||||||
|
{ v: 'R', label: 'Requested' },
|
||||||
|
{ v: 'I', label: 'Ignore' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
const SENT_STATUSES = [
|
const SENT_STATUSES = [
|
||||||
{ v: 'R', label: 'Requested' },
|
{ v: 'R', label: 'Requested' },
|
||||||
{ v: 'N', label: 'No' },
|
{ v: 'N', label: 'No' },
|
||||||
@@ -42,16 +85,105 @@ function fmtDate(iso: string): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fmtQslDate renders a QSL/LoTW/eQSL/ClubLog date (ADIF YYYYMMDD, or an ISO
|
||||||
|
// datetime) as YYYY-MM-DD — same shape as the QSO date, without the time.
|
||||||
|
export function fmtQslDate(s?: string): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const t = s.trim();
|
||||||
|
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||||
|
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||||
|
const d = new Date(t);
|
||||||
|
if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
// 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.
|
// and download confirmations, while the rest of the app stays usable.
|
||||||
export function QSLManagerPanel() {
|
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
const [service, setService] = useState('lotw');
|
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('');
|
||||||
|
const [potaAddMissing, setPotaAddMissing] = useState(false);
|
||||||
|
// Only sync hunts made under the active profile's callsign (skip XV9Q/NQ2H…).
|
||||||
|
const [potaOnlyMyCall, setPotaOnlyMyCall] = useState(true);
|
||||||
|
|
||||||
|
async function syncPota() {
|
||||||
|
setPotaSyncing(true); setPotaErr(''); setPotaRes(null);
|
||||||
|
try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing, potaOnlyMyCall)) as any as POTASync); }
|
||||||
|
catch (e: any) { setPotaErr(String(e?.message ?? e)); }
|
||||||
|
finally { setPotaSyncing(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Paper QSL: search a callsign, bulk-set sent/received + via + date ──
|
||||||
|
const [paperCall, setPaperCall] = useState('');
|
||||||
|
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
|
||||||
|
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
|
||||||
|
const [paperBusy, setPaperBusy] = useState(false);
|
||||||
|
const [paperMsg, setPaperMsg] = useState('');
|
||||||
|
const [qslRcvd, setQslRcvd] = useState('Y');
|
||||||
|
const [qslSent, setQslSent] = useState('_');
|
||||||
|
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||||
|
const [qslSentDate, setQslSentDate] = useState('');
|
||||||
|
const [qslVia, setQslVia] = useState('');
|
||||||
|
|
||||||
|
const searchPaper = useCallback(async () => {
|
||||||
|
const c = paperCall.trim().toUpperCase();
|
||||||
|
if (!c) return;
|
||||||
|
setPaperBusy(true); setPaperMsg('');
|
||||||
|
try {
|
||||||
|
const r: any = await ListQSO({ callsign: c, limit: 1000 } as any);
|
||||||
|
const list = (r ?? []) as LogQSO[];
|
||||||
|
setPaperRows(list);
|
||||||
|
setPaperSel(new Set(list.map((x) => x.id)));
|
||||||
|
} 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);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setPaperBusy(true); setPaperMsg('');
|
||||||
|
const ymd = (d: string) => d.replaceAll('-', '');
|
||||||
|
try {
|
||||||
|
const n = await BulkUpdateQSL(ids as any, {
|
||||||
|
sent_status: qslSent === '_' ? '' : qslSent,
|
||||||
|
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
||||||
|
sent_date: ymd(qslSentDate),
|
||||||
|
rcvd_date: ymd(qslRcvdDate),
|
||||||
|
via: qslVia,
|
||||||
|
} as any);
|
||||||
|
setPaperMsg(`${n} QSO updated.`);
|
||||||
|
await searchPaper();
|
||||||
|
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
||||||
|
finally { setPaperBusy(false); }
|
||||||
|
}
|
||||||
const [sent, setSent] = useState('R');
|
const [sent, setSent] = useState('R');
|
||||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly.
|
||||||
|
const gridRef = useRef<AgGridReact<UploadRow>>(null);
|
||||||
|
const [selectedCount, setSelectedCount] = useState(0);
|
||||||
|
const selectAllNext = useRef(false); // selectAll once after the next data load
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [addNotFound, setAddNotFound] = useState(false);
|
const [addNotFound, setAddNotFound] = useState(false);
|
||||||
|
// Download date window: 'last' = incremental since last pull, 'date' = from a
|
||||||
|
// chosen date, 'all' = everything.
|
||||||
|
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
|
||||||
|
const [sinceDate, setSinceDate] = useState('');
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
|
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
|
||||||
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
|
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
|
||||||
@@ -75,10 +207,20 @@ export function QSLManagerPanel() {
|
|||||||
return () => { offLog(); offDone(); offConf(); };
|
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]);
|
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 onUploadSelChanged() {
|
||||||
|
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0);
|
||||||
|
}
|
||||||
|
// After "Select required" loads new rows, select them all (the old default).
|
||||||
|
function onUploadRowsLoaded() {
|
||||||
|
if (selectAllNext.current) {
|
||||||
|
selectAllNext.current = false;
|
||||||
|
gridRef.current?.api?.selectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
switch (confFilter) {
|
switch (confFilter) {
|
||||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
||||||
@@ -95,32 +237,22 @@ export function QSLManagerPanel() {
|
|||||||
try {
|
try {
|
||||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||||
const list = (r ?? []) as UploadRow[];
|
const list = (r ?? []) as UploadRow[];
|
||||||
|
selectAllNext.current = true; // pre-select everything once the grid renders
|
||||||
setRows(list);
|
setRows(list);
|
||||||
setSelected(new Set(list.map((x) => x.id)));
|
setSelectedCount(list.length);
|
||||||
setViewMode('upload');
|
setViewMode('upload');
|
||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
setError(String(e?.message ?? e));
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setSelected(new Set());
|
setSelectedCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
}, [service, sent]);
|
}, [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() {
|
async function upload() {
|
||||||
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
try { await UploadQSOsManual(service, ids); }
|
try { await UploadQSOsManual(service, ids); }
|
||||||
@@ -128,8 +260,13 @@ export function QSLManagerPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function download() {
|
async function download() {
|
||||||
|
// Resolve the date window into the backend's `since` argument:
|
||||||
|
// 'all' → "" (everything)
|
||||||
|
// 'last' → "last" (incremental since last successful pull)
|
||||||
|
// 'date' → "YYYY-MM-DD" (the chosen date; falls back to all if empty)
|
||||||
|
const since = sinceMode === 'last' ? 'last' : sinceMode === 'date' ? sinceDate.trim() : '';
|
||||||
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||||
try { await DownloadConfirmations(service, addNotFound); }
|
try { await DownloadConfirmations(service, addNotFound, since); }
|
||||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,18 +286,72 @@ export function QSLManagerPanel() {
|
|||||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
{uploadCall && (
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
<div className="flex flex-col gap-1">
|
||||||
<Select value={sent} onValueChange={setSent}>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
<span
|
||||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
||||||
</Select>
|
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
||||||
</div>
|
>
|
||||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
{uploadCall}
|
||||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
</span>
|
||||||
Select required
|
</div>
|
||||||
</Button>
|
)}
|
||||||
|
{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
|
||||||
|
</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">
|
||||||
|
<Checkbox checked={potaOnlyMyCall} onCheckedChange={(c) => setPotaOnlyMyCall(!!c)} />
|
||||||
|
Only my profile callsign
|
||||||
|
</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)">
|
||||||
|
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
||||||
|
Add not-found QSOs to my log
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] text-muted-foreground self-center">Token in Settings → External services → POTA.</span>
|
||||||
|
</>
|
||||||
|
) : service === 'paper' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">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" />
|
||||||
|
</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
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</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>
|
||||||
|
</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
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{service === 'paper' && paperRows.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">{paperRows.length} QSO · {paperSel.size} selected</span>
|
||||||
|
)}
|
||||||
{!showLog && viewMode === 'confirmations' && (
|
{!showLog && viewMode === 'confirmations' && (
|
||||||
<div className="flex flex-col gap-1">
|
<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">Filter</label>
|
||||||
@@ -192,7 +383,85 @@ export function QSLManagerPanel() {
|
|||||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
||||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||||
|
|
||||||
{showLog ? (
|
{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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
) : 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>
|
||||||
|
)}
|
||||||
|
{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>}
|
||||||
|
{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).
|
||||||
|
{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})` : ''}.</>
|
||||||
|
)}
|
||||||
|
{' '}Rescan the POTA award to count the new references.
|
||||||
|
</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{potaRes.unmatched_list.map((u, i) => (
|
||||||
|
<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' : ''}>
|
||||||
|
<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>
|
||||||
|
<td className="py-1 px-2 font-mono">{u.reference}</td>
|
||||||
|
<td className="py-1 px-2 text-muted-foreground">
|
||||||
|
{u.reason}
|
||||||
|
{u.qso_id > 0 && <ExternalLink className="inline size-3 ml-1 opacity-60" />}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : showLog ? (
|
||||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||||
{logLines.length === 0 ? (
|
{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" /> starting…</div>
|
||||||
@@ -240,43 +509,87 @@ export function QSLManagerPanel() {
|
|||||||
) : rows.length === 0 ? (
|
) : 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">Pick a service + sent status, then “Select required”.</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs border-collapse">
|
<div className="h-full w-full">
|
||||||
<thead className="sticky top-0 bg-card">
|
<AgGridReact<UploadRow>
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
ref={gridRef}
|
||||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
theme={qslTheme}
|
||||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
rowData={rows}
|
||||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
columnDefs={UPLOAD_COLS}
|
||||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
|
defaultColDef={{ sortable: true, resizable: true, filter: true }}
|
||||||
</tr>
|
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
|
||||||
</thead>
|
onSelectionChanged={onUploadSelChanged}
|
||||||
<tbody>
|
onRowDataUpdated={onUploadRowsLoaded}
|
||||||
{rows.map((r) => (
|
animateRows={false}
|
||||||
<tr key={r.id}
|
suppressCellFocus
|
||||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
onClick={() => toggle(r.id)}>
|
/>
|
||||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Action bar */}
|
{/* Paper-QSL apply form */}
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
</Select>
|
||||||
|
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title="QSL received date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL sent</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>
|
||||||
|
</Select>
|
||||||
|
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title="QSL sent date" />
|
||||||
|
</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" />
|
||||||
|
</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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action bar (upload/download — not for POTA / Paper QSL) */}
|
||||||
|
{service !== 'pota' && service !== 'paper' && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||||
title="Fetch confirmations from the service and update received status">
|
title="Fetch confirmations from the service and update received status">
|
||||||
<DownloadCloud className="size-3.5" /> Download confirmations
|
<DownloadCloud className="size-3.5" /> Download confirmations
|
||||||
</Button>
|
</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">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="last">Since last download</SelectItem>
|
||||||
|
<SelectItem value="date">Since date…</SelectItem>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{sinceMode === 'date' && (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
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'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<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="Insert confirmed QSOs that aren't in your log yet">
|
||||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||||
Add not-found
|
Add not-found
|
||||||
@@ -286,6 +599,7 @@ export function QSLManagerPanel() {
|
|||||||
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react';
|
||||||
|
|
||||||
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
menu: QSOMenuState;
|
||||||
|
onClose: () => 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||||
|
{ service: 'qrz', label: 'Send to QRZ.com' },
|
||||||
|
{ service: 'clublog', label: 'Send to Club Log' },
|
||||||
|
{ service: 'hrdlog', label: 'Send to HRDLog.net' },
|
||||||
|
{ service: 'eqsl', label: 'Send to eQSL.cc' },
|
||||||
|
{ service: 'lotw', label: 'Send to 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. 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 }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const close = () => onClose();
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('mousedown', close);
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousedown', close);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [menu, onClose]);
|
||||||
|
|
||||||
|
if (!menu) return null;
|
||||||
|
const n = menu.ids.length;
|
||||||
|
// Keep the menu on-screen near the cursor.
|
||||||
|
const x = Math.min(menu.x, window.innerWidth - 248);
|
||||||
|
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||||
|
style={{ left: x, top: y }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
{n} QSO{n > 1 ? 's' : ''} selected
|
||||||
|
</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 & zones from cty.dat</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>
|
||||||
|
</button>
|
||||||
|
{onUpdateFromClublog && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<BadgeCheck className="size-4 text-violet-600" />
|
||||||
|
<span>Update from ClubLog (exceptions)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(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>Send OpsLog QSL by e-mail</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>Send recording by e-mail</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={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<PencilLine className="size-4 text-indigo-600" />
|
||||||
|
<span>Bulk edit field… ({n})</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(onExportSelected || onExportFiltered) && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
{onExportSelected && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-sky-600" />
|
||||||
|
<span>Export selected to ADIF ({n})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onExportFiltered && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportFiltered(); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-violet-600" />
|
||||||
|
<span>Export filtered view to ADIF (no limit)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onSendTo && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
{UPLOAD_TARGETS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.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(); }}
|
||||||
|
>
|
||||||
|
<Upload className="size-4 text-emerald-600" />
|
||||||
|
<span>{t.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||||
import { LookupCallsign } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||||
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -13,11 +16,25 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { flagURL } from '@/lib/flags';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
|
|
||||||
|
// Quick prefix from a callsign (drops portable suffixes, keeps a slashed
|
||||||
|
// prefix). Read-only display, mirrors Log4OM's PFX box.
|
||||||
|
function pfxOf(call: string): string {
|
||||||
|
const c = (call || '').trim().toUpperCase();
|
||||||
|
if (!c) return '';
|
||||||
|
const base = c.includes('/') ? c.split('/')[0] : c;
|
||||||
|
let lastDigit = -1;
|
||||||
|
for (let i = 0; i < base.length; i++) if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
|
||||||
|
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||||
|
}
|
||||||
|
|
||||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
const BANDS = ['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'];
|
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||||
const QSL_STATUSES = [
|
const QSL_STATUSES = [
|
||||||
@@ -29,17 +46,59 @@ const QSL_STATUSES = [
|
|||||||
];
|
];
|
||||||
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||||
|
|
||||||
|
// Confirmation channels — each maps to its QSO sent/received status, dates and
|
||||||
|
// (paper-only) via fields. Drives the "Manage Confirmation" editor and the
|
||||||
|
// live status grid (Log4OM style).
|
||||||
|
type ConfDef = {
|
||||||
|
key: string; label: string;
|
||||||
|
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
||||||
|
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
||||||
|
via?: keyof QSOForm;
|
||||||
|
};
|
||||||
|
const CONFIRMATIONS: ConfDef[] = [
|
||||||
|
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
|
||||||
|
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||||
|
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||||
|
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||||
|
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||||
|
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Colour-coded status cell for the confirmation grid.
|
||||||
|
function StatusCell({ value }: { value?: string }) {
|
||||||
|
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 cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
||||||
|
: v === 'R' ? 'bg-orange-400 text-white'
|
||||||
|
: v === 'I' ? 'bg-stone-400 text-white'
|
||||||
|
: 'bg-amber-400 text-amber-950';
|
||||||
|
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
qso: QSO;
|
qso: QSO;
|
||||||
onSave: (q: QSO) => void;
|
onSave: (q: QSO) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
countries?: string[];
|
||||||
|
bands?: string[];
|
||||||
|
modes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocalISO(d: any): string {
|
function toLocalISO(d: any): string {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
const date = new Date(d);
|
const date = new Date(d);
|
||||||
if (isNaN(date.getTime())) return '';
|
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');
|
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())}`;
|
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
||||||
}
|
}
|
||||||
@@ -49,23 +108,6 @@ function parseLocalISO(s: string): string | null {
|
|||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
|
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
|
||||||
}
|
}
|
||||||
function stringifyExtras(e?: Record<string, string>): string {
|
|
||||||
if (!e) return '';
|
|
||||||
return Object.entries(e).map(([k, v]) => `${k} = ${v}`).join('\n');
|
|
||||||
}
|
|
||||||
function parseExtras(t: string): Record<string, string> | undefined {
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
for (const raw of t.split('\n')) {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
const idx = line.indexOf('=');
|
|
||||||
if (idx < 0) continue;
|
|
||||||
const k = line.slice(0, idx).trim().toUpperCase();
|
|
||||||
const v = line.slice(idx + 1).trim();
|
|
||||||
if (k && v) out[k] = v;
|
|
||||||
}
|
|
||||||
return Object.keys(out).length ? out : undefined;
|
|
||||||
}
|
|
||||||
function numOrUndef(v: any): number | undefined {
|
function numOrUndef(v: any): number | undefined {
|
||||||
if (v === '' || v === null || v === undefined) return undefined;
|
if (v === '' || v === null || v === undefined) return undefined;
|
||||||
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
||||||
@@ -96,21 +138,97 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||||
|
// 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.
|
||||||
|
const bandList = useMemo(() => {
|
||||||
|
const base = (bands && bands.length ? bands : BANDS).slice();
|
||||||
|
if (qso.band && !base.includes(qso.band)) base.unshift(qso.band);
|
||||||
|
return base;
|
||||||
|
}, [bands, qso.band]);
|
||||||
|
const modeList = useMemo(() => {
|
||||||
|
const base = (modes && modes.length ? modes : MODES).slice();
|
||||||
|
if (qso.mode && !base.includes(qso.mode)) base.unshift(qso.mode);
|
||||||
|
return base;
|
||||||
|
}, [modes, qso.mode]);
|
||||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||||
const [freqMhz, setFreqMhz] = useState(draft.freq_hz ? String(draft.freq_hz / 1_000_000) : '');
|
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||||
const [freqRxMhz, setFreqRxMhz] = useState(draft.freq_rx_hz ? String(draft.freq_rx_hz / 1_000_000) : '');
|
const splitHz = (hz?: number) => hz
|
||||||
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
|
: { khz: '', hz: '' };
|
||||||
|
const f0 = splitHz(draft.freq_hz);
|
||||||
|
const fr0 = splitHz(draft.freq_rx_hz);
|
||||||
|
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||||
|
const [freqHz, setFreqHz] = useState(f0.hz);
|
||||||
|
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
||||||
|
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
||||||
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
||||||
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
|
||||||
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
|
const [dateOff, setDateOff] = useState(dateOffISO);
|
||||||
|
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
|
||||||
|
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
||||||
const [localErr, setLocalErr] = useState('');
|
const [localErr, setLocalErr] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
|
// === Award references (Log4OM-style tab) ===
|
||||||
|
// Manual refs are edited as a "CODE@REF;…" string; computed refs (DXCC, WAZ,
|
||||||
|
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
||||||
|
const awardFieldRef = useRef<Record<string, string>>({});
|
||||||
|
const [awardRefs, setAwardRefs] = useState('');
|
||||||
|
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
||||||
|
|
||||||
|
// Load award definitions once, then seed the editable manual refs from the QSO.
|
||||||
|
useEffect(() => {
|
||||||
|
GetAwardDefs()
|
||||||
|
.then(async (defs) => {
|
||||||
|
const list = (defs ?? []) as any[];
|
||||||
|
const fieldOf: Record<string, string> = {};
|
||||||
|
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
|
||||||
|
awardFieldRef.current = fieldOf;
|
||||||
|
// Seed the editable manual refs from the backend, which already matched
|
||||||
|
// each reference against its award's own list. Seeding from the raw QSO
|
||||||
|
// field instead would wrongly seed every state-award (WAS/RAC/WAJA) from
|
||||||
|
// the same `state` value — e.g. a US "CA" would seed RAC@CA too.
|
||||||
|
try {
|
||||||
|
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
||||||
|
const seed = all
|
||||||
|
.filter((r: any) => r.pickable)
|
||||||
|
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
||||||
|
.join(';');
|
||||||
|
setAwardRefs(seed);
|
||||||
|
} catch { /* leave manual refs empty on failure */ }
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Recompute the read-only computed refs whenever a source field changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
||||||
|
setComputedRefs(all.filter((r: any) => !r.pickable).map((r: any) => ({ code: r.code, ref: r.ref, name: r.name })));
|
||||||
|
} catch { setComputedRefs([]); }
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
|
||||||
|
|
||||||
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||||
setDraft((d) => ({ ...d, [key]: value }));
|
setDraft((d) => ({ ...d, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Country drives the DXCC entity number (ADIF). The DXCC field is read-only;
|
||||||
|
// picking a Country resolves and stamps its DXCC# so they can't diverge.
|
||||||
|
async function onCountryChange(v: string) {
|
||||||
|
set('country', v);
|
||||||
|
try {
|
||||||
|
const n = await DXCCForCountry(v);
|
||||||
|
set('dxcc', (n && n > 0 ? n : undefined) as any);
|
||||||
|
} catch { /* leave DXCC as-is if resolution fails */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
|
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
|
||||||
// the draft — handy after correcting the callsign. Only overwrites the
|
// the draft — handy after correcting the callsign. Only overwrites the
|
||||||
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
// lookup-derived fields; leaves call/band/mode/RST/dates alone.
|
||||||
@@ -159,16 +277,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
operator: (draft.operator ?? '').trim().toUpperCase(),
|
operator: (draft.operator ?? '').trim().toUpperCase(),
|
||||||
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
|
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
|
||||||
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
|
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
|
||||||
iota: (draft.iota ?? '').trim().toUpperCase(),
|
// iota / sota_ref / pota_ref are set below from the Award Refs tab.
|
||||||
sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(),
|
|
||||||
pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(),
|
|
||||||
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
|
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
|
||||||
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
|
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
|
||||||
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
|
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
|
||||||
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
|
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
|
||||||
qso_date_off: parseLocalISO(dateOff) ?? undefined,
|
qso_date_off: endEnabled ? (parseLocalISO(dateOff) ?? undefined) : undefined,
|
||||||
freq_hz: freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined,
|
freq_hz: freqKHz.trim() ? parseInt(freqKHz, 10) * 1000 + (parseInt(freqHz, 10) || 0) : undefined,
|
||||||
freq_rx_hz: freqRxMhz.trim() ? Math.round(parseFloat(freqRxMhz) * 1_000_000) : undefined,
|
freq_rx_hz: freqRxKHz.trim() ? parseInt(freqRxKHz, 10) * 1000 + (parseInt(freqRxHz, 10) || 0) : undefined,
|
||||||
dxcc: intOrUndef(draft.dxcc),
|
dxcc: intOrUndef(draft.dxcc),
|
||||||
cqz: intOrUndef(draft.cqz),
|
cqz: intOrUndef(draft.cqz),
|
||||||
ituz: intOrUndef(draft.ituz),
|
ituz: intOrUndef(draft.ituz),
|
||||||
@@ -182,8 +298,18 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
|
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
|
||||||
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
|
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
|
||||||
tx_pwr: numOrUndef(draft.tx_pwr),
|
tx_pwr: numOrUndef(draft.tx_pwr),
|
||||||
extras: parseExtras(extrasText),
|
distance: numOrUndef(draft.distance),
|
||||||
|
rx_pwr: numOrUndef(draft.rx_pwr),
|
||||||
|
a_index: numOrUndef(draft.a_index),
|
||||||
|
k_index: numOrUndef(draft.k_index),
|
||||||
|
sfi: numOrUndef(draft.sfi),
|
||||||
|
extras: draft.extras && Object.keys(draft.extras).length ? draft.extras : undefined,
|
||||||
};
|
};
|
||||||
|
// The Award Refs tab is authoritative for the reference-list awards. Reset
|
||||||
|
// the dedicated columns, then route the picked refs back onto the payload
|
||||||
|
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
||||||
|
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
||||||
|
applyAwardRefs(out, awardRefs, awardFieldRef.current);
|
||||||
onSave(out);
|
onSave(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,17 +336,18 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="basic" className="flex flex-col overflow-hidden min-h-0">
|
<Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
|
||||||
<TabsList className="px-3 overflow-x-auto">
|
<TabsList className="px-3 overflow-x-auto">
|
||||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
<TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
|
||||||
<TabsTrigger value="contacted">Contacted</TabsTrigger>
|
<TabsTrigger value="contact">Contact's details</TabsTrigger>
|
||||||
<TabsTrigger value="qsl">QSL</TabsTrigger>
|
<TabsTrigger value="awards">Award Refs</TabsTrigger>
|
||||||
|
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
|
||||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||||
<TabsTrigger value="mystation">My station</TabsTrigger>
|
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
||||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
|
||||||
<TabsTrigger value="extras">
|
<TabsTrigger value="extras">
|
||||||
Extras
|
ADIF fields
|
||||||
{extrasCount > 0 && (
|
{extrasCount > 0 && (
|
||||||
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -234,106 +361,218 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto px-5 py-4 flex-1">
|
<div className="overflow-y-auto px-5 py-4 flex-1">
|
||||||
<TabsContent value="basic" className="mt-0">
|
<TabsContent value="qsoinfo" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
{/* Top: Callsign + RST + Fetch */}
|
||||||
<F label="Callsign" span={6}>
|
<div className="flex items-end gap-2 mb-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11 flex-1"
|
<Label>Callsign</Label>
|
||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||||
<Button type="button" variant="outline" className="h-11" onClick={fetchLookup} disabled={looking}
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, QTH, country, grid, zones…">
|
</div>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />}
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
Fetch
|
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
||||||
</Button>
|
<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
|
||||||
|
</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 className="flex items-center gap-2">
|
||||||
|
<Label className="w-20 shrink-0">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>
|
||||||
</F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
|
<Label className="w-20 shrink-0">RX Band</Label>
|
||||||
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
|
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||||
<F label="Band">
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
<SelectContent><SelectItem value="_">—</SelectItem>{bandList.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
</Select>
|
||||||
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
</div>
|
||||||
</Select>
|
<div className="flex items-center gap-2">
|
||||||
</F>
|
<Label className="w-20 shrink-0">Mode</Label>
|
||||||
<F label="Mode">
|
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||||
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectContent>{modeList.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
</Select>
|
||||||
</Select>
|
</div>
|
||||||
</F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="Submode"><Input value={draft.submode ?? ''} onChange={(e) => set('submode', e.target.value)} /></F>
|
<Label className="w-20 shrink-0">Country</Label>
|
||||||
<F label="Band RX">
|
<Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
|
||||||
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
onChange={onCountryChange} className="flex-1" />
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
<div className="flex items-center gap-2">
|
||||||
<SelectItem value="_">—</SelectItem>
|
<Label className="w-20 shrink-0">ITU</Label>
|
||||||
{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
|
<Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||||
</SelectContent>
|
<Label>CQ</Label>
|
||||||
</Select>
|
<Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
|
||||||
</F>
|
<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" />
|
||||||
<F label="Freq (MHz)"><Input value={freqMhz} onChange={(e) => setFreqMhz(e.target.value)} /></F>
|
{flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
|
||||||
<F label="Freq RX (MHz)"><Input value={freqRxMhz} onChange={(e) => setFreqRxMhz(e.target.value)} /></F>
|
</div>
|
||||||
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
|
<div className="flex items-center gap-2">
|
||||||
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
|
<Label className="w-20 shrink-0">Freq</Label>
|
||||||
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
||||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
</div>
|
||||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="w-20 shrink-0">RX Freq</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 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 className="flex items-center gap-2">
|
||||||
|
<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);
|
||||||
|
}} /> QSO End (UTC)
|
||||||
|
</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 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>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="contacted" className="mt-0">
|
<TabsContent value="contact" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
{/* Left column */}
|
||||||
<F label="QTH" span={3}><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></F>
|
<div className="flex flex-col gap-2.5">
|
||||||
<F label="Address" span={6}><Input value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></F>
|
<div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
|
||||||
<F label="Email" span={3}><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></F>
|
<div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
|
||||||
<F label="Web" span={3}><Input value={draft.web ?? ''} onChange={(e) => set('web', e.target.value)} /></F>
|
<div>
|
||||||
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
<Label>Continent</Label>
|
||||||
<F label="DXCC"><Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} /></F>
|
<Select value={draft.cont || '_'} onValueChange={(v) => set('cont', v === '_' ? '' : v)}>
|
||||||
<F label="Cont"><Input value={draft.cont ?? ''} onChange={(e) => set('cont', e.target.value)} /></F>
|
<SelectTrigger className="h-9"><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
<F label="CQ zone"><Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} /></F>
|
<SelectContent>
|
||||||
<F label="ITU zone"><Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} /></F>
|
<SelectItem value="_">—</SelectItem>
|
||||||
<F label="State"><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></F>
|
{['NA', 'SA', 'EU', 'AF', 'AS', 'OC', 'AN'].map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
<F label="County"><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></F>
|
</SelectContent>
|
||||||
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
</Select>
|
||||||
<F label="Grid ext"><Input value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} /></F>
|
</div>
|
||||||
<F label="VUCC grids" span={2}><Input value={draft.vucc_grids ?? ''} onChange={(e) => set('vucc_grids', e.target.value)} /></F>
|
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||||
<F label="IOTA"><Input value={draft.iota ?? ''} onChange={(e) => set('iota', e.target.value)} /></F>
|
<div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||||
<F label="SOTA ref"><Input value={draft.sota_ref ?? ''} onChange={(e) => set('sota_ref', e.target.value)} /></F>
|
</div>
|
||||||
<F label="POTA ref"><Input value={draft.pota_ref ?? ''} onChange={(e) => set('pota_ref', e.target.value)} /></F>
|
{/* Right column */}
|
||||||
<F label="Age"><Input type="number" value={draft.age ?? ''} onChange={(e) => set('age', intOrUndef(e.target.value) as any)} /></F>
|
<div className="flex flex-col gap-2.5">
|
||||||
<F label="Latitude"><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} /></F>
|
<div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
|
||||||
<F label="Longitude"><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} /></F>
|
<div className="flex items-end gap-2">
|
||||||
<F label="Rig (contacted)" span={3}><Input value={draft.rig ?? ''} onChange={(e) => set('rig', e.target.value)} /></F>
|
<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>
|
||||||
<F label="Antenna (contacted)" span={3}><Input value={draft.ant ?? ''} onChange={(e) => set('ant', e.target.value)} /></F>
|
<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>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="awards" className="mt-0">
|
||||||
|
<div className="grid grid-cols-[1fr_240px] gap-5">
|
||||||
|
{/* Left: pick reference-list awards (POTA/SOTA/IOTA/WWFF/…) */}
|
||||||
|
<div>
|
||||||
|
<AwardRefSelector dxcc={draft.dxcc} value={awardRefs} onChange={setAwardRefs} fieldValues={{ state: draft.state ?? '', cnty: draft.cnty ?? '' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
computedRefs.map((r) => (
|
||||||
|
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
|
||||||
|
<span className="font-mono font-semibold">{r.code}@{r.ref}</span>
|
||||||
|
{r.name && <span className="text-[10px] text-muted-foreground ml-1.5 truncate">{r.name}</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="qsl" className="mt-0">
|
<TabsContent value="qsl" className="mt-0">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
{(() => {
|
||||||
<F label="QSL sent"><QslSelect value={draft.qsl_sent ?? ''} onChange={(v) => set('qsl_sent', v)} /></F>
|
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||||
<F label="QSL rcvd"><QslSelect value={draft.qsl_rcvd ?? ''} onChange={(v) => set('qsl_rcvd', v)} /></F>
|
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||||
<F label="QSL sent date"><Input value={draft.qsl_sent_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_sent_date', e.target.value)} /></F>
|
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||||
<F label="QSL rcvd date"><Input value={draft.qsl_rcvd_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_rcvd_date', e.target.value)} /></F>
|
return (
|
||||||
<F label="QSL via" span={3}><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></F>
|
<div className="flex gap-6">
|
||||||
<F label="QSL message" span={3}><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></F>
|
{/* Left: edit one confirmation channel at a time */}
|
||||||
<F label="QSL message rcvd" span={6}><Input value={draft.qslmsg_rcvd ?? ''} onChange={(e) => set('qslmsg_rcvd', e.target.value)} /></F>
|
<div className="flex-1 max-w-sm space-y-3">
|
||||||
<F label="LoTW sent"><QslSelect value={draft.lotw_sent ?? ''} onChange={(v) => set('lotw_sent', v)} /></F>
|
<div>
|
||||||
<F label="LoTW rcvd"><QslSelect value={draft.lotw_rcvd ?? ''} onChange={(v) => set('lotw_rcvd', v)} /></F>
|
<Label>Manage Confirmation</Label>
|
||||||
<F label="LoTW sent date"><Input value={draft.lotw_sent_date ?? ''} onChange={(e) => set('lotw_sent_date', e.target.value)} /></F>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<F label="LoTW rcvd date"><Input value={draft.lotw_rcvd_date ?? ''} onChange={(e) => set('lotw_rcvd_date', e.target.value)} /></F>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<F label="eQSL sent"><QslSelect value={draft.eqsl_sent ?? ''} onChange={(v) => set('eqsl_sent', v)} /></F>
|
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</SelectContent>
|
||||||
<F label="eQSL rcvd"><QslSelect value={draft.eqsl_rcvd ?? ''} onChange={(v) => set('eqsl_rcvd', v)} /></F>
|
</Select>
|
||||||
<F label="eQSL sent date"><Input value={draft.eqsl_sent_date ?? ''} onChange={(e) => set('eqsl_sent_date', e.target.value)} /></F>
|
</div>
|
||||||
<F label="eQSL rcvd date"><Input value={draft.eqsl_rcvd_date ?? ''} onChange={(e) => set('eqsl_rcvd_date', e.target.value)} /></F>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<F label="Clublog status" span={2}><Input value={draft.clublog_qso_upload_status ?? ''} onChange={(e) => set('clublog_qso_upload_status', e.target.value)} /></F>
|
<div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||||
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F>
|
<div><Label>Received</Label>
|
||||||
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F>
|
{def.rcvd
|
||||||
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F>
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||||
<F label="QRZ.com status" span={2}><Input value={draft.qrzcom_qso_upload_status ?? ''} onChange={(e) => set('qrzcom_qso_upload_status', e.target.value)} /></F>
|
: <Input disabled value="—" />}
|
||||||
<F label="QRZ.com date"><Input value={draft.qrzcom_qso_upload_date ?? ''} onChange={(e) => set('qrzcom_qso_upload_date', e.target.value)} /></F>
|
</div>
|
||||||
</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>
|
||||||
|
{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>
|
||||||
|
<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>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: live status grid for every channel */}
|
||||||
|
<div className="w-72 shrink-0">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="contest" className="mt-0">
|
<TabsContent value="contest" className="mt-0">
|
||||||
@@ -372,7 +611,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', 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="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="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||||
<F label="Country" span={2}><Input value={draft.my_country ?? ''} onChange={(e) => set('my_country', 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="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="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="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||||
@@ -391,19 +630,74 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="notes" className="mt-0">
|
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
||||||
<div className="grid grid-cols-6 gap-3">
|
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
||||||
<F label="Comment" span={6}><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></F>
|
<div>
|
||||||
<F label="Notes" span={6}><Textarea rows={6} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></F>
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Power & propagation */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Power & space weather</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="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 & clubs</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="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>
|
||||||
|
<F label="DARC DOK"><Input value={draft.darc_dok ?? ''} onChange={(e) => set('darc_dok', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flags & credits */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags & credits</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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My station extras */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">My station (ADIF)</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="extras" className="mt-0 space-y-2">
|
<TabsContent value="extras" className="mt-0">
|
||||||
<p className="text-xs text-muted-foreground">
|
<AdifExtrasEditor value={draft.extras} onChange={(next) => set('extras', next as any)} />
|
||||||
ADIF fields not promoted to first-class columns. One per line:{' '}
|
|
||||||
<code className="bg-muted px-1 py-0.5 rounded font-mono">FIELD_NAME = value</code>
|
|
||||||
</p>
|
|
||||||
<Textarea rows={14} className="font-mono text-xs" value={extrasText} onChange={(e) => setExtrasText(e.target.value)} />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3 } from 'lucide-react';
|
import { Columns3, FilterX } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
@@ -44,7 +46,19 @@ type Props = {
|
|||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
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;
|
||||||
|
// 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';
|
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
@@ -65,8 +79,12 @@ function fmtDateUTC(s: any): string {
|
|||||||
}
|
}
|
||||||
function fmtDateOnly(s: any): string {
|
function fmtDateOnly(s: any): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = new Date(s);
|
const t = String(s).trim();
|
||||||
if (isNaN(d.getTime())) return s;
|
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
||||||
|
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||||
|
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||||
|
const d = new Date(t);
|
||||||
|
if (isNaN(d.getTime())) return t;
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
@@ -74,9 +92,11 @@ function fmtDateOnly(s: any): string {
|
|||||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||||
// = shown out of the box; anything else stays hidden until the user toggles
|
// = shown out of the box; anything else stays hidden until the user toggles
|
||||||
// it in the Columns dialog.
|
// it in the Columns dialog.
|
||||||
type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
export type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
|
||||||
|
|
||||||
const COL_CATALOG: ColEntry[] = [
|
// 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[] = [
|
||||||
// ── QSO basics ──
|
// ── 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 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: '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) },
|
||||||
@@ -85,8 +105,8 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
{ 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: '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: '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: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', 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: '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: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', 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: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
{ 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: '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: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
@@ -134,15 +154,22 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' 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 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: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R 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: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', 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 },
|
||||||
|
|
||||||
// ── Uploads ──
|
// ── Uploads (online logbooks) ──
|
||||||
{ group: 'Uploads', label: 'ClubLog upload date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog date', field: 'clublog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||||
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 },
|
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||||
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||||
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
|
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||||
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ 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: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
|
{ 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: 'QRZ.com confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 },
|
{ 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) },
|
||||||
|
|
||||||
// ── Contest ──
|
// ── Contest ──
|
||||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||||
@@ -190,22 +217,52 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
{ 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: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUP_ORDER = [
|
export const GROUP_ORDER = [
|
||||||
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
|
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
|
||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) {
|
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const ev = e.event as MouseEvent | undefined;
|
||||||
|
ev?.preventDefault();
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (e.node && !e.node.isSelected()) {
|
||||||
|
api.deselectAll();
|
||||||
|
e.node.setSelected(true);
|
||||||
|
}
|
||||||
|
const ids = (api.getSelectedRows() as QSOForm[])
|
||||||
|
.map((r) => r.id as number)
|
||||||
|
.filter((n) => !!n);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
|
||||||
|
}
|
||||||
|
|
||||||
// Compute initial column defs: all columns defined, but those not marked
|
// Compute initial column defs: all columns defined, but those not marked
|
||||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||||
// overrides this so a previously toggled column wins.
|
// overrides this so a previously toggled column wins.
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const base = COL_CATALOG.map((c) => {
|
||||||
return { ...rest, hide: !defaultVisible };
|
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: `${a.name} — reference this QSO counts for`,
|
||||||
|
width: 110,
|
||||||
|
cellClass: 'text-[11px]',
|
||||||
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
|
}));
|
||||||
|
return [...base, ...awards];
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -215,29 +272,40 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
try {
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
if (raw) {
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
const state = JSON.parse(raw) as ColumnState[];
|
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||||
if (Array.isArray(state)) {
|
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||||
e.api.applyColumnState({ state, applyOrder: true });
|
if (remote && !local) {
|
||||||
}
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
} catch {}
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
try {
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
if (!api) return;
|
||||||
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
|
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
@@ -281,6 +349,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<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
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||||
<Columns3 className="size-3.5" /> Columns
|
<Columns3 className="size-3.5" /> Columns
|
||||||
</Button>
|
</Button>
|
||||||
@@ -293,7 +365,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
rowData={rows}
|
rowData={rows}
|
||||||
columnDefs={columnDefs}
|
columnDefs={columnDefs}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
onGridReady={onGridReady}
|
onGridReady={onGridReady}
|
||||||
onColumnResized={saveColumnState}
|
onColumnResized={saveColumnState}
|
||||||
onColumnMoved={saveColumnState}
|
onColumnMoved={saveColumnState}
|
||||||
@@ -302,6 +374,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowDoubleClicked={handleRowDoubleClicked}
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
onSelectionChanged={onSelectionChanged}
|
onSelectionChanged={onSelectionChanged}
|
||||||
|
onCellContextMenu={onCellContextMenu}
|
||||||
|
preventDefaultOnContextMenu
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
@@ -309,6 +383,20 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QSOContextMenu
|
||||||
|
menu={menu}
|
||||||
|
onClose={() => setMenu(null)}
|
||||||
|
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||||
|
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||||
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
|
onSendTo={onSendTo}
|
||||||
|
onSendRecording={onSendRecording}
|
||||||
|
onSendEQSL={onSendEQSL}
|
||||||
|
onBulkEdit={onBulkEdit}
|
||||||
|
onExportSelected={onExportSelected}
|
||||||
|
onExportFiltered={onExportFiltered}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -318,7 +406,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
Your selection is saved.
|
Your selection is saved.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto py-2">
|
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||||
if (cols.length === 0) return null;
|
if (cols.length === 0) return null;
|
||||||
@@ -345,6 +433,26 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
|
|||||||
</div>
|
</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">Awards</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)); }}>all</button>
|
||||||
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// RotorCompass — an azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
|
||||||
|
//
|
||||||
|
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
|
||||||
|
// a green azimuth bezel. A green needle shows the antenna heading (two needles
|
||||||
|
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
||||||
|
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||||
|
// to turn the antenna there.
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||||
|
import { feature } from 'topojson-client';
|
||||||
|
import landTopo from 'world-atlas/land-110m.json';
|
||||||
|
import { Compass, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||||
|
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
||||||
|
const GRATICULE = geoGraticule10();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bearing?: number | null; // short-path azimuth to DX (deg)
|
||||||
|
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;
|
||||||
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE = 168;
|
||||||
|
const C = SIZE / 2;
|
||||||
|
const R = C - 6; // outer bezel radius
|
||||||
|
const MAP_R = R - 6; // map/clip radius (inside the bezel)
|
||||||
|
|
||||||
|
function pt(az: number, radius: number): [number, number] {
|
||||||
|
const a = ((az - 90) * Math.PI) / 180;
|
||||||
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
|
}
|
||||||
|
|
||||||
|
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' } ],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Project the world centred on the QTH (north up; antipode at the bezel).
|
||||||
|
const { land, grat } = useMemo(() => {
|
||||||
|
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
|
||||||
|
const proj = geoAzimuthalEquidistant()
|
||||||
|
.rotate([-centerLon, -centerLat])
|
||||||
|
.clipAngle(179.9)
|
||||||
|
.scale(MAP_R / Math.PI)
|
||||||
|
.translate([C, C]);
|
||||||
|
const path = geoPath(proj as any);
|
||||||
|
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
|
||||||
|
}, [centerLat, centerLon]);
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
|
if (!onGoto) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
|
||||||
|
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
|
||||||
|
az = ((az % 360) + 360) % 360;
|
||||||
|
onGoto(Math.round(az));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headLabel = headings.length ? headings[0] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||||
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
||||||
|
<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>
|
||||||
|
{onClose && (
|
||||||
|
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center p-2 min-h-0">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||||
|
style={{ width: SIZE, height: SIZE }}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
|
||||||
|
</defs>
|
||||||
|
{/* water + world map, clipped to the dial */}
|
||||||
|
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
|
||||||
|
<g clipPath="url(#rotorDial)">
|
||||||
|
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
|
||||||
|
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
|
||||||
|
</g>
|
||||||
|
{/* green azimuth bezel */}
|
||||||
|
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
|
||||||
|
|
||||||
|
{/* ticks every 10°, longer at 30° */}
|
||||||
|
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
|
||||||
|
const major = d % 30 === 0;
|
||||||
|
const [x1, y1] = pt(d, MAP_R);
|
||||||
|
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
|
||||||
|
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
|
||||||
|
})}
|
||||||
|
{/* cardinal labels + degree numbers at 45° */}
|
||||||
|
{cardinals.map(({ d, l }) => {
|
||||||
|
const [x, y] = pt(d, MAP_R - 13);
|
||||||
|
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* DX short-path bearing → small red marker on the bezel */}
|
||||||
|
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
|
||||||
|
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
||||||
|
); })()}
|
||||||
|
|
||||||
|
{/* 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} />
|
||||||
|
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
|
||||||
|
</g>
|
||||||
|
); })}
|
||||||
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Radio, Square, Send, Plug, Power, RefreshCw, X, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
|
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 { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface WKMacro { label: string; text: string }
|
||||||
|
export interface WKStatus {
|
||||||
|
connected: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
wpm: number;
|
||||||
|
version: number;
|
||||||
|
port: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: WKStatus;
|
||||||
|
ports: string[];
|
||||||
|
port: string;
|
||||||
|
wpm: number;
|
||||||
|
macros: WKMacro[];
|
||||||
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
|
onSelectPort: (p: string) => void;
|
||||||
|
onRefreshPorts: () => void;
|
||||||
|
onConnect: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onSetSpeed: (wpm: number) => void;
|
||||||
|
onSend: (text: string) => void; // raw text (App resolves variables)
|
||||||
|
onSendMacro: (index: number) => void; // App resolves the macro + sends
|
||||||
|
onStop: () => void;
|
||||||
|
onClose: () => void; // disable the keyer (hide the panel)
|
||||||
|
sendOnType: boolean;
|
||||||
|
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
|
||||||
|
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
|
||||||
|
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
|
||||||
|
export function WinkeyerPanel({
|
||||||
|
status, ports, port, wpm, macros, sent,
|
||||||
|
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||||
|
onSend, onSendMacro, onStop, onClose,
|
||||||
|
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||||
|
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||||
|
}: Props) {
|
||||||
|
const [cwText, setCwText] = useState('');
|
||||||
|
const [speed, setSpeed] = useState(wpm);
|
||||||
|
// Step the speed (compact +/- control replaces the old slider).
|
||||||
|
const changeSpeed = (delta: number) => {
|
||||||
|
const w = Math.max(5, Math.min(50, speed + delta));
|
||||||
|
setSpeed(w);
|
||||||
|
onSetSpeed(w);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the local speed in sync when the device/config changes it.
|
||||||
|
useEffect(() => { setSpeed(status.connected ? status.wpm || wpm : wpm); }, [status.wpm, status.connected, wpm]);
|
||||||
|
|
||||||
|
const connected = status.connected;
|
||||||
|
|
||||||
|
function sendText() {
|
||||||
|
const t = cwText.trim();
|
||||||
|
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
|
||||||
|
setCwText('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In "send on type" mode, key each newly-typed char immediately, and send a
|
||||||
|
// WinKeyer backspace for each deleted char (removes it from the buffer if it
|
||||||
|
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
|
||||||
|
function onCwChange(v: string) {
|
||||||
|
if (sendOnType && connected) {
|
||||||
|
const old = cwText;
|
||||||
|
if (v.length > old.length && v.startsWith(old)) {
|
||||||
|
onSendRaw(v.slice(old.length));
|
||||||
|
} else if (v.length < old.length && old.startsWith(v)) {
|
||||||
|
for (let i = 0; i < old.length - v.length; i++) onBackspace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCwText(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-2 h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
{/* Header / connection bar */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||||
|
<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'} />
|
||||||
|
<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>
|
||||||
|
<SelectContent>
|
||||||
|
{ports.length === 0 && <SelectItem value="_" disabled>No ports</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}>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
|
||||||
|
<Plug className="size-3.5" /> Connect
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
|
||||||
|
<Power className="size-3.5" /> Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Hide / disable WinKeyer" onClick={onClose}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5 px-3 pb-2 min-h-0 overflow-y-auto">
|
||||||
|
{/* Live transmitted text (echoed by the keyer) + compact speed stepper. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs w-8 shrink-0">TX</Label>
|
||||||
|
<div className={cn(
|
||||||
|
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
|
||||||
|
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{sent || <span className="opacity-50">—</span>}
|
||||||
|
{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)">
|
||||||
|
<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"
|
||||||
|
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"
|
||||||
|
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CW text */}
|
||||||
|
<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
|
||||||
|
<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)">
|
||||||
|
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||||
|
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||||
|
send on type
|
||||||
|
</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…'}
|
||||||
|
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'}
|
||||||
|
</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>
|
||||||
|
</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="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.">
|
||||||
|
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||||
|
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||||
|
Auto-call
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] text-muted-foreground">gap</span>
|
||||||
|
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
|
||||||
|
<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">click a CQ macro to loop it</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) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSendMacro(i)}
|
||||||
|
disabled={!connected}
|
||||||
|
title={m.text}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-left transition-colors',
|
||||||
|
connected ? 'hover:border-primary/60 hover:bg-accent/40' : 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, Star } from 'lucide-react';
|
import { Columns3, FilterX, Star } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
|
import { COL_CATALOG, GROUP_ORDER } from './RecentQSOsGrid';
|
||||||
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
@@ -37,23 +40,25 @@ const hamlogTheme = themeQuartz.withParams({
|
|||||||
iconSize: 12,
|
iconSize: 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number];
|
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
wb: WorkedBeforeView | null;
|
wb: WorkedBeforeView | null;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentCall: string;
|
currentCall: string;
|
||||||
|
onRowDoubleClicked?: (q: QSOForm) => 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;
|
||||||
|
// One column per defined award (cell = the reference this QSO counts for).
|
||||||
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1';
|
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
||||||
|
|
||||||
function fmtDateTime(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 `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
||||||
}
|
|
||||||
function fmtDate(s: any): string {
|
function fmtDate(s: any): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = new Date(s);
|
const d = new Date(s);
|
||||||
@@ -62,80 +67,67 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bandPill = (p: any) => p.value
|
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) {
|
||||||
? <span style={{
|
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
|
||||||
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
|
|
||||||
}}>{p.value}</span>
|
|
||||||
: '';
|
|
||||||
const modePill = (p: any) => p.value
|
|
||||||
? <span style={{
|
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
|
||||||
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
|
|
||||||
}}>{p.value}</span>
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
|
|
||||||
const flagRenderer = (p: any) => {
|
|
||||||
if (p.value === 'Y') {
|
|
||||||
return <span style={{
|
|
||||||
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
|
|
||||||
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
|
|
||||||
fontSize: 10, fontWeight: 700, lineHeight: '16px',
|
|
||||||
}}>Y</span>;
|
|
||||||
}
|
|
||||||
return <span style={{ color: '#a8a29e' }}>—</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
|
|
||||||
|
|
||||||
const COL_CATALOG: ColEntry[] = [
|
|
||||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
|
|
||||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
|
|
||||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
|
|
||||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
|
||||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
|
|
||||||
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
|
||||||
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
|
||||||
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
|
||||||
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
|
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
|
}
|
||||||
|
function onCellContextMenu(e: any) {
|
||||||
|
const ev = e.event as MouseEvent | undefined;
|
||||||
|
ev?.preventDefault();
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (e.node && !e.node.isSelected()) {
|
||||||
|
api.deselectAll();
|
||||||
|
e.node.setSelected(true);
|
||||||
|
}
|
||||||
|
const ids = (api.getSelectedRows() as WorkedEntry[])
|
||||||
|
.map((r) => r.id as number)
|
||||||
|
.filter((n) => !!n);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
|
||||||
|
}
|
||||||
|
|
||||||
const hasCall = currentCall.trim() !== '';
|
const hasCall = currentCall.trim() !== '';
|
||||||
const count = wb?.count ?? 0;
|
const count = wb?.count ?? 0;
|
||||||
const entries = wb?.entries ?? [];
|
const entries = wb?.entries ?? [];
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
|
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const base = COL_CATALOG.map((c) => {
|
||||||
return { ...rest, hide: !defaultVisible };
|
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: `${a.name} — reference this QSO counts for`,
|
||||||
|
width: 110,
|
||||||
|
cellClass: 'text-[11px]',
|
||||||
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
|
}));
|
||||||
|
return [...base, ...awards];
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
try {
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
const raw = localStorage.getItem(COL_STATE_KEY);
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
if (raw) {
|
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||||
const state = JSON.parse(raw) as ColumnState[];
|
if (remote && !local) {
|
||||||
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
} catch {}
|
});
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
try {
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
@@ -218,6 +210,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<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
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
|
||||||
<Columns3 className="size-3.5" /> Columns
|
<Columns3 className="size-3.5" /> Columns
|
||||||
</Button>
|
</Button>
|
||||||
@@ -237,6 +233,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
onColumnPinned={saveColumnState}
|
onColumnPinned={saveColumnState}
|
||||||
onColumnVisible={saveColumnState}
|
onColumnVisible={saveColumnState}
|
||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onCellContextMenu={onCellContextMenu}
|
||||||
|
preventDefaultOnContextMenu
|
||||||
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
@@ -244,6 +244,17 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QSOContextMenu
|
||||||
|
menu={menu}
|
||||||
|
onClose={() => setMenu(null)}
|
||||||
|
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||||
|
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||||
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
|
onSendTo={onSendTo}
|
||||||
|
onSendRecording={onSendRecording}
|
||||||
|
onSendEQSL={onSendEQSL}
|
||||||
|
/>
|
||||||
|
|
||||||
{count > entries.length && (
|
{count > entries.length && (
|
||||||
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
|
<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)
|
+ {count - entries.length} older QSOs (not shown — capped for performance)
|
||||||
@@ -251,19 +262,19 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Worked-before columns</DialogTitle>
|
<DialogTitle>Worked-before columns</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick the columns you want visible in the Worked-before table.
|
Pick the columns you want visible in the Worked-before table.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||||
if (cols.length === 0) return null;
|
if (cols.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
|
<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">
|
<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">{group}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
@@ -285,6 +296,26 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
|
|||||||
</div>
|
</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">Awards</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)); }}>all</button>
|
||||||
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 1–5 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
|
|||||||
// only an exact (case-insensitive) match — otherwise it reverts, so the field
|
// 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.
|
// can't hold a typo'd value that isn't in the list.
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
value, onChange, options, placeholder, className, allowFreeText = false,
|
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
@@ -14,6 +14,10 @@ export function Combobox({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
allowFreeText?: boolean;
|
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 [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -41,9 +45,13 @@ export function Combobox({
|
|||||||
// Defer so a click on an option registers first.
|
// Defer so a click on an option registers first.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
|
const trimmed = query.trim();
|
||||||
if (exact) { onChange(exact); setQuery(exact); }
|
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
|
||||||
else if (allowFreeText) { onChange(query.trim()); }
|
// 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
|
else { setQuery(value); } // revert typo
|
||||||
}, 120);
|
}, 120);
|
||||||
}
|
}
|
||||||
@@ -56,7 +64,7 @@ export function Combobox({
|
|||||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||||
// open the list (so tabbing in doesn't pop the dropdown).
|
// open the list (so tabbing in doesn't pop the dropdown).
|
||||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
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}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Shared helpers for per-QSO award references.
|
||||||
|
//
|
||||||
|
// In the UI a QSO's manually-assigned award references are edited as a single
|
||||||
|
// semicolon-delimited string of "CODE@REF" entries, e.g.
|
||||||
|
// "POTA@FR-11553;IOTA@EU-064"
|
||||||
|
// On save each entry is routed to the QSO field its award actually reads from
|
||||||
|
// (see internal/award/award.go): POTA/SOTA/IOTA have dedicated columns; WWFF
|
||||||
|
// and custom awards live in uppercase ADIF extras keys.
|
||||||
|
|
||||||
|
// parseAwardRefs turns "POTA@FR-11553;IOTA@EU-064" into
|
||||||
|
// { POTA: "FR-11553", IOTA: "EU-064" }. Repeated codes join with commas.
|
||||||
|
export function parseAwardRefs(v: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const entry of (v ?? '').split(';').filter(Boolean)) {
|
||||||
|
const at = entry.indexOf('@');
|
||||||
|
if (at <= 0) continue;
|
||||||
|
const code = entry.slice(0, at).toUpperCase();
|
||||||
|
const ref = entry.slice(at + 1).trim().toUpperCase();
|
||||||
|
if (!ref) continue;
|
||||||
|
out[code] = out[code] ? `${out[code]},${ref}` : ref;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendTokens adds space-separated tokens (a "A,B" ref string) to a text field,
|
||||||
|
// skipping any already present, so re-picking is idempotent.
|
||||||
|
function appendTokens(existing: string | undefined, refs: string): string {
|
||||||
|
let out = (existing ?? '').trim();
|
||||||
|
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
const re = new RegExp(`(^|\\s)${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, 'i');
|
||||||
|
if (!re.test(out)) out = out ? `${out} ${tok}` : tok;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
case 'sota_ref': payload.sota_ref = ref; break;
|
||||||
|
case 'pota_ref': payload.pota_ref = ref; break;
|
||||||
|
// Predefined-list awards on a QSO field (WAS/RAC/WAJA on state, JCC on
|
||||||
|
// county): picking a reference writes it straight into that column.
|
||||||
|
case 'state': payload.state = ref; break;
|
||||||
|
case 'cnty': payload.cnty = ref; break;
|
||||||
|
case 'wwff':
|
||||||
|
extras['WWFF_REF'] = ref;
|
||||||
|
extras['SIG'] = 'WWFF';
|
||||||
|
extras['SIG_INFO'] = ref;
|
||||||
|
break;
|
||||||
|
// 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:
|
||||||
|
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
|
||||||
|
// applyAwardRefs. Used to seed the editor when opening an existing QSO.
|
||||||
|
export function awardRefValue(qso: any, code: string, field: string): string {
|
||||||
|
switch (field) {
|
||||||
|
case 'iota': return (qso.iota ?? '').toUpperCase();
|
||||||
|
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
|
||||||
|
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
|
||||||
|
case 'state': return (qso.state ?? '').toUpperCase();
|
||||||
|
case 'cnty': return (qso.cnty ?? '').toUpperCase();
|
||||||
|
case 'wwff': {
|
||||||
|
const ex = qso.extras ?? {};
|
||||||
|
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
|
||||||
|
if (String(ex['SIG'] ?? '').toUpperCase() === 'WWFF') return String(ex['SIG_INFO'] ?? '').toUpperCase();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const ex = qso.extras ?? {};
|
||||||
|
return String(ex[field.toUpperCase()] ?? '').toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAwardRefs reconstructs the "CODE@REF;…" editor string from a QSO for the
|
||||||
|
// given pickable awards (code → field). Only awards with a stored value appear.
|
||||||
|
export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field: string }>): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const { code, field } of pickable) {
|
||||||
|
const v = awardRefValue(qso, code, field);
|
||||||
|
// A multi-reference field (n-fer POTA "US-6544,US-0680") becomes one
|
||||||
|
// editor entry per reference, so each shows on its own removable line.
|
||||||
|
for (const ref of v.split(/[,;]/).map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
out.push(`${code.toUpperCase()}@${ref}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.join(';');
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Portable grid-column preferences (visibility / order / width / sort).
|
||||||
|
//
|
||||||
|
// Stored in the DB settings table (so they travel with the logbook and
|
||||||
|
// survive a reinstall) AND mirrored to the WebView localStorage as a fast,
|
||||||
|
// flicker-free cache. On a fresh machine localStorage is empty, so we fall
|
||||||
|
// back to the DB copy and re-seed the cache.
|
||||||
|
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
|
// to apply instantly, before the async DB round-trip).
|
||||||
|
export function loadLocal(key: string): any[] | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
|
return Array.isArray(v) ? v : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||||
|
export async function loadRemote(key: string): Promise<any[] | null> {
|
||||||
|
try {
|
||||||
|
const v = await GetUIPref(key);
|
||||||
|
const parsed = v ? JSON.parse(v) : null;
|
||||||
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
||||||
|
export function saveState(key: string, state: any[]) {
|
||||||
|
const json = JSON.stringify(state);
|
||||||
|
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
||||||
|
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
|
export function seedLocal(key: string, state: any[]) {
|
||||||
|
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
@@ -49,6 +49,44 @@ export function gridToLatLon(grid: string): { lat: number; lon: number } | null
|
|||||||
return { lat, lon };
|
return { lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latLonToGrid encodes a lat/lon to a Maidenhead locator (default 6 chars).
|
||||||
|
// Inverse of gridToLatLon. Used to derive a default grid from a cty.dat entity
|
||||||
|
// centroid when no provider grid is available (e.g. a portable/rare DX call) —
|
||||||
|
// a centre-of-entity locator is more useful than an empty field.
|
||||||
|
export function latLonToGrid(lat: number, lon: number, precision = 6): string {
|
||||||
|
let adjLon = Math.min(359.9999, Math.max(0, lon + 180));
|
||||||
|
let adjLat = Math.min(179.9999, Math.max(0, lat + 90));
|
||||||
|
const A = 'A'.charCodeAt(0);
|
||||||
|
const a = 'a'.charCodeAt(0);
|
||||||
|
const fLon = Math.floor(adjLon / 20);
|
||||||
|
const fLat = Math.floor(adjLat / 10);
|
||||||
|
adjLon -= fLon * 20;
|
||||||
|
adjLat -= fLat * 10;
|
||||||
|
const sLon = Math.floor(adjLon / 2);
|
||||||
|
const sLat = Math.floor(adjLat);
|
||||||
|
let grid = String.fromCharCode(A + fLon) + String.fromCharCode(A + fLat) + sLon + sLat;
|
||||||
|
if (precision >= 6) {
|
||||||
|
adjLon -= sLon * 2;
|
||||||
|
adjLat -= sLat;
|
||||||
|
grid += String.fromCharCode(a + Math.min(23, Math.floor(adjLon * 12)))
|
||||||
|
+ String.fromCharCode(a + Math.min(23, Math.floor(adjLat * 24)));
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// gridSquareBounds returns the SW/NE corners of a Maidenhead square so a map
|
||||||
|
// can draw its outline. Half-extents shrink with locator precision.
|
||||||
|
export function gridSquareBounds(grid: string):
|
||||||
|
{ south: number; west: number; north: number; east: number } | null {
|
||||||
|
const c = gridToLatLon(grid);
|
||||||
|
if (!c) return null;
|
||||||
|
const g = grid.trim();
|
||||||
|
let dLon = 1, dLat = 0.5; // 4-char square: 2°×1°
|
||||||
|
if (g.length >= 6) { dLon = 1 / 24; dLat = 0.5 / 24; }
|
||||||
|
if (g.length >= 8) { dLon = 1 / 24 / 10; dLat = 0.5 / 24 / 10; }
|
||||||
|
return { south: c.lat - dLat, north: c.lat + dLat, west: c.lon - dLon, east: c.lon + dLon };
|
||||||
|
}
|
||||||
|
|
||||||
// PathInfo describes both short and long great-circle path between two
|
// PathInfo describes both short and long great-circle path between two
|
||||||
// points. Bearing in degrees from true north (0–360). Distance in km.
|
// points. Bearing in degrees from true north (0–360). Distance in km.
|
||||||
export interface PathInfo {
|
export interface PathInfo {
|
||||||
@@ -64,6 +102,16 @@ export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null {
|
|||||||
const a = gridToLatLon(fromGrid);
|
const a = gridToLatLon(fromGrid);
|
||||||
const b = gridToLatLon(toGrid);
|
const b = gridToLatLon(toGrid);
|
||||||
if (!a || !b) return null;
|
if (!a || !b) return null;
|
||||||
|
return pathBetweenLatLon(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathBetweenLatLon computes the great-circle path between two lat/lon points.
|
||||||
|
// Used as a fallback when a station has a known location (e.g. cty.dat entity
|
||||||
|
// coordinates for Svalbard) but no Maidenhead grid.
|
||||||
|
export function pathBetweenLatLon(
|
||||||
|
a: { lat: number; lon: number },
|
||||||
|
b: { lat: number; lon: number },
|
||||||
|
): PathInfo {
|
||||||
const φ1 = toRad(a.lat);
|
const φ1 = toRad(a.lat);
|
||||||
const φ2 = toRad(b.lat);
|
const φ2 = toRad(b.lat);
|
||||||
const Δλ = toRad(b.lon - a.lon);
|
const Δλ = toRad(b.lon - a.lon);
|
||||||
@@ -88,5 +136,56 @@ export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// greatCirclePoints returns n+1 [lat, lon] points along the short great-circle
|
||||||
|
// path between two lat/lon points (spherical slerp). Longitudes are unwrapped
|
||||||
|
// to stay continuous (no ±180 jump) so a map polyline draws as one smooth arc.
|
||||||
|
export function greatCirclePoints(
|
||||||
|
lat1: number, lon1: number, lat2: number, lon2: number, n = 96,
|
||||||
|
): [number, number][] {
|
||||||
|
const φ1 = toRad(lat1), λ1 = toRad(lon1);
|
||||||
|
const φ2 = toRad(lat2), λ2 = toRad(lon2);
|
||||||
|
// Angular distance between the two points.
|
||||||
|
const sinΔφ = Math.sin((φ2 - φ1) / 2);
|
||||||
|
const sinΔλ = Math.sin((λ2 - λ1) / 2);
|
||||||
|
const h = sinΔφ * sinΔφ + Math.cos(φ1) * Math.cos(φ2) * sinΔλ * sinΔλ;
|
||||||
|
const d = 2 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||||
|
const out: [number, number][] = [];
|
||||||
|
if (d === 0) return [[lat1, lon1]];
|
||||||
|
let prevLon = NaN;
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const f = i / n;
|
||||||
|
const A = Math.sin((1 - f) * d) / Math.sin(d);
|
||||||
|
const B = Math.sin(f * d) / Math.sin(d);
|
||||||
|
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
|
||||||
|
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
|
||||||
|
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
|
||||||
|
const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
|
||||||
|
let lon = toDeg(Math.atan2(y, x));
|
||||||
|
// Unwrap longitude so the polyline never snaps across the whole map.
|
||||||
|
if (!Number.isNaN(prevLon)) {
|
||||||
|
while (lon - prevLon > 180) lon -= 360;
|
||||||
|
while (lon - prevLon < -180) lon += 360;
|
||||||
|
}
|
||||||
|
prevLon = lon;
|
||||||
|
out.push([lat, lon]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
||||||
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
||||||
|
|
||||||
|
// destinationPoint returns the lat/lon reached from (lat,lon) by travelling
|
||||||
|
// distanceKm along the great circle at the given initial bearing. Used to draw
|
||||||
|
// the antenna beam lobe (a sector of bearings) on the world map.
|
||||||
|
export function destinationPoint(lat: number, lon: number, bearingDeg: number, distanceKm: number): { lat: number; lon: number } {
|
||||||
|
const delta = distanceKm / EARTH_KM;
|
||||||
|
const theta = toRad(bearingDeg);
|
||||||
|
const phi1 = toRad(lat), lam1 = toRad(lon);
|
||||||
|
const sinPhi2 = Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(theta);
|
||||||
|
const phi2 = Math.asin(Math.max(-1, Math.min(1, sinPhi2)));
|
||||||
|
const y = Math.sin(theta) * Math.sin(delta) * Math.cos(phi1);
|
||||||
|
const x = Math.cos(delta) - Math.sin(phi1) * sinPhi2;
|
||||||
|
const lam2 = lam1 + Math.atan2(y, x);
|
||||||
|
return { lat: toDeg(phi2), lon: ((toDeg(lam2) + 540) % 360) - 180 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Portable UI preferences.
|
||||||
|
//
|
||||||
|
// A handful of small UI prefs historically lived only in the WebView's
|
||||||
|
// localStorage, so they did NOT travel when the operator copied the OpsLog
|
||||||
|
// folder (data/) to another machine. These helpers mirror them into the DB
|
||||||
|
// settings table (ui.* keys, like the grid columns) so the whole setup is
|
||||||
|
// identical after a copy.
|
||||||
|
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||||
|
// localStorage copy is just a fast, synchronous cache).
|
||||||
|
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
|
||||||
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
|
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||||
|
'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:
|
||||||
|
// • DB has a value → copy it into localStorage (a copied folder restores it);
|
||||||
|
// • DB empty, local set → seed the DB from local (migrates the current value).
|
||||||
|
// Call it BEFORE the first render so the app's synchronous localStorage reads
|
||||||
|
// already see the portable values — no per-component hydration needed.
|
||||||
|
export async function syncPortablePrefs(): Promise<void> {
|
||||||
|
await Promise.all(PORTABLE_KEYS.map(async (key) => {
|
||||||
|
try {
|
||||||
|
const db = await GetUIPref(key);
|
||||||
|
const local = localStorage.getItem(key);
|
||||||
|
if (db != null && db !== '') {
|
||||||
|
if (db !== local) { try { localStorage.setItem(key, db); } catch { /* quota */ } }
|
||||||
|
} else if (local != null && local !== '') {
|
||||||
|
await SetUIPref(key, local);
|
||||||
|
}
|
||||||
|
} catch { /* backend not ready / no DB — keep whatever is in localStorage */ }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeUiPref write-throughs a value to the local cache AND the portable DB.
|
||||||
|
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||||
|
export function writeUiPref(key: string, value: string): void {
|
||||||
|
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||||
|
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
|
||||||
|
}
|
||||||
+11
-5
@@ -2,13 +2,19 @@ import React from 'react'
|
|||||||
import {createRoot} from 'react-dom/client'
|
import {createRoot} from 'react-dom/client'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { syncPortablePrefs } from './lib/uiPref'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
const root = createRoot(container!)
|
const root = createRoot(container!)
|
||||||
|
|
||||||
root.render(
|
// Pull portable UI prefs (DB → localStorage) before the first render so the
|
||||||
<React.StrictMode>
|
// app's synchronous reads see the values copied along with the data/ folder.
|
||||||
<App/>
|
// Render regardless of the outcome so a backend hiccup never blocks startup.
|
||||||
</React.StrictMode>
|
syncPortablePrefs().finally(() => {
|
||||||
)
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App/>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -50,11 +50,15 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
html, body, #root { height: 100%; }
|
html, body, #root { height: 100%; }
|
||||||
|
/* Root rem base. Tailwind sizing (text/heights/padding/gaps) is in rem, so
|
||||||
|
this is the single global density knob — shrinks the whole UI uniformly
|
||||||
|
while Leaflet maps (px-based) stay correct. Default browser base is 16px. */
|
||||||
|
html { font-size: 13px; }
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 13px;
|
font-size: 0.95rem;
|
||||||
line-height: 1.45;
|
line-height: 1.4;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.13';
|
||||||
|
|
||||||
|
// Author / credits, shown in Help -> About.
|
||||||
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
Vendored
+415
-5
@@ -1,24 +1,64 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {adif} from '../models';
|
||||||
import {qso} from '../models';
|
import {qso} from '../models';
|
||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {profile} from '../models';
|
|
||||||
import {adif} from '../models';
|
|
||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
|
import {profile} from '../models';
|
||||||
|
import {antgenius} from '../models';
|
||||||
|
import {award} from '../models';
|
||||||
|
import {awardref} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
|
import {powergenius} from '../models';
|
||||||
|
import {winkeyer} from '../models';
|
||||||
|
import {audio} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
|
|
||||||
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
|
export function ADIFVersion():Promise<string>;
|
||||||
|
|
||||||
export function ActivateProfile(arg1:number):Promise<void>;
|
export function ActivateProfile(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
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 ClearLookupCache():Promise<void>;
|
||||||
|
|
||||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||||
|
|
||||||
|
export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef>>;
|
||||||
|
|
||||||
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||||
|
|
||||||
export function ConnectAllClusters():Promise<void>;
|
export function ConnectAllClusters():Promise<void>;
|
||||||
@@ -27,8 +67,30 @@ export function ConnectClusterServer(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function CountQSO():Promise<number>;
|
export function CountQSO():Promise<number>;
|
||||||
|
|
||||||
|
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
|
||||||
|
|
||||||
|
export function CreateDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DVKCancelRecord():Promise<void>;
|
||||||
|
|
||||||
|
export function DVKPlay(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DVKPreview(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DVKStartRecord(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DVKStop():Promise<void>;
|
||||||
|
|
||||||
|
export function DVKStopRecord():Promise<void>;
|
||||||
|
|
||||||
|
export function DXCCForCountry(arg1:string):Promise<number>;
|
||||||
|
|
||||||
|
export function DXCCName(arg1:number):Promise<string>;
|
||||||
|
|
||||||
export function DeleteAllQSO():Promise<number>;
|
export function DeleteAllQSO():Promise<number>;
|
||||||
|
|
||||||
|
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function DeleteClusterServer(arg1:number):Promise<void>;
|
export function DeleteClusterServer(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DeleteOperatingAntenna(arg1:number):Promise<void>;
|
export function DeleteOperatingAntenna(arg1:number):Promise<void>;
|
||||||
@@ -39,44 +101,184 @@ export function DeleteProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DeleteQSO(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 DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DisconnectAllClusters():Promise<void>;
|
export function DisconnectAllClusters():Promise<void>;
|
||||||
|
|
||||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean):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>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
|
export function FilterFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||||
|
|
||||||
|
export function FlexATUBypass():Promise<void>;
|
||||||
|
|
||||||
|
export function FlexATUStart():Promise<void>;
|
||||||
|
|
||||||
|
export function FlexAmpOperate(arg1:boolean):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 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 FlexSetSidetoneLevel(arg1:number):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 GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
|
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>>;
|
||||||
|
|
||||||
|
export function GetAwardPresets():Promise<Array<awardref.Preset>>;
|
||||||
|
|
||||||
|
export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
||||||
|
|
||||||
|
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||||
|
|
||||||
|
export function GetAwards():Promise<Array<award.Result>>;
|
||||||
|
|
||||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||||
|
|
||||||
export function GetCATSettings():Promise<main.CATSettings>;
|
export function GetCATSettings():Promise<main.CATSettings>;
|
||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
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>;
|
export function GetClusterAutoConnect():Promise<boolean>;
|
||||||
|
|
||||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||||
|
|
||||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
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>;
|
||||||
|
|
||||||
|
export function GetDataDir():Promise<string>;
|
||||||
|
|
||||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||||
|
|
||||||
|
export function GetEmailSettings():Promise<main.EmailSettings>;
|
||||||
|
|
||||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||||
|
|
||||||
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
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>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
@@ -85,11 +287,65 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
|||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
|
|
||||||
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||||
|
|
||||||
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
|
|
||||||
|
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function IcomRefresh():Promise<void>;
|
||||||
|
|
||||||
|
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 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 IcomSetPreamp(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetRFGain(arg1:number):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 ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||||
|
|
||||||
|
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||||
|
|
||||||
|
export function ListAwardReferences(arg1:string):Promise<Array<awardref.Ref>>;
|
||||||
|
|
||||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||||
|
|
||||||
@@ -101,6 +357,10 @@ export function ListProfiles():Promise<Array<profile.Profile>>;
|
|||||||
|
|
||||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListSerialPorts():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
@@ -119,20 +379,80 @@ export function OpenExternalURL(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
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>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickOpenDatabase():Promise<string>;
|
export function PickOpenDatabase():Promise<string>;
|
||||||
|
|
||||||
export function PickSaveDatabase():Promise<string>;
|
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 QSOAudioRestart():Promise<boolean>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
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>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
|
|
||||||
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
@@ -143,22 +463,40 @@ export function RunBackupNow():Promise<string>;
|
|||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
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 SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
||||||
|
|
||||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):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 SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
||||||
|
|
||||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
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>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
@@ -169,32 +507,104 @@ export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
|
|
||||||
|
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
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 SendClusterCommand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SendClusterSpot(arg1:string,arg2:number,arg3: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 SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATMode(arg1:string):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>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
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 SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
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 TestLoTWUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function TestMySQLConnection(arg1:main.MySQLSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
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>;
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
|
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
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 UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerBackspace():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerConnect():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerDisconnect():Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerSend(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function WinkeyerStop():Promise<void>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1687
-53
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,21 @@ module hamlog
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/braheezy/shine-mp3 v0.1.0
|
||||||
github.com/go-ole/go-ole v1.3.0
|
github.com/go-ole/go-ole v1.3.0
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
|
github.com/moutend/go-wca v0.3.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
github.com/wneessen/go-mail v0.7.3
|
||||||
|
go.bug.st/serial v1.7.1
|
||||||
golang.org/x/net v0.35.0
|
golang.org/x/net v0.35.0
|
||||||
golang.org/x/sys v0.45.0
|
golang.org/x/sys v0.45.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.37.0
|
||||||
modernc.org/sqlite v1.50.1
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
||||||
|
github.com/braheezy/shine-mp3 v0.1.0/go.mod h1:0H/pmcpFAd+Fnrj6Pc7du7wL36U/HqtfcgPJuCgc1L4=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
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=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
@@ -40,6 +47,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/moutend/go-wca v0.3.0 h1:IzhsQ44zBzMdT42xlBjiLSVya9cPYOoKx9E+yXVhFo8=
|
||||||
|
github.com/moutend/go-wca v0.3.0/go.mod h1:7VrPO512jnjFGJ6rr+zOoCfiYjOHRPNfbttJuxAurcw=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
@@ -69,15 +78,20 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
|||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
|
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
|
||||||
|
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
|
||||||
|
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
|
||||||
|
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -88,11 +102,11 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
|||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loggers (notably Log4OM's UDP/exported ADIF) sometimes declare a field
|
||||||
|
// length as the CHARACTER count instead of the UTF-8 byte count, truncating
|
||||||
|
// multibyte values mid-rune. The parser must recover the full value.
|
||||||
|
func TestCharCountLengthRepair(t *testing.T) {
|
||||||
|
cases := []struct{ name, wantQTH, wantName, adi string }{
|
||||||
|
{
|
||||||
|
name: "latin",
|
||||||
|
wantQTH: "Tóalmás", // 7 chars / 9 bytes, declared 7
|
||||||
|
wantName: "Laci Budai",
|
||||||
|
adi: "<EOH>\n<CALL:5>HA5XY<QTH:7>Tóalmás<NAME:10>Laci Budai<EOR>\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cyrillic",
|
||||||
|
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
|
||||||
|
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
|
||||||
|
adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
var got Record
|
||||||
|
if err := Parse(strings.NewReader(c.adi), func(r Record) error { got = r; return nil }); err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if got["qth"] != c.wantQTH {
|
||||||
|
t.Errorf("qth = %q, want %q", got["qth"], c.wantQTH)
|
||||||
|
}
|
||||||
|
if got["name"] != c.wantName {
|
||||||
|
t.Errorf("name = %q, want %q", got["name"], c.wantName)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(got["name"]) || !utf8.ValidString(got["qth"]) {
|
||||||
|
t.Errorf("result not valid UTF-8: name=%q qth=%q", got["name"], got["qth"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-14
@@ -27,31 +27,61 @@ type Exporter struct {
|
|||||||
// AppName / AppVersion populate the ADIF header comments. Optional.
|
// AppName / AppVersion populate the ADIF header comments. Optional.
|
||||||
AppName string
|
AppName string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
|
|
||||||
|
// IncludeAppFields controls whether application-specific fields (ADIF
|
||||||
|
// "APP_<programid>_<name>" tags, e.g. Log4OM's APP_LOG4OM_* or our own
|
||||||
|
// OpsLog extensions) are written. Leave false for a clean standard-ADIF
|
||||||
|
// export destined for another logger; set true for a full OpsLog→OpsLog
|
||||||
|
// round-trip that preserves everything.
|
||||||
|
IncludeAppFields bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
||||||
|
// by-ids) all match this shape so the document writer stays source-agnostic.
|
||||||
|
type iterator func(ctx context.Context, fn func(qso.QSO) error) error
|
||||||
|
|
||||||
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
||||||
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
|
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
|
||||||
|
return e.exportFileWith(ctx, path, e.Repo.IterateAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportFileFiltered writes only the QSOs matching f (no row limit).
|
||||||
|
func (e *Exporter) ExportFileFiltered(ctx context.Context, path string, f qso.QueryFilter) (ExportResult, error) {
|
||||||
|
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
|
||||||
|
return e.Repo.IterateFiltered(ctx, f, fn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportFileByIDs writes only the QSOs with the given ids.
|
||||||
|
func (e *Exporter) ExportFileByIDs(ctx context.Context, path string, ids []int64) (ExportResult, error) {
|
||||||
|
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
|
||||||
|
return e.Repo.IterateByIDs(ctx, ids, fn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Exporter) exportFileWith(ctx context.Context, path string, iter iterator) (ExportResult, error) {
|
||||||
f, err := os.Create(path)
|
f, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
|
return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
count, err := e.Export(ctx, f)
|
count, err := e.writeDoc(ctx, f, iter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExportResult{Path: path, Count: count}, err
|
return ExportResult{Path: path, Count: count}, err
|
||||||
}
|
}
|
||||||
info, _ := f.Stat()
|
info, _ := f.Stat()
|
||||||
return ExportResult{
|
return ExportResult{Path: path, Count: count, SizeKB: info.Size() / 1024}, nil
|
||||||
Path: path,
|
|
||||||
Count: count,
|
|
||||||
SizeKB: info.Size() / 1024,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export writes a complete ADIF document (header + records + EOF) to w.
|
// Export writes a complete ADIF document (header + records + EOF) to w for
|
||||||
// Returns the number of QSOs successfully written.
|
// every QSO. Returns the number of QSOs written.
|
||||||
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||||
|
return e.writeDoc(ctx, w, e.Repo.IterateAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDoc writes the ADIF header then streams every QSO from iter.
|
||||||
|
func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (int, error) {
|
||||||
bw := bufio.NewWriterSize(w, 64*1024)
|
bw := bufio.NewWriterSize(w, 64*1024)
|
||||||
defer bw.Flush()
|
defer bw.Flush()
|
||||||
|
|
||||||
@@ -62,15 +92,15 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
|||||||
ver := strings.TrimSpace(e.AppVersion)
|
ver := strings.TrimSpace(e.AppVersion)
|
||||||
now := time.Now().UTC().Format("20060102 150405")
|
now := time.Now().UTC().Format("20060102 150405")
|
||||||
fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
|
fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
|
||||||
fmt.Fprintf(bw, "<ADIF_VER:5>3.1.0 <PROGRAMID:%d>%s", len(app), app)
|
fmt.Fprintf(bw, "<ADIF_VER:%d>%s <PROGRAMID:%d>%s", len(adifVersion), adifVersion, len(app), app)
|
||||||
if ver != "" {
|
if ver != "" {
|
||||||
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
|
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
|
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
|
err := iter(ctx, func(q qso.QSO) error {
|
||||||
writeRecord(bw, q)
|
writeRecord(bw, q, e.IncludeAppFields)
|
||||||
count++
|
count++
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -84,16 +114,36 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
|||||||
func SingleRecordADIF(q qso.QSO) string {
|
func SingleRecordADIF(q qso.QSO) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
writeRecord(bw, q)
|
// Uploads target other services — keep it standard (no app-specific tags).
|
||||||
|
writeRecord(bw, q, false)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.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>.
|
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||||
func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
||||||
// --- Core ---
|
// --- Core ---
|
||||||
writeField(bw, "CALL", q.Callsign)
|
writeField(bw, "CALL", q.Callsign)
|
||||||
|
|
||||||
@@ -217,9 +267,49 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
|||||||
writeField(bw, "COMMENT", q.Comment)
|
writeField(bw, "COMMENT", q.Comment)
|
||||||
writeField(bw, "NOTES", q.Notes)
|
writeField(bw, "NOTES", q.Notes)
|
||||||
|
|
||||||
|
// --- ADIF 3.1.7 additional promoted fields ---
|
||||||
|
writeField(bw, "SIG", q.SIG)
|
||||||
|
writeField(bw, "SIG_INFO", q.SIGInfo)
|
||||||
|
writeField(bw, "MY_SIG", q.MySIG)
|
||||||
|
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
|
||||||
|
writeField(bw, "WWFF_REF", q.WWFFRef)
|
||||||
|
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
|
||||||
|
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
|
||||||
|
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
|
||||||
|
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
|
||||||
|
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
|
||||||
|
writeFloatPtr(bw, "SFI", q.SFI, 0)
|
||||||
|
writeField(bw, "SKCC", q.SKCC)
|
||||||
|
writeField(bw, "FISTS", q.FISTS)
|
||||||
|
writeField(bw, "TEN_TEN", q.TenTen)
|
||||||
|
writeField(bw, "CONTACTED_OP", q.ContactedOp)
|
||||||
|
writeField(bw, "EQ_CALL", q.EqCall)
|
||||||
|
writeField(bw, "PFX", q.PFX)
|
||||||
|
writeField(bw, "MY_NAME", q.MyName)
|
||||||
|
writeField(bw, "CLASS", q.Class)
|
||||||
|
writeField(bw, "DARC_DOK", q.DarcDOK)
|
||||||
|
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
|
||||||
|
writeField(bw, "REGION", q.Region)
|
||||||
|
writeField(bw, "SILENT_KEY", q.SilentKey)
|
||||||
|
writeField(bw, "SWL", q.SWL)
|
||||||
|
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
|
||||||
|
writeField(bw, "QSO_RANDOM", q.QSORandom)
|
||||||
|
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
|
||||||
|
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
|
||||||
|
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
|
||||||
|
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
|
||||||
|
|
||||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||||
|
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
||||||
|
// application-specific tags AND any non-standard / vendor tag — keeping
|
||||||
|
// the file strictly portable to other loggers. Full mode keeps every
|
||||||
|
// extra for a lossless OpsLog round-trip.
|
||||||
for k, v := range q.Extras {
|
for k, v := range q.Extras {
|
||||||
writeField(bw, strings.ToUpper(k), v)
|
tag := strings.ToUpper(k)
|
||||||
|
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeField(bw, tag, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
bw.WriteString("<EOR>\n")
|
bw.WriteString("<EOR>\n")
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// This file embeds the complete ADIF 3.1.7 QSO-field dictionary. It is the
|
||||||
|
// single source of truth for:
|
||||||
|
// - the generic "ADIF fields" editor in the UI (any field becomes editable),
|
||||||
|
// - the "standard ADIF" export mode (only spec fields are emitted),
|
||||||
|
// - import diagnostics (a tag absent here is non-standard / vendor-specific).
|
||||||
|
//
|
||||||
|
// Promoted fields (those with dedicated QSO columns) are flagged Promoted=true
|
||||||
|
// so the UI can hide them from the generic editor — they already have proper
|
||||||
|
// inputs in the main tabs.
|
||||||
|
|
||||||
|
// FieldKind is a coarse input type used by the generic editor to pick a widget.
|
||||||
|
type FieldKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindText FieldKind = "text" // String / IntlString / MultilineString
|
||||||
|
KindNumber FieldKind = "number" // Number / PositiveInteger
|
||||||
|
KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
|
||||||
|
KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
|
||||||
|
KindBool FieldKind = "boolean" // Boolean (Y/N)
|
||||||
|
KindEnum FieldKind = "enum" // Enumeration
|
||||||
|
KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FieldDef describes one ADIF QSO field.
|
||||||
|
type FieldDef struct {
|
||||||
|
Name string `json:"name"` // canonical uppercase ADIF tag
|
||||||
|
Kind FieldKind `json:"kind"` // editor widget hint
|
||||||
|
Category string `json:"category"` // grouping for the UI
|
||||||
|
Promoted bool `json:"promoted"` // has a dedicated QSO column
|
||||||
|
Deprecated bool `json:"deprecated"` // import-only per the spec
|
||||||
|
Intl bool `json:"intl"` // *_INTL UTF-8 variant
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifVersion is the ADIF spec version OpsLog targets for import/export.
|
||||||
|
const adifVersion = "3.1.7"
|
||||||
|
|
||||||
|
// ADIFVersion returns the ADIF spec version OpsLog conforms to.
|
||||||
|
func ADIFVersion() string { return adifVersion }
|
||||||
|
|
||||||
|
// Fields is the full ADIF 3.1.7 QSO-field set. Order is alphabetical within
|
||||||
|
// each category; categories order is roughly "most-used first" for the UI.
|
||||||
|
var Fields = []FieldDef{
|
||||||
|
// ── Core / contact ──────────────────────────────────────────────
|
||||||
|
{Name: "CALL", Kind: KindText, Category: "Core", Promoted: true},
|
||||||
|
{Name: "QSO_DATE", Kind: KindDate, Category: "Core", Promoted: true},
|
||||||
|
{Name: "TIME_ON", Kind: KindTime, Category: "Core", Promoted: true},
|
||||||
|
{Name: "QSO_DATE_OFF", Kind: KindDate, Category: "Core", Promoted: true},
|
||||||
|
{Name: "TIME_OFF", Kind: KindTime, Category: "Core", Promoted: true},
|
||||||
|
{Name: "BAND", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||||
|
{Name: "BAND_RX", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||||
|
{Name: "MODE", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||||
|
{Name: "SUBMODE", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||||
|
{Name: "FREQ", Kind: KindNumber, Category: "Core", Promoted: true},
|
||||||
|
{Name: "FREQ_RX", Kind: KindNumber, Category: "Core", Promoted: true},
|
||||||
|
{Name: "RST_SENT", Kind: KindText, Category: "Core", Promoted: true},
|
||||||
|
{Name: "RST_RCVD", Kind: KindText, Category: "Core", Promoted: true},
|
||||||
|
{Name: "QSO_COMPLETE", Kind: KindEnum, Category: "Core", Promoted: true},
|
||||||
|
{Name: "QSO_RANDOM", Kind: KindBool, Category: "Core", Promoted: true},
|
||||||
|
{Name: "SWL", Kind: KindBool, Category: "Core", Promoted: true},
|
||||||
|
|
||||||
|
// ── Contacted station ───────────────────────────────────────────
|
||||||
|
{Name: "NAME", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "NAME_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||||
|
{Name: "QTH", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "QTH_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||||
|
{Name: "ADDRESS", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "ADDRESS_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||||
|
{Name: "EMAIL", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "WEB", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "GRIDSQUARE", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "GRIDSQUARE_EXT", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "VUCC_GRIDS", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "COUNTRY", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "COUNTRY_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||||
|
{Name: "STATE", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "CNTY", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "CNTY_ALT", Kind: KindEnum, Category: "Contacted"},
|
||||||
|
{Name: "DXCC", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "CONT", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "CQZ", Kind: KindNumber, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "ITUZ", Kind: KindNumber, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "IOTA", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "IOTA_ISLAND_ID", Kind: KindText, Category: "Contacted"},
|
||||||
|
{Name: "REGION", Kind: KindEnum, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "PFX", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "AGE", Kind: KindNumber, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "LAT", Kind: KindLoc, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "LON", Kind: KindLoc, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "ALTITUDE", Kind: KindNumber, Category: "Contacted"},
|
||||||
|
{Name: "DISTANCE", Kind: KindNumber, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "RIG", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "RIG_INTL", Kind: KindText, Category: "Contacted", Intl: true},
|
||||||
|
{Name: "ANT", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "CONTACTED_OP", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "EQ_CALL", Kind: KindText, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "GUEST_OP", Kind: KindText, Category: "Contacted", Deprecated: true},
|
||||||
|
{Name: "OWNER_CALLSIGN", Kind: KindText, Category: "Contacted"},
|
||||||
|
{Name: "SILENT_KEY", Kind: KindBool, Category: "Contacted", Promoted: true},
|
||||||
|
{Name: "USACA_COUNTIES", Kind: KindText, Category: "Contacted"},
|
||||||
|
|
||||||
|
// ── Special activity (POTA/SOTA/WWFF/SIG) ───────────────────────
|
||||||
|
{Name: "SIG", Kind: KindText, Category: "Activity", Promoted: true},
|
||||||
|
{Name: "SIG_INFO", Kind: KindText, Category: "Activity", Promoted: true},
|
||||||
|
{Name: "SIG_INTL", Kind: KindText, Category: "Activity", Intl: true},
|
||||||
|
{Name: "SIG_INFO_INTL", Kind: KindText, Category: "Activity", Intl: true},
|
||||||
|
{Name: "POTA_REF", Kind: KindText, Category: "Activity", Promoted: true},
|
||||||
|
{Name: "SOTA_REF", Kind: KindText, Category: "Activity", Promoted: true},
|
||||||
|
{Name: "WWFF_REF", Kind: KindText, Category: "Activity", Promoted: true},
|
||||||
|
|
||||||
|
// ── Power / propagation / space wx ──────────────────────────────
|
||||||
|
{Name: "TX_PWR", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "RX_PWR", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "A_INDEX", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "K_INDEX", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "SFI", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "PROP_MODE", Kind: KindEnum, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "SAT_NAME", Kind: KindText, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "SAT_MODE", Kind: KindText, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "ANT_AZ", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "ANT_EL", Kind: KindNumber, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "ANT_PATH", Kind: KindEnum, Category: "Propagation", Promoted: true},
|
||||||
|
{Name: "FORCE_INIT", Kind: KindBool, Category: "Propagation"},
|
||||||
|
{Name: "MAX_BURSTS", Kind: KindNumber, Category: "Propagation"},
|
||||||
|
{Name: "MS_SHOWER", Kind: KindText, Category: "Propagation"},
|
||||||
|
{Name: "NR_BURSTS", Kind: KindNumber, Category: "Propagation"},
|
||||||
|
{Name: "NR_PINGS", Kind: KindNumber, Category: "Propagation"},
|
||||||
|
|
||||||
|
// ── QSL / confirmations ─────────────────────────────────────────
|
||||||
|
{Name: "QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL"},
|
||||||
|
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL"},
|
||||||
|
{Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true},
|
||||||
|
{Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "LOTW_QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "LOTW_QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "LOTW_QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "LOTW_QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "EQSL_QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "EQSL_QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "EQSL_QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "EQSL_QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "EQSL_AG", Kind: KindBool, Category: "QSL"},
|
||||||
|
{Name: "DCL_QSL_SENT", Kind: KindEnum, Category: "QSL"},
|
||||||
|
{Name: "DCL_QSL_RCVD", Kind: KindEnum, Category: "QSL"},
|
||||||
|
{Name: "DCL_QSLSDATE", Kind: KindDate, Category: "QSL"},
|
||||||
|
{Name: "DCL_QSLRDATE", Kind: KindDate, Category: "QSL"},
|
||||||
|
{Name: "CLUBLOG_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "CLUBLOG_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "HRDLOG_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "HRDLOG_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QRZCOM_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||||
|
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||||
|
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||||
|
{Name: "HAMQTH_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||||
|
|
||||||
|
// ── Awards / credits ────────────────────────────────────────────
|
||||||
|
{Name: "CREDIT_SUBMITTED", Kind: KindText, Category: "Awards", Promoted: true},
|
||||||
|
{Name: "CREDIT_GRANTED", Kind: KindText, Category: "Awards", Promoted: true},
|
||||||
|
{Name: "AWARD_SUBMITTED", Kind: KindText, Category: "Awards"},
|
||||||
|
{Name: "AWARD_GRANTED", Kind: KindText, Category: "Awards"},
|
||||||
|
|
||||||
|
// ── Contest ─────────────────────────────────────────────────────
|
||||||
|
{Name: "CONTEST_ID", Kind: KindText, Category: "Contest", Promoted: true},
|
||||||
|
{Name: "SRX", Kind: KindNumber, Category: "Contest", Promoted: true},
|
||||||
|
{Name: "STX", Kind: KindNumber, Category: "Contest", Promoted: true},
|
||||||
|
{Name: "SRX_STRING", Kind: KindText, Category: "Contest", Promoted: true},
|
||||||
|
{Name: "STX_STRING", Kind: KindText, Category: "Contest", Promoted: true},
|
||||||
|
{Name: "CHECK", Kind: KindText, Category: "Contest", Promoted: true},
|
||||||
|
{Name: "CLASS", Kind: KindText, Category: "Contest", Promoted: true},
|
||||||
|
{Name: "PRECEDENCE", Kind: KindText, Category: "Contest", Promoted: true},
|
||||||
|
{Name: "ARRL_SECT", Kind: KindEnum, Category: "Contest", Promoted: true},
|
||||||
|
|
||||||
|
// ── Club memberships ────────────────────────────────────────────
|
||||||
|
{Name: "SKCC", Kind: KindText, Category: "Clubs", Promoted: true},
|
||||||
|
{Name: "FISTS", Kind: KindNumber, Category: "Clubs", Promoted: true},
|
||||||
|
{Name: "FISTS_CC", Kind: KindNumber, Category: "Clubs"},
|
||||||
|
{Name: "TEN_TEN", Kind: KindNumber, Category: "Clubs", Promoted: true},
|
||||||
|
{Name: "UKSMG", Kind: KindNumber, Category: "Clubs"},
|
||||||
|
{Name: "DARC_DOK", Kind: KindText, Category: "Clubs", Promoted: true},
|
||||||
|
|
||||||
|
// ── Morse key (3.1.5+) ──────────────────────────────────────────
|
||||||
|
{Name: "MORSE_KEY_TYPE", Kind: KindEnum, Category: "Morse key"},
|
||||||
|
{Name: "MORSE_KEY_INFO", Kind: KindText, Category: "Morse key"},
|
||||||
|
|
||||||
|
// ── Misc / crypto ───────────────────────────────────────────────
|
||||||
|
{Name: "COMMENT", Kind: KindText, Category: "Misc", Promoted: true},
|
||||||
|
{Name: "COMMENT_INTL", Kind: KindText, Category: "Misc", Intl: true},
|
||||||
|
{Name: "NOTES", Kind: KindText, Category: "Misc", Promoted: true},
|
||||||
|
{Name: "NOTES_INTL", Kind: KindText, Category: "Misc", Intl: true},
|
||||||
|
{Name: "PUBLIC_KEY", Kind: KindText, Category: "Misc"},
|
||||||
|
{Name: "VE_PROV", Kind: KindText, Category: "Misc", Deprecated: true},
|
||||||
|
|
||||||
|
// ── My station / operator ───────────────────────────────────────
|
||||||
|
{Name: "STATION_CALLSIGN", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "OPERATOR", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_NAME", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_NAME_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_GRIDSQUARE", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_GRIDSQUARE_EXT", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_VUCC_GRIDS", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_COUNTRY", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_COUNTRY_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_STATE", Kind: KindEnum, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_CNTY", Kind: KindEnum, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_CNTY_ALT", Kind: KindEnum, Category: "My station"},
|
||||||
|
{Name: "MY_DXCC", Kind: KindEnum, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_CQ_ZONE", Kind: KindNumber, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_ITU_ZONE", Kind: KindNumber, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_IOTA", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_IOTA_ISLAND_ID", Kind: KindText, Category: "My station"},
|
||||||
|
{Name: "MY_SOTA_REF", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_POTA_REF", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_WWFF_REF", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_SIG", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_SIG_INFO", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_SIG_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_SIG_INFO_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_LAT", Kind: KindLoc, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_LON", Kind: KindLoc, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_ALTITUDE", Kind: KindNumber, Category: "My station"},
|
||||||
|
{Name: "MY_STREET", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_STREET_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_CITY", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_CITY_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_POSTAL_CODE", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_POSTAL_CODE_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_RIG", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_RIG_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_ANTENNA", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_ANTENNA_INTL", Kind: KindText, Category: "My station", Intl: true},
|
||||||
|
{Name: "MY_ARRL_SECT", Kind: KindEnum, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_USACA_COUNTIES", Kind: KindText, Category: "My station"},
|
||||||
|
{Name: "MY_DARC_DOK", Kind: KindText, Category: "My station", Promoted: true},
|
||||||
|
{Name: "MY_FISTS", Kind: KindNumber, Category: "My station"},
|
||||||
|
{Name: "MY_MORSE_KEY_TYPE", Kind: KindEnum, Category: "My station"},
|
||||||
|
{Name: "MY_MORSE_KEY_INFO", Kind: KindText, Category: "My station"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldIndex maps an uppercase ADIF tag to its definition for O(1) lookup.
|
||||||
|
var fieldIndex = func() map[string]FieldDef {
|
||||||
|
m := make(map[string]FieldDef, len(Fields))
|
||||||
|
for _, f := range Fields {
|
||||||
|
m[f.Name] = f
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// IsStandardField reports whether tag (any case) is a defined ADIF 3.1.7
|
||||||
|
// field. APP_* and USERDEF tags are non-standard and return false.
|
||||||
|
func IsStandardField(tag string) bool {
|
||||||
|
_, ok := fieldIndex[strings.ToUpper(strings.TrimSpace(tag))]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupField returns the definition for a tag (any case), ok=false if unknown.
|
||||||
|
func LookupField(tag string) (FieldDef, bool) {
|
||||||
|
f, ok := fieldIndex[strings.ToUpper(strings.TrimSpace(tag))]
|
||||||
|
return f, ok
|
||||||
|
}
|
||||||
+147
-1
@@ -20,6 +20,7 @@ import (
|
|||||||
type ImportResult struct {
|
type ImportResult struct {
|
||||||
Total int `json:"total"` // records found in the file
|
Total int `json:"total"` // records found in the file
|
||||||
Imported int `json:"imported"` // successfully inserted
|
Imported int `json:"imported"` // successfully inserted
|
||||||
|
Updated int `json:"updated"` // existing QSOs refreshed (update-duplicates mode)
|
||||||
Skipped int `json:"skipped"` // dropped (missing required fields)
|
Skipped int `json:"skipped"` // dropped (missing required fields)
|
||||||
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
||||||
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
||||||
@@ -36,6 +37,19 @@ type Importer struct {
|
|||||||
Repo *qso.Repo
|
Repo *qso.Repo
|
||||||
BatchSize int // 0 → 500
|
BatchSize int // 0 → 500
|
||||||
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
||||||
|
// UpdateDuplicates, when true, takes precedence over SkipDuplicates:
|
||||||
|
// a record matching an existing QSO MERGES its non-empty fields onto
|
||||||
|
// that QSO (refreshes QSL/confirmation statuses on re-sync) instead of
|
||||||
|
// being skipped or re-inserted.
|
||||||
|
UpdateDuplicates bool
|
||||||
|
// Enrich, when set, is called on each parsed QSO before dedup/insert.
|
||||||
|
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
|
||||||
|
// source file (common with contest loggers) is corrected on the way in.
|
||||||
|
Enrich func(*qso.QSO)
|
||||||
|
// OnProgress, when set, is called periodically with (processed, total)
|
||||||
|
// record counts so the UI can show a progress bar. total is an estimate
|
||||||
|
// from counting <EOR> tags up front.
|
||||||
|
OnProgress func(processed, total int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportFile reads the file at path and imports it into the repo. The
|
// ImportFile reads the file at path and imports it into the repo. The
|
||||||
@@ -62,6 +76,14 @@ func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult,
|
|||||||
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
|
||||||
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
// two bytes, and the parser reading 7 bytes after the tag would chop the
|
||||||
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
|
||||||
|
// ValueDecoderFor returns the per-field byte decoder appropriate for a raw
|
||||||
|
// ADIF payload: identity when it's valid UTF-8, otherwise a Windows-1252
|
||||||
|
// decoder. Exposed so non-file ingest paths (UDP auto-log from Log4OM /
|
||||||
|
// JTAlert) transcode accented NAME/QTH fields the same way file import does.
|
||||||
|
func ValueDecoderFor(data []byte) func([]byte) string {
|
||||||
|
return pickValueDecoder(data)
|
||||||
|
}
|
||||||
|
|
||||||
func pickValueDecoder(data []byte) func([]byte) string {
|
func pickValueDecoder(data []byte) func([]byte) string {
|
||||||
if utf8.Valid(data) {
|
if utf8.Valid(data) {
|
||||||
return nil // identity
|
return nil // identity
|
||||||
@@ -97,6 +119,15 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
res := ImportResult{}
|
res := ImportResult{}
|
||||||
batch := make([]qso.QSO, 0, im.BatchSize)
|
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||||
|
|
||||||
|
// Up-front record-count estimate (count <EOR> tags, case-insensitive) so
|
||||||
|
// the UI progress bar has a denominator. Cheap single scan.
|
||||||
|
total := countEOR(data)
|
||||||
|
reportProgress := func(force bool) {
|
||||||
|
if im.OnProgress != nil && (force || res.Total%200 == 0) {
|
||||||
|
im.OnProgress(res.Total, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One upfront query for every existing dedup key — cheaper than N
|
// One upfront query for every existing dedup key — cheaper than N
|
||||||
// per-record EXISTS calls. The same map gets new keys appended as we
|
// per-record EXISTS calls. The same map gets new keys appended as we
|
||||||
// import so duplicates inside the file are caught too. Loaded
|
// import so duplicates inside the file are caught too. Loaded
|
||||||
@@ -107,6 +138,16 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
return res, fmt.Errorf("load dedupe keys: %w", err)
|
return res, fmt.Errorf("load dedupe keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update-duplicates mode needs the existing row's ID per key so it can
|
||||||
|
// fetch, merge and write it back. Loaded only when needed (extra query).
|
||||||
|
var keyIDs map[string]int64
|
||||||
|
if im.UpdateDuplicates {
|
||||||
|
keyIDs, err = im.Repo.DedupeKeyIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("load dedupe ids: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flush := func() error {
|
flush := func() error {
|
||||||
if len(batch) == 0 {
|
if len(batch) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -119,6 +160,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
|
|
||||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||||
res.Total++
|
res.Total++
|
||||||
|
reportProgress(false)
|
||||||
q, ok := recordToQSO(rec)
|
q, ok := recordToQSO(rec)
|
||||||
if !ok {
|
if !ok {
|
||||||
res.Skipped++
|
res.Skipped++
|
||||||
@@ -128,6 +170,9 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if im.Enrich != nil {
|
||||||
|
im.Enrich(&q)
|
||||||
|
}
|
||||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||||
if _, dup := seen[key]; dup {
|
if _, dup := seen[key]; dup {
|
||||||
res.Duplicates++
|
res.Duplicates++
|
||||||
@@ -138,6 +183,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
||||||
q.Band, q.Mode))
|
q.Band, q.Mode))
|
||||||
}
|
}
|
||||||
|
if im.UpdateDuplicates {
|
||||||
|
if id, ok := keyIDs[key]; ok {
|
||||||
|
existing, gerr := im.Repo.GetByID(ctx, id)
|
||||||
|
if gerr != nil {
|
||||||
|
if len(res.Errors) < maxErrors {
|
||||||
|
res.Errors = append(res.Errors,
|
||||||
|
fmt.Sprintf("record %d (%s): load existing: %v", res.Total, q.Callsign, gerr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qso.MergeNonZero(&existing, q)
|
||||||
|
if uerr := im.Repo.Update(ctx, existing); uerr != nil {
|
||||||
|
if len(res.Errors) < maxErrors {
|
||||||
|
res.Errors = append(res.Errors,
|
||||||
|
fmt.Sprintf("record %d (%s): update: %v", res.Total, q.Callsign, uerr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res.Updated++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if im.SkipDuplicates {
|
if im.SkipDuplicates {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -159,9 +226,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
|||||||
if err := flush(); err != nil {
|
if err := flush(); err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
reportProgress(true) // final 100%
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countEOR estimates the record count by counting case-insensitive <EOR>
|
||||||
|
// tags. Used only to give the import progress bar a denominator.
|
||||||
|
func countEOR(data []byte) int {
|
||||||
|
n := 0
|
||||||
|
for i := 0; i+4 <= len(data); i++ {
|
||||||
|
if data[i] != '<' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (data[i+1] == 'e' || data[i+1] == 'E') &&
|
||||||
|
(data[i+2] == 'o' || data[i+2] == 'O') &&
|
||||||
|
(data[i+3] == 'r' || data[i+3] == 'R') &&
|
||||||
|
(i+4 < len(data) && (data[i+4] == '>' || data[i+4] == ':')) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
||||||
// Anything not in this set ends up in Extras.
|
// Anything not in this set ends up in Extras.
|
||||||
var adifPromoted = stringSet(
|
var adifPromoted = stringSet(
|
||||||
@@ -178,7 +264,7 @@ var adifPromoted = stringSet(
|
|||||||
"age", "lat", "lon", "rig", "ant",
|
"age", "lat", "lon", "rig", "ant",
|
||||||
// QSL
|
// QSL
|
||||||
"qsl_sent", "qsl_rcvd",
|
"qsl_sent", "qsl_rcvd",
|
||||||
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd",
|
"qslsdate", "qslrdate", "qsl_via", "qsl_sent_via", "qsl_rcvd_via", "qslmsg", "qslmsg_rcvd",
|
||||||
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
||||||
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
||||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||||
@@ -198,6 +284,12 @@ var adifPromoted = stringSet(
|
|||||||
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
|
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
|
||||||
// Misc
|
// Misc
|
||||||
"tx_pwr", "comment", "notes",
|
"tx_pwr", "comment", "notes",
|
||||||
|
// ADIF 3.1.7 additional promoted fields
|
||||||
|
"sig", "sig_info", "my_sig", "my_sig_info", "wwff_ref", "my_wwff_ref",
|
||||||
|
"distance", "rx_pwr", "a_index", "k_index", "sfi",
|
||||||
|
"skcc", "fists", "ten_ten", "contacted_op", "eq_call", "pfx", "my_name", "class",
|
||||||
|
"darc_dok", "my_darc_dok", "region", "silent_key", "swl", "qso_complete", "qso_random",
|
||||||
|
"credit_granted", "credit_submitted", "my_arrl_sect", "my_vucc_grids",
|
||||||
)
|
)
|
||||||
|
|
||||||
func stringSet(items ...string) map[string]struct{} {
|
func stringSet(items ...string) map[string]struct{} {
|
||||||
@@ -253,6 +345,15 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
|
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
|
||||||
q.FreqRXHz = &hz
|
q.FreqRXHz = &hz
|
||||||
}
|
}
|
||||||
|
// RX defaults to TX when the ADIF omits split info: an empty BAND_RX /
|
||||||
|
// FREQ_RX means the contact wasn't cross-band/split, so RX = TX.
|
||||||
|
if q.BandRX == "" {
|
||||||
|
q.BandRX = q.Band
|
||||||
|
}
|
||||||
|
if q.FreqRXHz == nil && q.FreqHz != nil {
|
||||||
|
v := *q.FreqHz
|
||||||
|
q.FreqRXHz = &v
|
||||||
|
}
|
||||||
|
|
||||||
q.RSTSent = rec["rst_sent"]
|
q.RSTSent = rec["rst_sent"]
|
||||||
q.RSTRcvd = rec["rst_rcvd"]
|
q.RSTRcvd = rec["rst_rcvd"]
|
||||||
@@ -300,6 +401,9 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
q.QSLSentDate = rec["qslsdate"]
|
q.QSLSentDate = rec["qslsdate"]
|
||||||
q.QSLRcvdDate = rec["qslrdate"]
|
q.QSLRcvdDate = rec["qslrdate"]
|
||||||
q.QSLVia = rec["qsl_via"]
|
q.QSLVia = rec["qsl_via"]
|
||||||
|
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead
|
||||||
|
q.QSLVia = rec["qsl_sent_via"]
|
||||||
|
}
|
||||||
q.QSLMsg = rec["qslmsg"]
|
q.QSLMsg = rec["qslmsg"]
|
||||||
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
||||||
q.LOTWSent = rec["lotw_qsl_sent"]
|
q.LOTWSent = rec["lotw_qsl_sent"]
|
||||||
@@ -384,6 +488,48 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
q.Comment = rec["comment"]
|
q.Comment = rec["comment"]
|
||||||
q.Notes = rec["notes"]
|
q.Notes = rec["notes"]
|
||||||
|
|
||||||
|
// ADIF 3.1.7 additional promoted fields
|
||||||
|
q.SIG = rec["sig"]
|
||||||
|
q.SIGInfo = rec["sig_info"]
|
||||||
|
q.MySIG = rec["my_sig"]
|
||||||
|
q.MySIGInfo = rec["my_sig_info"]
|
||||||
|
q.WWFFRef = strings.ToUpper(rec["wwff_ref"])
|
||||||
|
q.MyWWFFRef = strings.ToUpper(rec["my_wwff_ref"])
|
||||||
|
if v, ok := parseFloat(rec["distance"]); ok {
|
||||||
|
q.Distance = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["rx_pwr"]); ok {
|
||||||
|
q.RXPower = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["a_index"]); ok {
|
||||||
|
q.AIndex = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["k_index"]); ok {
|
||||||
|
q.KIndex = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["sfi"]); ok {
|
||||||
|
q.SFI = &v
|
||||||
|
}
|
||||||
|
q.SKCC = rec["skcc"]
|
||||||
|
q.FISTS = rec["fists"]
|
||||||
|
q.TenTen = rec["ten_ten"]
|
||||||
|
q.ContactedOp = strings.ToUpper(rec["contacted_op"])
|
||||||
|
q.EqCall = strings.ToUpper(rec["eq_call"])
|
||||||
|
q.PFX = strings.ToUpper(rec["pfx"])
|
||||||
|
q.MyName = rec["my_name"]
|
||||||
|
q.Class = rec["class"]
|
||||||
|
q.DarcDOK = rec["darc_dok"]
|
||||||
|
q.MyDarcDOK = rec["my_darc_dok"]
|
||||||
|
q.Region = rec["region"]
|
||||||
|
q.SilentKey = strings.ToUpper(rec["silent_key"])
|
||||||
|
q.SWL = strings.ToUpper(rec["swl"])
|
||||||
|
q.QSOComplete = rec["qso_complete"]
|
||||||
|
q.QSORandom = strings.ToUpper(rec["qso_random"])
|
||||||
|
q.CreditGranted = rec["credit_granted"]
|
||||||
|
q.CreditSubmitted = rec["credit_submitted"]
|
||||||
|
q.MyARRLSect = strings.ToUpper(rec["my_arrl_sect"])
|
||||||
|
q.MyVUCCGrids = strings.ToUpper(rec["my_vucc_grids"])
|
||||||
|
|
||||||
// Everything else lands in extras (uppercased ADIF names).
|
// Everything else lands in extras (uppercased ADIF names).
|
||||||
var extras map[string]string
|
var extras map[string]string
|
||||||
for k, v := range rec {
|
for k, v := range rec {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Record is a single ADIF record. Keys are lowercased field names.
|
// Record is a single ADIF record. Keys are lowercased field names.
|
||||||
@@ -83,6 +84,17 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
if _, err := io.ReadFull(br, val); err != nil {
|
if _, err := io.ReadFull(br, val); err != nil {
|
||||||
return fmt.Errorf("read field %s: %w", name, err)
|
return fmt.Errorf("read field %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
// Repair character-count lengths. The ADIF spec says LENGTH is a
|
||||||
|
// byte count, but some loggers (notably Log4OM's UDP "ADIF
|
||||||
|
// message") write the CHARACTER count instead. For UTF-8 values
|
||||||
|
// with accented chars that truncates mid-rune — e.g. "<QTH:7>
|
||||||
|
// Tóalmás" is 9 bytes but says 7, leaving an orphan byte that
|
||||||
|
// renders as "Tóalm�". When we're in UTF-8 mode (no Windows-1252
|
||||||
|
// decoder) and the naive byte read isn't valid UTF-8, keep reading
|
||||||
|
// until the value holds `length` whole runes (or the next tag).
|
||||||
|
if decodeValue == nil && !utf8.Valid(val) {
|
||||||
|
val = extendToRunes(br, val, length)
|
||||||
|
}
|
||||||
if headerDone && name != "" {
|
if headerDone && name != "" {
|
||||||
if decodeValue != nil {
|
if decodeValue != nil {
|
||||||
rec[name] = decodeValue(val)
|
rec[name] = decodeValue(val)
|
||||||
@@ -94,6 +106,37 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extendToRunes recovers a value whose declared length was a character count
|
||||||
|
// rather than a byte count. `have` holds the first `wantRunes` BYTES of the
|
||||||
|
// value, which turned out to be invalid UTF-8 (a multibyte rune was cut). We
|
||||||
|
// append bytes from br until the value holds `wantRunes` complete runes — or
|
||||||
|
// until the next '<' (start of the following tag) / EOF, so we never cross
|
||||||
|
// into another field. Capped so a genuinely-corrupt value can't run away.
|
||||||
|
func extendToRunes(br *bufio.Reader, have []byte, wantRunes int) []byte {
|
||||||
|
const maxExtra = 8 // at most ~4 extra bytes/rune for the few cut runes
|
||||||
|
limit := len(have) + maxExtra*wantRunes + maxExtra
|
||||||
|
for len(have) < limit {
|
||||||
|
// Stop only when the value is complete UTF-8 (no partial trailing
|
||||||
|
// rune) AND holds enough runes. Checking utf8.RuneCount alone is a
|
||||||
|
// trap: a trailing orphan lead byte (e.g. the D0 of a cut Cyrillic
|
||||||
|
// "а") counts as one rune, so the loop would stop one continuation
|
||||||
|
// byte short → "Чайк�". Requiring utf8.Valid forces us to read it.
|
||||||
|
if utf8.Valid(have) && utf8.RuneCount(have) >= wantRunes {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b, err := br.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if b == '<' {
|
||||||
|
_ = br.UnreadByte() // belongs to the next tag — leave it
|
||||||
|
break
|
||||||
|
}
|
||||||
|
have = append(have, b)
|
||||||
|
}
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
||||||
// name is lowercased; length is 0 for control tags or when missing.
|
// name is lowercased; length is 0 for control tags or when missing.
|
||||||
func parseSpec(spec string) (name string, length int) {
|
func parseSpec(spec string) (name string, length int) {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPromotedFieldsRoundTrip writes a QSO carrying the ADIF 3.1.7 promoted
|
||||||
|
// fields, parses it back, and checks they survive — guarding the export
|
||||||
|
// writeRecord ↔ import recordToQSO field-name mapping against typos.
|
||||||
|
func TestPromotedFieldsRoundTrip(t *testing.T) {
|
||||||
|
dist := 1234.5
|
||||||
|
rxp := 5.0
|
||||||
|
a := 12.0
|
||||||
|
in := qso.QSO{
|
||||||
|
Callsign: "EA8ABC", Band: "20m", Mode: "SSB",
|
||||||
|
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
|
||||||
|
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
|
||||||
|
WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001",
|
||||||
|
Distance: &dist, RXPower: &rxp, AIndex: &a,
|
||||||
|
SKCC: "12345S", FISTS: "999", TenTen: "55555",
|
||||||
|
ContactedOp: "EA8XYZ", EqCall: "EA8OLD", PFX: "EA8", MyName: "Greg",
|
||||||
|
Class: "1A", DarcDOK: "A01", MyDarcDOK: "B02", Region: "IV",
|
||||||
|
SilentKey: "N", SWL: "N", QSOComplete: "Y", QSORandom: "Y",
|
||||||
|
CreditGranted: "DXCC", CreditSubmitted: "WAS",
|
||||||
|
MyARRLSect: "EMA", MyVUCCGrids: "FN20,FN21",
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
bw := bufio.NewWriter(&buf)
|
||||||
|
bw.WriteString("<EOH>\n")
|
||||||
|
writeRecord(bw, in, true)
|
||||||
|
bw.Flush()
|
||||||
|
|
||||||
|
var rec Record
|
||||||
|
if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
out, ok := recordToQSO(rec)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("recordToQSO returned !ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := map[string]struct{ got, want string }{
|
||||||
|
"SIG": {out.SIG, in.SIG},
|
||||||
|
"SIG_INFO": {out.SIGInfo, in.SIGInfo},
|
||||||
|
"MY_SIG": {out.MySIG, in.MySIG},
|
||||||
|
"MY_SIG_INFO": {out.MySIGInfo, in.MySIGInfo},
|
||||||
|
"WWFF_REF": {out.WWFFRef, in.WWFFRef},
|
||||||
|
"MY_WWFF_REF": {out.MyWWFFRef, in.MyWWFFRef},
|
||||||
|
"SKCC": {out.SKCC, in.SKCC},
|
||||||
|
"FISTS": {out.FISTS, in.FISTS},
|
||||||
|
"TEN_TEN": {out.TenTen, in.TenTen},
|
||||||
|
"CONTACTED_OP": {out.ContactedOp, in.ContactedOp},
|
||||||
|
"EQ_CALL": {out.EqCall, in.EqCall},
|
||||||
|
"PFX": {out.PFX, in.PFX},
|
||||||
|
"MY_NAME": {out.MyName, in.MyName},
|
||||||
|
"CLASS": {out.Class, in.Class},
|
||||||
|
"DARC_DOK": {out.DarcDOK, in.DarcDOK},
|
||||||
|
"MY_DARC_DOK": {out.MyDarcDOK, in.MyDarcDOK},
|
||||||
|
"REGION": {out.Region, in.Region},
|
||||||
|
"SILENT_KEY": {out.SilentKey, in.SilentKey},
|
||||||
|
"SWL": {out.SWL, in.SWL},
|
||||||
|
"QSO_COMPLETE": {out.QSOComplete, in.QSOComplete},
|
||||||
|
"QSO_RANDOM": {out.QSORandom, in.QSORandom},
|
||||||
|
"CREDIT_GRANTED": {out.CreditGranted, in.CreditGranted},
|
||||||
|
"CREDIT_SUBMITTED": {out.CreditSubmitted, in.CreditSubmitted},
|
||||||
|
"MY_ARRL_SECT": {out.MyARRLSect, in.MyARRLSect},
|
||||||
|
"MY_VUCC_GRIDS": {out.MyVUCCGrids, in.MyVUCCGrids},
|
||||||
|
}
|
||||||
|
for tag, c := range checks {
|
||||||
|
if c.got != c.want {
|
||||||
|
t.Errorf("%s round-trip = %q, want %q", tag, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Distance == nil || *out.Distance != dist {
|
||||||
|
t.Errorf("DISTANCE round-trip = %v, want %v", out.Distance, dist)
|
||||||
|
}
|
||||||
|
if out.RXPower == nil || *out.RXPower != rxp {
|
||||||
|
t.Errorf("RX_PWR round-trip = %v, want %v", out.RXPower, rxp)
|
||||||
|
}
|
||||||
|
if out.AIndex == nil || *out.AIndex != a {
|
||||||
|
t.Errorf("A_INDEX round-trip = %v, want %v", out.AIndex, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStandardExportDropsNonStandard verifies that standard mode strips
|
||||||
|
// vendor/APP tags while full mode keeps them.
|
||||||
|
func TestStandardExportDropsNonStandard(t *testing.T) {
|
||||||
|
q := qso.QSO{
|
||||||
|
Callsign: "F4BPO", Band: "20m", Mode: "CW",
|
||||||
|
Extras: map[string]string{
|
||||||
|
"APP_LOG4OM_FOO": "x",
|
||||||
|
"DARC_DOK": "A01", // standard → kept in both
|
||||||
|
"MY_VENDOR_TAG": "y", // non-standard → dropped in standard mode
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
standard := renderRecord(q, false)
|
||||||
|
if strings.Contains(standard, "APP_LOG4OM_FOO") || strings.Contains(standard, "MY_VENDOR_TAG") {
|
||||||
|
t.Errorf("standard export should drop non-standard tags:\n%s", standard)
|
||||||
|
}
|
||||||
|
|
||||||
|
full := renderRecord(q, true)
|
||||||
|
if !strings.Contains(full, "APP_LOG4OM_FOO") || !strings.Contains(full, "MY_VENDOR_TAG") {
|
||||||
|
t.Errorf("full export should keep all extras:\n%s", full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderRecord(q qso.QSO, includeApp bool) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
bw := bufio.NewWriter(&buf)
|
||||||
|
writeRecord(bw, q, includeApp)
|
||||||
|
bw.Flush()
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
if err := c.send(fmt.Sprintf("port set %d rxant=%d txant=%d", port, antenna, 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
// Package audio drives Windows audio endpoints via WASAPI (through go-ole /
|
||||||
|
// go-wca) — pure Go, no CGO, the same COM stack OmniRig already uses. It
|
||||||
|
// powers the Digital Voice Keyer (record/play voice messages to the rig) and
|
||||||
|
// the QSO recorder (rolling-buffer capture saved as WAV).
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/go-ole/go-ole"
|
||||||
|
"github.com/moutend/go-wca/pkg/wca"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Device is one audio endpoint (a capture input or a render output).
|
||||||
|
type Device struct {
|
||||||
|
ID string `json:"id"` // stable WASAPI endpoint id (persisted)
|
||||||
|
Name string `json:"name"` // friendly name shown in dropdowns
|
||||||
|
Default bool `json:"default"` // is this the system default endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInputDevices returns the active capture endpoints — microphones,
|
||||||
|
// line-in, and the soundcard input wired to the rig's audio out ("From Radio").
|
||||||
|
func ListInputDevices() ([]Device, error) { return listEndpoints(wca.ECapture) }
|
||||||
|
|
||||||
|
// ListOutputDevices returns the active render endpoints — speakers and the
|
||||||
|
// soundcard output wired to the rig's mic/data input ("To Radio").
|
||||||
|
func ListOutputDevices() ([]Device, error) { return listEndpoints(wca.ERender) }
|
||||||
|
|
||||||
|
// listEndpoints enumerates active endpoints for a data-flow direction. COM is
|
||||||
|
// thread-affine, so we lock the OS thread and Co(Un)Initialize around the work
|
||||||
|
// — this is a one-shot call from a Wails binding, not a long-lived session.
|
||||||
|
func listEndpoints(flow uint32) (out []Device, err error) {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
if e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); e != nil {
|
||||||
|
// 0x1 = S_FALSE → already initialised on this thread, fine.
|
||||||
|
if oe, ok := e.(*ole.OleError); !ok || oe.Code() != 0x00000001 {
|
||||||
|
return nil, fmt.Errorf("CoInitializeEx: %w", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer ole.CoUninitialize()
|
||||||
|
|
||||||
|
var mmde *wca.IMMDeviceEnumerator
|
||||||
|
if e := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL,
|
||||||
|
wca.IID_IMMDeviceEnumerator, &mmde); e != nil {
|
||||||
|
return nil, fmt.Errorf("create MMDeviceEnumerator: %w", e)
|
||||||
|
}
|
||||||
|
defer mmde.Release()
|
||||||
|
|
||||||
|
// Record the default endpoint id so the UI can flag it.
|
||||||
|
var defID string
|
||||||
|
var defDev *wca.IMMDevice
|
||||||
|
if e := mmde.GetDefaultAudioEndpoint(flow, wca.EConsole, &defDev); e == nil && defDev != nil {
|
||||||
|
_ = defDev.GetId(&defID)
|
||||||
|
defDev.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
var coll *wca.IMMDeviceCollection
|
||||||
|
if e := mmde.EnumAudioEndpoints(flow, wca.DEVICE_STATE_ACTIVE, &coll); e != nil {
|
||||||
|
return nil, fmt.Errorf("enum endpoints: %w", e)
|
||||||
|
}
|
||||||
|
defer coll.Release()
|
||||||
|
|
||||||
|
var count uint32
|
||||||
|
if e := coll.GetCount(&count); e != nil {
|
||||||
|
return nil, fmt.Errorf("count endpoints: %w", e)
|
||||||
|
}
|
||||||
|
for i := uint32(0); i < count; i++ {
|
||||||
|
var dev *wca.IMMDevice
|
||||||
|
if coll.Item(i, &dev) != nil || dev == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
_ = dev.GetId(&id)
|
||||||
|
name := endpointName(dev, id)
|
||||||
|
dev.Release()
|
||||||
|
out = append(out, Device{ID: id, Name: name, Default: id != "" && id == defID})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointName reads PKEY_Device_FriendlyName, falling back to the raw id.
|
||||||
|
func endpointName(dev *wca.IMMDevice, fallback string) string {
|
||||||
|
var ps *wca.IPropertyStore
|
||||||
|
if dev.OpenPropertyStore(wca.STGM_READ, &ps) != nil || ps == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
defer ps.Release()
|
||||||
|
var pv wca.PROPVARIANT
|
||||||
|
if ps.GetValue(&wca.PKEY_Device_FriendlyName, &pv) != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if s := pv.String(); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/go-ole/go-ole"
|
||||||
|
"github.com/moutend/go-wca/pkg/wca"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AUDCLNT_BUFFERFLAGS_SILENT — the capture packet is silent; emit zeros.
|
||||||
|
bufferFlagSilent uint32 = 0x1
|
||||||
|
// 1-second WASAPI buffer (REFERENCE_TIME is in 100-ns units).
|
||||||
|
bufferDuration100ns = 10_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
func coInit() error {
|
||||||
|
if e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); e != nil {
|
||||||
|
if oe, ok := e.(*ole.OleError); !ok || oe.Code() != 0x00000001 { // S_FALSE ok
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openDevice resolves an IMMDevice by endpoint id, falling back to the default
|
||||||
|
// endpoint for the flow when id is empty or not found. Caller must Release().
|
||||||
|
func openDevice(flow uint32, id string) (*wca.IMMDevice, error) {
|
||||||
|
var mmde *wca.IMMDeviceEnumerator
|
||||||
|
if err := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL,
|
||||||
|
wca.IID_IMMDeviceEnumerator, &mmde); err != nil {
|
||||||
|
return nil, fmt.Errorf("create enumerator: %w", err)
|
||||||
|
}
|
||||||
|
defer mmde.Release()
|
||||||
|
|
||||||
|
if id != "" {
|
||||||
|
var coll *wca.IMMDeviceCollection
|
||||||
|
if err := mmde.EnumAudioEndpoints(flow, wca.DEVICE_STATE_ACTIVE, &coll); err == nil && coll != nil {
|
||||||
|
defer coll.Release()
|
||||||
|
var count uint32
|
||||||
|
coll.GetCount(&count)
|
||||||
|
for i := uint32(0); i < count; i++ {
|
||||||
|
var dev *wca.IMMDevice
|
||||||
|
if coll.Item(i, &dev) != nil || dev == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var did string
|
||||||
|
dev.GetId(&did)
|
||||||
|
if did == id {
|
||||||
|
return dev, nil // caller owns it
|
||||||
|
}
|
||||||
|
dev.Release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var dev *wca.IMMDevice
|
||||||
|
if err := mmde.GetDefaultAudioEndpoint(flow, wca.EConsole, &dev); err != nil {
|
||||||
|
return nil, fmt.Errorf("no audio endpoint (id %q): %w", id, err)
|
||||||
|
}
|
||||||
|
return dev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pcmFormat is the fixed capture format (16 kHz mono 16-bit PCM). WASAPI's
|
||||||
|
// AUTOCONVERTPCM resamples from the device's native mix format for us.
|
||||||
|
func pcmFormat() *wca.WAVEFORMATEX {
|
||||||
|
return &wca.WAVEFORMATEX{
|
||||||
|
WFormatTag: 1, // WAVE_FORMAT_PCM
|
||||||
|
NChannels: channels,
|
||||||
|
NSamplesPerSec: sampleRate,
|
||||||
|
NAvgBytesPerSec: bytesPerSec,
|
||||||
|
NBlockAlign: blockAlign,
|
||||||
|
WBitsPerSample: bitsPerSample,
|
||||||
|
CbSize: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
|
||||||
|
|
||||||
|
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
||||||
|
// stop channel is closed.
|
||||||
|
func recordPCM(deviceID string, stop <-chan struct{}) ([]byte, error) {
|
||||||
|
out := make([]byte, 0, bytesPerSec*4)
|
||||||
|
err := captureStream(deviceID, stop, func(chunk []byte) { out = append(out, chunk...) })
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureStream opens a device and calls onChunk with freshly-captured 16 kHz
|
||||||
|
// mono 16-bit PCM as it arrives, until stop closes. onChunk receives a private
|
||||||
|
// copy it may retain. Runs on a COM-initialised, OS-locked thread.
|
||||||
|
func captureStream(deviceID string, stop <-chan struct{}, onChunk func([]byte)) error {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
if err := coInit(); err != nil {
|
||||||
|
return fmt.Errorf("CoInitialize: %w", err)
|
||||||
|
}
|
||||||
|
defer ole.CoUninitialize()
|
||||||
|
|
||||||
|
dev, err := openDevice(wca.ECapture, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dev.Release()
|
||||||
|
|
||||||
|
var ac *wca.IAudioClient
|
||||||
|
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
||||||
|
return fmt.Errorf("activate capture: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Release()
|
||||||
|
|
||||||
|
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
||||||
|
wca.REFERENCE_TIME(bufferDuration100ns), 0, pcmFormat(), nil); err != nil {
|
||||||
|
return fmt.Errorf("initialize capture: %w", err)
|
||||||
|
}
|
||||||
|
var acc *wca.IAudioCaptureClient
|
||||||
|
if err := ac.GetService(wca.IID_IAudioCaptureClient, &acc); err != nil {
|
||||||
|
return fmt.Errorf("get capture service: %w", err)
|
||||||
|
}
|
||||||
|
defer acc.Release()
|
||||||
|
|
||||||
|
if err := ac.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start capture: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
var packet uint32
|
||||||
|
if err := acc.GetNextPacketSize(&packet); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if packet == 0 {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for packet > 0 {
|
||||||
|
var data *byte
|
||||||
|
var frames, flags uint32
|
||||||
|
var devpos, qpcpos uint64
|
||||||
|
if err := acc.GetBuffer(&data, &frames, &flags, &devpos, &qpcpos); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n := int(frames) * blockAlign
|
||||||
|
if n > 0 {
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
if flags&bufferFlagSilent == 0 && data != nil {
|
||||||
|
copy(chunk, unsafe.Slice(data, n))
|
||||||
|
}
|
||||||
|
onChunk(chunk)
|
||||||
|
}
|
||||||
|
acc.ReleaseBuffer(frames)
|
||||||
|
if err := acc.GetNextPacketSize(&packet); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// playPCM renders raw PCM (with the given format) to a device, stopping early
|
||||||
|
// if the stop channel closes. Runs on a COM-initialised, OS-locked thread.
|
||||||
|
func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
||||||
|
if len(pcm) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
if err := coInit(); err != nil {
|
||||||
|
return fmt.Errorf("CoInitialize: %w", err)
|
||||||
|
}
|
||||||
|
defer ole.CoUninitialize()
|
||||||
|
|
||||||
|
dev, err := openDevice(wca.ERender, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dev.Release()
|
||||||
|
|
||||||
|
var ac *wca.IAudioClient
|
||||||
|
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
||||||
|
return fmt.Errorf("activate render: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Release()
|
||||||
|
|
||||||
|
frameBytes := ch * bits / 8
|
||||||
|
if frameBytes <= 0 {
|
||||||
|
return fmt.Errorf("bad audio format")
|
||||||
|
}
|
||||||
|
wfx := &wca.WAVEFORMATEX{
|
||||||
|
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
|
||||||
|
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
|
||||||
|
WBitsPerSample: uint16(bits), CbSize: 0,
|
||||||
|
}
|
||||||
|
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
||||||
|
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
|
||||||
|
return fmt.Errorf("initialize render: %w", err)
|
||||||
|
}
|
||||||
|
var bufFrames uint32
|
||||||
|
if err := ac.GetBufferSize(&bufFrames); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var arc *wca.IAudioRenderClient
|
||||||
|
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
|
||||||
|
return fmt.Errorf("get render service: %w", err)
|
||||||
|
}
|
||||||
|
defer arc.Release()
|
||||||
|
|
||||||
|
totalFrames := len(pcm) / frameBytes
|
||||||
|
written := 0
|
||||||
|
feed := func(maxFrames int) error {
|
||||||
|
if maxFrames <= 0 || written >= totalFrames {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := totalFrames - written
|
||||||
|
if n > maxFrames {
|
||||||
|
n = maxFrames
|
||||||
|
}
|
||||||
|
var data *byte
|
||||||
|
if err := arc.GetBuffer(uint32(n), &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst := unsafe.Slice(data, n*frameBytes)
|
||||||
|
copy(dst, pcm[written*frameBytes:(written+n)*frameBytes])
|
||||||
|
arc.ReleaseBuffer(uint32(n), 0)
|
||||||
|
written += n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill before starting to avoid an initial glitch.
|
||||||
|
if err := feed(int(bufFrames)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ac.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start render: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Stop()
|
||||||
|
|
||||||
|
for written < totalFrames {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
var padding uint32
|
||||||
|
ac.GetCurrentPadding(&padding)
|
||||||
|
if err := feed(int(bufFrames - padding)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(8 * time.Millisecond)
|
||||||
|
}
|
||||||
|
// Drain the remaining buffered audio.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
var padding uint32
|
||||||
|
if ac.GetCurrentPadding(&padding) != nil || padding == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
||||||
|
// one playback at a time. Device ids are passed per call so the host can route
|
||||||
|
// recording to the mic and playback to the rig (or the preview speakers).
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
recStop chan struct{}
|
||||||
|
recDone chan recResult
|
||||||
|
playStop chan struct{}
|
||||||
|
onChange func() // fired on any record/playback state transition
|
||||||
|
}
|
||||||
|
|
||||||
|
type recResult struct {
|
||||||
|
pcm []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a DVK manager. onChange (optional) is called whenever the
|
||||||
|
// recording/playback state changes, so the host can push an audio:status event.
|
||||||
|
func NewManager(onChange func()) *Manager { return &Manager{onChange: onChange} }
|
||||||
|
|
||||||
|
func (m *Manager) notify() {
|
||||||
|
if m.onChange != nil {
|
||||||
|
m.onChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartRecording begins capturing from deviceID into memory. Finish with
|
||||||
|
// StopRecording (which writes the WAV) or CancelRecording (which discards it).
|
||||||
|
func (m *Manager) StartRecording(deviceID string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.recStop != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("already recording")
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
done := make(chan recResult, 1)
|
||||||
|
m.recStop, m.recDone = stop, done
|
||||||
|
m.mu.Unlock() // release BEFORE notify — onChange re-enters via IsRecording()
|
||||||
|
go func() {
|
||||||
|
pcm, err := recordPCM(deviceID, stop)
|
||||||
|
done <- recResult{pcm, err}
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopRecording ends the capture and writes it to path as a WAV file.
|
||||||
|
func (m *Manager) StopRecording(path string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop, done := m.recStop, m.recDone
|
||||||
|
m.recStop, m.recDone = nil, nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop == nil {
|
||||||
|
return fmt.Errorf("not recording")
|
||||||
|
}
|
||||||
|
close(stop)
|
||||||
|
res := <-done
|
||||||
|
m.notify()
|
||||||
|
if res.err != nil {
|
||||||
|
return res.err
|
||||||
|
}
|
||||||
|
if len(res.pcm) == 0 {
|
||||||
|
return fmt.Errorf("captured no audio (check the recording device)")
|
||||||
|
}
|
||||||
|
return writeWAV(path, res.pcm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelRecording aborts a recording without saving.
|
||||||
|
func (m *Manager) CancelRecording() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop, done := m.recStop, m.recDone
|
||||||
|
m.recStop, m.recDone = nil, nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
|
m.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) IsRecording() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.recStop != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) IsPlaying() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.playStop != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play renders a WAV file to deviceID. Any current playback is stopped first.
|
||||||
|
// Returns immediately; playback runs in the background.
|
||||||
|
func (m *Manager) Play(deviceID, path string) error {
|
||||||
|
pcm, rate, ch, bits, err := readWAV(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.StopPlayback()
|
||||||
|
stop := make(chan struct{})
|
||||||
|
m.mu.Lock()
|
||||||
|
m.playStop = stop
|
||||||
|
m.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.playStop == stop {
|
||||||
|
m.playStop = nil
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.notify()
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPlayback halts any in-progress playback.
|
||||||
|
func (m *Manager) StopPlayback() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop := m.playStop
|
||||||
|
m.playStop = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
m.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mp3Rate is the encode sample rate. The capture pipeline is 16 kHz, but the
|
||||||
|
// Shine encoder emits broken "free-format" frames at MPEG-2 rates (16/22/24
|
||||||
|
// kHz) that most players reject. Encoding at an MPEG-1 rate (we upsample ×2 to
|
||||||
|
// 32 kHz) produces standard, universally-playable MP3s.
|
||||||
|
const mp3Rate = sampleRate * 2 // 32000
|
||||||
|
|
||||||
|
// writeMP3 encodes 16 kHz mono 16-bit PCM to a standard MP3 file using the
|
||||||
|
// pure-Go Shine encoder (no CGO). Two quirks are worked around:
|
||||||
|
// - 16 kHz (MPEG-2) yields broken free-format frames → upsample ×2 to 32 kHz.
|
||||||
|
// - Shine's Write only encodes half the samples for MONO input (its loop
|
||||||
|
// advances by samples_per_pass*2). Feeding STEREO interleaved data (the
|
||||||
|
// encoder reads samples_per_pass*channels per pass) encodes everything, so
|
||||||
|
// we duplicate mono → L=R stereo.
|
||||||
|
func writeMP3(path string, pcm []byte) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
mono32 := upsample2(bytesToInt16(pcm)) // 16 kHz → 32 kHz mono
|
||||||
|
stereo := make([]int16, len(mono32)*2) // L=R interleaved
|
||||||
|
for i, v := range mono32 {
|
||||||
|
stereo[2*i], stereo[2*i+1] = v, v
|
||||||
|
}
|
||||||
|
enc := mp3.NewEncoder(mp3Rate, 2)
|
||||||
|
return enc.Write(f, stereo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
|
||||||
|
func upsample2(in []int16) []int16 {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
out := make([]int16, len(in)*2)
|
||||||
|
for i := range in {
|
||||||
|
out[2*i] = in[i]
|
||||||
|
if i+1 < len(in) {
|
||||||
|
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
|
||||||
|
} else {
|
||||||
|
out[2*i+1] = in[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
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
|
||||||
|
// single mono track, so both sides of the contact are captured.
|
||||||
|
//
|
||||||
|
// Lifecycle: Start() runs capture+mix in the background. BeginQSO() snapshots
|
||||||
|
// the pre-roll and starts accumulating; SaveQSO() writes the WAV; DiscardQSO()
|
||||||
|
// drops it. Stop() tears down capture.
|
||||||
|
type Recorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
running bool
|
||||||
|
|
||||||
|
prerollSamples int
|
||||||
|
|
||||||
|
// Per-source sample queues (guarded by srcMu), drained by the mixer.
|
||||||
|
srcMu sync.Mutex
|
||||||
|
bufA []int16 // From Radio
|
||||||
|
bufB []int16 // mic
|
||||||
|
twoSrc bool
|
||||||
|
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||||
|
gainB float64 // mic gain
|
||||||
|
|
||||||
|
// Mixed output state (guarded by mu).
|
||||||
|
ring []int16 // last prerollSamples of mixed audio
|
||||||
|
active bool
|
||||||
|
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRecorder() *Recorder { return &Recorder{gainA: 1, gainB: 1} }
|
||||||
|
|
||||||
|
// SetGains sets the per-source mix levels (1.0 = unity). Use this to balance a
|
||||||
|
// hot mic against quieter rig RX audio. Values ≤0 fall back to unity.
|
||||||
|
func (r *Recorder) SetGains(fromGain, micGain float64) {
|
||||||
|
if fromGain <= 0 {
|
||||||
|
fromGain = 1
|
||||||
|
}
|
||||||
|
if micGain <= 0 {
|
||||||
|
micGain = 1
|
||||||
|
}
|
||||||
|
r.srcMu.Lock()
|
||||||
|
r.gainA, r.gainB = fromGain, micGain
|
||||||
|
r.srcMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleSample applies gain to a sample with clamping.
|
||||||
|
func scaleSample(s int16, g float64) int16 {
|
||||||
|
if g == 1 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
v := float64(s) * g
|
||||||
|
if v > 32767 {
|
||||||
|
return 32767
|
||||||
|
}
|
||||||
|
if v < -32768 {
|
||||||
|
return -32768
|
||||||
|
}
|
||||||
|
return int16(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Recorder) Running() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.running
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Recorder) Active() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins continuous capture from fromDev (required) mixed with micDev
|
||||||
|
// (optional — "" or same as fromDev → single source). prerollSec is how much
|
||||||
|
// audio to retain ahead of BeginQSO.
|
||||||
|
func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.running {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if prerollSec < 0 {
|
||||||
|
prerollSec = 0
|
||||||
|
}
|
||||||
|
r.prerollSamples = prerollSec * sampleRate
|
||||||
|
r.twoSrc = micDev != "" && micDev != fromDev
|
||||||
|
r.stopCh = make(chan struct{})
|
||||||
|
r.running = true
|
||||||
|
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
|
||||||
|
stop := r.stopCh
|
||||||
|
twoSrc := r.twoSrc
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Capture goroutine(s) feed the per-source queues.
|
||||||
|
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()
|
||||||
|
r.bufA = append(r.bufA, s...)
|
||||||
|
r.srcMu.Unlock()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
if twoSrc {
|
||||||
|
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()
|
||||||
|
r.bufB = append(r.bufB, s...)
|
||||||
|
r.srcMu.Unlock()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixer goroutine.
|
||||||
|
r.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer r.wg.Done()
|
||||||
|
defer recoverGoroutine("recorder mixer")
|
||||||
|
t := time.NewTicker(40 * time.Millisecond)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
r.mixTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||||
|
// ring + active accumulation.
|
||||||
|
func (r *Recorder) mixTick() {
|
||||||
|
r.srcMu.Lock()
|
||||||
|
var mixed []int16
|
||||||
|
if r.twoSrc {
|
||||||
|
n := len(r.bufA)
|
||||||
|
if len(r.bufB) < n {
|
||||||
|
n = len(r.bufB)
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
mixed = make([]int16, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
|
||||||
|
}
|
||||||
|
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
|
||||||
|
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
|
||||||
|
}
|
||||||
|
// Drift guard: if the clocks diverge, drop the excess so the two
|
||||||
|
// sources stay roughly aligned (≤1 s skew).
|
||||||
|
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
|
||||||
|
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
|
||||||
|
} else if d < -sampleRate {
|
||||||
|
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
|
||||||
|
}
|
||||||
|
} else if len(r.bufA) > 0 {
|
||||||
|
mixed = make([]int16, len(r.bufA))
|
||||||
|
for i, s := range r.bufA {
|
||||||
|
mixed[i] = scaleSample(s, r.gainA)
|
||||||
|
}
|
||||||
|
r.bufA = r.bufA[:0]
|
||||||
|
}
|
||||||
|
r.srcMu.Unlock()
|
||||||
|
|
||||||
|
if len(mixed) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.ring = append(r.ring, mixed...)
|
||||||
|
if len(r.ring) > r.prerollSamples {
|
||||||
|
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||||
|
}
|
||||||
|
if r.active {
|
||||||
|
r.acc = append(r.acc, mixed...)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginQSO starts accumulating a recording, seeded with the current pre-roll.
|
||||||
|
// No-op if already accumulating or not running.
|
||||||
|
func (r *Recorder) BeginQSO() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.running || r.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
|
r.active = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||||
|
// re-seeding from the pre-roll ring. Used when the target QSO changes (a new
|
||||||
|
// call+freq from a clicked spot or an external app) so the previous take is
|
||||||
|
// dropped and a new one starts from the pre-roll, rather than continuing to
|
||||||
|
// accumulate the old contact.
|
||||||
|
func (r *Recorder) RestartQSO() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.acc = append([]int16(nil), r.ring...)
|
||||||
|
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 nil, fmt.Errorf("no active recording")
|
||||||
|
}
|
||||||
|
samples := r.acc
|
||||||
|
r.acc, r.active = nil, false
|
||||||
|
r.mu.Unlock()
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return nil, fmt.Errorf("recording was empty")
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
r.acc, r.active = nil, false
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop tears down capture+mix.
|
||||||
|
func (r *Recorder) Stop() {
|
||||||
|
r.mu.Lock()
|
||||||
|
if !r.running {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.running = false
|
||||||
|
stop := r.stopCh
|
||||||
|
r.stopCh = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
close(stop)
|
||||||
|
r.wg.Wait()
|
||||||
|
r.mu.Lock()
|
||||||
|
r.ring, r.acc, r.active = nil, nil, false
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.srcMu.Lock()
|
||||||
|
r.bufA, r.bufB = nil, nil
|
||||||
|
r.srcMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampSum(a, b int16) int16 {
|
||||||
|
v := int32(a) + int32(b)
|
||||||
|
if v > 32767 {
|
||||||
|
return 32767
|
||||||
|
}
|
||||||
|
if v < -32768 {
|
||||||
|
return -32768
|
||||||
|
}
|
||||||
|
return int16(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToInt16(b []byte) []int16 {
|
||||||
|
out := make([]int16, len(b)/2)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = int16(binary.LittleEndian.Uint16(b[i*2:]))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func int16sToBytes(s []int16) []byte {
|
||||||
|
b := make([]byte, len(s)*2)
|
||||||
|
for i, v := range s {
|
||||||
|
binary.LittleEndian.PutUint16(b[i*2:], uint16(v))
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The DVK/recorder pipeline uses a single fixed PCM format end-to-end: 16 kHz
|
||||||
|
// mono 16-bit. That's plenty for SSB voice (3 kHz audio bandwidth), keeps files
|
||||||
|
// tiny (~32 KB/s), and — fed through WASAPI's AUTOCONVERTPCM — plays/records on
|
||||||
|
// any device regardless of its native mix format.
|
||||||
|
const (
|
||||||
|
sampleRate = 16000
|
||||||
|
channels = 1
|
||||||
|
bitsPerSample = 16
|
||||||
|
blockAlign = channels * bitsPerSample / 8 // bytes per frame (=2)
|
||||||
|
bytesPerSec = sampleRate * blockAlign // =32000
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeWAV writes 16-bit PCM as a canonical RIFF/WAVE file.
|
||||||
|
func writeWAV(path string, pcm []byte) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
dataLen := len(pcm)
|
||||||
|
put := func(v any) { _ = binary.Write(f, binary.LittleEndian, v) }
|
||||||
|
f.WriteString("RIFF")
|
||||||
|
put(uint32(36 + dataLen))
|
||||||
|
f.WriteString("WAVE")
|
||||||
|
f.WriteString("fmt ")
|
||||||
|
put(uint32(16)) // PCM fmt chunk size
|
||||||
|
put(uint16(1)) // WAVE_FORMAT_PCM
|
||||||
|
put(uint16(channels)) //
|
||||||
|
put(uint32(sampleRate)) //
|
||||||
|
put(uint32(bytesPerSec)) // byte rate
|
||||||
|
put(uint16(blockAlign)) //
|
||||||
|
put(uint16(bitsPerSample)) //
|
||||||
|
f.WriteString("data")
|
||||||
|
put(uint32(dataLen))
|
||||||
|
_, err = f.Write(pcm)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWAV reads a PCM WAV and returns the raw sample bytes plus its format.
|
||||||
|
// Handles arbitrary chunk ordering (walks the RIFF chunk list).
|
||||||
|
func readWAV(path string) (pcm []byte, rate, ch, bits int, err error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if len(b) < 12 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" {
|
||||||
|
return nil, 0, 0, 0, fmt.Errorf("not a WAVE file")
|
||||||
|
}
|
||||||
|
i := 12
|
||||||
|
for i+8 <= len(b) {
|
||||||
|
id := string(b[i : i+4])
|
||||||
|
size := int(binary.LittleEndian.Uint32(b[i+4 : i+8]))
|
||||||
|
body := i + 8
|
||||||
|
if body+size > len(b) {
|
||||||
|
size = len(b) - body
|
||||||
|
}
|
||||||
|
switch id {
|
||||||
|
case "fmt ":
|
||||||
|
if size >= 16 {
|
||||||
|
ch = int(binary.LittleEndian.Uint16(b[body+2 : body+4]))
|
||||||
|
rate = int(binary.LittleEndian.Uint32(b[body+4 : body+8]))
|
||||||
|
bits = int(binary.LittleEndian.Uint16(b[body+14 : body+16]))
|
||||||
|
}
|
||||||
|
case "data":
|
||||||
|
pcm = b[body : body+size]
|
||||||
|
}
|
||||||
|
i = body + size
|
||||||
|
if size%2 == 1 {
|
||||||
|
i++ // chunks are word-aligned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pcm == nil || rate == 0 {
|
||||||
|
return nil, 0, 0, 0, fmt.Errorf("WAV missing fmt/data")
|
||||||
|
}
|
||||||
|
return pcm, rate, ch, bits, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,970 @@
|
|||||||
|
// Package award computes amateur-radio award progress (worked / confirmed)
|
||||||
|
// directly from the logbook. An award is defined declaratively: a QSO FIELD to
|
||||||
|
// scan plus an optional regular-expression PATTERN that extracts the reference
|
||||||
|
// from that field. With no pattern the whole field value is the reference; with
|
||||||
|
// a pattern, capture group 1 (or the whole match) is the reference and a single
|
||||||
|
// QSO may yield several references (e.g. a Note holding "D74 D73").
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// DXCC : field "dxcc" (no pattern) → entity number
|
||||||
|
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
||||||
|
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
||||||
|
// WPX : field "prefix" (computed from callsign)
|
||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AwardType selects how a QSO is matched to an award's references.
|
||||||
|
//
|
||||||
|
// "DXCC" — match the QSO's DXCC entity number (references keyed by entity)
|
||||||
|
// "QSOFIELDS" — search a QSO field for a reference code/description/pattern
|
||||||
|
// "REFERENCE" — the reference is carried by a dedicated field (POTA_REF, …) or
|
||||||
|
// the per-reference DXCC list (e.g. RAC provinces by state)
|
||||||
|
// "GRID" — match a Maidenhead grid square
|
||||||
|
type AwardType = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeDXCC AwardType = "DXCC"
|
||||||
|
TypeQSOFields AwardType = "QSOFIELDS"
|
||||||
|
TypeReference AwardType = "REFERENCE"
|
||||||
|
TypeGrid AwardType = "GRID"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Def defines one award. Fields mirror Log4OM's Award Management model: an
|
||||||
|
// identity + scope (when the award applies) + a matching rule (how a QSO maps
|
||||||
|
// to a reference) + confirmation rules. Most fields are optional; the zero
|
||||||
|
// value of a legacy Def (only Field/Pattern/DXCCFilter/Confirm/Total set) still
|
||||||
|
// behaves as before.
|
||||||
|
type Def struct {
|
||||||
|
// --- Identity ---
|
||||||
|
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||||
|
Name string `json:"name"` // friendly name
|
||||||
|
Description string `json:"description,omitempty"` // free text
|
||||||
|
Valid bool `json:"valid"` // award enabled
|
||||||
|
Protected bool `json:"protected,omitempty"` // shipped/locked award
|
||||||
|
URL string `json:"url,omitempty"` // award home page
|
||||||
|
DownloadURL string `json:"download_url,omitempty"` // reference-list source
|
||||||
|
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
|
||||||
|
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||||
|
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||||
|
Alias string `json:"alias,omitempty"`
|
||||||
|
|
||||||
|
// --- Type & matching ---
|
||||||
|
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||||
|
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||||
|
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"` // award-level 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
|
||||||
|
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
||||||
|
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
|
||||||
|
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
|
||||||
|
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
|
||||||
|
|
||||||
|
// --- Confirmation ---
|
||||||
|
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||||
|
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||||
|
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||||
|
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||||
|
|
||||||
|
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||||
|
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
|
||||||
|
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
||||||
|
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
||||||
|
// accepts it (WAC).
|
||||||
|
lq := []string{"lotw", "qsl"}
|
||||||
|
lo := []string{"lotw"}
|
||||||
|
return []Def{
|
||||||
|
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate upgrades award definitions saved before the richer model existed.
|
||||||
|
// Such defs have Type=="" and the zero value for the new fields (notably
|
||||||
|
// Valid==false, which would otherwise hide every legacy award). For each legacy
|
||||||
|
// def it enables the award, fills the matching/confirmation fields from the
|
||||||
|
// matching built-in default (preserving the user's field/filters/confirm), and
|
||||||
|
// fixes the DDFM capture pattern. Returns the (possibly) migrated slice and
|
||||||
|
// whether anything changed. Idempotent: a def with Type!="" is left untouched.
|
||||||
|
func Migrate(defs []Def) ([]Def, bool) {
|
||||||
|
defaults := map[string]Def{}
|
||||||
|
for _, d := range Defaults() {
|
||||||
|
defaults[strings.ToUpper(d.Code)] = d
|
||||||
|
}
|
||||||
|
const oldDDFM = `(?i)\bD(\d{1,2}[AB]?)\b`
|
||||||
|
changed := false
|
||||||
|
out := make([]Def, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
if d.Type != "" {
|
||||||
|
out[i] = d // already on the new model
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
d.Valid = true // legacy defs predate the Valid flag → enable them
|
||||||
|
if def, ok := defaults[strings.ToUpper(d.Code)]; ok {
|
||||||
|
d.Type = def.Type
|
||||||
|
d.ExactMatch = def.ExactMatch
|
||||||
|
d.Dynamic = def.Dynamic
|
||||||
|
d.Protected = def.Protected
|
||||||
|
if len(d.Validate) == 0 {
|
||||||
|
d.Validate = def.Validate
|
||||||
|
}
|
||||||
|
// Fix DDFM's capture group ("06" → "D06") so refs match the list.
|
||||||
|
if strings.EqualFold(d.Code, "DDFM") && (d.Pattern == "" || d.Pattern == oldDDFM) {
|
||||||
|
d.Pattern = def.Pattern
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d.Type = TypeQSOFields // sensible default for custom legacy awards
|
||||||
|
}
|
||||||
|
out[i] = d
|
||||||
|
}
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields lists the scannable QSO fields for the award editor.
|
||||||
|
func Fields() []string {
|
||||||
|
return []string{
|
||||||
|
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||||
|
"state", "cont", "country", "grid", "grid4",
|
||||||
|
"iota", "sota_ref", "pota_ref", "wwff",
|
||||||
|
"name", "qth", "address", "comment", "note",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BandCount holds distinct-reference counts on one band.
|
||||||
|
type BandCount struct {
|
||||||
|
Band string `json:"band"`
|
||||||
|
Worked int `json:"worked"`
|
||||||
|
Confirmed int `json:"confirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref is one reference's status within an award.
|
||||||
|
type Ref struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
SubGrp string `json:"subgrp,omitempty"`
|
||||||
|
Worked bool `json:"worked"`
|
||||||
|
Confirmed bool `json:"confirmed"`
|
||||||
|
Validated bool `json:"validated"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
ConfirmedBands []string `json:"confirmed_bands"`
|
||||||
|
ValidatedBands []string `json:"validated_bands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is an award's computed progress.
|
||||||
|
type Result struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Field string `json:"field"`
|
||||||
|
Worked int `json:"worked"`
|
||||||
|
Confirmed int `json:"confirmed"`
|
||||||
|
Validated int `json:"validated"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Bands []BandCount `json:"bands"`
|
||||||
|
Refs []Ref `json:"refs"`
|
||||||
|
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
||||||
|
type NameResolver func(field, ref string) string
|
||||||
|
|
||||||
|
type refAgg struct {
|
||||||
|
bands map[string]struct{}
|
||||||
|
confirmedBands map[string]struct{}
|
||||||
|
validatedBands map[string]struct{}
|
||||||
|
anyConfirmed bool
|
||||||
|
anyValidated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// refList is the per-award reference data Compute needs (a thin view of
|
||||||
|
// awardref.Ref, kept local so the award package stays storage-agnostic).
|
||||||
|
type refList struct {
|
||||||
|
byCode map[string]RefMeta // uppercased code → metadata
|
||||||
|
codes []string // codes in input order (for stable unworked listing)
|
||||||
|
withPattern []string // codes whose reference declares a regex (usually none)
|
||||||
|
names []nameCode // (uppercased name → code) for MatchBy="description"
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameCode pairs a reference's uppercased description with its code, for
|
||||||
|
// description-based matching (e.g. WAJA finding a prefecture NAME in the QTH).
|
||||||
|
type nameCode struct{ name, code string }
|
||||||
|
|
||||||
|
// RefMeta is one reference's metadata for the engine: enough to enforce a
|
||||||
|
// predefined list, per-reference DXCC scoping, a per-reference pattern, and to
|
||||||
|
// label results.
|
||||||
|
type RefMeta struct {
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Group string
|
||||||
|
SubGrp string
|
||||||
|
DXCCList []int // nil = any
|
||||||
|
Pattern string
|
||||||
|
re *regexp.Regexp
|
||||||
|
Valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||||
|
func NewRefList(metas []RefMeta) refList {
|
||||||
|
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||||
|
for _, m := range metas {
|
||||||
|
code := normalizeRef(m.Code)
|
||||||
|
if code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||||
|
if re, err := regexp.Compile(p); err == nil {
|
||||||
|
m.re = re
|
||||||
|
rl.withPattern = append(rl.withPattern, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.Code = code
|
||||||
|
if _, dup := rl.byCode[code]; !dup {
|
||||||
|
rl.codes = append(rl.codes, code)
|
||||||
|
if nm := strings.ToUpper(strings.TrimSpace(m.Name)); nm != "" {
|
||||||
|
rl.names = append(rl.names, nameCode{name: nm, code: code})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.byCode[code] = m
|
||||||
|
}
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute runs every definition over the QSOs in a single pass. refMetas maps an
|
||||||
|
// award code to its reference metadata; awards present there with Dynamic=false
|
||||||
|
// are "predefined" (only listed references count, and the full list — including
|
||||||
|
// unworked references — appears in the result).
|
||||||
|
func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf NameResolver) []Result {
|
||||||
|
refLists := make(map[string]refList, len(refMetas))
|
||||||
|
for code, metas := range refMetas {
|
||||||
|
refLists[strings.ToUpper(strings.TrimSpace(code))] = NewRefList(metas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-compile award-level patterns once.
|
||||||
|
res := make([]*regexp.Regexp, len(defs))
|
||||||
|
perr := make([]string, len(defs))
|
||||||
|
for i := range defs {
|
||||||
|
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
||||||
|
re, err := regexp.Compile(p)
|
||||||
|
if err != nil {
|
||||||
|
perr[i] = "bad pattern: " + err.Error()
|
||||||
|
} else {
|
||||||
|
res[i] = re
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agg := make([]map[string]*refAgg, len(defs))
|
||||||
|
for i := range defs {
|
||||||
|
agg[i] = map[string]*refAgg{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for qi := range qsos {
|
||||||
|
q := &qsos[qi]
|
||||||
|
for i := range defs {
|
||||||
|
d := &defs[i]
|
||||||
|
if perr[i] != "" || !inScope(d, q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rl, hasList := refLists[strings.ToUpper(d.Code)]
|
||||||
|
refs := candidates(d, res[i], q, rl, hasList)
|
||||||
|
if len(refs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||||
|
isConf := confirmed(q, d.Confirm)
|
||||||
|
isVal := confirmed(q, d.Validate)
|
||||||
|
for _, ref := range refs {
|
||||||
|
a := agg[i][ref]
|
||||||
|
if a == nil {
|
||||||
|
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
|
||||||
|
agg[i][ref] = a
|
||||||
|
}
|
||||||
|
if band != "" {
|
||||||
|
a.bands[band] = struct{}{}
|
||||||
|
}
|
||||||
|
if isConf {
|
||||||
|
a.anyConfirmed = true
|
||||||
|
if band != "" {
|
||||||
|
a.confirmedBands[band] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVal {
|
||||||
|
a.anyValidated = true
|
||||||
|
if band != "" {
|
||||||
|
a.validatedBands[band] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Result, len(defs))
|
||||||
|
for i := range defs {
|
||||||
|
d := &defs[i]
|
||||||
|
rl, hasList := refLists[strings.ToUpper(d.Code)]
|
||||||
|
predefined := hasList && !d.Dynamic
|
||||||
|
r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]}
|
||||||
|
bandWorked := map[string]int{}
|
||||||
|
bandConfirmed := map[string]int{}
|
||||||
|
for ref, a := range agg[i] {
|
||||||
|
r.Worked++
|
||||||
|
if a.anyConfirmed {
|
||||||
|
r.Confirmed++
|
||||||
|
}
|
||||||
|
if a.anyValidated {
|
||||||
|
r.Validated++
|
||||||
|
}
|
||||||
|
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||||
|
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
|
||||||
|
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||||
|
r.Refs = append(r.Refs, rf)
|
||||||
|
for b := range a.bands {
|
||||||
|
bandWorked[b]++
|
||||||
|
}
|
||||||
|
for b := range a.confirmedBands {
|
||||||
|
bandConfirmed[b]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Predefined awards: the full list is the denominator, and unworked
|
||||||
|
// references are listed too (greyed in the UI).
|
||||||
|
if predefined {
|
||||||
|
r.Total = len(rl.codes)
|
||||||
|
for _, code := range rl.codes {
|
||||||
|
if _, worked := agg[i][code]; worked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := rl.byCode[code]
|
||||||
|
if !m.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}, ValidatedBands: []string{}}
|
||||||
|
if rf.Name == "" && nameOf != nil {
|
||||||
|
rf.Name = nameOf(d.Field, code)
|
||||||
|
}
|
||||||
|
r.Refs = append(r.Refs, rf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(r.Refs, func(a, b int) bool {
|
||||||
|
if r.Refs[a].Worked != r.Refs[b].Worked {
|
||||||
|
return r.Refs[a].Worked // worked first
|
||||||
|
}
|
||||||
|
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
|
||||||
|
return r.Refs[a].Confirmed
|
||||||
|
}
|
||||||
|
return natLess(r.Refs[a].Ref, r.Refs[b].Ref)
|
||||||
|
})
|
||||||
|
for _, b := range sortedBands(bandWorked) {
|
||||||
|
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
|
||||||
|
}
|
||||||
|
// Never return nil slices: they marshal to JSON null, and the UI calls
|
||||||
|
// .filter/.length on them (an award with nothing worked yet — e.g. a
|
||||||
|
// freshly-created WWFF/WAJA — would otherwise white-screen the panel).
|
||||||
|
if r.Refs == nil {
|
||||||
|
r.Refs = []Ref{}
|
||||||
|
}
|
||||||
|
if r.Bands == nil {
|
||||||
|
r.Bands = []BandCount{}
|
||||||
|
}
|
||||||
|
out[i] = r
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchQSO returns the reference codes a single QSO contributes to for one
|
||||||
|
// award (respecting scope + predefined enforcement). metas is the award's
|
||||||
|
// reference list (empty/nil for dynamic awards). Used for cell drill-down.
|
||||||
|
func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
||||||
|
if !inScope(&d, q) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var re *regexp.Regexp
|
||||||
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
|
if c, err := regexp.Compile(p); err == nil {
|
||||||
|
re = c
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl := NewRefList(metas)
|
||||||
|
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
|
||||||
|
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||||
|
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
||||||
|
|
||||||
|
// InScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||||
|
// bands, modes, emission, dates) — independent of whether a reference was
|
||||||
|
// found. Used to surface "in scope but no reference" gaps (e.g. a French QSO
|
||||||
|
// missing its department for DDFM).
|
||||||
|
func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
|
||||||
|
|
||||||
|
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||||
|
func EmissionOf(mode string) string { return emissionOf(mode) }
|
||||||
|
|
||||||
|
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||||
|
// name resolver as a fallback).
|
||||||
|
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||||
|
if hasList {
|
||||||
|
if m, ok := rl.byCode[code]; ok {
|
||||||
|
rf.Name, rf.Group, rf.SubGrp = m.Name, m.Group, m.SubGrp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rf.Name == "" && nameOf != nil {
|
||||||
|
rf.Name = nameOf(d.Field, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
|
||||||
|
// a predefined list when one applies.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
|
||||||
|
|
||||||
|
var found []string
|
||||||
|
switch {
|
||||||
|
case re != nil:
|
||||||
|
// Award-level regex: capture group 1 (or whole match) for each hit.
|
||||||
|
found = regexTokens(re, raw)
|
||||||
|
case byDesc:
|
||||||
|
// Match references by their DESCRIPTION/name appearing in the field
|
||||||
|
// (e.g. WAJA finds the prefecture name inside the QTH). ExactMatch means
|
||||||
|
// the field equals the name; otherwise the name is a substring of it.
|
||||||
|
up := strings.ToUpper(raw)
|
||||||
|
for _, nc := range rl.names {
|
||||||
|
if exact {
|
||||||
|
if up == nc.name {
|
||||||
|
found = append(found, nc.code)
|
||||||
|
}
|
||||||
|
} else if strings.Contains(up, nc.name) {
|
||||||
|
found = append(found, nc.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
for _, tok := range tokenize(raw) {
|
||||||
|
if _, ok := rl.byCode[tok]; ok {
|
||||||
|
found = append(found, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range rl.withPattern {
|
||||||
|
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
|
||||||
|
found = append(found, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Whole field value is the candidate, split on comma/semicolon so a
|
||||||
|
// multi-reference field (e.g. an n-fer POTA QSO "US-6544,US-0680")
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
// Enforce the predefined list: keep only listed, valid references. The
|
||||||
|
// award-level DXCCFilter already scopes which QSOs are considered (see
|
||||||
|
// inScope), so we do NOT additionally require the QSO's entity to match the
|
||||||
|
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
|
||||||
|
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
|
||||||
|
var out []string
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, c := range found {
|
||||||
|
c = normalizeRef(c)
|
||||||
|
m, ok := rl.byCode[c]
|
||||||
|
if !ok || !m.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[c]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = struct{}{}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func regexTokens(re *regexp.Regexp, raw string) []string {
|
||||||
|
matches := re.FindAllStringSubmatch(raw, -1)
|
||||||
|
out := make([]string, 0, len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
ref := m[0]
|
||||||
|
if len(m) > 1 && m[1] != "" {
|
||||||
|
ref = m[1]
|
||||||
|
}
|
||||||
|
if ref = normalizeRef(ref); ref != "" {
|
||||||
|
out = append(out, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupe(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupe(in []string) []string {
|
||||||
|
if len(in) <= 1 {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(in))
|
||||||
|
out := in[:0]
|
||||||
|
for _, s := range in {
|
||||||
|
if _, ok := seen[s]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize splits a field into uppercased tokens on any non-alphanumeric run,
|
||||||
|
// keeping '-' and '/' which appear inside reference codes (e.g. "FR-11553").
|
||||||
|
// The whole trimmed value is also returned so single-token fields match.
|
||||||
|
func tokenize(raw string) []string {
|
||||||
|
up := strings.ToUpper(strings.TrimSpace(raw))
|
||||||
|
if up == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := []string{up}
|
||||||
|
cur := strings.Builder{}
|
||||||
|
for _, r := range up {
|
||||||
|
if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == '-' || r == '/' {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
} else if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripAffix removes a leading and/or trailing literal string before matching.
|
||||||
|
func stripAffix(s, lead, trail string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if lead != "" {
|
||||||
|
s = strings.TrimPrefix(s, lead)
|
||||||
|
}
|
||||||
|
if trail != "" {
|
||||||
|
s = strings.TrimSuffix(s, trail)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
||||||
|
|
||||||
|
// splitRefs splits a field value on comma/semicolon into normalized references,
|
||||||
|
// so a multi-reference field (n-fer POTA "US-6544,US-0680") yields one entry
|
||||||
|
// per reference. A value with no separator yields a single reference.
|
||||||
|
func splitRefs(raw string) []string {
|
||||||
|
if !strings.ContainsAny(raw, ",;") {
|
||||||
|
return []string{normalizeRef(raw)}
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' }) {
|
||||||
|
if n := normalizeRef(p); n != "" {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupe(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||||
|
|
||||||
|
// natLess is a natural ("human") comparison: digit runs compare as numbers, so
|
||||||
|
// references sort 1,2,…,9,10,11 (not 1,10,11,2) and "D2A" before "D10".
|
||||||
|
func natLess(a, b string) bool {
|
||||||
|
ia, ib := 0, 0
|
||||||
|
for ia < len(a) && ib < len(b) {
|
||||||
|
ca, cb := a[ia], b[ib]
|
||||||
|
if isDigit(ca) && isDigit(cb) {
|
||||||
|
ja, jb := ia, ib
|
||||||
|
for ja < len(a) && isDigit(a[ja]) {
|
||||||
|
ja++
|
||||||
|
}
|
||||||
|
for jb < len(b) && isDigit(b[jb]) {
|
||||||
|
jb++
|
||||||
|
}
|
||||||
|
na := strings.TrimLeft(a[ia:ja], "0")
|
||||||
|
nb := strings.TrimLeft(b[ib:jb], "0")
|
||||||
|
if len(na) != len(nb) {
|
||||||
|
return len(na) < len(nb) // fewer digits = smaller number
|
||||||
|
}
|
||||||
|
if na != nb {
|
||||||
|
return na < nb
|
||||||
|
}
|
||||||
|
ia, ib = ja, jb
|
||||||
|
} else {
|
||||||
|
if ca != cb {
|
||||||
|
return ca < cb
|
||||||
|
}
|
||||||
|
ia++
|
||||||
|
ib++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(a)-ia < len(b)-ib
|
||||||
|
}
|
||||||
|
|
||||||
|
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||||
|
// bands, modes, emission category, validity dates).
|
||||||
|
func inScope(d *Def, q *qso.QSO) bool {
|
||||||
|
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(list []string, v string) bool {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
for _, x := range list {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(x), v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// emissionOf maps an ADIF mode to its broad emission category.
|
||||||
|
func emissionOf(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "PHONE", "C4FM":
|
||||||
|
return "PHONE"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return "DIGITAL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldRaw returns the raw string value of a QSO field (computed for numeric /
|
||||||
|
// derived fields). Unknown fields yield "".
|
||||||
|
func fieldRaw(field string, q *qso.QSO) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(field)) {
|
||||||
|
case "dxcc":
|
||||||
|
if q.DXCC != nil && *q.DXCC > 0 {
|
||||||
|
return strconv.Itoa(*q.DXCC)
|
||||||
|
}
|
||||||
|
case "cqz":
|
||||||
|
if q.CQZ != nil && *q.CQZ > 0 {
|
||||||
|
return strconv.Itoa(*q.CQZ)
|
||||||
|
}
|
||||||
|
case "ituz":
|
||||||
|
if q.ITUZ != nil && *q.ITUZ > 0 {
|
||||||
|
return strconv.Itoa(*q.ITUZ)
|
||||||
|
}
|
||||||
|
case "prefix":
|
||||||
|
return wpxPrefix(q.Callsign)
|
||||||
|
case "callsign":
|
||||||
|
return q.Callsign
|
||||||
|
case "state":
|
||||||
|
return q.State
|
||||||
|
case "cont":
|
||||||
|
return q.Continent
|
||||||
|
case "country":
|
||||||
|
return q.Country
|
||||||
|
case "grid":
|
||||||
|
return q.Grid
|
||||||
|
case "grid4":
|
||||||
|
// VUCC: distinct 4-character grid squares. A QSO on a grid line carries
|
||||||
|
// several in VUCC_GRIDS; otherwise the 4-char prefix of GRIDSQUARE. The
|
||||||
|
// comma-joined result is split into one reference per square downstream.
|
||||||
|
return grid4Refs(q)
|
||||||
|
case "iota":
|
||||||
|
return q.IOTA
|
||||||
|
case "sota_ref":
|
||||||
|
return q.SOTARef
|
||||||
|
case "pota_ref":
|
||||||
|
return q.POTARef
|
||||||
|
case "name":
|
||||||
|
return q.Name
|
||||||
|
case "qth":
|
||||||
|
return q.QTH
|
||||||
|
case "address":
|
||||||
|
return q.Address
|
||||||
|
case "comment":
|
||||||
|
return q.Comment
|
||||||
|
case "note", "notes":
|
||||||
|
return q.Notes
|
||||||
|
case "wwff":
|
||||||
|
if q.Extras != nil {
|
||||||
|
if v := strings.TrimSpace(q.Extras["WWFF_REF"]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if strings.EqualFold(q.Extras["SIG"], "WWFF") {
|
||||||
|
return q.Extras["SIG_INFO"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// grid4Refs returns the distinct 4-character grid squares a QSO contributes —
|
||||||
|
// from VUCC_GRIDS (a comma list when the contact straddles grid lines) if set,
|
||||||
|
// else the 4-char prefix of GRIDSQUARE. Joined with commas so the matcher
|
||||||
|
// counts each square separately (VUCC awards).
|
||||||
|
func grid4Refs(q *qso.QSO) string {
|
||||||
|
src := strings.TrimSpace(q.VUCCGrids)
|
||||||
|
if src == "" {
|
||||||
|
src = strings.TrimSpace(q.Grid)
|
||||||
|
}
|
||||||
|
if src == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []string
|
||||||
|
for _, tok := range strings.FieldsFunc(src, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
||||||
|
g := strings.ToUpper(strings.TrimSpace(tok))
|
||||||
|
if len(g) > 4 {
|
||||||
|
g = g[:4]
|
||||||
|
}
|
||||||
|
if g == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[g]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[g] = struct{}{}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dxccAllowed(dxcc *int, filter []int) bool {
|
||||||
|
if dxcc == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, f := range filter {
|
||||||
|
if *dxcc == f {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmed reports whether the QSO satisfies any accepted confirmation source.
|
||||||
|
// ADIF *_QSL_RCVD values Y (confirmed) and V (verified) both count.
|
||||||
|
func confirmed(q *qso.QSO, sources []string) bool {
|
||||||
|
for _, s := range sources {
|
||||||
|
switch s {
|
||||||
|
case "lotw":
|
||||||
|
if isYes(q.LOTWRcvd) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "qsl":
|
||||||
|
if isYes(q.QSLRcvd) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "eqsl":
|
||||||
|
if isYes(q.EQSLRcvd) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isYes(v string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(v)) {
|
||||||
|
case "Y", "V":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setToSorted(m map[string]struct{}) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
|
||||||
|
|
||||||
|
func sortedBands(m map[string]int) []string {
|
||||||
|
idx := map[string]int{}
|
||||||
|
for i, b := range bandOrder {
|
||||||
|
idx[b] = i
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for b := range m {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(a, b int) bool {
|
||||||
|
ia, oka := idx[out[a]]
|
||||||
|
ib, okb := idx[out[b]]
|
||||||
|
if oka && okb {
|
||||||
|
return ia < ib
|
||||||
|
}
|
||||||
|
if oka != okb {
|
||||||
|
return oka
|
||||||
|
}
|
||||||
|
return out[a] < out[b]
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWPXPrefix(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"F4BPO": "F4",
|
||||||
|
"EA8ABC": "EA8",
|
||||||
|
"9A1AA": "9A1",
|
||||||
|
"OH2BH": "OH2",
|
||||||
|
"K1ABC": "K1",
|
||||||
|
"RAEM": "RA0",
|
||||||
|
"F4BPO/P": "F4",
|
||||||
|
"F4BPO/9": "F9",
|
||||||
|
"VP8/F4BPO": "VP8",
|
||||||
|
"PA0XYZ": "PA0",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := wpxPrefix(in); got != want {
|
||||||
|
t.Errorf("wpxPrefix(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ip(n int) *int { return &n }
|
||||||
|
|
||||||
|
func TestComputeDXCCAndConfirm(t *testing.T) {
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA", LOTWRcvd: "Y"},
|
||||||
|
{Callsign: "K2DEF", Band: "40m", DXCC: ip(291), State: "NY"}, // worked, not confirmed
|
||||||
|
{Callsign: "DL1XYZ", Band: "20m", DXCC: ip(230), QSLRcvd: "Y"}, // DXCC Germany confirmed
|
||||||
|
{Callsign: "F4BPO", Band: "20m", DXCC: ip(227), Notes: "nice qso D74", EQSLRcvd: "Y"}, // France dept 74 in note
|
||||||
|
{Callsign: "F5ABC", Band: "40m", DXCC: ip(227), Notes: "D2A Corsica", QSLRcvd: "Y"}, // France dept 2A, confirmed
|
||||||
|
}
|
||||||
|
res := Compute(Defaults(), qsos, nil, nil)
|
||||||
|
by := map[string]Result{}
|
||||||
|
for _, r := range res {
|
||||||
|
by[r.Code] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
dxcc := by["DXCC"]
|
||||||
|
if dxcc.Worked != 3 { // USA, Germany, France
|
||||||
|
t.Errorf("DXCC worked = %d, want 3", dxcc.Worked)
|
||||||
|
}
|
||||||
|
// DXCC confirms on lotw|qsl → USA(lotw) + Germany(qsl) + France(qsl via F5ABC).
|
||||||
|
if dxcc.Confirmed != 3 {
|
||||||
|
t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed)
|
||||||
|
}
|
||||||
|
// Validated is the stricter LoTW-only tier: a paper QSL confirms but does
|
||||||
|
// NOT validate, so only USA (LoTW) counts.
|
||||||
|
if dxcc.Validated != 1 {
|
||||||
|
t.Errorf("DXCC validated = %d, want 1 (LoTW only, QSL doesn't validate)", dxcc.Validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
was := by["WAS"]
|
||||||
|
if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter)
|
||||||
|
t.Errorf("WAS worked = %d, want 2", was.Worked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DDFM scans the Note field with pattern D(\d{1,2}[AB]?): 74 and 2A.
|
||||||
|
ddfm := by["DDFM"]
|
||||||
|
if ddfm.Worked != 2 {
|
||||||
|
t.Errorf("DDFM worked = %d, want 2 (refs %v)", ddfm.Worked, refCodes(ddfm))
|
||||||
|
}
|
||||||
|
if ddfm.Confirmed != 1 { // 2A confirmed via QSL; 74 only eQSL (not accepted)
|
||||||
|
t.Errorf("DDFM confirmed = %d, want 1", ddfm.Confirmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNatLess(t *testing.T) {
|
||||||
|
in := []string{"10", "2", "1", "20", "3", "D10", "D2A", "D2", "AL", "AK"}
|
||||||
|
want := []string{"1", "2", "3", "10", "20", "AK", "AL", "D2", "D2A", "D10"}
|
||||||
|
sort.Slice(in, func(i, j int) bool { return natLess(in[i], in[j]) })
|
||||||
|
for i := range want {
|
||||||
|
if in[i] != want[i] {
|
||||||
|
t.Fatalf("natLess order = %v, want %v", in, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A multi-reference field (n-fer POTA) counts each park separately.
|
||||||
|
func TestComputeMultiRef(t *testing.T) {
|
||||||
|
def := Def{Code: "POTA", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "W2QMI", Band: "20m", POTARef: "US-6544,US-0680", LOTWRcvd: "Y"},
|
||||||
|
{Callsign: "K1ABC", Band: "40m", POTARef: "US-0680"}, // shared park
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, nil, nil)[0]
|
||||||
|
if r.Worked != 2 { // distinct parks: US-6544, US-0680
|
||||||
|
t.Errorf("POTA worked = %d, want 2 (%v)", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAJA-style award: MatchBy="description", non-exact, scanning the QTH for a
|
||||||
|
// reference's NAME (the prefecture). Also guards against the nil-slice crash:
|
||||||
|
// an award with nothing worked must return empty (non-nil) Refs/Bands.
|
||||||
|
func TestComputeMatchByDescription(t *testing.T) {
|
||||||
|
def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "JA1ABC", Band: "20m", DXCC: ip(339), QTH: "Tokyo city", LOTWRcvd: "Y"},
|
||||||
|
{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "Osaka"},
|
||||||
|
{Callsign: "JA9XYZ", Band: "20m", DXCC: ip(339), QTH: "nowhere special"}, // no prefecture name
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAJA": {
|
||||||
|
{Code: "100", Name: "Tokyo", Valid: true},
|
||||||
|
{Code: "270", Name: "Osaka", Valid: true},
|
||||||
|
{Code: "010", Name: "Hokkaido", Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 2 { // Tokyo + Osaka found by name inside QTH
|
||||||
|
t.Errorf("WAJA worked = %d, want 2 (%v)", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
if r.Total != 3 { // predefined denominator = list size
|
||||||
|
t.Errorf("WAJA total = %d, want 3", r.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nil-slice guard: an award with zero worked refs must still return
|
||||||
|
// non-nil (empty) Refs/Bands so the JSON isn't null (UI white-screen).
|
||||||
|
empty := Compute([]Def{{Code: "WWFF", Type: TypeReference, Field: "wwff", Dynamic: true, Valid: true}}, nil, nil, nil)[0]
|
||||||
|
if empty.Refs == nil || empty.Bands == nil {
|
||||||
|
t.Errorf("empty award must have non-nil Refs/Bands, got Refs=%v Bands=%v", empty.Refs, empty.Bands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
|
||||||
|
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
||||||
|
// 4-char prefix of GRIDSQUARE.
|
||||||
|
func TestComputeGrid4VUCC(t *testing.T) {
|
||||||
|
def := Def{Code: "VUCC", Type: TypeGrid, Field: "grid4", Dynamic: true,
|
||||||
|
Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "K1ABC", Band: "6m", Grid: "FN31PR", LOTWRcvd: "Y"}, // → FN31
|
||||||
|
{Callsign: "W2DEF", Band: "6m", VUCCGrids: "FN20,FN21,FN30,FN31"}, // grid-line: 4 squares
|
||||||
|
{Callsign: "W3GHI", Band: "2m", Grid: "FN20XX"}, // → FN20 (dup of above)
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, nil, nil)[0]
|
||||||
|
// Distinct squares: FN31, FN20, FN21, FN30 = 4
|
||||||
|
if r.Worked != 4 {
|
||||||
|
t.Errorf("VUCC worked = %d, want 4 (%v)", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refCodes(r Result) []string {
|
||||||
|
out := make([]string, 0, len(r.Refs))
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
out = append(out, rf.Ref)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy defs (Type=="", Valid==false, old DDFM pattern) get upgraded.
|
||||||
|
func TestMigrate(t *testing.T) {
|
||||||
|
legacy := []Def{
|
||||||
|
{Code: "DXCC", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340}, // Valid=false, Type=""
|
||||||
|
{Code: "DDFM", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, Total: 96},
|
||||||
|
{Code: "MYAWARD", Field: "note"}, // custom legacy
|
||||||
|
}
|
||||||
|
out, changed := Migrate(legacy)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("expected migration to change legacy defs")
|
||||||
|
}
|
||||||
|
for _, d := range out {
|
||||||
|
if !d.Valid {
|
||||||
|
t.Errorf("%s should be enabled after migration", d.Code)
|
||||||
|
}
|
||||||
|
if d.Type == "" {
|
||||||
|
t.Errorf("%s should have a type after migration", d.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out[1].Pattern != `(?i)\b(D\d{1,2}[AB]?)\b` {
|
||||||
|
t.Errorf("DDFM pattern not fixed: %q", out[1].Pattern)
|
||||||
|
}
|
||||||
|
if out[2].Type != TypeQSOFields {
|
||||||
|
t.Errorf("custom legacy award type = %q, want QSOFIELDS", out[2].Type)
|
||||||
|
}
|
||||||
|
// Idempotent: a second pass changes nothing.
|
||||||
|
if _, changed2 := Migrate(out); changed2 {
|
||||||
|
t.Error("migration should be idempotent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: a predefined reference whose own DXCC differs from the QSO's
|
||||||
|
// entity must still count when the field code matches and the award-level
|
||||||
|
// DXCC filter allows it (WAS Alaska: state AK, but DXCC entity 6, not 291).
|
||||||
|
func TestComputePredefinedCrossDXCC(t *testing.T) {
|
||||||
|
def := Def{Code: "WAS", Type: TypeQSOFields, Field: "state", ExactMatch: true,
|
||||||
|
DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "KL5DX", Band: "20m", DXCC: ip(6), State: "AK", LOTWRcvd: "Y"}, // Alaska
|
||||||
|
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // continental
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAS": {
|
||||||
|
{Code: "AK", Name: "Alaska", DXCCList: []int{291}, Valid: true}, // wrong DXCC on purpose
|
||||||
|
{Code: "MA", Name: "Massachusetts", DXCCList: []int{291}, Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 2 {
|
||||||
|
t.Errorf("WAS worked = %d, want 2 (Alaska must count despite DXCC 6) %v", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A predefined award only counts references present in its list, lists the
|
||||||
|
// unworked ones too, and uses the list size as the denominator.
|
||||||
|
func TestComputePredefinedList(t *testing.T) {
|
||||||
|
def := Def{Code: "RAC", Name: "RAC", Type: TypeQSOFields, Field: "state", ExactMatch: true,
|
||||||
|
DXCCFilter: []int{1}, Confirm: []string{"lotw", "qsl"}, Validate: []string{"lotw"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "VE3AAA", Band: "20m", DXCC: ip(1), State: "ON", LOTWRcvd: "Y"}, // worked+confirmed+validated
|
||||||
|
{Callsign: "VE7BBB", Band: "40m", DXCC: ip(1), State: "BC", QSLRcvd: "Y"}, // worked+confirmed (not validated)
|
||||||
|
{Callsign: "VE9CCC", Band: "20m", DXCC: ip(1), State: "ZZ"}, // not a real province → ignored
|
||||||
|
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // wrong DXCC → ignored
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"RAC": {
|
||||||
|
{Code: "ON", Name: "Ontario", Valid: true},
|
||||||
|
{Code: "BC", Name: "British Columbia", Valid: true},
|
||||||
|
{Code: "AB", Name: "Alberta", Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 2 {
|
||||||
|
t.Errorf("RAC worked = %d, want 2 (%v)", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
if r.Confirmed != 2 {
|
||||||
|
t.Errorf("RAC confirmed = %d, want 2", r.Confirmed)
|
||||||
|
}
|
||||||
|
if r.Validated != 1 { // only ON via LoTW
|
||||||
|
t.Errorf("RAC validated = %d, want 1", r.Validated)
|
||||||
|
}
|
||||||
|
if r.Total != 3 { // denominator = list size
|
||||||
|
t.Errorf("RAC total = %d, want 3", r.Total)
|
||||||
|
}
|
||||||
|
if len(r.Refs) != 3 { // ON, BC worked + AB unworked
|
||||||
|
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// first group (F4BPO→F4, EA8ABC→EA8, 9A1AA→9A1, OH2BH→OH2)
|
||||||
|
// - no digit: first two letters + "0" (RAEM→RA0)
|
||||||
|
// - portable "A/B": a short alpha(+digit) segment is treated as the prefix
|
||||||
|
// designator; a lone-digit segment replaces the call's digit (F4BPO/9→F9)
|
||||||
|
func wpxPrefix(call string) string {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
if c == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "/") {
|
||||||
|
return portablePrefix(c)
|
||||||
|
}
|
||||||
|
return standardPrefix(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func portablePrefix(c string) string {
|
||||||
|
parts := strings.Split(c, "/")
|
||||||
|
// Drop pure operating-modifier suffixes.
|
||||||
|
kept := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
switch p {
|
||||||
|
case "P", "M", "MM", "AM", "QRP", "A", "R", "B", "LH":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p != "" {
|
||||||
|
kept = append(kept, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
kept = parts
|
||||||
|
}
|
||||||
|
// Pick a base = the longest segment (the actual call).
|
||||||
|
base := kept[0]
|
||||||
|
for _, p := range kept[1:] {
|
||||||
|
if len(p) > len(base) {
|
||||||
|
base = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range kept {
|
||||||
|
if p == base {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isAllDigits(p) {
|
||||||
|
// Lone digit replaces the call's region digit: F4BPO/9 → F9.
|
||||||
|
return replaceLastDigit(standardPrefix(base), p)
|
||||||
|
}
|
||||||
|
if len(p) <= 4 && hasLetter(p) {
|
||||||
|
// Prefix designator wins: VP8/F4BPO → VP8 (+digit if missing).
|
||||||
|
return ensureTrailingDigit(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return standardPrefix(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
// standardPrefix applies the basic WPX rule to a plain callsign: the prefix is
|
||||||
|
// the call up to and including its last digit (9A1AA→9A1, EA8ABC→EA8). Standard
|
||||||
|
// callsigns carry no digit in the suffix, so "last digit" is the prefix digit.
|
||||||
|
func standardPrefix(c string) string {
|
||||||
|
lastDigit := -1
|
||||||
|
for i := 0; i < len(c); i++ {
|
||||||
|
if c[i] >= '0' && c[i] <= '9' {
|
||||||
|
lastDigit = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastDigit < 0 {
|
||||||
|
// No digit at all: first two letters + 0.
|
||||||
|
if len(c) >= 2 {
|
||||||
|
return c[:2] + "0"
|
||||||
|
}
|
||||||
|
return c + "0"
|
||||||
|
}
|
||||||
|
return c[:lastDigit+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureTrailingDigit(p string) string {
|
||||||
|
for i := 0; i < len(p); i++ {
|
||||||
|
if p[i] >= '0' && p[i] <= '9' {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p + "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceLastDigit(prefix, digit string) string {
|
||||||
|
for i := len(prefix) - 1; i >= 0; i-- {
|
||||||
|
if prefix[i] >= '0' && prefix[i] <= '9' {
|
||||||
|
return prefix[:i] + digit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix + digit
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllDigits(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] < '0' || s[i] > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLetter(s string) bool {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// Package awardref stores and updates award reference lists (POTA parks, SOTA
|
||||||
|
// summits, WWFF references, …). These provide award totals, reference names,
|
||||||
|
// and per-DXCC filtering. Lists are downloaded from each program's public file.
|
||||||
|
package awardref
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allCols is the column list shared by read queries so they stay in sync.
|
||||||
|
const allCols = `ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias`
|
||||||
|
|
||||||
|
func encodeDXCCList(l []int) string {
|
||||||
|
if len(l) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(l)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDXCCList(s string) []int {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var l []int
|
||||||
|
if json.Unmarshal([]byte(s), &l) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanRef reads one row selected with allCols into a Ref.
|
||||||
|
func scanRef(rows *sql.Rows) (Ref, error) {
|
||||||
|
var r Ref
|
||||||
|
var dxccList string
|
||||||
|
var valid int
|
||||||
|
if err := rows.Scan(&r.Code, &r.Name, &r.DXCC, &r.Group, &r.SubGrp,
|
||||||
|
&dxccList, &r.Pattern, &valid, &r.ValidFrom, &r.ValidTo,
|
||||||
|
&r.Score, &r.Bonus, &r.GridSquare, &r.Alias); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
r.DXCCList = decodeDXCCList(dxccList)
|
||||||
|
r.Valid = valid != 0
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref is one award reference. The first five fields are the original schema;
|
||||||
|
// the rest mirror Log4OM's per-reference editor (group/subgroup, multi-DXCC,
|
||||||
|
// per-reference regex, validity window, score/bonus, grid, alias).
|
||||||
|
type Ref struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"` // description
|
||||||
|
DXCC int `json:"dxcc"` // primary entity (kept for compatibility / fast filter)
|
||||||
|
Group string `json:"group"`
|
||||||
|
SubGrp string `json:"subgrp"`
|
||||||
|
|
||||||
|
DXCCList []int `json:"dxcc_list,omitempty"` // all entities this ref is valid for
|
||||||
|
Pattern string `json:"pattern,omitempty"` // per-reference Go regexp
|
||||||
|
Valid bool `json:"valid"` // reference enabled
|
||||||
|
ValidFrom string `json:"valid_from,omitempty"`
|
||||||
|
ValidTo string `json:"valid_to,omitempty"`
|
||||||
|
Score int `json:"score,omitempty"`
|
||||||
|
Bonus int `json:"bonus,omitempty"`
|
||||||
|
GridSquare string `json:"gridsquare,omitempty"`
|
||||||
|
Alias string `json:"alias,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo accesses the award_references table.
|
||||||
|
type Repo struct{ db *sql.DB }
|
||||||
|
|
||||||
|
// NewRepo builds a reference repo on the given connection.
|
||||||
|
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||||
|
|
||||||
|
// ReplaceAll atomically replaces every reference for one award.
|
||||||
|
func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (int, error) {
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||||
|
if code == "" {
|
||||||
|
return 0, fmt.Errorf("empty award code")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM award_references WHERE award_code = ?`, code); err != nil {
|
||||||
|
return 0, fmt.Errorf("clear refs: %w", err)
|
||||||
|
}
|
||||||
|
stmt, err := tx.PrepareContext(ctx,
|
||||||
|
`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 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, ref := range refs {
|
||||||
|
rc := strings.ToUpper(strings.TrimSpace(ref.Code))
|
||||||
|
if rc == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A bulk-replaced list is the authoritative enabled set: store every
|
||||||
|
// row as valid. Per-reference disabling is done through Upsert.
|
||||||
|
if _, err := stmt.ExecContext(ctx, code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
|
||||||
|
encodeDXCCList(ref.DXCCList), ref.Pattern, 1, ref.ValidFrom, ref.ValidTo,
|
||||||
|
ref.Score, ref.Bonus, ref.GridSquare, ref.Alias); err != nil {
|
||||||
|
return 0, fmt.Errorf("insert ref %s: %w", rc, err)
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns how many references an award has stored.
|
||||||
|
func (r *Repo) Count(ctx context.Context, awardCode string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM award_references WHERE award_code = ?`,
|
||||||
|
strings.ToUpper(strings.TrimSpace(awardCode))).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts returns reference counts for every award.
|
||||||
|
func (r *Repo) Counts(ctx context.Context) (map[string]int, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT award_code, COUNT(*) FROM award_references GROUP BY award_code`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var code string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&code, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[code] = n
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamesFor returns ref_code → name for the given codes of one award (batched so
|
||||||
|
// we never load a 250k-row map just to label a few worked references).
|
||||||
|
func (r *Repo) NamesFor(ctx context.Context, awardCode string, codes []string) (map[string]string, error) {
|
||||||
|
out := map[string]string{}
|
||||||
|
if len(codes) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||||
|
// Chunk to stay under SQLite's parameter limit.
|
||||||
|
const chunk = 400
|
||||||
|
for start := 0; start < len(codes); start += chunk {
|
||||||
|
end := start + chunk
|
||||||
|
if end > len(codes) {
|
||||||
|
end = len(codes)
|
||||||
|
}
|
||||||
|
batch := codes[start:end]
|
||||||
|
ph := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
|
||||||
|
args := make([]any, 0, len(batch)+1)
|
||||||
|
args = append(args, code)
|
||||||
|
for _, c := range batch {
|
||||||
|
args = append(args, strings.ToUpper(strings.TrimSpace(c)))
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT ref_code, name FROM award_references WHERE award_code = ? AND ref_code IN (`+ph+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var rc, name string
|
||||||
|
if err := rows.Scan(&rc, &name); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[rc] = name
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns up to `limit` references of an award matching a code/name
|
||||||
|
// query, optionally restricted to a DXCC entity. Drives the per-QSO picker.
|
||||||
|
func (r *Repo) Search(ctx context.Context, awardCode, query string, dxcc, limit int) ([]Ref, error) {
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||||
|
q := strings.TrimSpace(query)
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
sqlStr := `SELECT ` + allCols + ` FROM award_references WHERE award_code = ?`
|
||||||
|
args := []any{code}
|
||||||
|
if dxcc > 0 {
|
||||||
|
// Match the primary dxcc OR the multi-DXCC list (JSON contains the id).
|
||||||
|
sqlStr += ` AND (dxcc = ? OR dxcc_list LIKE ?)`
|
||||||
|
args = append(args, dxcc, fmt.Sprintf("%%%d%%", dxcc))
|
||||||
|
}
|
||||||
|
if q != "" {
|
||||||
|
sqlStr += ` AND (ref_code LIKE ? OR name LIKE ?)`
|
||||||
|
args = append(args, "%"+strings.ToUpper(q)+"%", "%"+q+"%")
|
||||||
|
}
|
||||||
|
sqlStr += ` ORDER BY ref_code LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
rows, err := r.db.QueryContext(ctx, sqlStr, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Ref
|
||||||
|
for rows.Next() {
|
||||||
|
ref, err := scanRef(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, ref)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns every reference of an award, ordered by code. Used by the
|
||||||
|
// reference editor and (via the engine) to show unworked references.
|
||||||
|
func (r *Repo) List(ctx context.Context, awardCode string) ([]Ref, error) {
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT `+allCols+` FROM award_references WHERE award_code = ? ORDER BY ref_code`, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Ref
|
||||||
|
for rows.Next() {
|
||||||
|
ref, err := scanRef(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, ref)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert inserts or updates a single reference of an award.
|
||||||
|
func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(awardCode))
|
||||||
|
rc := strings.ToUpper(strings.TrimSpace(ref.Code))
|
||||||
|
if code == "" || rc == "" {
|
||||||
|
return fmt.Errorf("empty award or reference code")
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`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,
|
||||||
|
encodeDXCCList(ref.DXCCList), ref.Pattern, b2i(ref.Valid), ref.ValidFrom, ref.ValidTo,
|
||||||
|
ref.Score, ref.Bonus, ref.GridSquare, ref.Alias)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes one reference from an award.
|
||||||
|
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM award_references WHERE award_code = ? AND ref_code = ?`,
|
||||||
|
strings.ToUpper(strings.TrimSpace(awardCode)), strings.ToUpper(strings.TrimSpace(refCode)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func b2i(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package awardref
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuiltinRefs returns the seed reference list for a built-in award (DXCC
|
||||||
|
// entities, CQ zones, continents, US states, French departments). ok=false for
|
||||||
|
// awards whose list is downloaded online (POTA/SOTA/WWFF) or fully custom.
|
||||||
|
//
|
||||||
|
// The reference CODE must equal what award.Compute extracts from the QSO field
|
||||||
|
// so worked references map onto the list:
|
||||||
|
// - DXCC → entity number ("291")
|
||||||
|
// - WAZ → CQ zone number ("1".."40")
|
||||||
|
// - WAC → continent code ("EU", "NA", …)
|
||||||
|
// - WAS → ADIF STATE code ("AL", …)
|
||||||
|
// - DDFM → "D06" (the award pattern captures the leading D)
|
||||||
|
func BuiltinRefs(code string) ([]Ref, bool) {
|
||||||
|
switch code {
|
||||||
|
case "DXCC":
|
||||||
|
return dxccEntities(), true
|
||||||
|
case "WAZ":
|
||||||
|
return cqZones(), true
|
||||||
|
case "WAC":
|
||||||
|
return continents(), true
|
||||||
|
case "WAS":
|
||||||
|
return usStates().Refs, true
|
||||||
|
case "DDFM":
|
||||||
|
return frenchDepartments(), true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func dxccEntities() []Ref {
|
||||||
|
ents := dxcc.AllEntities()
|
||||||
|
out := make([]Ref, 0, len(ents))
|
||||||
|
for _, e := range ents {
|
||||||
|
out = append(out, ref(strconv.Itoa(e.Num), e.Name, e.Num))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cqZones() []Ref {
|
||||||
|
out := make([]Ref, 0, 40)
|
||||||
|
for z := 1; z <= 40; z++ {
|
||||||
|
out = append(out, ref(strconv.Itoa(z), "CQ Zone "+strconv.Itoa(z), 0))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func continents() []Ref {
|
||||||
|
pairs := [][2]string{
|
||||||
|
{"AF", "Africa"}, {"AN", "Antarctica"}, {"AS", "Asia"},
|
||||||
|
{"EU", "Europe"}, {"NA", "North America"}, {"OC", "Oceania"}, {"SA", "South America"},
|
||||||
|
}
|
||||||
|
out := make([]Ref, 0, len(pairs))
|
||||||
|
for _, p := range pairs {
|
||||||
|
out = append(out, ref(p[0], p[1], 0))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// frenchDepartments — the 96 metropolitan French departments (DXCC 227).
|
||||||
|
func frenchDepartments() []Ref {
|
||||||
|
const fr = 227
|
||||||
|
deps := [][2]string{
|
||||||
|
{"D01", "Ain"}, {"D02", "Aisne"}, {"D03", "Allier"}, {"D04", "Alpes-de-Haute-Provence"},
|
||||||
|
{"D05", "Hautes-Alpes"}, {"D06", "Alpes-Maritimes"}, {"D07", "Ardèche"}, {"D08", "Ardennes"},
|
||||||
|
{"D09", "Ariège"}, {"D10", "Aube"}, {"D11", "Aude"}, {"D12", "Aveyron"},
|
||||||
|
{"D13", "Bouches-du-Rhône"}, {"D14", "Calvados"}, {"D15", "Cantal"}, {"D16", "Charente"},
|
||||||
|
{"D17", "Charente-Maritime"}, {"D18", "Cher"}, {"D19", "Corrèze"}, {"D2A", "Corse-du-Sud"},
|
||||||
|
{"D2B", "Haute-Corse"}, {"D21", "Côte-d'Or"}, {"D22", "Côtes-d'Armor"}, {"D23", "Creuse"},
|
||||||
|
{"D24", "Dordogne"}, {"D25", "Doubs"}, {"D26", "Drôme"}, {"D27", "Eure"},
|
||||||
|
{"D28", "Eure-et-Loir"}, {"D29", "Finistère"}, {"D30", "Gard"}, {"D31", "Haute-Garonne"},
|
||||||
|
{"D32", "Gers"}, {"D33", "Gironde"}, {"D34", "Hérault"}, {"D35", "Ille-et-Vilaine"},
|
||||||
|
{"D36", "Indre"}, {"D37", "Indre-et-Loire"}, {"D38", "Isère"}, {"D39", "Jura"},
|
||||||
|
{"D40", "Landes"}, {"D41", "Loir-et-Cher"}, {"D42", "Loire"}, {"D43", "Haute-Loire"},
|
||||||
|
{"D44", "Loire-Atlantique"}, {"D45", "Loiret"}, {"D46", "Lot"}, {"D47", "Lot-et-Garonne"},
|
||||||
|
{"D48", "Lozère"}, {"D49", "Maine-et-Loire"}, {"D50", "Manche"}, {"D51", "Marne"},
|
||||||
|
{"D52", "Haute-Marne"}, {"D53", "Mayenne"}, {"D54", "Meurthe-et-Moselle"}, {"D55", "Meuse"},
|
||||||
|
{"D56", "Morbihan"}, {"D57", "Moselle"}, {"D58", "Nièvre"}, {"D59", "Nord"},
|
||||||
|
{"D60", "Oise"}, {"D61", "Orne"}, {"D62", "Pas-de-Calais"}, {"D63", "Puy-de-Dôme"},
|
||||||
|
{"D64", "Pyrénées-Atlantiques"}, {"D65", "Hautes-Pyrénées"}, {"D66", "Pyrénées-Orientales"}, {"D67", "Bas-Rhin"},
|
||||||
|
{"D68", "Haut-Rhin"}, {"D69", "Rhône"}, {"D70", "Haute-Saône"}, {"D71", "Saône-et-Loire"},
|
||||||
|
{"D72", "Sarthe"}, {"D73", "Savoie"}, {"D74", "Haute-Savoie"}, {"D75", "Paris"},
|
||||||
|
{"D76", "Seine-Maritime"}, {"D77", "Seine-et-Marne"}, {"D78", "Yvelines"}, {"D79", "Deux-Sèvres"},
|
||||||
|
{"D80", "Somme"}, {"D81", "Tarn"}, {"D82", "Tarn-et-Garonne"}, {"D83", "Var"},
|
||||||
|
{"D84", "Vaucluse"}, {"D85", "Vendée"}, {"D86", "Vienne"}, {"D87", "Haute-Vienne"},
|
||||||
|
{"D88", "Vosges"}, {"D89", "Yonne"}, {"D90", "Territoire de Belfort"}, {"D91", "Essonne"},
|
||||||
|
{"D92", "Hauts-de-Seine"}, {"D93", "Seine-Saint-Denis"}, {"D94", "Val-de-Marne"}, {"D95", "Val-d'Oise"},
|
||||||
|
}
|
||||||
|
out := make([]Ref, 0, len(deps))
|
||||||
|
for _, d := range deps {
|
||||||
|
out = append(out, ref(d[0], d[1], fr))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package awardref
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Importer downloads and parses a program's reference list into []Ref.
|
||||||
|
type Importer struct {
|
||||||
|
AwardCode string
|
||||||
|
URL string
|
||||||
|
Fetch func(ctx context.Context, body io.Reader) ([]Ref, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Importers is the registry of built-in reference-list updaters, keyed by
|
||||||
|
// award code. Awards not present here have no online list (manual only).
|
||||||
|
var Importers = map[string]Importer{
|
||||||
|
"POTA": {AwardCode: "POTA", URL: "https://pota.app/all_parks.csv", Fetch: parsePOTA},
|
||||||
|
"SOTA": {AwardCode: "SOTA", URL: "https://www.sotadata.org.uk/summitslist.csv", Fetch: parseSOTA},
|
||||||
|
"WWFF": {AwardCode: "WWFF", URL: "https://wwff.co/wwff-data/wwff_directory.csv", Fetch: parseWWFF},
|
||||||
|
"IOTA": {AwardCode: "IOTA", URL: "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json", Fetch: parseIOTA},
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIOTA reads iota-world.org's groups.json (refreshed daily): an array of
|
||||||
|
// {refno, name, dxcc_num, grp_region}. The reference is the IOTA number
|
||||||
|
// (EU-005); the DXCC number lets the per-QSO picker filter by entity.
|
||||||
|
func parseIOTA(_ context.Context, body io.Reader) ([]Ref, error) {
|
||||||
|
var groups []struct {
|
||||||
|
RefNo string `json:"refno"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DXCC string `json:"dxcc_num"`
|
||||||
|
Region string `json:"grp_region"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(body).Decode(&groups); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse IOTA json: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Ref, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
ref := strings.ToUpper(strings.TrimSpace(g.RefNo))
|
||||||
|
if ref == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dxcc, _ := strconv.Atoi(strings.TrimSpace(g.DXCC))
|
||||||
|
grp := strings.TrimSpace(g.Region)
|
||||||
|
if grp == "" { // fall back to the continent prefix (AF/EU/NA/…)
|
||||||
|
if i := strings.IndexByte(ref, '-'); i > 0 {
|
||||||
|
grp = ref[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Ref{Code: ref, Name: strings.TrimSpace(g.Name), DXCC: dxcc, Group: grp})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanUpdate reports whether an award has an online reference list.
|
||||||
|
func CanUpdate(awardCode string) bool {
|
||||||
|
_, ok := Importers[strings.ToUpper(strings.TrimSpace(awardCode))]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download fetches and parses the reference list for an award (does not store).
|
||||||
|
func Download(ctx context.Context, awardCode string) ([]Ref, error) {
|
||||||
|
imp, ok := Importers[strings.ToUpper(strings.TrimSpace(awardCode))]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no online list for award %q", awardCode)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", imp.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
client := &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download %s: %w", imp.URL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("download %s: http %d", imp.URL, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return imp.Fetch(ctx, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// headerIndex maps lowercased header names to their column index.
|
||||||
|
func headerIndex(header []string) map[string]int {
|
||||||
|
m := make(map[string]int, len(header))
|
||||||
|
for i, h := range header {
|
||||||
|
m[strings.ToLower(strings.TrimSpace(h))] = i
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(rec []string, idx int) string {
|
||||||
|
if idx < 0 || idx >= len(rec) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(rec[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePOTA: "reference","name","active","entityId","locationDesc"
|
||||||
|
func parsePOTA(_ context.Context, body io.Reader) ([]Ref, error) {
|
||||||
|
r := csv.NewReader(body)
|
||||||
|
r.FieldsPerRecord = -1
|
||||||
|
header, err := r.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := headerIndex(header)
|
||||||
|
iRef, iName, iActive, iEnt, iLoc := h["reference"], h["name"], h["active"], h["entityid"], h["locationdesc"]
|
||||||
|
var out []Ref
|
||||||
|
for {
|
||||||
|
rec, err := r.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if iActive >= 0 && get(rec, iActive) == "0" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dxcc, _ := strconv.Atoi(get(rec, iEnt))
|
||||||
|
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), DXCC: dxcc, Group: get(rec, iLoc)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSOTA: first line is a title, then header
|
||||||
|
// SummitCode,AssociationName,RegionName,SummitName,…
|
||||||
|
func parseSOTA(_ context.Context, body io.Reader) ([]Ref, error) {
|
||||||
|
r := csv.NewReader(body)
|
||||||
|
r.FieldsPerRecord = -1
|
||||||
|
// First record is the "SOTA Summits List (Date=…)" title line — skip it.
|
||||||
|
if _, err := r.Read(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
header, err := r.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := headerIndex(header)
|
||||||
|
iRef, iName, iAssoc, iRegion := h["summitcode"], h["summitname"], h["associationname"], h["regionname"]
|
||||||
|
var out []Ref
|
||||||
|
for {
|
||||||
|
rec, err := r.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), Group: get(rec, iAssoc), SubGrp: get(rec, iRegion)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWWFF: reference,status,name,…,dxccEnum,… (header-driven)
|
||||||
|
func parseWWFF(_ context.Context, body io.Reader) ([]Ref, error) {
|
||||||
|
r := csv.NewReader(body)
|
||||||
|
r.FieldsPerRecord = -1
|
||||||
|
header, err := r.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := headerIndex(header)
|
||||||
|
iRef, iName, iStatus, iCountry := h["reference"], h["name"], h["status"], h["country"]
|
||||||
|
iDXCC := h["dxccenum"]
|
||||||
|
if iDXCC < 0 {
|
||||||
|
iDXCC = h["dxcc"]
|
||||||
|
}
|
||||||
|
var out []Ref
|
||||||
|
for {
|
||||||
|
rec, err := r.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if iStatus >= 0 && !strings.EqualFold(get(rec, iStatus), "active") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dxcc, _ := strconv.Atoi(get(rec, iDXCC))
|
||||||
|
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), DXCC: dxcc, Group: get(rec, iCountry)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package awardref
|
||||||
|
|
||||||
|
// Preset is a ready-made reference list a user can apply to an award in one
|
||||||
|
// click (Canadian provinces, US states, …). Codes match the values that land
|
||||||
|
// in the corresponding QSO field (e.g. ADIF STATE codes).
|
||||||
|
type Preset struct {
|
||||||
|
Key string `json:"key"` // stable id, e.g. "ca_provinces"
|
||||||
|
Name string `json:"name"` // friendly label
|
||||||
|
Field string `json:"field"` // suggested QSO field to scan
|
||||||
|
DXCC int `json:"dxcc"` // suggested DXCC scope (0 = none)
|
||||||
|
Refs []Ref `json:"refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets is the catalogue of built-in reference lists, returned to the UI.
|
||||||
|
func Presets() []Preset {
|
||||||
|
return []Preset{
|
||||||
|
caProvinces(),
|
||||||
|
usStates(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresetByKey returns a preset by its key (ok=false if unknown).
|
||||||
|
func PresetByKey(key string) (Preset, bool) {
|
||||||
|
for _, p := range Presets() {
|
||||||
|
if p.Key == key {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Preset{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ref(code, name string, dxcc int) Ref {
|
||||||
|
return Ref{Code: code, Name: name, DXCC: dxcc, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// caProvinces — RAC Canadian Provinces (DXCC 1 = Canada). Codes are ADIF STATE
|
||||||
|
// values for VE provinces/territories.
|
||||||
|
func caProvinces() Preset {
|
||||||
|
const ca = 1
|
||||||
|
return Preset{
|
||||||
|
Key: "ca_provinces", Name: "Canadian Provinces (RAC)", Field: "state", DXCC: ca,
|
||||||
|
Refs: []Ref{
|
||||||
|
ref("AB", "Alberta", ca), ref("BC", "British Columbia", ca),
|
||||||
|
ref("MB", "Manitoba", ca), ref("NB", "New Brunswick", ca),
|
||||||
|
ref("NL", "Newfoundland and Labrador", ca), ref("NS", "Nova Scotia", ca),
|
||||||
|
ref("NT", "Northwest Territories", ca), ref("NU", "Nunavut", ca),
|
||||||
|
ref("ON", "Ontario", ca), ref("PE", "Prince Edward Island", ca),
|
||||||
|
ref("QC", "Quebec", ca), ref("SK", "Saskatchewan", ca),
|
||||||
|
ref("YT", "Yukon", ca),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usStates — Worked All States (DXCC 291 = United States). 50 ADIF STATE codes.
|
||||||
|
func usStates() Preset {
|
||||||
|
const us = 291
|
||||||
|
codes := [][2]string{
|
||||||
|
{"AL", "Alabama"}, {"AK", "Alaska"}, {"AZ", "Arizona"}, {"AR", "Arkansas"},
|
||||||
|
{"CA", "California"}, {"CO", "Colorado"}, {"CT", "Connecticut"}, {"DE", "Delaware"},
|
||||||
|
{"FL", "Florida"}, {"GA", "Georgia"}, {"HI", "Hawaii"}, {"ID", "Idaho"},
|
||||||
|
{"IL", "Illinois"}, {"IN", "Indiana"}, {"IA", "Iowa"}, {"KS", "Kansas"},
|
||||||
|
{"KY", "Kentucky"}, {"LA", "Louisiana"}, {"ME", "Maine"}, {"MD", "Maryland"},
|
||||||
|
{"MA", "Massachusetts"}, {"MI", "Michigan"}, {"MN", "Minnesota"}, {"MS", "Mississippi"},
|
||||||
|
{"MO", "Missouri"}, {"MT", "Montana"}, {"NE", "Nebraska"}, {"NV", "Nevada"},
|
||||||
|
{"NH", "New Hampshire"}, {"NJ", "New Jersey"}, {"NM", "New Mexico"}, {"NY", "New York"},
|
||||||
|
{"NC", "North Carolina"}, {"ND", "North Dakota"}, {"OH", "Ohio"}, {"OK", "Oklahoma"},
|
||||||
|
{"OR", "Oregon"}, {"PA", "Pennsylvania"}, {"RI", "Rhode Island"}, {"SC", "South Carolina"},
|
||||||
|
{"SD", "South Dakota"}, {"TN", "Tennessee"}, {"TX", "Texas"}, {"UT", "Utah"},
|
||||||
|
{"VT", "Vermont"}, {"VA", "Virginia"}, {"WA", "Washington"}, {"WV", "West Virginia"},
|
||||||
|
{"WI", "Wisconsin"}, {"WY", "Wyoming"},
|
||||||
|
}
|
||||||
|
refs := make([]Ref, 0, len(codes))
|
||||||
|
for _, c := range codes {
|
||||||
|
refs = append(refs, ref(c[0], c[1], us))
|
||||||
|
}
|
||||||
|
return Preset{Key: "us_states", Name: "US States (WAS)", Field: "state", DXCC: us, Refs: refs}
|
||||||
|
}
|
||||||
+115
-28
@@ -36,13 +36,14 @@ func DefaultFolder(dataDir string) string {
|
|||||||
return filepath.Join(dataDir, "backups")
|
return filepath.Join(dataDir, "backups")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run executes one backup pass: checkpoint WAL → copy the database file
|
// Run executes one backup pass: VACUUM INTO a fresh snapshot → optionally
|
||||||
// → optionally zip → rotate. Returns the path of the file that was
|
// zip → rotate. Returns the path of the file that was written so the caller
|
||||||
// written so the caller can surface it to the UI.
|
// can surface it to the UI.
|
||||||
//
|
//
|
||||||
// dbConn is used to issue the WAL checkpoint so the on-disk file is
|
// VACUUM INTO produces a transactionally-consistent copy in a single SQL
|
||||||
// self-consistent before we copy it. It's the same *sql.DB the app uses;
|
// statement (no torn-copy window while the app keeps writing), and compacts
|
||||||
// SQLite tolerates concurrent reads during the copy.
|
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
|
||||||
|
// which could capture a half-written page during a concurrent write.
|
||||||
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
|
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
|
||||||
if dbConn == nil {
|
if dbConn == nil {
|
||||||
return "", fmt.Errorf("nil db connection")
|
return "", fmt.Errorf("nil db connection")
|
||||||
@@ -56,24 +57,38 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
|||||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||||
return "", fmt.Errorf("create backup folder: %w", err)
|
return "", fmt.Errorf("create backup folder: %w", err)
|
||||||
}
|
}
|
||||||
// Flush WAL into the main file so a raw copy is a complete database.
|
|
||||||
// TRUNCATE removes the -wal file's contents after checkpointing.
|
|
||||||
if _, err := dbConn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE);`); err != nil {
|
|
||||||
return "", fmt.Errorf("wal_checkpoint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stamp := time.Now().Format("2006-01-02")
|
stamp := time.Now().Format("2006-01-02")
|
||||||
base := fmt.Sprintf("opslog-%s", stamp)
|
base := fmt.Sprintf("opslog-%s", stamp)
|
||||||
|
|
||||||
|
// VACUUM INTO requires a non-existent target → use a temp file, then
|
||||||
|
// move/zip it into place.
|
||||||
|
tmp := filepath.Join(folder, base+".vacuum.tmp")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if _, err := dbConn.ExecContext(ctx, `VACUUM INTO ?;`, tmp); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("vacuum into: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var dstPath string
|
var dstPath string
|
||||||
if doZip {
|
if doZip {
|
||||||
dstPath = filepath.Join(folder, base+".db.zip")
|
dstPath = filepath.Join(folder, base+".db.zip")
|
||||||
if err := copyZipped(dbPath, dstPath); err != nil {
|
// Inner name = the live DB's base so unzip restores e.g. "opslog.db".
|
||||||
|
if err := copyZipped(tmp, dstPath, filepath.Base(dbPath)); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
} else {
|
} else {
|
||||||
dstPath = filepath.Join(folder, base+".db")
|
dstPath = filepath.Join(folder, base+".db")
|
||||||
if err := copyFile(dbPath, dstPath); err != nil {
|
_ = os.Remove(dstPath)
|
||||||
return "", err
|
if err := os.Rename(tmp, dstPath); err != nil {
|
||||||
|
// Rename can fail across filesystems — fall back to a copy.
|
||||||
|
if cerr := copyFile(tmp, dstPath); cerr != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", cerr
|
||||||
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +99,54 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
|||||||
return dstPath, nil
|
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
|
// 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.
|
// the source is the live database; we want a fresh standalone file.
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
@@ -105,9 +168,9 @@ func copyFile(src, dst string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// copyZipped writes a single-entry deflate zip containing the database.
|
// copyZipped writes a single-entry deflate zip containing the database.
|
||||||
// The inner filename is just the base of the source so unzip restores
|
// innerName is the entry name inside the zip (the live DB's base name) so
|
||||||
// "hamlog.db" wherever the user extracts.
|
// unzip restores e.g. "opslog.db" wherever the user extracts.
|
||||||
func copyZipped(src, dst string) error {
|
func copyZipped(src, dst, innerName string) error {
|
||||||
in, err := os.Open(src)
|
in, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open source: %w", err)
|
return fmt.Errorf("open source: %w", err)
|
||||||
@@ -119,7 +182,7 @@ func copyZipped(src, dst string) error {
|
|||||||
return fmt.Errorf("create dest: %w", err)
|
return fmt.Errorf("create dest: %w", err)
|
||||||
}
|
}
|
||||||
zw := zip.NewWriter(out)
|
zw := zip.NewWriter(out)
|
||||||
w, err := zw.Create(filepath.Base(src))
|
w, err := zw.Create(innerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zw.Close()
|
zw.Close()
|
||||||
out.Close()
|
out.Close()
|
||||||
@@ -140,10 +203,18 @@ func copyZipped(src, dst string) error {
|
|||||||
return out.Close()
|
return out.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotate keeps the most recent `keep` backups in folder and deletes the
|
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
|
||||||
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
|
// opslog-*.db.zip) and deletes the rest.
|
||||||
// are touched — never user files that happen to live in the same folder.
|
|
||||||
func rotate(folder string, keep int) error {
|
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)
|
entries, err := os.ReadDir(folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -158,10 +229,17 @@ func rotate(folder string, keep int) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := e.Name()
|
name := e.Name()
|
||||||
if !strings.HasPrefix(name, "opslog-") {
|
if !strings.HasPrefix(name, prefix) {
|
||||||
continue
|
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
|
continue
|
||||||
}
|
}
|
||||||
info, err := e.Info()
|
info, err := e.Info()
|
||||||
@@ -183,16 +261,25 @@ func rotate(folder string, keep int) error {
|
|||||||
return firstErr
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasBackupToday returns true if a backup for today's date already exists
|
// HasBackupToday returns true if a SQLite backup for today's date already
|
||||||
// in folder. Used by the startup auto-backup to skip when the user has
|
// exists in folder. Used by the auto-backup to skip when the user has already
|
||||||
// already restarted the app once today.
|
// restarted the app once today.
|
||||||
func HasBackupToday(folder string) bool {
|
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 == "" {
|
if folder == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
stamp := time.Now().Format("2006-01-02")
|
stamp := time.Now().Format("2006-01-02")
|
||||||
for _, ext := range []string{".db", ".db.zip"} {
|
for _, ext := range exts {
|
||||||
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
|
if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+293
-24
@@ -27,6 +27,9 @@ type Backend interface {
|
|||||||
// Implementations decide USB vs LSB (typically by current freq) and
|
// Implementations decide USB vs LSB (typically by current freq) and
|
||||||
// generic vs specific digital modes (most rigs just have DATA).
|
// generic vs specific digital modes (most rigs just have DATA).
|
||||||
SetMode(mode string) error
|
SetMode(mode string) error
|
||||||
|
// SetPTT keys (on=true) or unkeys the transmitter. Used by the Digital
|
||||||
|
// Voice Keyer to put the rig into TX while a message plays.
|
||||||
|
SetPTT(on bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// RigState is the snapshot exchanged with the frontend.
|
// RigState is the snapshot exchanged with the frontend.
|
||||||
@@ -54,6 +57,7 @@ type RigState struct {
|
|||||||
// Manager owns the active backend and runs the polling loop.
|
// Manager owns the active backend and runs the polling loop.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
|
||||||
state RigState
|
state RigState
|
||||||
emit func(RigState)
|
emit func(RigState)
|
||||||
backend Backend
|
backend Backend
|
||||||
@@ -112,7 +116,13 @@ func (m *Manager) State() RigState {
|
|||||||
// state.Error rather than returned, so the UI can keep retrying via the
|
// state.Error rather than returned, so the UI can keep retrying via the
|
||||||
// poll loop on next reconnect attempt.
|
// poll loop on next reconnect attempt.
|
||||||
func (m *Manager) Start(b Backend) {
|
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.mu.Lock()
|
||||||
m.stopCh = make(chan struct{})
|
m.stopCh = make(chan struct{})
|
||||||
m.doneCh = make(chan struct{})
|
m.doneCh = make(chan struct{})
|
||||||
@@ -131,6 +141,18 @@ func (m *Manager) Start(b Backend) {
|
|||||||
|
|
||||||
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
|
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
|
||||||
func (m *Manager) Stop() {
|
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()
|
m.mu.Lock()
|
||||||
stop := m.stopCh
|
stop := m.stopCh
|
||||||
done := m.doneCh
|
done := m.doneCh
|
||||||
@@ -145,10 +167,6 @@ func (m *Manager) Stop() {
|
|||||||
if done != nil {
|
if done != nil {
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
|
||||||
m.state = RigState{Enabled: false}
|
|
||||||
m.mu.Unlock()
|
|
||||||
m.emitState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
||||||
@@ -161,6 +179,247 @@ func (m *Manager) SetMode(mode string) error {
|
|||||||
return m.exec(func(b Backend) error { return b.SetMode(mode) })
|
return m.exec(func(b Backend) error { return b.SetMode(mode) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
|
||||||
|
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
|
||||||
|
AGCMode string `json:"agc_mode,omitempty"`
|
||||||
|
AGCThreshold int `json:"agc_threshold"`
|
||||||
|
AudioLevel int `json:"audio_level"`
|
||||||
|
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
|
||||||
|
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"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
func (m *Manager) exec(fn func(Backend) error) error {
|
func (m *Manager) exec(fn func(Backend) error) error {
|
||||||
@@ -187,23 +446,27 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
defer runtime.UnlockOSThread()
|
defer runtime.UnlockOSThread()
|
||||||
defer close(done)
|
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()
|
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)
|
ticker := time.NewTicker(pollEvery)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -215,12 +478,18 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
fn()
|
fn()
|
||||||
m.applyCommandDelay()
|
m.applyCommandDelay()
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
if !connected {
|
||||||
|
tryConnect()
|
||||||
|
continue
|
||||||
|
}
|
||||||
ns, err := b.ReadState()
|
ns, err := b.ReadState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.update(RigState{
|
// Lost the rig — drop the backend so the next attempt reconnects
|
||||||
Enabled: true, Backend: b.Name(), Connected: false,
|
// cleanly, then back off before retrying.
|
||||||
Error: err.Error(), UpdatedAt: time.Now(),
|
connected = false
|
||||||
})
|
lastAttempt = time.Now()
|
||||||
|
b.Disconnect()
|
||||||
|
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ns.Enabled = true
|
ns.Enabled = true
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// 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
|
||||||
|
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)
|
||||||
|
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||||
|
|
||||||
|
SubDataMode = 0x06
|
||||||
|
SubPTT = 0x00
|
||||||
|
|
||||||
|
// CmdLevel sub-commands.
|
||||||
|
SubLevelAF = 0x01 // AF (volume)
|
||||||
|
SubLevelRF = 0x02 // RF gain
|
||||||
|
SubLevelNR = 0x06 // noise-reduction depth
|
||||||
|
SubLevelNB = 0x12 // noise-blanker depth
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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
|
||||||
|
rx []byte // accumulated bytes awaiting a complete frame
|
||||||
|
model string
|
||||||
|
|
||||||
|
curFreq int64 // last frequency read (for sideband choice)
|
||||||
|
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
b.port = port
|
||||||
|
b.rx = b.rx[:0]
|
||||||
|
b.model = civ.ModelName(b.rigAddr)
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.readDSP() // best-effort initial snapshot for the control tab
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) Disconnect() {
|
||||||
|
if b.port != nil {
|
||||||
|
_ = b.port.Close()
|
||||||
|
b.port = 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 {
|
||||||
|
return RigState{}, err
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// recv reads from the port until a frame from the rig satisfies match or the
|
||||||
|
// timeout elapses. Frames that are our own echo (from == controller) or don't
|
||||||
|
// match are discarded.
|
||||||
|
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
tmp := make([]byte, 256)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := b.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return civ.Decoded{}, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.rx = append(b.rx, tmp[:n]...)
|
||||||
|
frames, consumed := civ.Scan(b.rx)
|
||||||
|
if consumed > 0 {
|
||||||
|
b.rx = append(b.rx[:0], b.rx[consumed:]...)
|
||||||
|
}
|
||||||
|
for _, f := range frames {
|
||||||
|
if f.From != b.rigAddr {
|
||||||
|
continue // skip echo of our own commands
|
||||||
|
}
|
||||||
|
if match(f) {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
st.Mode = b.dsp.Mode // preserve mode (set by ReadState)
|
||||||
|
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.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) 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
|
||||||
|
}
|
||||||
+30
-12
@@ -6,20 +6,38 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// debugLog writes CAT debug events to %APPDATA%/HamLog/cat.log so users can
|
// LogSink, when set by the host app at startup, receives every CAT debug
|
||||||
// diagnose mode/freq mismatches without rebuilding with -windowsconsole.
|
// line so they land in the unified app log (opslog.log) alongside the rest
|
||||||
//
|
// of OpsLog's diagnostics. Until it's wired we fall back to a dedicated
|
||||||
// Initialised lazily on first use. Falls back to the standard library
|
// cat.log file so early-startup lines aren't lost.
|
||||||
// default logger (stderr, usually invisible in a Wails GUI build) if the
|
var LogSink func(format string, args ...any)
|
||||||
// log file can't be opened.
|
|
||||||
var debugLog = openDebugLog()
|
|
||||||
|
|
||||||
func openDebugLog() *log.Logger {
|
// catLogger forwards Printf either to the host LogSink (preferred) or to a
|
||||||
|
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
|
||||||
|
// unchanged.
|
||||||
|
type catLogger struct{ fallback *log.Logger }
|
||||||
|
|
||||||
|
func (c *catLogger) Printf(format string, args ...any) {
|
||||||
|
if LogSink != nil {
|
||||||
|
LogSink("cat: "+format, args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.fallback != nil {
|
||||||
|
c.fallback.Printf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
|
||||||
|
// without rebuilding with a console. Once LogSink is set, lines flow into the
|
||||||
|
// main opslog.log.
|
||||||
|
var debugLog = &catLogger{fallback: openFallbackLog()}
|
||||||
|
|
||||||
|
func openFallbackLog() *log.Logger {
|
||||||
base, err := os.UserConfigDir()
|
base, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return log.Default()
|
return log.Default()
|
||||||
}
|
}
|
||||||
dir := filepath.Join(base, "HamLog")
|
dir := filepath.Join(base, "OpsLog")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return log.Default()
|
return log.Default()
|
||||||
}
|
}
|
||||||
@@ -31,12 +49,12 @@ func openDebugLog() *log.Logger {
|
|||||||
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DebugLogPath returns the path the cat.log file would be opened at, for
|
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the
|
||||||
// surfacing in the UI / docs.
|
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
|
||||||
func DebugLogPath() string {
|
func DebugLogPath() string {
|
||||||
base, err := os.UserConfigDir()
|
base, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return filepath.Join(base, "HamLog", "cat.log")
|
return filepath.Join(base, "OpsLog", "cat.log")
|
||||||
}
|
}
|
||||||
|
|||||||
+213
-62
@@ -3,11 +3,21 @@ package cat
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-ole/go-ole"
|
"github.com/go-ole/go-ole"
|
||||||
"github.com/go-ole/go-ole/oleutil"
|
"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.
|
// 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
|
// All methods MUST be called from the same OS thread (the one Manager.run
|
||||||
@@ -21,6 +31,15 @@ type OmniRig struct {
|
|||||||
|
|
||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *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.
|
// 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 {
|
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
||||||
s.Mode = omniRigMode(modeVar.Val)
|
s.Mode = omniRigMode(modeVar.Val)
|
||||||
}
|
}
|
||||||
|
rawVfo := int64(0)
|
||||||
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||||
|
rawVfo = vfoVar.Val
|
||||||
s.Vfo = omniRigVfo(vfoVar.Val)
|
s.Vfo = omniRigVfo(vfoVar.Val)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read both VFO frequencies separately so we can expose split TX/RX.
|
// Read the active/displayed frequency (generic Freq) AND both VFOs. The
|
||||||
// Fall back to generic Freq if the rig only exposes the merged property.
|
// generic Freq is what the rig is operating on — the reliable source for the
|
||||||
freqA, freqB := int64(0), int64(0)
|
// 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 {
|
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||||
freqA = v.Val
|
freqA = v.Val
|
||||||
}
|
}
|
||||||
@@ -121,38 +146,58 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
freqB = v.Val
|
freqB = v.Val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split detection: trust the explicit Split property when it's set,
|
// Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
|
||||||
// BUT only call it a real split if both VFO frequencies are non-zero
|
// compared to PM_SPLITON, not "!= 0".
|
||||||
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
|
splitRaw := int64(0)
|
||||||
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
|
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
|
||||||
// slice model doesn't map to VFO A/B — that would yield a useless
|
splitRaw = v.Val
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
|
||||||
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
|
// normal operation stays quiet but toggling split on the radio is captured —
|
||||||
switch s.Vfo {
|
// needed to pin down this rig's PM_SPLITON value.
|
||||||
case "AB":
|
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
|
||||||
s.FreqHz = freqB // TX
|
o.lastSig = sig
|
||||||
s.RxFreqHz = freqA // RX
|
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
|
||||||
case "BA":
|
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
|
||||||
s.FreqHz = freqA // TX
|
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
|
||||||
s.RxFreqHz = freqB // RX
|
return v.Val
|
||||||
case "B", "BB":
|
}
|
||||||
s.FreqHz = freqB
|
return -1
|
||||||
default: // "A", "AA", "" — single VFO on A or unknown
|
}())
|
||||||
s.FreqHz = freqA
|
|
||||||
}
|
}
|
||||||
if s.FreqHz == 0 {
|
|
||||||
// Last resort — some rigs only update generic Freq.
|
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
|
||||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
// distinct and non-zero, AND they're in the same band. The same-band test
|
||||||
s.FreqHz = v.Val
|
// 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 {
|
||||||
|
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
||||||
|
// ADIF: FreqHz = TX, RxFreqHz = RX.
|
||||||
|
s.Split = true
|
||||||
|
switch s.Vfo {
|
||||||
|
case "BA":
|
||||||
|
s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B
|
||||||
|
default: // "AB" and the usual "TX on the other VFO" case
|
||||||
|
s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A
|
||||||
|
}
|
||||||
|
} 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
|
return s, nil
|
||||||
@@ -160,42 +205,96 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
|
|
||||||
func (o *OmniRig) SetFrequency(hz int64) error {
|
func (o *OmniRig) SetFrequency(hz int64) error {
|
||||||
if o.rig == nil {
|
if o.rig == nil {
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
|
||||||
return fmt.Errorf("not connected")
|
return fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
|
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
|
||||||
if hz < 0 || hz > 0x7fffffff {
|
if hz < 0 || hz > 0x7fffffff {
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency(%d): out of int32 range", hz)
|
||||||
return fmt.Errorf("frequency out of OmniRig int32 range")
|
return fmt.Errorf("frequency out of OmniRig int32 range")
|
||||||
}
|
}
|
||||||
hz32 := int32(hz)
|
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()
|
||||||
|
|
||||||
// Pick the right OmniRig property. Many rig .ini files only define a
|
// Log the rig's writable-params, status and VFO state up front so a
|
||||||
// WRITE command for FreqA/FreqB but not the generic Freq — in which case
|
// friend's session shows exactly what OmniRig reports for their rig.
|
||||||
// PutProperty(Freq) silently succeeds but the rig never moves. Write to
|
status, statusStr, rigType := int64(-1), "", ""
|
||||||
// the active VFO's specific property when we know it; fall back to Freq.
|
if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
|
||||||
prop := "FreqA"
|
status = v.Val
|
||||||
|
}
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
|
||||||
|
statusStr = v.ToString()
|
||||||
|
}
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||||
|
rigType = v.ToString()
|
||||||
|
}
|
||||||
|
rawVfo, vfo := int64(-1), ""
|
||||||
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||||
switch omniRigVfo(vfoVar.Val) {
|
rawVfo = vfoVar.Val
|
||||||
|
vfo = omniRigVfo(vfoVar.Val)
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency: Vfo read error: %v", err)
|
||||||
|
}
|
||||||
|
split := int64(0)
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
|
||||||
|
split = v.Val
|
||||||
|
}
|
||||||
|
// What can this rig's .ini actually write? OmniRig exposes a WriteableParams
|
||||||
|
// bitmask — if FreqA/FreqB/Freq bits are missing, the write is a silent no-op.
|
||||||
|
writeable := int64(-1)
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
|
||||||
|
writeable = v.Val
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz): rig=%q status=%d(%s) vfo=%q(raw=%d) split=%d writeableParams=0x%X",
|
||||||
|
hz, float64(hz)/1e6, rigType, status, statusStr, vfo, rawVfo, split, writeable)
|
||||||
|
|
||||||
|
// Primary path: OmniRig's SetSimplexMode is the rig-agnostic "QSY here"
|
||||||
|
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
||||||
|
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
||||||
|
// Clearing split is the right thing when tuning to a spot anyway.
|
||||||
|
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err)
|
||||||
|
// Fallback: write the active VFO's property AND the generic Freq
|
||||||
|
// (always — some .ini honour only one, and split here is often misread).
|
||||||
|
prop := "FreqA"
|
||||||
|
switch vfo {
|
||||||
case "B", "BB", "BA":
|
case "B", "BB", "BA":
|
||||||
prop = "FreqB"
|
prop = "FreqB"
|
||||||
case "A", "AA", "AB":
|
|
||||||
prop = "FreqA"
|
|
||||||
}
|
}
|
||||||
}
|
okAny := false
|
||||||
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz) → %s", hz, float64(hz)/1e6, prop)
|
for _, p := range []string{prop, "Freq"} {
|
||||||
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil {
|
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
|
||||||
debugLog.Printf("OmniRig.SetFrequency(%s) error: %v — falling back to Freq", prop, err)
|
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
|
||||||
if _, err2 := oleutil.PutProperty(o.rig, "Freq", hz32); err2 != nil {
|
} else {
|
||||||
debugLog.Printf("OmniRig.SetFrequency(Freq) also failed: %v", err2)
|
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", p, hz32)
|
||||||
return err2
|
okAny = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !okAny {
|
||||||
|
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read back the active VFO freq after a short delay so the log shows
|
// Read back all three immediately. OmniRig is async (the CAT command is
|
||||||
// whether the rig actually moved. Useful when the .ini accepts the write
|
// queued + sent over serial), so these may still show the OLD value for
|
||||||
// silently but the rig doesn't honour it (wrong WRITE command etc.).
|
// one poll cycle — but if they NEVER change in the next poll, the rig
|
||||||
if fv, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
// isn't honouring the write (wrong .ini WRITE command for this model).
|
||||||
debugLog.Printf("OmniRig.Freq immediately after Put = %d Hz", fv.Val)
|
fa, fb, fg := int64(-1), int64(-1), int64(-1)
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||||
|
fa = v.Val
|
||||||
}
|
}
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
|
||||||
|
fb = v.Val
|
||||||
|
}
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
|
fg = v.Val
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,9 +323,15 @@ func (o *OmniRig) SetMode(mode string) error {
|
|||||||
case "CW":
|
case "CW":
|
||||||
bit, bitName = pmCWU, "PM_CW_U"
|
bit, bitName = pmCWU, "PM_CW_U"
|
||||||
case "SSB":
|
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
|
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
|
freq = freqVar.Val
|
||||||
}
|
}
|
||||||
if freq > 0 && freq < 10_000_000 {
|
if freq > 0 && freq < 10_000_000 {
|
||||||
@@ -263,6 +368,48 @@ func (o *OmniRig) SetMode(mode string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPTT keys or unkeys the rig via OmniRig's SetTx(PM_RX|PM_TX). Used by the
|
||||||
|
// Digital Voice Keyer to put the rig into TX while a voice message plays.
|
||||||
|
func (o *OmniRig) SetPTT(on bool) error {
|
||||||
|
if o.rig == nil {
|
||||||
|
debugLog.Printf("OmniRig.SetPTT(%v): NOT CONNECTED", on)
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
status, statusStr, writeable := int64(-1), "", int64(-1)
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
|
||||||
|
status = v.Val
|
||||||
|
}
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
|
||||||
|
statusStr = v.ToString()
|
||||||
|
}
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
|
||||||
|
writeable = v.Val
|
||||||
|
}
|
||||||
|
txWriteable := writeable != -1 && writeable&pmTX != 0
|
||||||
|
param, name := pmRX, "PM_RX"
|
||||||
|
if on {
|
||||||
|
param, name = pmTX, "PM_TX"
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s",
|
||||||
|
on, status, statusStr, writeable, txWriteable, name)
|
||||||
|
// When OmniRig DID report its writeable params (writeable != -1) and PM_TX
|
||||||
|
// is NOT among them, writing Tx is a silent no-op: the rig never keys and
|
||||||
|
// SetPTT would otherwise return success, leaving the user puzzled ("Test PTT
|
||||||
|
// does nothing"). Surface a clear, actionable error instead. If we couldn't
|
||||||
|
// read the writeable params (-1), fall through and try anyway (best effort).
|
||||||
|
if on && writeable != -1 && writeable&pmTX == 0 {
|
||||||
|
debugLog.Printf("OmniRig.SetPTT: ⚠ PM_TX not writeable for this rig profile (writeableParams=0x%X)", writeable)
|
||||||
|
return fmt.Errorf("this rig's OmniRig profile doesn't expose CAT TX keying (PM_TX not writeable) — use RTS/DTR or VOX for PTT")
|
||||||
|
}
|
||||||
|
// OmniRig has NO SetTx method (that returns "unknown name"); the Tx
|
||||||
|
// parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX).
|
||||||
|
if _, err := oleutil.PutProperty(o.rig, "Tx", int32(param)); err != nil {
|
||||||
|
debugLog.Printf("OmniRig.SetPTT error: %v", err)
|
||||||
|
return fmt.Errorf("set Tx=%s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ===== OmniRig enum decoders =====
|
// ===== OmniRig enum decoders =====
|
||||||
|
|
||||||
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
|
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
|
||||||
@@ -272,6 +419,8 @@ func (o *OmniRig) SetMode(mode string) error {
|
|||||||
// too low which causes every mode to map to the slot below it (AM → DIG_L,
|
// too low which causes every mode to map to the slot below it (AM → DIG_L,
|
||||||
// FT8 → SSB_L, etc.).
|
// FT8 → SSB_L, etc.).
|
||||||
const (
|
const (
|
||||||
|
pmRX int64 = 1 << 20 // 0x00100000 — PM_RX (receive)
|
||||||
|
pmTX int64 = 1 << 21 // 0x00200000 — PM_TX (transmit / PTT on)
|
||||||
pmCWU int64 = 1 << 23 // 0x00800000
|
pmCWU int64 = 1 << 23 // 0x00800000
|
||||||
pmCWL int64 = 1 << 24 // 0x01000000
|
pmCWL int64 = 1 << 24 // 0x01000000
|
||||||
pmSSBU int64 = 1 << 25 // 0x02000000
|
pmSSBU int64 = 1 << 25 // 0x02000000
|
||||||
@@ -302,20 +451,22 @@ func omniRigMode(m int64) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
|
||||||
|
// documented PM_VFO* constants.
|
||||||
func omniRigVfo(v int64) string {
|
func omniRigVfo(v int64) string {
|
||||||
switch {
|
switch {
|
||||||
case v&1024 != 0:
|
case v&0x40 != 0: // PM_VFOAA
|
||||||
return "A"
|
|
||||||
case v&2048 != 0:
|
|
||||||
return "B"
|
|
||||||
case v&64 != 0:
|
|
||||||
return "AA"
|
return "AA"
|
||||||
case v&128 != 0:
|
case v&0x80 != 0: // PM_VFOAB
|
||||||
return "AB"
|
return "AB"
|
||||||
case v&256 != 0:
|
case v&0x100 != 0: // PM_VFOBA
|
||||||
return "BA"
|
return "BA"
|
||||||
case v&512 != 0:
|
case v&0x200 != 0: // PM_VFOBB
|
||||||
return "BB"
|
return "BB"
|
||||||
|
case v&0x400 != 0: // PM_VFOA
|
||||||
|
return "A"
|
||||||
|
case v&0x800 != 0: // PM_VFOB
|
||||||
|
return "B"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Package clublog uses the ClubLog Country File (cty.xml) to resolve callsign
|
||||||
|
// EXCEPTIONS — date-ranged full-callsign overrides for DXpeditions and special
|
||||||
|
// operations that prefix-based files (cty.dat) get wrong. e.g. VK2/SP9FIH was
|
||||||
|
// Lord Howe Island (not Australia) between specific 2025 dates.
|
||||||
|
package clublog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exception is one date-ranged full-callsign override.
|
||||||
|
type Exception struct {
|
||||||
|
Call string
|
||||||
|
Entity string
|
||||||
|
ADIF int
|
||||||
|
CQZ int
|
||||||
|
Cont string
|
||||||
|
Lat float64
|
||||||
|
Lon float64
|
||||||
|
Start time.Time // zero = no lower bound
|
||||||
|
End time.Time // zero = no upper bound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Exception) covers(t time.Time) bool {
|
||||||
|
if !e.Start.IsZero() && t.Before(e.Start) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !e.End.IsZero() && t.After(e.End) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB holds the parsed exception + prefix tables. Exceptions are keyed by the
|
||||||
|
// full callsign; prefixes are keyed by the prefix string (both may hold several
|
||||||
|
// date-ranged entries). cty.xml carries entity + CQ zone + continent per
|
||||||
|
// record, but NOT ITU zone.
|
||||||
|
type DB struct {
|
||||||
|
exceptions map[string][]Exception
|
||||||
|
prefixes map[string][]Exception
|
||||||
|
date string // cty.xml generation date (for the UI)
|
||||||
|
count int
|
||||||
|
prefixCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns how many exceptions were loaded.
|
||||||
|
func (db *DB) Count() int { return db.count }
|
||||||
|
|
||||||
|
// PrefixCount returns how many prefix records were loaded.
|
||||||
|
func (db *DB) PrefixCount() int { return db.prefixCount }
|
||||||
|
|
||||||
|
// Date returns the cty.xml generation timestamp.
|
||||||
|
func (db *DB) Date() string { return db.date }
|
||||||
|
|
||||||
|
// xml decode shapes.
|
||||||
|
type xlException struct {
|
||||||
|
Call string `xml:"call"`
|
||||||
|
Entity string `xml:"entity"`
|
||||||
|
ADIF int `xml:"adif"`
|
||||||
|
CQZ int `xml:"cqz"`
|
||||||
|
Cont string `xml:"cont"`
|
||||||
|
Long string `xml:"long"`
|
||||||
|
Lat string `xml:"lat"`
|
||||||
|
Start string `xml:"start"`
|
||||||
|
End string `xml:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadGzip parses a gzipped ClubLog cty.xml stream.
|
||||||
|
func LoadGzip(r io.Reader) (*DB, error) {
|
||||||
|
zr, err := gzip.NewReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gunzip: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
return Load(zr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parses a (plain) ClubLog cty.xml stream, extracting only the
|
||||||
|
// <exceptions> section via a streaming decoder (the file is ~10 MB).
|
||||||
|
func Load(r io.Reader) (*DB, error) {
|
||||||
|
db := &DB{exceptions: map[string][]Exception{}, prefixes: map[string][]Exception{}}
|
||||||
|
dec := xml.NewDecoder(r)
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("xml: %w", err)
|
||||||
|
}
|
||||||
|
se, ok := tok.(xml.StartElement)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch se.Name.Local {
|
||||||
|
case "clublog":
|
||||||
|
for _, a := range se.Attr {
|
||||||
|
if a.Name.Local == "date" {
|
||||||
|
db.date = a.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "exception":
|
||||||
|
var x xlException
|
||||||
|
if err := dec.DecodeElement(&x, &se); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(x.Call))
|
||||||
|
if call == "" || x.ADIF == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := Exception{
|
||||||
|
Call: call, Entity: x.Entity, ADIF: x.ADIF, CQZ: x.CQZ,
|
||||||
|
Cont: strings.ToUpper(strings.TrimSpace(x.Cont)),
|
||||||
|
Lat: parseFloat(x.Lat), Lon: parseFloat(x.Long),
|
||||||
|
Start: parseTime(x.Start), End: parseTime(x.End),
|
||||||
|
}
|
||||||
|
db.exceptions[call] = append(db.exceptions[call], e)
|
||||||
|
db.count++
|
||||||
|
case "prefix":
|
||||||
|
var x xlException // same shape; <call> holds the prefix string
|
||||||
|
if err := dec.DecodeElement(&x, &se); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx := strings.ToUpper(strings.TrimSpace(x.Call))
|
||||||
|
if pfx == "" || x.ADIF == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := Exception{
|
||||||
|
Call: pfx, Entity: x.Entity, ADIF: x.ADIF, CQZ: x.CQZ,
|
||||||
|
Cont: strings.ToUpper(strings.TrimSpace(x.Cont)),
|
||||||
|
Lat: parseFloat(x.Lat), Lon: parseFloat(x.Long),
|
||||||
|
Start: parseTime(x.Start), End: parseTime(x.End),
|
||||||
|
}
|
||||||
|
db.prefixes[pfx] = append(db.prefixes[pfx], e)
|
||||||
|
db.prefixCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePrefix returns the longest prefix entry matching a callsign and valid
|
||||||
|
// at the given date (cascade step 2). The callsign should already be normalized
|
||||||
|
// (operating affixes stripped); we still strip a trailing "/x" defensively.
|
||||||
|
func (db *DB) ResolvePrefix(call string, date time.Time) (Exception, bool) {
|
||||||
|
if db == nil {
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
if i := strings.LastIndex(c, "/"); i > 0 {
|
||||||
|
// Prefer the operating prefix when present (MM/DL1ABC → MM).
|
||||||
|
if pre, post := c[:i], c[i+1:]; len(pre) <= len(post) {
|
||||||
|
c = pre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for n := len(c); n >= 1; n-- {
|
||||||
|
for _, e := range db.prefixes[c[:n]] {
|
||||||
|
if e.covers(date) {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFull runs the ClubLog cascade: a full-callsign Exception first, then
|
||||||
|
// the longest valid Prefix. Returns the matched record and its source
|
||||||
|
// ("exception" | "prefix"), or ok=false when ClubLog has nothing (caller falls
|
||||||
|
// back to cty.dat).
|
||||||
|
func (db *DB) ResolveFull(call string, date time.Time) (e Exception, source string, ok bool) {
|
||||||
|
if db == nil {
|
||||||
|
return Exception{}, "", false
|
||||||
|
}
|
||||||
|
if e, ok := db.Resolve(call, date); ok {
|
||||||
|
return e, "exception", true
|
||||||
|
}
|
||||||
|
if e, ok := db.ResolvePrefix(call, date); ok {
|
||||||
|
return e, "prefix", true
|
||||||
|
}
|
||||||
|
return Exception{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the exception for a callsign valid at the given date, if any.
|
||||||
|
// It tries the call as-is, then with a trailing "/x" affix stripped (so
|
||||||
|
// VK2/SP9FIH/P still matches the VK2/SP9FIH exception).
|
||||||
|
func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
|
||||||
|
if db == nil {
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
for _, key := range candidates(c) {
|
||||||
|
for _, e := range db.exceptions[key] {
|
||||||
|
if e.covers(date) {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidates yields the call and a version with one trailing affix removed.
|
||||||
|
func candidates(c string) []string {
|
||||||
|
out := []string{c}
|
||||||
|
if i := strings.LastIndex(c, "/"); i > 0 {
|
||||||
|
suffix := c[i+1:]
|
||||||
|
// Only strip short operational affixes, not a real prefix override
|
||||||
|
// (e.g. keep "VK2/SP9FIH" intact; strip "VK2/SP9FIH/P").
|
||||||
|
switch suffix {
|
||||||
|
case "P", "M", "MM", "AM", "QRP", "A":
|
||||||
|
out = append(out, c[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(s string) time.Time {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var f float64
|
||||||
|
fmt.Sscanf(s, "%g", &f)
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package clublog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ctyURL is the ClubLog Country File endpoint. It returns a gzipped cty.xml.
|
||||||
|
const ctyURL = "https://cdn.clublog.org/cty.php?api="
|
||||||
|
|
||||||
|
// Manager owns the on-disk cty.xml.gz cache and the parsed exception DB.
|
||||||
|
type Manager struct {
|
||||||
|
apiKey string
|
||||||
|
cacheDir string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(apiKey, cacheDir string) *Manager {
|
||||||
|
return &Manager{apiKey: apiKey, cacheDir: cacheDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path is where the cached gzipped country file lives.
|
||||||
|
func (m *Manager) Path() string {
|
||||||
|
return filepath.Join(m.cacheDir, "clublog_cty.xml.gz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loaded reports whether an exception DB is in memory.
|
||||||
|
func (m *Manager) Loaded() bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.db != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns the loaded file's generation date + exception count (zeros when
|
||||||
|
// not loaded).
|
||||||
|
func (m *Manager) Info() (date string, count int) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if m.db == nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
return m.db.Date(), m.db.Count()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
|
||||||
|
func (m *Manager) EnsureLoaded() error {
|
||||||
|
if m.Loaded() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.LoadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromDisk parses the cached cty.xml.gz and swaps it in.
|
||||||
|
func (m *Manager) LoadFromDisk() error {
|
||||||
|
f, err := os.Open(m.Path())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
db, err := LoadGzip(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.db = db
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download fetches a fresh cty.xml.gz from ClubLog and writes it atomically,
|
||||||
|
// then loads it.
|
||||||
|
func (m *Manager) Download(ctx context.Context) error {
|
||||||
|
if m.apiKey == "" {
|
||||||
|
return fmt.Errorf("clublog api key not set")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(m.cacheDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", ctyURL+m.apiKey, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("clublog HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(m.cacheDir, "clublog-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
if err := os.Rename(tmpName, m.Path()); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.LoadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the matching exception for a callsign at a date, if loaded.
|
||||||
|
func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if db == nil {
|
||||||
|
return Exception{}, false
|
||||||
|
}
|
||||||
|
return db.Resolve(call, date)
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ type Spot struct {
|
|||||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
|
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||||
|
POTAName string `json:"pota_name,omitempty"` // park name
|
||||||
}
|
}
|
||||||
|
|
||||||
// State enumerates the per-server lifecycle.
|
// State enumerates the per-server lifecycle.
|
||||||
@@ -315,7 +317,7 @@ func (s *session) run() {
|
|||||||
// Returns the moment we marked the link "connected" (zero if dial failed)
|
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||||
// and the error that ended the session (nil if stopCh).
|
// and the error that ended the session (nil if stopCh).
|
||||||
func (s *session) runOnce() (time.Time, error) {
|
func (s *session) runOnce() (time.Time, error) {
|
||||||
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
|
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
|
||||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user