Compare commits
491
Commits
14c87f7fa9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd9fddbc5b | ||
|
|
74acf88976 | ||
|
|
da9c76e161 | ||
|
|
fb78b1c052 | ||
|
|
e51abd262e | ||
|
|
0ec855d95a | ||
|
|
ae06495f91 | ||
|
|
4dd2c3b997 | ||
|
|
ec71dd1661 | ||
|
|
31f9bdfc98 | ||
|
|
57e98139ab | ||
|
|
b10a867125 | ||
|
|
17819ea673 | ||
|
|
a8fac52400 | ||
|
|
2bf98a9801 | ||
|
|
80dac64b56 | ||
|
|
79427ccd18 | ||
|
|
4352b9aec5 | ||
|
|
a0f7f2abf0 | ||
|
|
40f5960c76 | ||
|
|
59135d55ab | ||
|
|
44cf5954fd | ||
|
|
f1b7a7e477 | ||
|
|
19ae00124f | ||
|
|
642ed358c2 | ||
|
|
1d8cd25205 | ||
|
|
1f55cfe9fa | ||
|
|
90c6458af0 | ||
|
|
18f44a5aa3 | ||
|
|
4d13cf7d15 | ||
|
|
366d9df634 | ||
|
|
f4b7c9dcf8 | ||
|
|
48c448e6f3 | ||
|
|
9c757e5175 | ||
|
|
626edca8ba | ||
|
|
b202d98ae5 | ||
|
|
949cc17ed1 | ||
|
|
e9feeffdc3 | ||
|
|
7d7d1042c0 | ||
|
|
9cbfd39da0 | ||
|
|
5338ad0b29 | ||
|
|
970127cc16 | ||
|
|
3704dbdd99 | ||
|
|
3cdf16b501 | ||
|
|
9a72afd467 | ||
|
|
e2bfe73bdb | ||
|
|
6e2e2cc3aa | ||
|
|
75d11a4069 | ||
|
|
cfe5d6f2a7 | ||
|
|
78c289d681 | ||
|
|
761f554ac0 | ||
|
|
4c1b4fd1a8 | ||
|
|
f2cc4f11cd | ||
|
|
faf084ddfc | ||
|
|
01dcd91253 | ||
|
|
3d603d34aa | ||
|
|
3d1602c4bd | ||
|
|
a10fb2413a | ||
|
|
bd8ca2e9de | ||
|
|
df56391eb8 | ||
|
|
82ce3353c4 | ||
|
|
20dc3e83a6 | ||
|
|
62cdabe114 | ||
|
|
9bfb44925b | ||
|
|
fb1af9b6a2 | ||
|
|
57a9bed145 | ||
|
|
fbd323af26 | ||
|
|
689d1cc902 | ||
|
|
67602fd485 | ||
|
|
2de5bce998 | ||
|
|
f0959c796d | ||
|
|
16e64fd7b7 | ||
|
|
7e696a72bb | ||
|
|
9af2732314 | ||
|
|
f82d92565f | ||
|
|
d0f25aea9b | ||
|
|
f91b83ee12 | ||
|
|
a554257d5f | ||
|
|
a20d9a0b76 | ||
|
|
92ee8aadde | ||
|
|
61c6666a4c | ||
|
|
9f5c482667 | ||
|
|
b9ea077a64 | ||
|
|
a48fa0b2e7 | ||
|
|
821883dfd3 | ||
|
|
ef087492cc | ||
|
|
98f11ee3d0 | ||
|
|
058f164ab6 | ||
|
|
6c47c27775 | ||
|
|
7410fa4825 | ||
|
|
246d9d1b6a | ||
|
|
d12cfe94cd | ||
|
|
ede81d3926 | ||
|
|
f27faa81a6 | ||
|
|
69e6c20b97 | ||
|
|
d93c40ebb3 | ||
|
|
dfe3afbf2d | ||
|
|
bb3542c920 | ||
|
|
a966abd9df | ||
|
|
95482ec1d8 | ||
|
|
500fe8bfec | ||
|
|
87ff0a9e16 | ||
|
|
18f9b915c5 | ||
|
|
b3fdd0b7fa | ||
|
|
9846354bf9 | ||
|
|
3da1d71323 | ||
|
|
72f692aa04 | ||
|
|
f6cd6e999a | ||
|
|
296a4a55c0 | ||
|
|
acf73c73c7 | ||
|
|
5777c119cb | ||
|
|
85bf0da006 | ||
|
|
b6ea07e3a3 | ||
|
|
549adc8c0e | ||
|
|
0e1e7d9f3c | ||
|
|
3dd428d748 | ||
|
|
c62d992ad6 | ||
|
|
a83acb0f9a | ||
|
|
d8b7b86eb7 | ||
|
|
3e1c36d186 | ||
|
|
6255a9f218 | ||
|
|
599621a998 | ||
|
|
d7bcf21203 | ||
|
|
589e0b9b6a | ||
|
|
c21d6cc23a | ||
|
|
ae21ddb9d7 | ||
|
|
a4623e9ea3 | ||
|
|
06e34372ed | ||
|
|
d23c4c4037 | ||
|
|
d4bfd30636 | ||
|
|
258fa717f1 | ||
|
|
aa995c250c | ||
|
|
72fee7a090 | ||
|
|
ed67ed7fe3 | ||
|
|
fddb3c45c4 | ||
|
|
da1f3eb2bd | ||
|
|
5e0bb6e68e | ||
|
|
2b61f43780 | ||
|
|
2b66e5bc7f | ||
|
|
84b06ed47b | ||
|
|
39bd1ff414 | ||
|
|
6d54100bc9 | ||
|
|
4fe5811fd2 | ||
|
|
d42e6669e1 | ||
|
|
c14353a399 | ||
|
|
734931cf54 | ||
|
|
cad4ec4b72 | ||
|
|
5623b860c2 | ||
|
|
3e22c4d1a3 | ||
|
|
d90f953df4 | ||
|
|
e4217da010 | ||
|
|
c5ac945de0 | ||
|
|
eb40991482 | ||
|
|
781cf636ea | ||
|
|
62b6fe0a3a | ||
|
|
bd2a8524dc | ||
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 | ||
|
|
93b4964b3b | ||
|
|
14c57035e1 | ||
|
|
7a7fa62af7 | ||
|
|
6ee4c430c7 | ||
|
|
0df6370c59 | ||
|
|
fa94657619 | ||
|
|
f5b74856c6 | ||
|
|
d42fdab85c | ||
|
|
83a8708d60 | ||
|
|
c0671bfe2e | ||
|
|
ec0cb95de9 | ||
|
|
9d691343cf | ||
|
|
c6088bb2de | ||
|
|
069728ac68 | ||
|
|
f221c0ee1e | ||
|
|
013492adb0 | ||
|
|
3a87b58ba3 | ||
|
|
e6b8a17772 | ||
|
|
802c6e8c43 | ||
|
|
f82631690a | ||
|
|
3b58e39eec | ||
|
|
45e3f3edb8 | ||
|
|
c931d8a762 | ||
|
|
eb271e8f20 | ||
|
|
e2d2485703 | ||
|
|
712a124081 | ||
|
|
08a932c9c1 | ||
|
|
fee2303625 | ||
|
|
4d5c5c3eb3 | ||
|
|
d322ea9a07 | ||
|
|
75c02ea2a0 | ||
|
|
ef84b04c99 | ||
|
|
fbd6cf400f | ||
|
|
b16cb973f2 | ||
|
|
da886de189 | ||
|
|
7759e09b4e | ||
|
|
a8990ff29d | ||
|
|
e3b810b4ff | ||
|
|
337392bb6d | ||
|
|
d98bb2e929 | ||
|
|
3ab6b95ff8 | ||
|
|
b0da6a3d14 | ||
|
|
2d7469a7f7 | ||
|
|
775f411606 | ||
|
|
6501e97895 | ||
|
|
f3807c21ba | ||
|
|
d536c39ef5 | ||
|
|
284a7a18c7 | ||
|
|
482f81fe45 | ||
|
|
5d7a9a9562 | ||
|
|
a036120871 | ||
|
|
ccceab9d02 | ||
|
|
35db1440e4 | ||
|
|
e3aabc06a4 | ||
|
|
ab9d0bfe0f | ||
|
|
a3fd32ec91 | ||
|
|
c297f91ca8 | ||
|
|
ee1f9ccf35 | ||
|
|
6fb7f06375 | ||
|
|
54caee37c4 | ||
|
|
15fdbce22a | ||
|
|
c7737aabd6 | ||
|
|
6f5acc2eef | ||
|
|
67a03c3ddc | ||
|
|
8da3a27803 | ||
|
|
e0cefb5c41 | ||
|
|
113faede14 | ||
|
|
8e491544dd | ||
|
|
cbe571a742 | ||
|
|
bdda7ad07a | ||
|
|
3fd6763ff0 | ||
|
|
a89c6e0df8 | ||
|
|
48d92ff547 | ||
|
|
adadb632fa | ||
|
|
b0b25c7f1f | ||
|
|
e120bd4f04 | ||
|
|
40df4fe22f | ||
|
|
d114a73729 | ||
|
|
d0666581c3 | ||
|
|
37dc2a07e5 | ||
|
|
7424bc6e81 | ||
|
|
01c93d979f | ||
|
|
0190255762 | ||
|
|
0cc806722e | ||
|
|
08f3e54afb | ||
|
|
520d17810c | ||
|
|
c9218310ae | ||
|
|
8cc6997eec | ||
|
|
6d309cada1 | ||
|
|
7153768579 | ||
|
|
df4155108f | ||
|
|
9bc5a14c69 | ||
|
|
842d4708a7 | ||
|
|
e2aba828a9 | ||
|
|
753d8d2ffa | ||
|
|
38b480a985 | ||
|
|
67005a8d50 | ||
|
|
9cfa7a4dd1 | ||
|
|
a1c4305f20 | ||
|
|
3b296b19ab | ||
|
|
8a0d76fa0c | ||
|
|
bd5f9c0746 | ||
|
|
29591c5f0c | ||
|
|
01bcf256e2 | ||
|
|
c9d1fc1cc0 | ||
|
|
f43d41892e | ||
|
|
1f2e86c04d | ||
|
|
305da583e1 | ||
|
|
71bde30e24 | ||
|
|
31b13cfbc0 | ||
|
|
30d88276f3 | ||
|
|
b18db7acfc | ||
|
|
d303dee768 | ||
|
|
ad02583ef7 | ||
|
|
add4b175fc | ||
|
|
11940ae843 | ||
|
|
91f046444a | ||
|
|
386fb7f030 | ||
|
|
5a6db2b656 | ||
|
|
c3958be0b2 | ||
|
|
a42a3d5713 | ||
|
|
0a1569dbbd | ||
|
|
843dccf0c4 | ||
|
|
d49126d45c | ||
|
|
cc1ad06a9d | ||
|
|
4dd15b09e9 | ||
|
|
4eec272c0b | ||
|
|
816a727e88 | ||
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 | ||
|
|
05d64024ef | ||
|
|
cb27aa5ebf | ||
|
|
aefb984974 | ||
|
|
91b5af1c7b | ||
|
|
4fd70f6a9d | ||
|
|
51e279887d | ||
|
|
7e08553e6e | ||
|
|
3564eecc36 | ||
|
|
3e7f3832e0 | ||
|
|
34b60f9f20 | ||
|
|
b7bfd39652 | ||
|
|
6a1103bf5f | ||
|
|
91653bca57 | ||
|
|
6be0f43dd0 | ||
|
|
370fde42f7 | ||
|
|
e5ff30823d | ||
|
|
9e86d57dac | ||
|
|
00bfee4ed2 | ||
|
|
f4bc55cd41 | ||
|
|
3a9dda13c4 | ||
|
|
9b677c6b35 | ||
|
|
933d601c03 | ||
|
|
b4f0e0bc29 | ||
|
|
9f384402fa | ||
|
|
18b69ee8b4 | ||
|
|
8c1b7af5b3 | ||
|
|
6e953ab1f4 | ||
|
|
88202efddb | ||
|
|
3f15608c59 | ||
|
|
e6a6f04ccf | ||
|
|
3b1a8ef01a | ||
|
|
fd097a647f | ||
|
|
b2bd818ac4 | ||
|
|
d71d09cbb6 | ||
|
|
557fb162c3 | ||
|
|
77a2350240 | ||
|
|
2b3d118d84 | ||
|
|
638ffcb326 | ||
|
|
bf4fba484a | ||
|
|
3ea7f44fd1 | ||
|
|
384925f24a | ||
|
|
898323c49f | ||
|
|
48345a22fc | ||
|
|
b83d55cc32 | ||
|
|
76e4288b9e | ||
|
|
246ecf23ba | ||
|
|
1717677056 | ||
|
|
15159f38a5 | ||
|
|
6a28344e93 | ||
|
|
a14ade9277 | ||
|
|
513ab178ec | ||
|
|
e3aeeae616 | ||
|
|
a5cfecbf76 | ||
|
|
7d644d05cc | ||
|
|
75510a1161 | ||
|
|
1a0a349fb3 | ||
|
|
5e09b6a796 | ||
|
|
d6a2e84eed | ||
|
|
7b0ef0ba97 | ||
|
|
aa871a07b7 | ||
|
|
89584f173d | ||
|
|
a71e48f811 | ||
|
|
5c4f101402 | ||
|
|
ede9461010 | ||
|
|
e6ca7a8bdd | ||
|
|
9be3f9147b | ||
|
|
f7f801cdb7 | ||
|
|
43f15f1a2c | ||
|
|
2c5416500f | ||
|
|
fcdc5809e6 | ||
|
|
7254950162 | ||
|
|
1668455ff4 | ||
|
|
c07b746d4b | ||
|
|
d128dabc19 | ||
|
|
2823f3e401 | ||
|
|
1070637c40 | ||
|
|
13de53772b | ||
|
|
6b04072e52 | ||
|
|
64b8ba7fdc | ||
|
|
a9ca50b2dd | ||
|
|
512b5fd35b | ||
|
|
77e3d011a0 | ||
|
|
b7dd8d4852 | ||
|
|
4c75680689 | ||
|
|
5aac28f564 | ||
|
|
c91c8c3b47 | ||
|
|
968da5488c | ||
|
|
1b5b0c2e90 | ||
|
|
0385aed760 | ||
|
|
24a5a0480d | ||
|
|
828f99b8ac | ||
|
|
9e5868b839 | ||
|
|
40d0ca57c3 | ||
|
|
aa537f9eea | ||
|
|
ea1bd2a66d | ||
|
|
1f54656256 | ||
|
|
54dd109288 | ||
|
|
2c1d7235f0 | ||
|
|
adf9844d1b | ||
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a | ||
|
|
be66ac1e19 | ||
|
|
6aab4a6989 | ||
|
|
9c62bf0152 | ||
|
|
1630c8fdf4 | ||
|
|
408791ddf5 | ||
|
|
3de47a8825 | ||
|
|
880ecdbbb5 | ||
|
|
615df0dc10 | ||
|
|
2194279602 | ||
|
|
51c4bda71a | ||
|
|
7d7d175ede | ||
|
|
be1ae76eb3 | ||
|
|
50157a25d3 | ||
|
|
fe1a77a54d | ||
|
|
b818a2d947 | ||
|
|
5c4ae0cfd7 | ||
|
|
69635a15bc | ||
|
|
78220e700f | ||
|
|
42a6b9c76a | ||
|
|
56affa4bed | ||
|
|
aa5af4fc75 | ||
|
|
46772e54fe | ||
|
|
a0cea352ff | ||
|
|
190b86eb1c | ||
|
|
cfc3d00ea1 | ||
|
|
9033e8518c | ||
|
|
bfbd9fa61a | ||
|
|
f0c4f22942 | ||
|
|
19c91f32a0 | ||
|
|
3ec23bc613 | ||
|
|
2fbb922bd2 | ||
|
|
f0afdcc498 | ||
|
|
22352c5748 | ||
|
|
6356d60a66 | ||
|
|
cafade0dbb | ||
|
|
2e39615554 | ||
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 | ||
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b | ||
|
|
0a9a09bec2 | ||
|
|
34ec91684e | ||
|
|
11f1e332f7 | ||
|
|
bd9e091e65 | ||
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 |
@@ -46,3 +46,4 @@ cat.log
|
||||
.env.local
|
||||
*.pem
|
||||
*.key
|
||||
*.syso
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
OpsLog is a Windows ham-radio logger built with **Wails v2** — Go backend, React/TypeScript frontend, compiled into a single `.exe`. Author F4BPO. See `README.md` for the user-facing feature list and `wiki/` for end-user documentation.
|
||||
|
||||
**Pure Go, no CGO** — SQLite is `modernc.org/sqlite`, serial is `go.bug.st/serial`. Any dependency requiring cgo breaks the build; check before adding one.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115)
|
||||
wails build # full build → build/bin/OpsLog.exe (~25 s)
|
||||
wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings)
|
||||
|
||||
go build ./... # fast Go-only check
|
||||
go test ./... # all Go tests
|
||||
go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test
|
||||
gofmt -w <file>
|
||||
|
||||
cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too)
|
||||
```
|
||||
|
||||
Release: `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* → *Release OpsLog*) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.
|
||||
|
||||
Prefer `wails build` for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.
|
||||
|
||||
## Navigating app.go
|
||||
|
||||
`app.go` is ~14 000 lines and holds nearly every Wails binding. **Do not read it linearly** — it is organised into banner-delimited sections:
|
||||
|
||||
```bash
|
||||
grep -n "^// ──\|^// ---" app.go # table of contents
|
||||
```
|
||||
|
||||
Sections map to features (`── Motorized antenna (Ultrabeam / SteppIR) ──`, `── NET Control ──`, `── DX-cluster spot alerts ──`, …). Find the banner, then read that range. The `App` struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.
|
||||
|
||||
Smaller root files split off self-contained areas: `app_cw.go`, `app_qsl_designer.go`, `app_secret.go`, `chat.go`, `offline.go`, `relayauto.go`, `adifwatch.go`, `livestatus.go`, `update.go`, `telemetry.go`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Wails binding boundary
|
||||
|
||||
Exported methods on `*App` are the entire frontend API. Adding or changing a signature requires `wails generate module`, which writes `frontend/wailsjs/go/main/App.d.ts` and `models.ts`. Parameters and return types must be Wails-serializable — the generator prints `Not found: time.Time` noise for unsupported types (that particular message is long-standing and harmless).
|
||||
|
||||
Backend→frontend push uses Wails events (`runtime.EventsEmit` / `EventsOn`), e.g. `qso:logged`, `update:progress`. Most hardware status is **polled** by the frontend on an interval rather than pushed.
|
||||
|
||||
### Two separate databases
|
||||
|
||||
This split is easy to get wrong and matters:
|
||||
|
||||
- **`a.db`** — settings database (`settings.db` / `opslog.db`). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in `data/config.json`.
|
||||
- **`a.logDb`** — the logbook (QSOs). Either a per-profile SQLite file, the default `logbook.db`, or a **shared MySQL** so several operators log into one database. `a.dbBackend` says which.
|
||||
|
||||
QSOs never go to `a.db`. `internal/db` holds the connection helpers and the MySQL specifics; `internal/qso` is the repository.
|
||||
|
||||
Remote MySQL is the slow path: startup deliberately brings CAT/rig links up **before** connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory `wcbm` worked-index and the `clusterEvents` queue that keeps the telnet socket draining.
|
||||
|
||||
### Per-profile settings scoping
|
||||
|
||||
`internal/settings.Store` is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (`p3.`), so each station profile has a complete independent set. Two consequences:
|
||||
|
||||
- `App.settingsScoped` (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.
|
||||
- Settings keys are declared as `key<Thing>` string constants near the top of `app.go` (~180 of them) — grep `key[A-Z].*= "` to find one.
|
||||
|
||||
Password-type keys are encrypted at rest when the secret vault is unlocked (`internal/secret`); a locked vault returns `""` rather than ciphertext, and callers treat that as "not configured".
|
||||
|
||||
### Hardware device pattern
|
||||
|
||||
The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:
|
||||
|
||||
1. **`internal/<device>/`** — a self-contained package exposing a `Client` with `New(...)`, `Start()`, `Stop()`, `GetStatus()`. The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known `Status`. Transports are usually TCP *or* serial behind one `io.ReadWriteCloser`.
|
||||
2. **Settings** — `key<Thing>*` constants plus `Get<Thing>Settings()` / `Save<Thing>Settings()` bindings in `app.go`.
|
||||
3. **Lifecycle** — a `start<Thing>()` method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved.
|
||||
4. **Status binding** — `Get<Thing>Status()`, polled by the frontend.
|
||||
5. **UI** — a settings panel in `frontend/src/components/SettingsModal.tsx` and a live widget in `frontend/src/components/`.
|
||||
|
||||
`internal/steppir` and `internal/antgenius` are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against **real captured frames** — keep that up, since a wrong byte silently mistunes an antenna.
|
||||
|
||||
Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a small interface (`motorAntenna`) plus thin per-device adapters rather than branching everywhere.
|
||||
|
||||
### CAT
|
||||
|
||||
`internal/cat` (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB **and** remote-over-internet), and TCI (SunSDR / Expert Electronics). `cat.Manager` exposes a backend-agnostic `State()`; backend-specific features are reached through typed escapes such as `FlexDo(func(cat.FlexController) error)`. Check `State().Backend` before using one.
|
||||
|
||||
### Logging
|
||||
|
||||
`internal/applog` — Wails builds with the Windows GUI subsystem, so `fmt.Println` is discarded. Use `applog.Printf` (or plain `log.Printf` inside `internal/` packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.
|
||||
|
||||
## Conventions
|
||||
|
||||
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
|
||||
|
||||
**Append new entries at the BOTTOM of a version block**, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
|
||||
|
||||
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
|
||||
|
||||
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
|
||||
|
||||
**Commit messages must not include a `Co-Authored-By` line or any mention of the model.**
|
||||
|
||||
**Comments explain *why*, not *what*.** The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Adding a promoted ADIF field touches five places in lockstep.** `internal/adif` promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in `internal/qso/qso.go`; the dictionary entry (`Promoted: true`) in `internal/adif/fields.go`; the `adifPromoted` list *and* the assignment in `internal/adif/import.go`; the writer in `internal/adif/export.go`; then the frontend column in `RecentQSOsGrid.tsx` with its EN+FR i18n keys. Trace an existing field (e.g. `ant_path`) across the repo as a template.
|
||||
- **Generated files — don't hand-edit.** `internal/dxcc/dxcc_names_gen.go` (cty.dat joined to the ARRL/ADIF entity list) and `internal/awardref/uscounties_gen.go` (emitted by `cmd/cntygen` from the FIPS county CSV; a one-shot generator, not part of the build).
|
||||
- **Single-instance guard** (`main.go`): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency.
|
||||
- **Frontend controlled inputs**: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.
|
||||
+80
-20
@@ -1,7 +1,11 @@
|
||||
# OpsLog
|
||||
|
||||
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
||||
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
|
||||
</a>
|
||||
|
||||
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie en
|
||||
bandeau unique, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
|
||||
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
|
||||
@@ -28,7 +32,7 @@ Développé par **F4BPO**.
|
||||
|
||||
## Journalisation
|
||||
|
||||
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator,
|
||||
- **Bandeau de saisie unique :** indicatif, RST émis/reçu, nom/QTH/locator,
|
||||
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
|
||||
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
|
||||
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
|
||||
@@ -38,7 +42,10 @@ Développé par **F4BPO**.
|
||||
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
||||
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
|
||||
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
||||
QSL.
|
||||
QSL. Un **compteur de sélection** en direct, une bascule **Tout sélectionner /
|
||||
Tout désélectionner**, et une limite d'affichage qui garde le journal complet
|
||||
rapide **mais qui saute dès qu'un filtre est actif** (toutes les correspondances
|
||||
sont montrées, pas seulement la première page).
|
||||
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
||||
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
||||
sélectionnées.
|
||||
@@ -63,7 +70,9 @@ Développé par **F4BPO**.
|
||||
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
||||
rotor.
|
||||
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
||||
PstRotator.
|
||||
**PstRotator** (UDP), un **Rotator Genius 4O3A** (TCP natif) ou un **microHAM
|
||||
ARCO** contrôlé nativement — sans PstRotator — via le **réseau** ou l'**USB**
|
||||
avec son protocole Yaesu GS-232A.
|
||||
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
|
||||
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
|
||||
et sur la carte, pour toujours savoir où pointe l'antenne.
|
||||
@@ -77,10 +86,12 @@ Développé par **F4BPO**.
|
||||
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
||||
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
||||
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
||||
panadapter).
|
||||
panadapter). En option, tous les **modes numériques comptent comme un seul**
|
||||
(style DXCC) pour le coloriage nouveau/nouveau-créneau et les badges de la
|
||||
matrice des déjà-contactés (Réglages → Général).
|
||||
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
||||
`api.pota.app`).
|
||||
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
|
||||
- **Alertes de spots :** règles sur indicatif / pays / bande /
|
||||
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
||||
*Gestion des alertes*).
|
||||
|
||||
@@ -93,7 +104,8 @@ connexion rapide non bloquante — une radio éteinte ne fige jamais l'applicati
|
||||
supportée par OmniRig.
|
||||
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
||||
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
||||
du cluster poussés sur l'écran Flex, clic → remplir l'indicatif).
|
||||
du cluster poussés sur l'écran Flex ; un clic remplit l'indicatif et **accorde
|
||||
la radio sur la bonne fréquence ET le bon mode**).
|
||||
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
|
||||
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
||||
RS-BA1 ni Remote Utility nécessaires.
|
||||
@@ -110,9 +122,17 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
|
||||
|
||||
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
||||
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
||||
- **Réception (slice active) :** sélecteurs d'**antenne** RX/TX et une bascule
|
||||
**DAX** (audio d'émission via DAX, pour WSJT-X & co), mode/seuil AGC, niveau
|
||||
audio, NB / WNB / NR / ANF, et — sur les radios **SmartSDR v4** (séries 8000 /
|
||||
Aurora) — les filtres DSP supplémentaires **NRL, NRS, NRF** (avec niveau) plus
|
||||
**RNN** (réduction de bruit par IA) et **ANFT** (notch automatique FFT),
|
||||
affichés automatiquement si la radio les supporte. **RIT / XIT** avec accord
|
||||
molette / ±.
|
||||
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
|
||||
- **Amplificateur :** la carte de commande suit l'ampli configuré, avec une liste
|
||||
déroulante pour le choisir quand **plusieurs amplificateurs** sont configurés
|
||||
(p. ex. deux SPE en parallèle). Voir *Amplis & commutateurs* ci-dessous.
|
||||
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
||||
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
||||
|
||||
@@ -159,9 +179,10 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
||||
## Keyers & audio
|
||||
|
||||
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
|
||||
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
|
||||
distance) ou **TCI**.
|
||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
|
||||
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
|
||||
sans WinKeyer ni SmartCAT), **Icom** (le keyer intégré de la radio via CI-V —
|
||||
sans matériel supplémentaire, fonctionne aussi à distance) ou **TCI**.
|
||||
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||
émettre.
|
||||
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||
@@ -170,10 +191,33 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
||||
|
||||
## Amplis & commutateurs
|
||||
|
||||
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
|
||||
sélecteur de mode ventilateur et affichage des défauts.
|
||||
- **Amplificateurs** — configurez **un ou plusieurs** amplis (Réglages →
|
||||
Amplificateur est une liste ; p. ex. deux SPE en parallèle pour plus de
|
||||
puissance). La carte de chaque ampli apparaît sur l'onglet FlexRadio et dans
|
||||
**Station Control**, avec une liste déroulante pour choisir lequel afficher ; la
|
||||
barre de statut du bas porte **une pastille cliquable par ampli** (vert =
|
||||
OPERATE, orange = STANDBY, rouge = hors ligne). Supportés :
|
||||
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
|
||||
ventilateur et affichage des défauts.
|
||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
|
||||
**réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de
|
||||
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
|
||||
ROS, courant PA, température, avertissements/alarmes).
|
||||
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) via **USB** ou le **réseau**
|
||||
(pont RS232-Ethernet) — operate/standby/arrêt et télémétrie en direct
|
||||
(puissance directe & réfléchie, ROS, température PA, bande, ventilateur,
|
||||
défauts). La mise en marche fonctionne via un câble série reliant les lignes
|
||||
DTR/RTS.
|
||||
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||
commutation A/B ancré.
|
||||
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
|
||||
**rotor**, la commande d'éléments **Ultrabeam** et des **cartes relais** —
|
||||
WebSwitch 1216H, KMTronic, **Denkovi** USB (bit-bang FT245 D2XX, 4 ou 8 relais)
|
||||
et USB-série générique (CH340 / LCUS, protocole A0) — pour l'alimentation, les
|
||||
antennes et accessoires de la station.
|
||||
- **Relais automatiques** (Réglages) : bascule les relais de Station Control
|
||||
automatiquement selon la fréquence / bande de la radio (comme PstRotator) — par
|
||||
relais, une fenêtre de fréquence ou un ensemble de bandes.
|
||||
|
||||
## QSL & diplômes
|
||||
|
||||
@@ -201,13 +245,23 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
||||
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||
direct.
|
||||
|
||||
## Statistiques
|
||||
|
||||
- **Tableau de bord des statistiques :** tuiles de synthèse (QSO, indicatifs
|
||||
uniques, entités DXCC, continents, % confirmés) plus des graphiques — QSO **par
|
||||
mode**, un split **CW / phonie / data** par bande, l'**activité dans le temps**
|
||||
(vues glissantes jour / 7 jours / 30 jours / 12 mois), par opérateur et par
|
||||
continent. Filtres par plage de dates, par opérateur et par concours, et une vue
|
||||
**Table** qui reprend chaque graphique.
|
||||
|
||||
## Statut opérateur en direct (événements spéciaux)
|
||||
|
||||
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
||||
**TM74TFR**) : Réglages → Général → *Publier le statut opérateur en direct*.
|
||||
**TM74TFR**), la publication est **automatique** — aucun réglage à activer.
|
||||
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
||||
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
||||
~15 s. Un petit rendu PHP
|
||||
~15 s, et repasse *hors antenne* automatiquement 5 min après le dernier QSO
|
||||
logué. Un petit rendu PHP
|
||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
|
||||
propre serveur web lit cette table et produit une page/image en direct que vous
|
||||
pouvez intégrer sur la bio **QRZ.com** de la station
|
||||
@@ -217,8 +271,11 @@ ce n'est pas un serveur web.
|
||||
## Net control
|
||||
|
||||
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
||||
session active en mémoire — pointez les stations présentes, puis loguez tout le
|
||||
net d'un coup en utilisant la fréquence CAT.
|
||||
session active en mémoire — pointez les stations présentes, puis loguez-les une
|
||||
par une ou tout le net d'un coup (**Logger tout le monde**) en utilisant la
|
||||
fréquence CAT. **Glisser-déposer** entre les deux listes (roster → on air démarre
|
||||
un QSO, on air → roster l'enregistre), et après chaque log la station on air
|
||||
suivante est sélectionnée automatiquement pour enchaîner les contacts.
|
||||
|
||||
## Apparence & langue
|
||||
|
||||
@@ -247,7 +304,10 @@ ce n'est pas un serveur web.
|
||||
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
||||
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
|
||||
- **Vérification de mise à jour** au démarrage et toutes les 5 minutes (et à
|
||||
l'ouverture d'Aide → À propos), avec un toast (désactivable), plus une fenêtre
|
||||
**Nouveautés** qui affiche le changelog (anglais / français) au premier
|
||||
lancement après une mise à jour — réouvrable à tout moment depuis le menu Aide.
|
||||
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
||||
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||
désactivable dans les Préférences).
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# OpsLog
|
||||
|
||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||
</a>
|
||||
|
||||
A modern, fast ham-radio logger for Windows — single-strip entry, real-time CAT
|
||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||
@@ -27,7 +31,7 @@ Developed by **F4BPO**.
|
||||
|
||||
## Logging
|
||||
|
||||
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||
- **Single-strip entry:** 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
|
||||
@@ -36,7 +40,10 @@ Developed by **F4BPO**.
|
||||
with `/MM` `/AM` `/B` (beacon) 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.
|
||||
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||
fast **but is lifted while a filter is active** (every match is shown, not just
|
||||
the first page).
|
||||
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
||||
presets) with filtered- and selected-row **ADIF export**.
|
||||
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
||||
@@ -55,7 +62,10 @@ Developed by **F4BPO**.
|
||||
- **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.
|
||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||
using its Yaesu GS-232A protocol.
|
||||
- **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.
|
||||
@@ -67,9 +77,11 @@ Developed by **F4BPO**.
|
||||
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).
|
||||
rig, and a multi-band **Band Map** (panadapter-style strips). Optionally, all
|
||||
**digital modes count as one** (DXCC-style) for the new/new-slot colouring and
|
||||
the worked-before matrix badges (Settings → General).
|
||||
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
|
||||
- **Spot alerts:** rules on call / country / band / mode /
|
||||
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
||||
management*).
|
||||
|
||||
@@ -81,7 +93,8 @@ non-blocking connect so a powered-off radio never freezes the app:
|
||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
||||
the Flex display, click → fill the call).
|
||||
the Flex display; a click fills the call and **tunes the rig to the right
|
||||
frequency AND mode**).
|
||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||
Remote Utility needed.
|
||||
@@ -98,9 +111,16 @@ 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.
|
||||
- **Receive (active slice):** RX/TX **antenna** selectors and a **DAX** toggle
|
||||
(TX audio through DAX, for WSJT-X & co), AGC mode/threshold, audio level,
|
||||
NB / WNB / NR / ANF, and — on **SmartSDR v4** radios (8000 / Aurora series) —
|
||||
the extra DSP tools **NRL, NRS, NRF** (with level) plus **RNN** (AI noise
|
||||
reduction) and **ANFT** (FFT auto-notch), shown automatically when the radio
|
||||
supports them. **RIT / XIT** with wheel / ± tuning.
|
||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
||||
- **Amplifier:** the amp card follows whichever amplifier is configured, with a
|
||||
dropdown to pick it when **several amplifiers** are set up (e.g. two SPEs run
|
||||
in parallel). See *Amplifiers & switches* below.
|
||||
- **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.
|
||||
|
||||
@@ -141,18 +161,40 @@ as Mumble.)
|
||||
## Keyers & audio
|
||||
|
||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
||||
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
|
||||
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
|
||||
SmartCAT needed), **Icom** (the radio's own keyer over CI-V — no extra hardware,
|
||||
works over the remote link too) or **TCI**.
|
||||
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||
|
||||
## Amplifiers & switches
|
||||
|
||||
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
|
||||
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||
list; e.g. two SPEs run in parallel for more power). Each amp's control card
|
||||
appears on the FlexRadio tab and in **Station Control**, with a dropdown to
|
||||
choose which one it shows; the bottom status bar carries **one clickable chip
|
||||
per amp** (green = OPERATE, orange = STANDBY, red = offline). Supported:
|
||||
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||
selector and fault display.
|
||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
||||
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||
Low / Mid / High output level, an output-power bar and live status (band,
|
||||
SWR, PA current, temperature, warnings/alarms).
|
||||
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||
over a serial cable that wires the DTR/RTS lines.
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget.
|
||||
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
|
||||
- **Relay auto-control** (Settings): switch Station-Control relays automatically
|
||||
from the rig frequency / band (like PstRotator) — per relay, a frequency window
|
||||
or a set of bands.
|
||||
|
||||
## QSL & awards
|
||||
|
||||
@@ -176,12 +218,21 @@ as Mumble.)
|
||||
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||
flags dupes and keeps a live scoreboard.
|
||||
|
||||
## Statistics
|
||||
|
||||
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
|
||||
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
|
||||
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
|
||||
30-day / 12-month views), by operator and by continent. Date-range, per-operator
|
||||
and per-contest filters, and a **Table** view that mirrors every chart.
|
||||
|
||||
## 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
|
||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**),
|
||||
publishing is **automatic** — no setting to turn on. Each OpsLog instance
|
||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||
`live_status` table every ~15 s. A small PHP renderer
|
||||
`live_status` table every ~15 s, and drops back to *off air* automatically 5 min
|
||||
after the last logged QSO. 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
|
||||
@@ -190,8 +241,11 @@ only writes to the DB — it is not a web server.
|
||||
## Net control
|
||||
|
||||
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
||||
in-memory active session — check stations in, then log the whole net at once
|
||||
using the CAT frequency.
|
||||
in-memory active session — check stations in, then log them individually or the
|
||||
whole net at once (**Log everyone**) using the CAT frequency. **Drag & drop**
|
||||
between the two lists (roster → on-air starts a QSO, on-air → roster logs it),
|
||||
and after each log the next on-air station is selected automatically so you can
|
||||
chain contacts.
|
||||
|
||||
## Appearance & language
|
||||
|
||||
@@ -218,7 +272,10 @@ only writes to the DB — it is not a web server.
|
||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||
OpsLog startup, skipping any already running.
|
||||
- **Backup:** optional database + ADIF backup at shutdown.
|
||||
- **Update check** at startup with a toast (toggleable).
|
||||
- **Update check** at startup and every 5 minutes (and on opening Help → About),
|
||||
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
|
||||
(English / French) on the first launch after an update — reopenable any time
|
||||
from the Help menu.
|
||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||
// just what happens.
|
||||
//
|
||||
// Design notes:
|
||||
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||
// contacts appended AFTER you add the file come in.
|
||||
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||
// the next poll instead of importing a truncated QSO.
|
||||
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||
// auto-log) + automatic external-service upload.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const keyADIFMonitor = "adifmon.config"
|
||||
|
||||
// ADIFWatchFile is one monitored ADIF file.
|
||||
type ADIFWatchFile struct {
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||
// initialised": the first scan sets it to the file's current size so existing
|
||||
// history is skipped.
|
||||
Offset int64 `json:"offset"`
|
||||
}
|
||||
|
||||
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||
type ADIFMonitorConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Files []ADIFWatchFile `json:"files"`
|
||||
}
|
||||
|
||||
const eorTag = "<eor>"
|
||||
|
||||
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||
var cfg ADIFMonitorConfig
|
||||
if a.settings == nil {
|
||||
return cfg
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||
b, _ := json.Marshal(cfg)
|
||||
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||
}
|
||||
|
||||
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||
a.adifMonMu.Lock()
|
||||
defer a.adifMonMu.Unlock()
|
||||
return a.loadADIFMonitorLocked()
|
||||
}
|
||||
|
||||
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||
// was added import); a file already present keeps its current read position.
|
||||
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||
a.adifMonMu.Lock()
|
||||
defer a.adifMonMu.Unlock()
|
||||
old := a.loadADIFMonitorLocked()
|
||||
oldOff := make(map[string]int64, len(old.Files))
|
||||
for _, f := range old.Files {
|
||||
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||
}
|
||||
for i := range cfg.Files {
|
||||
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||
} else {
|
||||
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||
}
|
||||
}
|
||||
a.saveADIFMonitorLocked(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Choose an ADIF file to monitor",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||
func (a *App) adifMonitorLoop() {
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
if a.ctx == nil || a.qso == nil {
|
||||
continue
|
||||
}
|
||||
a.scanADIFMonitors()
|
||||
}
|
||||
}
|
||||
|
||||
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||
// persisting advanced offsets.
|
||||
func (a *App) scanADIFMonitors() {
|
||||
a.adifMonMu.Lock()
|
||||
cfg := a.loadADIFMonitorLocked()
|
||||
a.adifMonMu.Unlock()
|
||||
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// path → new offset, for the files we advanced this pass.
|
||||
advanced := map[string]int64{}
|
||||
for i := range cfg.Files {
|
||||
f := &cfg.Files[i]
|
||||
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||
continue
|
||||
}
|
||||
fi, err := os.Stat(f.Path)
|
||||
if err != nil {
|
||||
continue // not present (yet) — try again next tick
|
||||
}
|
||||
size := fi.Size()
|
||||
if f.Offset < 0 {
|
||||
// First sight of this file → skip whatever history it already holds.
|
||||
advanced[f.Path] = size
|
||||
continue
|
||||
}
|
||||
if size < f.Offset {
|
||||
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||
}
|
||||
if size == f.Offset {
|
||||
continue // nothing new
|
||||
}
|
||||
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||
if newOff != f.Offset {
|
||||
advanced[f.Path] = newOff
|
||||
}
|
||||
if n > 0 {
|
||||
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(advanced) == 0 {
|
||||
return
|
||||
}
|
||||
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||
// file list: re-read the stored config and only patch offsets for paths that
|
||||
// still exist there.
|
||||
a.adifMonMu.Lock()
|
||||
cur := a.loadADIFMonitorLocked()
|
||||
for i := range cur.Files {
|
||||
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||
cur.Files[i].Offset = off
|
||||
}
|
||||
}
|
||||
a.saveADIFMonitorLocked(cur)
|
||||
a.adifMonMu.Unlock()
|
||||
}
|
||||
|
||||
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return from, 0
|
||||
}
|
||||
defer fh.Close()
|
||||
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||
return from, 0
|
||||
}
|
||||
buf := make([]byte, to-from)
|
||||
nRead, err := io.ReadFull(fh, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return from, 0
|
||||
}
|
||||
buf = buf[:nRead]
|
||||
|
||||
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||
// waits for the next poll.
|
||||
lower := bytes.ToLower(buf)
|
||||
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||
if last < 0 {
|
||||
return from, 0 // no complete record yet
|
||||
}
|
||||
end := last + len(eorTag)
|
||||
chunk := buf[:end]
|
||||
|
||||
count := 0
|
||||
for _, rec := range splitADIFRecords(chunk) {
|
||||
if strings.TrimSpace(rec) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||
count++
|
||||
}
|
||||
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||
}
|
||||
return from + int64(end), count
|
||||
}
|
||||
|
||||
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||
func splitADIFRecords(b []byte) []string {
|
||||
lower := bytes.ToLower(b)
|
||||
var out []string
|
||||
start := 0
|
||||
for {
|
||||
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
end := start + rel + len(eorTag)
|
||||
out = append(out, string(b[start:end]))
|
||||
start = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// keyAwardsTracked holds the PER-PROFILE list of award codes the operator wants
|
||||
// to follow (JSON array of award.Def.Code). Award definitions themselves are
|
||||
// global (keyAwardDefs, shared across profiles), but WHICH awards a station
|
||||
// tracks is a per-profile choice — a DX profile follows DXCC/WPX, a POTA profile
|
||||
// follows POTA/WWFF. An empty/unset list means "track them all" so the Awards
|
||||
// tab is never blank before the operator has picked anything.
|
||||
const keyAwardsTracked = "awards.tracked"
|
||||
|
||||
// GetTrackedAwards returns the active profile's followed award codes. An empty
|
||||
// slice means the operator has not narrowed the list — the Awards tab then shows
|
||||
// every award.
|
||||
func (a *App) GetTrackedAwards() ([]string, error) {
|
||||
out := []string{}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keyAwardsTracked)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveTrackedAwards persists the followed award codes for the active profile and
|
||||
// notifies the Awards tab to re-filter its list. Codes are stored verbatim; the
|
||||
// Awards tab intersects them with the live award definitions, so a code that no
|
||||
// longer exists is simply ignored (not an error).
|
||||
func (a *App) SaveTrackedAwards(codes []string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if codes == nil {
|
||||
codes = []string{}
|
||||
}
|
||||
b, err := json.Marshal(codes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyAwardsTracked, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
wruntime.EventsEmit(a.ctx, "awards:tracked-changed")
|
||||
return nil
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deniedCallHashes lists station callsigns not permitted to run this build,
|
||||
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
|
||||
// in the source or the compiled binary. Enforcement is best-effort by design —
|
||||
// the call is entered by the operator and can be changed — so this only turns
|
||||
// away straightforward use, not a determined one.
|
||||
var deniedCallHashes = map[string]struct{}{
|
||||
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
|
||||
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
|
||||
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
|
||||
}
|
||||
|
||||
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
|
||||
// same way extsvc does, so F4XYZ/P matches F4XYZ.
|
||||
func callDenied(call string) bool {
|
||||
base := denyBaseCall(call)
|
||||
if base == "" {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(base))
|
||||
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
|
||||
return bad
|
||||
}
|
||||
|
||||
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
|
||||
// longest token (the real call), otherwise the call itself, upper-cased.
|
||||
func denyBaseCall(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if !strings.Contains(s, "/") {
|
||||
return s
|
||||
}
|
||||
best := ""
|
||||
for _, part := range strings.Split(s, "/") {
|
||||
if len(part) > len(best) {
|
||||
best = part
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// enforceCallGate exits the process silently when any of the supplied callsigns
|
||||
// is denied. Called once at startup, after the profiles are loaded: the first
|
||||
// launch is where the operator enters and saves the call, so a denied build
|
||||
// simply does not come back up on the next launch. No window, no message.
|
||||
func enforceCallGate(calls ...string) {
|
||||
for _, c := range calls {
|
||||
if callDenied(c) {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-15
@@ -32,12 +32,19 @@ 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
|
||||
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
|
||||
)
|
||||
|
||||
// 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"
|
||||
|
||||
// appQSLCardRcvdField marks that a QSL was RECEIVED for this QSO (set by the
|
||||
// operator, e.g. when a card arrives by e-mail). It drives the PSE/TNX stamp on
|
||||
// the card: received → "TNX QSL" (thanks for your card), not received → "PSE QSL"
|
||||
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
|
||||
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
|
||||
|
||||
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}"
|
||||
@@ -437,28 +444,53 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
||||
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)
|
||||
// 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.
|
||||
//
|
||||
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||
return fmt.Errorf("QSL card 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.
|
||||
// qslDefaultMessage returns the operator's default QSL message (Settings), used
|
||||
// on the card when a QSO has no QSLMSG of its own.
|
||||
func (a *App) qslDefaultMessage() string {
|
||||
if a.settings == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keyQSLDefaultMsg)
|
||||
return s
|
||||
}
|
||||
|
||||
// SetOpsLogQSLReceived marks (or clears) that a QSL was received for a QSO. This
|
||||
// flips the card's PSE/TNX stamp. Stored as a timestamp in the QSO extras (a
|
||||
// single-key UPDATE, like the sent marker) so it survives concurrent uploads.
|
||||
func (a *App) SetOpsLogQSLReceived(qsoID int64, on bool) error {
|
||||
if a.qso == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
v := ""
|
||||
if on {
|
||||
v = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return a.qso.SetExtra(a.ctx, qsoID, appQSLCardRcvdField, v)
|
||||
}
|
||||
|
||||
// QSLEmailTemplates is the eQSL e-mail subject/body, the auto-send toggle, and
|
||||
// the default QSL message printed on the card when a QSO has none of its own.
|
||||
type QSLEmailTemplates struct {
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AutoSend bool `json:"auto_send"`
|
||||
DefaultMessage string `json:"default_message"`
|
||||
}
|
||||
|
||||
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
||||
@@ -467,7 +499,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
|
||||
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend, keyQSLDefaultMsg)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -478,6 +510,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
||||
out.Body = b
|
||||
}
|
||||
out.AutoSend = m[keyQSLAutoSend] == "1"
|
||||
out.DefaultMessage = m[keyQSLDefaultMsg]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -496,7 +529,10 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
||||
if t.AutoSend {
|
||||
v = "1"
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
|
||||
if err := a.settings.Set(a.ctx, keyQSLAutoSend, v); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyQSLDefaultMsg, t.DefaultMessage)
|
||||
}
|
||||
|
||||
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
|
||||
@@ -635,6 +671,18 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
||||
}
|
||||
return strconv.Itoa(z)
|
||||
}
|
||||
// The QSL message on the card: the QSO's own QSLMSG wins; when it's empty the
|
||||
// operator's default message (Settings) is used instead.
|
||||
msg := q.QSLMsg
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
msg = a.qslDefaultMessage()
|
||||
}
|
||||
// PSE/TNX stamp, chosen automatically by whether a QSL was received for this
|
||||
// QSO (appQSLCardRcvdField): received → thank them, otherwise ask for a card.
|
||||
pseTnx := "PSE QSL"
|
||||
if q.Extras != nil && strings.TrimSpace(q.Extras[appQSLCardRcvdField]) != "" {
|
||||
pseTnx = "TNX QSL"
|
||||
}
|
||||
vars := map[string]string{
|
||||
"profile.callsign": info.Callsign,
|
||||
"profile.operator_name": info.Operator,
|
||||
@@ -651,7 +699,8 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
||||
"qso.mode": q.Mode,
|
||||
"qso.submode": q.Submode,
|
||||
"qso.rst_sent": q.RSTSent,
|
||||
"qso.qsl_msg": q.QSLMsg,
|
||||
"qso.qsl_msg": msg,
|
||||
"qso.pse_tnx": pseTnx,
|
||||
"qso.name": q.Name,
|
||||
}
|
||||
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
||||
|
||||
@@ -26,6 +26,12 @@ var sensitiveSettingKeys = map[string]bool{
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtEQSLPassword: true,
|
||||
keyExtCloudlogAPIKey: true,
|
||||
// The web-publish config is one JSON blob and the FTP password lives inside
|
||||
// it, so the whole blob is encrypted. That costs nothing — it is read and
|
||||
// written as a unit anyway — and beats storing a server password in clear
|
||||
// next to the rest.
|
||||
keyWebPublish: true,
|
||||
}
|
||||
|
||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
|
||||
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
|
||||
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
|
||||
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
|
||||
// logged, not counted in any award slot, and exports without a BAND field.
|
||||
//
|
||||
// One frequency per band, taken inside the range, plus the edges that used to be
|
||||
// missing entirely.
|
||||
func TestBandPlansAgree(t *testing.T) {
|
||||
cases := []struct {
|
||||
hz int64
|
||||
want string
|
||||
}{
|
||||
{137000, "2190m"},
|
||||
{475000, "630m"},
|
||||
{1840000, "160m"},
|
||||
{3650000, "80m"},
|
||||
{7100000, "40m"},
|
||||
{10120000, "30m"},
|
||||
{14200000, "20m"},
|
||||
{18100000, "17m"},
|
||||
{21200000, "15m"},
|
||||
{24940000, "12m"},
|
||||
{28400000, "10m"},
|
||||
{50150000, "6m"},
|
||||
{70200000, "4m"},
|
||||
{144300000, "2m"},
|
||||
{223500000, "1.25m"},
|
||||
{432000000, "70cm"},
|
||||
{1296000000, "23cm"},
|
||||
// The microwave bands that were missing. 3 cm is the one an operator
|
||||
// reported: 10 GHz is worked and spotted, and resolved to nothing.
|
||||
{2320000000, "13cm"},
|
||||
{3400000000, "9cm"},
|
||||
{5760000000, "6cm"},
|
||||
{10368000000, "3cm"},
|
||||
{24048000000, "1.25cm"},
|
||||
{47088000000, "6mm"},
|
||||
{76032000000, "4mm"},
|
||||
{122250000000, "2.5mm"},
|
||||
{134928000000, "2mm"},
|
||||
{241920000000, "1mm"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cat.BandFromHz(c.hz); got != c.want {
|
||||
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
|
||||
}
|
||||
if got := bandForHz(c.hz); got != c.want {
|
||||
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A frequency in no amateur band must resolve to "" everywhere, not to the
|
||||
// nearest band: guessing here would put a QSO in a band the operator never used.
|
||||
func TestBandPlanRejectsOutOfBand(t *testing.T) {
|
||||
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
|
||||
if got := cat.BandFromHz(hz); got != "" {
|
||||
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
|
||||
}
|
||||
if got := bandForHz(hz); got != "" {
|
||||
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18902
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
|
||||
//
|
||||
// These two lists live in different packages — a TypeScript field table on one
|
||||
// side, a Go whitelist on the other — and they drifted apart the day the
|
||||
// confirmation dates were added: the new columns went into the QSL Manager's
|
||||
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
|
||||
// could not write and the operator got "field … is not bulk-editable" only after
|
||||
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
|
||||
//
|
||||
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
|
||||
// field added to the UI alone fails here immediately.
|
||||
func TestBulkEditFieldsAreWritable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
id := m[1]
|
||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||
// band together) and the extras live in extras_json.
|
||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||
continue
|
||||
}
|
||||
col, ok := bulkFieldColumns[id]
|
||||
if !ok {
|
||||
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
|
||||
continue
|
||||
}
|
||||
// Extras live outside the qso table (owner_callsign and friends): they are
|
||||
// written into extras_json, not a column, so the column whitelist does not
|
||||
// apply to them.
|
||||
if strings.HasPrefix(col, "extras.") {
|
||||
continue
|
||||
}
|
||||
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
|
||||
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same contract for the filter builder: every field it offers must be one the
|
||||
// query layer will accept.
|
||||
func TestFilterFieldsAreQueryable(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
|
||||
// not, and without it every operator (eq, contains, …) was read as a field.
|
||||
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
|
||||
if len(ids) < 10 {
|
||||
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
|
||||
}
|
||||
for _, m := range ids {
|
||||
col := m[1]
|
||||
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
|
||||
t.Errorf("filter builder offers %q, which the query layer refuses", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
//go:embed changelog.json
|
||||
var changelogJSON []byte
|
||||
|
||||
// ChangelogEntry is one release's user-facing notes, in English and French. It's
|
||||
// shown once on the first launch after an update (the "What's new" dialog),
|
||||
// derived from the release's commit history.
|
||||
type ChangelogEntry struct {
|
||||
Version string `json:"version"`
|
||||
Date string `json:"date"`
|
||||
EN []string `json:"en"`
|
||||
FR []string `json:"fr"`
|
||||
}
|
||||
|
||||
const keyChangelogSeen = "changelog.last_seen_version"
|
||||
|
||||
func loadChangelog() []ChangelogEntry {
|
||||
var out []ChangelogEntry
|
||||
if err := json.Unmarshal(changelogJSON, &out); err != nil {
|
||||
applog.Printf("changelog: parse failed: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetChangelog returns every changelog entry up to the running version (newest
|
||||
// first), without touching the "seen" marker — for a "What's new" button that
|
||||
// reopens the notes on demand.
|
||||
func (a *App) GetChangelog() []ChangelogEntry {
|
||||
out := []ChangelogEntry{}
|
||||
for _, e := range loadChangelog() {
|
||||
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
|
||||
// notes for every version newer than the last one they ran, up to the current
|
||||
// build — and records the current version as seen so it pops exactly once per
|
||||
// update. Empty when there's nothing new.
|
||||
func (a *App) GetWhatsNew() []ChangelogEntry {
|
||||
if a.settings == nil {
|
||||
return nil
|
||||
}
|
||||
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
|
||||
cur := appVersion
|
||||
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
|
||||
|
||||
out := []ChangelogEntry{}
|
||||
for _, e := range loadChangelog() {
|
||||
if strings.TrimSpace(e.Version) == "" {
|
||||
continue
|
||||
}
|
||||
if versionLess(cur, e.Version) {
|
||||
continue // don't preview notes for a version newer than we run
|
||||
}
|
||||
if lastSeen == "" {
|
||||
// First run of the feature (fresh install, or upgrade from a build that
|
||||
// predates it): show only the CURRENT version's notes, once.
|
||||
if e.Version == cur {
|
||||
out = append(out, e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if versionLess(lastSeen, e.Version) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+1016
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which class of power a mode falls into.
|
||||
//
|
||||
// This decides how much power leaves the amplifier, so an error here is not a
|
||||
// display bug: a digital mode sorted as phone gets the SSB setting, which is
|
||||
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
|
||||
func TestFlexPowerClass(t *testing.T) {
|
||||
for _, c := range []struct{ mode, want string }{
|
||||
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
|
||||
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
|
||||
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
|
||||
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
|
||||
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
|
||||
// Unknown or empty: no class, and the caller changes nothing. Setting a
|
||||
// transmit power from a name we do not recognise is not a good guess.
|
||||
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
|
||||
} {
|
||||
if got := flexPowerClass(c.mode); got != c.want {
|
||||
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2387
-341
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [draft, setDraft] = useState<Rule | null>(null);
|
||||
// Raw text of the callsigns box, kept separately from draft.calls. The list is
|
||||
// normalised (trim + drop empties) on the way into the rule, so binding the
|
||||
// textarea straight to calls.join('\n') round-trips every keystroke through
|
||||
// that normalisation — a trailing newline or space is stripped before React
|
||||
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
|
||||
// deriving the list from it keeps typing intact.
|
||||
const [callsText, setCallsText] = useState('');
|
||||
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||
const [emailTo, setEmailTo] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
}, []);
|
||||
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
||||
|
||||
// loadDraft opens a rule in the editor — always through here, so the raw
|
||||
// callsigns text is re-seeded from whatever rule is now being edited.
|
||||
const loadDraft = (r: Rule | null) => {
|
||||
setDraft(r);
|
||||
setCallsText((r?.calls ?? []).join('\n'));
|
||||
};
|
||||
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
||||
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||
if (!d) return d;
|
||||
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function del() {
|
||||
if (!draft) return;
|
||||
if (!draft.id) { setDraft(null); return; }
|
||||
if (!draft.id) { loadDraft(null); return; }
|
||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
||||
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||
{rules.map((r) => (
|
||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||
<button key={r.id} onClick={() => { loadDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
||||
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
||||
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||
value={(draft.calls ?? []).join('\n')}
|
||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
||||
value={callsText}
|
||||
onChange={(e) => {
|
||||
setCallsText(e.target.value);
|
||||
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
|
||||
}} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Flame, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
||||
// so Station Control (and anywhere else that needs it) shows the SAME card instead
|
||||
// of a stripped-down variant. It handles all three amp families:
|
||||
// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom)
|
||||
// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the
|
||||
// amp), fan mode from the direct GSCP link (amp.pgxl)
|
||||
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
||||
// can each get their own card.
|
||||
|
||||
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<button type="button" onClick={toggle}
|
||||
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||
</button>
|
||||
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// speMaxW / powerLevelLabel — same helpers the Flex panel uses.
|
||||
function speMaxW(model?: string): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
return 1300;
|
||||
}
|
||||
function powerLevelLabel(pl?: string): string {
|
||||
switch ((pl || '').trim().toUpperCase()) {
|
||||
case 'L': return 'Low';
|
||||
case 'M': return 'Mid';
|
||||
case 'H': return 'High';
|
||||
default: return pl || '';
|
||||
}
|
||||
}
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
|
||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||
// One second: the same window as the docked widget, so the two never show
|
||||
// different figures for the same amplifier, and neither trails the end of a
|
||||
// transmission long enough to look like lag.
|
||||
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 > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
const isSPE = !!amp.spe;
|
||||
const isACOM = !!amp.acom;
|
||||
|
||||
if (isSPE) {
|
||||
const spe = amp.spe;
|
||||
return (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
||||
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{powerLevelLabel(lvl)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
||||
</span>
|
||||
{spe.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{(spe.warnings || spe.alarms) && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
||||
)}
|
||||
</div>
|
||||
{spe.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
||||
lo={0} hi={speMaxW(spe.model)}
|
||||
display={`${Number(spe.output_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isACOM) {
|
||||
const acom = amp.acom;
|
||||
return (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
||||
</span>
|
||||
{acom.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{acom.err_text && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
||||
)}
|
||||
</div>
|
||||
{acom.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
||||
lo={0} hi={Number(acom.max_w) || 800}
|
||||
display={`${Number(acom.fwd_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
||||
const pg = amp.pgxl || {};
|
||||
const viaFlex = !!flex?.amp_available;
|
||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||
const connected = !!pg.connected || viaFlex;
|
||||
const fault = flex?.amp_fault;
|
||||
return (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{(pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||
className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
|
||||
>
|
||||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{fault && fault !== 'NONE' && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
||||
{viaFlex && (() => {
|
||||
const meters = (flex?.meters as any[]) || [];
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (amps.length === 0) return null;
|
||||
// Power comes from the radio's meter stream and nothing else. The
|
||||
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||
// over: it is a latched maximum that is never reset, and it survives in
|
||||
// the last-known status after the amp disconnects — so it claimed
|
||||
// 1350 W from an old transmission while the radio was putting out 10.
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amps.map((m) => {
|
||||
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||
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" />;
|
||||
}
|
||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||
let lo = m.lo, hi = m.hi;
|
||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||
lo = 0;
|
||||
hi = m.hi >= 25 ? m.hi : 25;
|
||||
}
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useRef } from 'react';
|
||||
import { Flame, X, Power } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
|
||||
// (which is the full-size card shown in the FlexRadio panel and Station
|
||||
// Control). It covers all three families, since an operator running an SPE and
|
||||
// a PowerGenius wants one widget, not two:
|
||||
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
|
||||
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
|
||||
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
|
||||
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||
// amp id) chosen from the toolbar icon's dropdown.
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
|
||||
// Full-scale watts for the output bar, from the model string.
|
||||
function maxW(model?: string, fallback = 1300): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
if (m.includes('13K')) return 1300;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function swrColour(swr?: number): string {
|
||||
if (!swr || swr <= 0) return 'text-muted-foreground';
|
||||
if (swr <= 1.5) return 'text-success';
|
||||
if (swr <= 2.0) return 'text-warning';
|
||||
return 'text-danger';
|
||||
}
|
||||
|
||||
// PowerMeter — the output-power LED bar that closes each column, straight under
|
||||
// the controls (no filler above it: pushed to the bottom it left a band of dead
|
||||
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
|
||||
// so it reads exactly like the meters on the rig panels.
|
||||
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
|
||||
return (
|
||||
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
|
||||
display={`${Math.round(watts)} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
);
|
||||
}
|
||||
|
||||
// usePeakHold keeps the highest reading seen for a short window, so a reading
|
||||
// sampled between two syllables doesn't collapse the meter. `live` is the key
|
||||
// part: pass false the moment the carrier drops and the held value is thrown
|
||||
// away at once — holding it there made releasing the PTT look like a five
|
||||
// second lag, which read as the whole app being slow.
|
||||
function usePeakHold(windowMs = 1000) {
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
return (key: string, val: number, live = true) => {
|
||||
if (!live) { delete peak.current[key]; return val; }
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
}
|
||||
|
||||
// Stat — one small labelled figure (power, SWR, temperature).
|
||||
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
|
||||
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
|
||||
// three families (only where the state comes from differs).
|
||||
function OperateButton({ operate, disabled, onClick, t }: {
|
||||
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" disabled={disabled} onClick={onClick}
|
||||
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
|
||||
operate
|
||||
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
|
||||
<Power className="size-3.5" />
|
||||
{operate ? t('ampw.operate') : t('ampw.standby')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// AmpBlock — one amplifier inside the widget.
|
||||
function AmpBlock({ amp, flex, showName, t }: {
|
||||
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const spe = amp.spe, acom = amp.acom;
|
||||
const hold = usePeakHold();
|
||||
|
||||
if (spe || acom) {
|
||||
const s = spe || acom;
|
||||
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||
// gets there first (the amp is polled on its own slower cycle).
|
||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
||||
// Power ON drives the remote-on control lines, so it stays available while
|
||||
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
|
||||
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" disabled={!canPowerOn}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
|
||||
{t('ampw.on')}
|
||||
</button>
|
||||
<button type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
|
||||
{t('ampw.off')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
|
||||
{spe && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(['L', 'M', 'H'] as const).map((lvl) => {
|
||||
const active = (s.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
|
||||
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{t(`ampw.lvl${lvl}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{s.connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
|
||||
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
|
||||
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(s.warnings || s.alarms || s.err_text) && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
|
||||
// reports the amp; the fan mode rides on our own GSCP link.
|
||||
const pg = amp.pgxl || {};
|
||||
const viaFlex = !!flex?.amp_available;
|
||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||
const connected = !!pg.connected || viaFlex;
|
||||
const fault = flex?.amp_fault;
|
||||
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
|
||||
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const fwd = meter(/^fwd|pwr/i);
|
||||
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
|
||||
// Whether there is power is the RADIO's transmit flag: it flips the instant
|
||||
// the PTT does, where the amplifier's own status poll answers a second and a
|
||||
// half late and holds TRANSMIT past the carrier.
|
||||
//
|
||||
// How MUCH is the live reading — the radio's meter stream, or the amp's own
|
||||
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
|
||||
// latched maximum which is never reset AND survives in the last-known status
|
||||
// after the amp disconnects, so it reported 1350 W from an old transmission
|
||||
// while the radio was putting out 10 W into a disconnected amplifier.
|
||||
const txing = typeof flex?.transmitting === 'boolean'
|
||||
? flex.transmitting
|
||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||
// Prefer the radio's VITA meter stream when it is actually flowing (local or
|
||||
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
|
||||
// link only when the radio isn't streaming its meters (a remote link without
|
||||
// the VITA path), so the reading survives but is naturally slower.
|
||||
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
||||
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||
const id = meter(/^ID$|current/i);
|
||||
// Live drain current, same source preference. peak_id latches like peak_w, so
|
||||
// use the instantaneous 'id' and gate it on the radio's transmit flag.
|
||||
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
||||
const temp = meter(/temp/i);
|
||||
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
|
||||
// that can read ~10°C higher); fall back to the direct temp when no VITA.
|
||||
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={operate} disabled={!connected} t={t}
|
||||
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
|
||||
{connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
|
||||
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
|
||||
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(pg.host || pg.connected) && (
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||
title={t('ampw.fan')}
|
||||
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
|
||||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||
</select>
|
||||
)}
|
||||
{fault && fault !== 'NONE' && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{t('flxp.fault')}: {fault}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmpWidget({ amps, flex, sel, onClose }: {
|
||||
amps: Amp[];
|
||||
flex: any;
|
||||
sel: string; // "all" or an amp id
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
// An id that no longer matches (the amp was removed in Settings) falls back to
|
||||
// showing everything rather than an empty widget.
|
||||
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
|
||||
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
|
||||
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
|
||||
|
||||
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">
|
||||
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
|
||||
made a widget taller than the dock row and both ended up half hidden. */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
|
||||
{shown.length === 0 ? (
|
||||
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
|
||||
) : (
|
||||
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
|
||||
{shown.map((a, i) => (
|
||||
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
|
||||
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,37 @@ function pretty(name: string): string {
|
||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||
// would be a new component *type* on every render, so React would unmount and
|
||||
// remount every port button each time the panel re-renders — harmless when that's
|
||||
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||
// different element instances), so antenna changes silently did nothing.
|
||||
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||
onActivate: (port: number, antenna: number) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}) {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
if (filtered.length > 0) list = filtered;
|
||||
}
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
: '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} />
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||
<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} />
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||
ExportAwardForCatalog,
|
||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
@@ -32,12 +33,15 @@ 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;
|
||||
ref_display?: string; // grid column shows: ref | name | both
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; prefix?: string; dynamic?: boolean;
|
||||
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;
|
||||
// The "custom" confirmation source: the field it reads and, optionally, the
|
||||
// value(s) that count. Empty value = any non-empty value confirms.
|
||||
confirm_field?: string; confirm_value?: string;
|
||||
total: number; builtin?: boolean; version?: number;
|
||||
};
|
||||
|
||||
type AwardOrRule = {
|
||||
@@ -62,7 +66,7 @@ const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
|
||||
|
||||
@@ -155,6 +159,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||
// the selected award's current version whenever the selection changes.
|
||||
const [catVer, setCatVer] = useState('1');
|
||||
|
||||
// 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
|
||||
@@ -212,6 +219,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
const cur = defs[sel];
|
||||
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||
|
||||
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||
type Rejected = { candidate: string; reason: string };
|
||||
@@ -222,7 +230,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
};
|
||||
type Explanation = {
|
||||
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
||||
ref_count: number; steps: Step[]; manual?: string[]; ambiguous?: string[]; result: string[];
|
||||
};
|
||||
type TestRow = { qso: any; explanation: Explanation };
|
||||
const [testCall, setTestCall] = useState('');
|
||||
@@ -299,6 +307,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||
async function exportForCatalog() {
|
||||
setErr('');
|
||||
if (!cur) return;
|
||||
const v = Math.trunc(Number(catVer));
|
||||
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||
try {
|
||||
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Import: LOOK FIRST, then ask.
|
||||
//
|
||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||
@@ -352,14 +373,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
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">
|
||||
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>{t('awed.awardManagement')}</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="border-r flex flex-col min-w-0 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" />
|
||||
@@ -404,8 +425,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Right: tabbed editor for selected award.
|
||||
min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
|
||||
track has min-width:auto — it refuses to shrink below its content's
|
||||
intrinsic width, so a wide row (the band/mode chip lists, a long
|
||||
translated label) grew the track and pushed the whole editor out
|
||||
past the dialog instead of scrolling inside it. Same reason on the
|
||||
Tabs below: a flex item defaults to min-width:auto too. */}
|
||||
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||
apply it — that would destroy their work — so we offer it, and say
|
||||
@@ -441,7 +468,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
{!cur ? (
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
<TabsList className="px-3 justify-start">
|
||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||
@@ -530,10 +557,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
</Field2>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
|
||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></Field2>
|
||||
</div>
|
||||
|
||||
{/* Fallback searches: tried in order, only while nothing
|
||||
@@ -596,6 +625,30 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "Custom" is only meaningful once it names a field, so the
|
||||
inputs appear as soon as it is ticked in either column —
|
||||
and a custom source with no field confirms nothing. */}
|
||||
{((cur.confirm ?? []).includes('custom') || (cur.validate ?? []).includes('custom')) && (
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Label className="text-xs font-semibold">{t('awed.customLabel')}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] text-muted-foreground">{t('awed.customField')}</Label>
|
||||
<Input className="h-8 font-mono text-xs" placeholder="APP_OPSLOG_QSL_RCVD"
|
||||
value={(cur as any).confirm_field ?? ''}
|
||||
onChange={(e) => patch({ confirm_field: e.target.value.trim().toUpperCase() } as any)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] text-muted-foreground">{t('awed.customValue')}</Label>
|
||||
<Input className="h-8 font-mono text-xs" placeholder="Y,V"
|
||||
value={(cur as any).confirm_value ?? ''}
|
||||
onChange={(e) => patch({ confirm_value: e.target.value } as any)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('awed.customHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||
did nothing at all. The stored values are kept (see award.Def);
|
||||
@@ -684,6 +737,13 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(ex.ambiguous ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
|
||||
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
|
||||
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(ex.manual ?? []).length > 0 && (
|
||||
<div className="px-3 py-2 text-xs">
|
||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||
@@ -711,7 +771,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
{/* Eight buttons on one unwrappable row fitted in English and overflowed
|
||||
in French, where the labels are half again as long. Wrap instead of
|
||||
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
|
||||
margin-left approach leaves no vertical gap once a row wraps. */}
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||
@@ -726,6 +790,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
title={t('awed.awardsFolderTip')}>
|
||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||
</Button>
|
||||
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||
file to paste over internal/award/catalog/<code>.json, so a release
|
||||
ships your change to the whole team. */}
|
||||
{cur && (
|
||||
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||
<Button variant="outline" onClick={exportForCatalog}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||
|
||||
@@ -216,8 +216,8 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||
</div>
|
||||
|
||||
{/* Selected ref chip */}
|
||||
{selectedRef ? (
|
||||
{/* Selected ref chip (nothing shown until one is picked) */}
|
||||
{selectedRef && (
|
||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||
@@ -225,10 +225,6 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
<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">
|
||||
{t('awrs.pickReference')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add — references are always scoped to the contacted DXCC */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -8,12 +9,15 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AwardEditor } from '@/components/AwardEditor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
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[];
|
||||
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||
modes?: string[]; confirmed_modes?: string[];
|
||||
};
|
||||
type AwardResult = {
|
||||
code: string; name: string; dimension: string;
|
||||
@@ -66,6 +70,9 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
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).
|
||||
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
|
||||
// are different results, and caching them under one key showed the previous
|
||||
// mode’s numbers after switching.
|
||||
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
@@ -74,6 +81,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||
// confirmed" is two questions at once, and answering only one of them is what
|
||||
// sends an operator to a spreadsheet.
|
||||
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('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);
|
||||
@@ -112,13 +123,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
// 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; }
|
||||
const key = `${code}|${modeFilter}`;
|
||||
if (!force && byCode[key]) { setSelected(code); return; }
|
||||
setSelected(code);
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = (await GetAward(code)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [code]: r }));
|
||||
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
|
||||
setByCode((m) => ({ ...m, [key]: r }));
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -126,13 +138,20 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
}
|
||||
}
|
||||
|
||||
// Load the award list (no QSO scan), then compute only the first award.
|
||||
// Load the award list (no QSO scan), then compute only the first award. The
|
||||
// list is narrowed to the awards the operator follows (Settings → Awards); an
|
||||
// empty follow-set means "show them all" so the tab is never blank.
|
||||
async function loadList() {
|
||||
try {
|
||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||
const list: AwardListItem[] = defs
|
||||
const [defs, tracked] = await Promise.all([
|
||||
GetAwardDefs().then((d) => (d ?? []) as any[]),
|
||||
GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
|
||||
]);
|
||||
const follow = new Set(tracked);
|
||||
let 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));
|
||||
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
||||
setAwardList(list);
|
||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||
if (first) compute(first.code);
|
||||
@@ -141,8 +160,17 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
}
|
||||
}
|
||||
useEffect(() => { loadList(); }, []);
|
||||
// Re-filter when the operator changes their followed awards in Settings.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('awards:tracked-changed', () => { loadList(); });
|
||||
return () => { off(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const current = byCode[selected];
|
||||
const current = byCode[`${selected}|${modeFilter}`];
|
||||
// Recompute when the mode class changes: the bands, counts and confirmations
|
||||
// all describe the selected mode, so they cannot be filtered client-side.
|
||||
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
||||
|
||||
// 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
|
||||
@@ -191,17 +219,81 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
return idx.length ? idx : all;
|
||||
}, [stats, awardBands]);
|
||||
|
||||
// Sort order for the reference table. Reference is the default because that
|
||||
// is how award lists are published and how an operator reads a paper list;
|
||||
// description answers the other question — "have I worked anything in
|
||||
// Alicante" — which reference order scatters across the whole table.
|
||||
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||
// travels with a copied data/ folder like every other UI preference here.
|
||||
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
|
||||
() => {
|
||||
const v = localStorage.getItem('opslog.awardRefSort');
|
||||
return v === 'name' || v === 'group' ? v : 'ref';
|
||||
});
|
||||
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
|
||||
if (k === refSort) {
|
||||
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||
setRefSortDir(d);
|
||||
writeUiPref('opslog.awardRefSortDir', d);
|
||||
} else {
|
||||
setRefSort(k);
|
||||
setRefSortDir('asc');
|
||||
writeUiPref('opslog.awardRefSort', k);
|
||||
writeUiPref('opslog.awardRefSortDir', 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRefs = useMemo(() => {
|
||||
if (!current) return [];
|
||||
const q = refSearch.trim().toUpperCase();
|
||||
return (current.refs ?? []).filter((r) => {
|
||||
const rows = (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 (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||
// a contradiction: the mode filter stands aside rather than emptying
|
||||
// the list.
|
||||
if (!(r.modes ?? []).includes(modeFilter)) return false;
|
||||
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
|
||||
// on CW and confirmed on SSB is still a CW entity to chase, and the
|
||||
// whole point of the filter is to find those.
|
||||
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) 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]);
|
||||
const dir = refSortDir === 'asc' ? 1 : -1;
|
||||
return rows.sort((a, b) => {
|
||||
if (refSort === 'name') {
|
||||
// localeCompare, not a byte comparison: these lists are Spanish, French
|
||||
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
|
||||
// than after "Zaragoza".
|
||||
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
|
||||
if (c !== 0) return c * dir;
|
||||
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||
}
|
||||
if (refSort === 'group') {
|
||||
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
|
||||
// entities. Blank prefixes sink to the bottom regardless of direction,
|
||||
// so an unresolved entity never heads the list.
|
||||
const ag = a.group ?? '', bg = b.group ?? '';
|
||||
if (!ag !== !bg) return ag ? -1 : 1;
|
||||
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
|
||||
if (c !== 0) return c * dir;
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||
|
||||
// The group column earns its width only when the list actually carries one
|
||||
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
||||
// value IS the primary prefix, so it is labelled as such.
|
||||
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
|
||||
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
@@ -240,7 +332,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<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];
|
||||
const r = byCode[`${a.code}|${modeFilter}`];
|
||||
return (
|
||||
<button
|
||||
key={a.code}
|
||||
@@ -325,6 +417,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setModeFilter(k)}
|
||||
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
<button
|
||||
onClick={() => setShowMissing(true)}
|
||||
@@ -386,8 +486,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<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">{t('awp.ref')}</th>
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
{hasGroup && (
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
|
||||
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{groupColLabel}
|
||||
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.description')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
@@ -397,9 +515,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
{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>
|
||||
{hasGroup && (
|
||||
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
|
||||
)}
|
||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
|
||||
{gridBands.map((b) => {
|
||||
const s = cellStatus(r, b);
|
||||
return (
|
||||
@@ -424,9 +543,24 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
<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">{t('awp.ref')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">
|
||||
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.ref')}
|
||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium">
|
||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{t('awp.name')}
|
||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-40">
|
||||
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||
{groupColLabel}
|
||||
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||
</tr>
|
||||
@@ -459,7 +593,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
||||
</div>
|
||||
|
||||
{cell && current && (
|
||||
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
||||
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
|
||||
)}
|
||||
{showMissing && current && (
|
||||
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||
@@ -644,17 +778,17 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
||||
}
|
||||
|
||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
|
||||
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [qsos, setQsos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
AwardCellQSOs(code, cell.ref, cell.band)
|
||||
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
|
||||
.then((r) => setQsos((r ?? []) as any))
|
||||
.catch(() => setQsos([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [code, cell.ref, cell.band]);
|
||||
}, [code, cell.ref, cell.band, modeClass]);
|
||||
|
||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||
|
||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||
@@ -24,7 +25,40 @@ interface Spot {
|
||||
spotter?: string;
|
||||
}
|
||||
|
||||
type SpotStatusEntry = { status: string; country?: string };
|
||||
// The FULL status entry, not the two fields the map used to declare. The extra
|
||||
// markers were already arriving in this object — the local type simply never
|
||||
// mentioned them, so they could not be drawn.
|
||||
type SpotStatusEntry = {
|
||||
status?: string;
|
||||
country?: string;
|
||||
worked_call?: boolean;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
};
|
||||
|
||||
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||||
// entity AND a new park. The cluster list stacks them as separate badges instead
|
||||
// of letting one replace another, so the map stacks them too — as segments of
|
||||
// the pill's left accent bar, which until now only repeated the pill's own
|
||||
// colour and carried no information of its own.
|
||||
//
|
||||
// Their colours come from lib/spotMarkers, shared with the cluster list: the
|
||||
// same fact must not be violet in one panel and green in the next.
|
||||
// The map shows three of the four. A new PREFIX is left to the cluster list: the
|
||||
// pill is 22 px tall, and a fourth segment turns the strip into a colour code
|
||||
// nobody can read at a glance. Add it here the day the strip earns more room.
|
||||
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx');
|
||||
const markersFor = (e: SpotStatusEntry | undefined) =>
|
||||
activeMarkers(e).filter((m) => m.key !== 'new_pfx');
|
||||
|
||||
// The legend spells the markers out; the cluster list's badges are abbreviated
|
||||
// ("NEW CTY") because they sit in a narrow cell, and there is room here.
|
||||
const BMP_MARKER_LABEL: Record<string, string> = {
|
||||
new_pota: 'bmp.legendNewPota',
|
||||
new_county: 'bmp.legendNewCounty',
|
||||
worked_call: 'bmp.legendWorkedCall',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
band: string;
|
||||
@@ -41,6 +75,10 @@ interface Props {
|
||||
// globally from the band-map tab toolbar.
|
||||
hideDigital?: boolean;
|
||||
fitToBand?: boolean;
|
||||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||
keyNav?: boolean;
|
||||
}
|
||||
|
||||
const BAND_RANGES: Record<string, [number, number]> = {
|
||||
@@ -60,25 +98,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
||||
'70cm': [430000, 440000],
|
||||
};
|
||||
|
||||
// Sub-band shading: CW / digital / phone.
|
||||
//
|
||||
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
||||
// categorical hues — never the status tokens. They used to use success / info /
|
||||
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
||||
// is "new band" in this very component's legend, so the whole SSB portion read as
|
||||
// a giant "new band" wash. Status colours are reserved for status.
|
||||
//
|
||||
// All three are drawn from the COOL end of the categorical order and laid down at
|
||||
// low opacity, so the band plan stays background context while the warm spot pills
|
||||
// keep the foreground to themselves.
|
||||
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
||||
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
||||
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
||||
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
||||
const SEG_CW = 'var(--chart-7)'; // magenta
|
||||
const SEG_DIGI = 'var(--chart-1)'; // blue
|
||||
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
||||
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||
// are read on top of it.
|
||||
const SEG_OPACITY = 0.13;
|
||||
|
||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
||||
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
||||
'60m': [[5350, 5450, 'fill-warning-muted']],
|
||||
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
||||
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
||||
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
||||
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
||||
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
||||
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
||||
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
||||
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
||||
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||||
'60m': [[5350, 5450, SEG_PHONE]],
|
||||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
||||
};
|
||||
|
||||
// Small coloured dot + label used in the band-map legend strip.
|
||||
function LegendDot({ cls, label }: { cls: string; label: string }) {
|
||||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn('size-2 rounded-full', cls)} />
|
||||
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -153,7 +213,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// last; ties broken by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
|
||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
@@ -332,6 +392,43 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [range]);
|
||||
|
||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
||||
// MUST stay ABOVE the `if (!range)` early return below: on an unknown band
|
||||
// (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
|
||||
// hook, so the hook count changed between renders and React crashed with #300
|
||||
// ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
|
||||
// back to [0,1] when there's no range), so it's safe to run here.
|
||||
useEffect(() => {
|
||||
if (!keyNav) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
const ae = document.activeElement as HTMLElement | null;
|
||||
const tag = (ae?.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
|
||||
const list = spots
|
||||
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
|
||||
.slice()
|
||||
.sort((a, b) => a.freq_hz - b.freq_hz);
|
||||
if (!list.length) return;
|
||||
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
|
||||
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
|
||||
let target: Spot | undefined;
|
||||
if (e.key === 'ArrowUp') {
|
||||
target = list.find((s) => s.freq_hz > cur + EPS);
|
||||
} else {
|
||||
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
|
||||
}
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
onSpotClick(target);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||||
|
||||
if (!range) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||||
@@ -411,10 +508,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
height={totalH}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{segments.map(([s, e, cls], i) => {
|
||||
{segments.map(([s, e, colour], i) => {
|
||||
const y1 = freqToY(Math.min(e, hi));
|
||||
const y2 = freqToY(Math.max(s, lo));
|
||||
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
|
||||
return (
|
||||
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
|
||||
fill={colour} fillOpacity={SEG_OPACITY} />
|
||||
);
|
||||
})}
|
||||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||||
</svg>
|
||||
@@ -501,10 +601,21 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
'hover:translate-x-0.5 hover:shadow',
|
||||
style.pill,
|
||||
)}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||
>
|
||||
{/* Status accent strip on the left */}
|
||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
||||
{/* Left accent strip. With no extra marker it repeats the status
|
||||
colour, exactly as before; otherwise it splits into one
|
||||
segment per marker, so "worked entity + new park" shows both
|
||||
instead of one hiding the other. */}
|
||||
{(() => {
|
||||
const marks = markersFor(entry);
|
||||
if (marks.length === 0) return <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />;
|
||||
return (
|
||||
<span className="w-1 shrink-0 flex flex-col" aria-hidden>
|
||||
{marks.map((m) => <span key={m.key} className="flex-1" style={{ background: m.colour }} />)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||
<span>{p.spot.dx_call}</span>
|
||||
{mode && (
|
||||
@@ -523,7 +634,17 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
||||
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
|
||||
{/* The stacked markers, straight from the shared table so the legend
|
||||
cannot drift from what the pills actually draw. */}
|
||||
{BMP_MARKERS.map((m) => (
|
||||
<LegendDot key={m.key} colour={m.colour} label={t(BMP_MARKER_LABEL[m.key])} />
|
||||
))}
|
||||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||
<span className="mx-0.5 opacity-40">|</span>
|
||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
||||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
||||
</div>
|
||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||
{t('bmp.footerHint')}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Star, Radio } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sunTimes } from '@/lib/sun';
|
||||
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||
import type { WorkedBeforeView } from '@/types';
|
||||
|
||||
type WorkedBefore = WorkedBeforeView;
|
||||
@@ -13,6 +15,16 @@ interface Props {
|
||||
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
|
||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||
// to an entity with no position at all, and the block simply does not appear.
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
// Set when the matrix is showing a QSO picked in the log grid rather than the
|
||||
// entry form — labelled so the two can never be confused.
|
||||
forCall?: string;
|
||||
// Open the QSO editor for a contact — makes callsigns in the cell drill-down
|
||||
// clickable. Threaded down from App.openEdit.
|
||||
onEditQso?: (id: number) => void;
|
||||
}
|
||||
|
||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||
@@ -84,7 +96,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||
}
|
||||
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||
// Cell drill-down: which band+class the operator clicked, or null.
|
||||
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||
// 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(
|
||||
@@ -94,6 +108,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
const dxcc = wb?.dxcc ?? 0;
|
||||
const dxccName = wb?.dxcc_name ?? '';
|
||||
const dxccCount = wb?.dxcc_count ?? 0;
|
||||
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
|
||||
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
||||
const hasDxcc = dxcc > 0;
|
||||
const newOne = hasDxcc && dxccCount === 0;
|
||||
@@ -109,14 +124,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
// "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.
|
||||
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
|
||||
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
|
||||
// DXCC-style grouping instead (Settings → General), where all digital modes
|
||||
// count as ONE — then FT4 after FT8 is just "worked".
|
||||
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
|
||||
const normMode = (m: string): string => {
|
||||
const u = (m || '').toUpperCase().trim();
|
||||
if (!groupDigital) return u;
|
||||
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
|
||||
};
|
||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||
const curMode = (currentMode || '').toUpperCase().trim();
|
||||
const curMode = normMode(currentMode);
|
||||
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);
|
||||
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
|
||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === 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.
|
||||
@@ -129,6 +151,50 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
const newMode = slotNew && bandWorked && !modeWorked;
|
||||
const newSlot = slotNew && bandWorked && modeWorked;
|
||||
|
||||
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
|
||||
// is on). Hotter colour the more wanted the entity is.
|
||||
const mwBadge = mwRank > 0 ? (
|
||||
<Badge
|
||||
title={`ClubLog Most Wanted #${mwRank}`}
|
||||
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
|
||||
mwRank <= 100 ? 'bg-danger text-danger-foreground'
|
||||
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
|
||||
: 'bg-muted text-muted-foreground')}
|
||||
>
|
||||
MW #{mwRank}
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
|
||||
// about to open or close on their side, which is the reason to look at a DX
|
||||
// station's grey line at all. Recomputed only when the position changes.
|
||||
const sun = useMemo(
|
||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||
[lat, lon],
|
||||
);
|
||||
const sunBlock = sun ? (
|
||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
||||
title="Sunrise / sunset at the DX station (UTC)">
|
||||
{sun.polarDay ? (
|
||||
<span className="font-semibold text-warning">midnight sun</span>
|
||||
) : sun.polarNight ? (
|
||||
<span className="font-semibold text-info">polar night</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunrise className="size-3.5 text-warning" />
|
||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunset className="size-3.5 text-info" />
|
||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">UTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -136,7 +202,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-[220px]">
|
||||
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
||||
{newOne ? (
|
||||
<>
|
||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||
@@ -147,12 +213,22 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||
{' '}· never worked this entity
|
||||
</span>
|
||||
{mwBadge}
|
||||
</>
|
||||
) : hasDxcc ? (
|
||||
<>
|
||||
{/* Says WHOSE stats these are when they come from a row picked in
|
||||
the log rather than from what's being typed. */}
|
||||
{forCall && (
|
||||
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
|
||||
title="Stats for the QSO selected in the log">
|
||||
{forCall}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||
{dxccName || `DXCC #${dxcc}`}
|
||||
</Badge>
|
||||
{mwBadge}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||
@@ -183,6 +259,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
Type a callsign to see entity stats
|
||||
</span>
|
||||
)}
|
||||
{sunBlock}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -222,10 +299,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
return (
|
||||
<td
|
||||
key={b.tag}
|
||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
|
||||
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||
className={cn(
|
||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||
// Only a filled cell has anything to show — an empty one
|
||||
// stays inert rather than opening a "no QSOs" dialog.
|
||||
st && 'cursor-pointer hover:brightness-110',
|
||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||
)}
|
||||
/>
|
||||
@@ -253,6 +334,119 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slot && (
|
||||
<SlotQSOModal
|
||||
call={wb?.callsign ?? ''}
|
||||
dxcc={dxcc}
|
||||
entity={dxccName}
|
||||
band={slot.band}
|
||||
cls={slot.cls}
|
||||
onClose={() => setSlot(null)}
|
||||
onEdit={onEditQso}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cell drill-down ──────────────────────────────────────────────────────────
|
||||
// The contacts behind one band+class cell: this exact callsign AND anyone else
|
||||
// in the entity, because that is the pair of facts the cell's colour encodes.
|
||||
// The call's own QSOs are marked so the two never blur together.
|
||||
|
||||
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
|
||||
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; onEdit?: (id: number) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<any[] | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
BandSlotQSOs(call, dxcc, band, cls)
|
||||
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
|
||||
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
|
||||
return () => { dead = true; };
|
||||
}, [call, dxcc, band, cls]);
|
||||
|
||||
useEffect(() => {
|
||||
// Capture phase: the app's global ESC handler resets the entry form, and
|
||||
// closing a dialog must not also wipe what the operator was typing.
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-[2px] p-4" onClick={onClose}>
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-[760px] max-w-full max-h-[72vh] flex flex-col overflow-hidden ring-1 ring-black/5"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border bg-muted/30">
|
||||
<span className="inline-flex items-center justify-center h-6 min-w-6 px-1.5 rounded-md bg-primary/15 text-primary text-[11px] font-bold shrink-0 tabular-nums">{band}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm leading-tight truncate">
|
||||
{cls}{entity && <span className="text-muted-foreground font-normal"> · {entity}</span>}
|
||||
</div>
|
||||
{onEdit && <div className="text-[10px] text-muted-foreground leading-tight">Click a callsign to edit the QSO</div>}
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{rows ? `${rows.length} QSO${rows.length > 1 ? 's' : ''}` : ''}</span>
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 rounded p-0.5 hover:bg-muted transition-colors">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
|
||||
{!rows ? (
|
||||
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Loading…
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-3 py-1.5">Date UTC</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Callsign</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Mode</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Freq</th>
|
||||
<th className="text-left font-medium px-3 py-1.5">Name</th>
|
||||
<th className="text-center font-medium px-3 py-1.5">Cfm</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((q, i) => {
|
||||
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
|
||||
const mine = q.callsign === call;
|
||||
return (
|
||||
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
|
||||
<td className="px-3 py-1.5 tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{onEdit ? (
|
||||
<button type="button" onClick={() => { onEdit(q.id as number); onClose(); }} title="Edit this QSO"
|
||||
className={cn('font-mono cursor-pointer text-left hover:underline underline-offset-2', mine ? 'font-bold text-primary' : 'text-foreground hover:text-primary')}>
|
||||
{q.callsign}
|
||||
</button>
|
||||
) : (
|
||||
<span className={cn('font-mono', mine && 'font-bold text-primary')}>{q.callsign}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">{q.mode}</td>
|
||||
<td className="px-3 py-1.5 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[180px]">{q.name}</td>
|
||||
<td className="px-3 py-1.5 text-center">{cfm ? <span className="text-success">✓</span> : <span className="text-muted-foreground/30">·</span>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type FieldKind = 'status' | 'text';
|
||||
type FieldKind = 'status' | 'text' | 'freq' | 'date';
|
||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||
|
||||
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||
@@ -21,19 +21,36 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const FIELDS: FieldDef[] = [
|
||||
// QSL / upload status
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
// One channel at a time, each with its status and its date, in the same order
|
||||
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
|
||||
// QSL is not uploaded — so every entry now reads "<channel> sent/received
|
||||
// status" or "… date", which is also what the ADIF field is called.
|
||||
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
// My station / operator
|
||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
||||
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||
@@ -65,6 +82,7 @@ const FIELDS: FieldDef[] = [
|
||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||
// Contacted station (location / activation refs / SIG)
|
||||
{ id: 'grid', label: 'bulk.fGrid', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||
@@ -74,6 +92,9 @@ const FIELDS: FieldDef[] = [
|
||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||
// Misc
|
||||
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||
// logged on a stale/default frequency after CAT dropped.
|
||||
{ id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' },
|
||||
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||
@@ -112,7 +133,7 @@ type Props = {
|
||||
// so they become eligible for upload.
|
||||
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [field, setField] = useState('hrdlog_upload');
|
||||
const [field, setField] = useState('qsl_sent');
|
||||
const [statusValue, setStatusValue] = useState('R');
|
||||
const [textValue, setTextValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -176,11 +197,35 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : def.kind === 'date' ? (
|
||||
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
|
||||
// writes an empty value — which is how a wrong date is removed
|
||||
// from a batch, the same meaning as leaving a text field blank.
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-xs font-mono"
|
||||
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
|
||||
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
|
||||
/>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
|
||||
title={t('bulk.todayUtc')}
|
||||
onClick={() => {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
// UTC: the whole log is UTC, and near midnight the local day
|
||||
// is the wrong one.
|
||||
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
|
||||
}}
|
||||
>{t('bulk.today')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={textValue}
|
||||
placeholder={t('bulk.clearPlaceholder')}
|
||||
inputMode={def.kind === 'freq' ? 'decimal' : undefined}
|
||||
placeholder={def.kind === 'freq' ? t('bulk.freqPlaceholder') : t('bulk.clearPlaceholder')}
|
||||
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||
/>
|
||||
)}
|
||||
@@ -188,7 +233,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { markerColour } from '@/lib/spotMarkers';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
@@ -53,6 +54,8 @@ export type SpotStatusEntry = {
|
||||
worked_call?: boolean;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
pfx?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -99,56 +102,74 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
];
|
||||
}
|
||||
|
||||
// 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.
|
||||
type Badge = { text: string; fg: string; bg: string; bd: string };
|
||||
// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
|
||||
//
|
||||
// The pills were dropped: a rounded box has its own height and padding, so it
|
||||
// sat off the row's baseline and the callsign inside it no longer lined up with
|
||||
// the plain callsigns above and below. A whole column of them read as chrome
|
||||
// rather than as data. Same reasoning — and the same semantic tokens — as the
|
||||
// Y/N/R QSL columns in lib/qslStatus.ts.
|
||||
//
|
||||
// Which cell is coloured IS the message, so one colour is enough for all three:
|
||||
//
|
||||
// yellow call → new DXCC yellow band → new band yellow mode → new mode
|
||||
// blue call → already worked
|
||||
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||
|
||||
// tok builds a badge from a semantic status token (success/warning/caution/
|
||||
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
||||
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
||||
function tok(name: string, text: string): Badge {
|
||||
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
||||
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
||||
}
|
||||
|
||||
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
||||
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
||||
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
||||
// null → plain text, no pill.
|
||||
function cellChip(value: any, name: string | null): any {
|
||||
// cellText renders a cell value in an optional colour. An empty value keeps the
|
||||
// muted dash the grid used before, so blank cells still read as "nothing here"
|
||||
// rather than as a gap.
|
||||
function cellText(value: any, color: string | null): any {
|
||||
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
||||
// callsign stays the same size as the plain callsigns next to it — just tinted
|
||||
// and rounded, not shrunk.
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
||||
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
||||
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
||||
}}>{txt}</span>
|
||||
);
|
||||
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
||||
}
|
||||
|
||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||
// statusColor is the colour for a resolved status in the Status column. County,
|
||||
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
|
||||
// DXCC story and an operator filters on them separately.
|
||||
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||
switch (s?.status) {
|
||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||
case 'new-band': return tok('warning', t('clg2.newBand'));
|
||||
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
||||
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
||||
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
||||
case 'new':
|
||||
case 'new-band':
|
||||
case 'new-mode':
|
||||
case 'new-slot':
|
||||
return NEW;
|
||||
default:
|
||||
return s?.worked_call ? WKD : null;
|
||||
}
|
||||
}
|
||||
|
||||
// isDull reports a spot that "represents nothing" under the current worked/slot
|
||||
// rules: the entity/band/mode is already worked (status resolved, not new in any
|
||||
// dimension), the callsign itself isn't in the log, and there's no POTA / county
|
||||
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
|
||||
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
|
||||
// what counts as dull follows those settings automatically. Unresolved statuses
|
||||
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
|
||||
function isDull(s: SpotStatusEntry | undefined): boolean {
|
||||
if (!s || !s.status) return false;
|
||||
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot') return false;
|
||||
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
|
||||
}
|
||||
|
||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{
|
||||
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||
defaultVisible: true,
|
||||
sort: 'desc',
|
||||
// Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
|
||||
// lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
|
||||
// below "2359Z", so spots received just after midnight fell to the bottom
|
||||
// and looked like the cluster had stopped. received_at is a full datetime
|
||||
// that keeps ordering correct across 0000Z.
|
||||
comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
|
||||
const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
|
||||
const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
|
||||
return ta - tb;
|
||||
},
|
||||
cellStyle: { color: 'var(--muted-foreground)' },
|
||||
},
|
||||
{
|
||||
@@ -156,15 +177,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
||||
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
||||
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
||||
// with the rest of the row instead of always shouting a colour.
|
||||
// NEW DXCC → yellow call. Already worked → blue call. Anything else keeps
|
||||
// the theme's normal ink so ordinary callsigns don't shout.
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
||||
const color = s?.worked_call ? 'var(--info)' : undefined;
|
||||
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
|
||||
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||
},
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
@@ -175,9 +193,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||
headerName: t('clg2.c.status'), width: 120, 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. NEW COUNTY
|
||||
// and NEW POTA are orthogonal, so they stack as extra badges.
|
||||
// Spells the status out in words so NEW SLOT (and the others) is obvious at
|
||||
// the row level, not just a single coloured cell — NEW SLOT in particular
|
||||
// colours no cell at all, since neither the band nor the mode is new on its
|
||||
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
|
||||
valueGetter: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
const parts: string[] = [];
|
||||
@@ -188,25 +207,33 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||
return parts.join(' ');
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
const badges: Badge[] = [];
|
||||
const b = statusBadge(t, s);
|
||||
if (b) badges.push(b);
|
||||
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
||||
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
||||
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
const parts: { text: string; color: string }[] = [];
|
||||
const main = statusColor(s);
|
||||
if (main) {
|
||||
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||
: s?.status === 'new-band' ? t('clg2.newBand')
|
||||
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||
: t('clg2.wkdCall');
|
||||
parts.push({ text: label, color: main });
|
||||
}
|
||||
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
||||
// never one colour here and another there.
|
||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
|
||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
|
||||
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||
{badges.map((bd, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
||||
fontWeight: 700, fontSize: 9,
|
||||
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||
}}>{bd.text}</span>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
{parts.map((pt, i) => (
|
||||
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
|
||||
{i > 0 ? ' · ' : ''}{pt.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
@@ -239,8 +266,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
// NEW BAND for this entity → small warning pill around the band text.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||
// NEW BAND for this entity → the band text turns yellow.
|
||||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||
},
|
||||
{
|
||||
@@ -253,7 +280,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||
// that case is signalled by the Status badge alone.
|
||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||
@@ -361,13 +388,36 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
||||
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||
// matching column events. Without this guard those events were persisted, so a
|
||||
// single language change overwrote the saved cluster layout — in the cache AND
|
||||
// in the database, with nothing to restore from.
|
||||
const restoringRef = useRef(true);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
|
||||
restoringRef.current = true;
|
||||
return COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), [COL_CATALOG]);
|
||||
});
|
||||
}, [COL_CATALOG]);
|
||||
|
||||
// Re-apply the saved state after every rebuild, then re-enable saving.
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(tm);
|
||||
}, [columnDefs]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
// Dim "represents nothing" spots at the CELL level, via a class that
|
||||
// refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on
|
||||
// every status update, which re-rendered the whole grid continuously and
|
||||
// pegged the CPU on a busy cluster (the 0.23.6 slowdown).
|
||||
cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) },
|
||||
}), []);
|
||||
|
||||
// Pass spotStatus through AG Grid's context so cell renderers can look up
|
||||
@@ -377,24 +427,37 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||
|
||||
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
||||
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
||||
// turns blue once its status loads.
|
||||
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
|
||||
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
|
||||
// refresh so e.g. a worked call turns blue once its status loads.
|
||||
//
|
||||
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
|
||||
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
|
||||
// into one refresh every 200 ms (still imperceptible) instead of one per update.
|
||||
const refreshPending = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (refreshPending.current !== undefined) return; // a refresh is already queued
|
||||
refreshPending.current = window.setTimeout(() => {
|
||||
refreshPending.current = undefined;
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
}, 200);
|
||||
}, [spotStatus]);
|
||||
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
// Restore AFTER the profile scope is known — this grid has no key= remount to
|
||||
// save it from reading the wrong (unscoped) cache key at first paint.
|
||||
async function onGridReady(e: GridReadyEvent) {
|
||||
await whenGridPrefsReady();
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
const remote = await loadRemote(COL_STATE_KEY);
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(COL_STATE_KEY, state);
|
||||
}, []);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -53,6 +53,14 @@ interface Props {
|
||||
prefix: string;
|
||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||
remoteGrid: string; // entry-strip Grid value — destination
|
||||
// Entry-strip text fields. Live award detection matches on these too — a DARC
|
||||
// DOK keys off QTH, not the address — and sending only the address meant such
|
||||
// an award stayed silent until the QSO was logged.
|
||||
qth?: string;
|
||||
name?: string;
|
||||
country?: string;
|
||||
comment?: string;
|
||||
note?: string;
|
||||
details: DetailsState;
|
||||
onChange: (patch: Partial<DetailsState>) => void;
|
||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||
@@ -61,6 +69,14 @@ interface Props {
|
||||
band: string;
|
||||
mode: string;
|
||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||
// When the entry form is empty and a QSO is selected in the log grid, the
|
||||
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
||||
// Only the matrix is redirected — every other tab still edits the live entry.
|
||||
slotCall?: string;
|
||||
slotBand?: string;
|
||||
slotMode?: string;
|
||||
slotWb?: any;
|
||||
slotWbBusy?: boolean;
|
||||
imageUrl?: string;
|
||||
onOpenImage?: () => void;
|
||||
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||
@@ -69,6 +85,8 @@ interface Props {
|
||||
// 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;
|
||||
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
|
||||
onEditQso?: (id: number) => void;
|
||||
}
|
||||
|
||||
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
@@ -113,28 +131,57 @@ function numOrUndef(v: string): number | undefined {
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
|
||||
// for literal class names, so an interpolated one is never emitted.
|
||||
const SPAN_CLASS: Record<number, string> = {
|
||||
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
|
||||
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
|
||||
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
||||
};
|
||||
|
||||
// Compact field helper to keep the JSX dense.
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
||||
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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')}>
|
||||
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
|
||||
<Label className="mb-1">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
// Live award detection: run the SAME engine used at log time over the current
|
||||
// contact (callsign + looked-up address/state/zones) so award references the
|
||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
||||
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
||||
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
||||
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
||||
// while the award editor's Test tab, which passes the whole QSO, matched it.
|
||||
// References the QSO will earn — WAPC matching "Beijing" inside the address,
|
||||
// a DOK matching the town — 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 }>>([]);
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
|
||||
|
||||
// NEW badge on the county: the same question the cluster's NEW CTY badge
|
||||
// answers, for the station being worked right now. Debounced because the
|
||||
// county field is typed into as well as filled by the lookup.
|
||||
const [newCounty, setNewCounty] = useState(false);
|
||||
useEffect(() => {
|
||||
const cnty = (details.cnty ?? '').trim();
|
||||
if (!cnty) { setNewCounty(false); return; }
|
||||
let alive = true;
|
||||
const id = window.setTimeout(async () => {
|
||||
try {
|
||||
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||
if (alive) setNewCounty(!!isNew);
|
||||
} catch { if (alive) setNewCounty(false); }
|
||||
}, 300);
|
||||
return () => { alive = false; window.clearTimeout(id); };
|
||||
}, [details.state, details.cnty]);
|
||||
useEffect(() => {
|
||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
@@ -142,6 +189,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
const q: any = {
|
||||
callsign, band, mode,
|
||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||
qth: qth ?? '', name: name ?? '', country: country ?? '',
|
||||
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
|
||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||
qso_date: new Date().toISOString(),
|
||||
};
|
||||
@@ -151,7 +200,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
const have = new Set(cur.split(';').filter(Boolean));
|
||||
let next = cur;
|
||||
for (const r of all) {
|
||||
if (!r.pickable) continue;
|
||||
// An ambiguous candidate is offered, never auto-added: adding one would
|
||||
// be the guess the award's "one reference per QSO" setting exists to refuse.
|
||||
if (!r.pickable || r.ambiguous) continue;
|
||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||
}
|
||||
@@ -160,7 +211,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}, 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]);
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
const path = useMemo(() => {
|
||||
@@ -174,6 +225,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
}
|
||||
return null;
|
||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
|
||||
// have one — it is what the operator can see and check — and the provider's
|
||||
// raw coordinates cover entities that resolve through cty.dat with no grid.
|
||||
const dxLL = useMemo(() => {
|
||||
const byGrid = gridToLatLon(remoteGrid);
|
||||
if (byGrid) return byGrid;
|
||||
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
|
||||
return null;
|
||||
}, [remoteGrid, details.lat, details.lon]);
|
||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||
|
||||
@@ -222,19 +282,40 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||
<BandSlotGrid
|
||||
wb={slotCall ? slotWb : wb}
|
||||
busy={slotCall ? !!slotWbBusy : !!wbBusy}
|
||||
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||
bands={bands}
|
||||
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||
forCall={slotCall}
|
||||
onEditQso={onEditQso}
|
||||
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'info' && (
|
||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||
<Field label={t('detp.statePref')}>
|
||||
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
||||
{/* Twelve columns, not six: the fields hold very different things. A US
|
||||
county name is long, a CQ zone is two digits — on an even six-column
|
||||
grid they were the same width, so the county truncated while the
|
||||
zones sat mostly empty. */}
|
||||
<Field label={t('detp.statePref')} span={2}>
|
||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.county')}>
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||
<Field label={t('detp.county')} span={4} className="relative">
|
||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
|
||||
{newCounty && (
|
||||
<span
|
||||
title={t('detp.newCountyTip')}
|
||||
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
|
||||
>
|
||||
{t('detp.newCounty')}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t('detp.prefix')}>
|
||||
<Field label={t('detp.prefix')} span={2}>
|
||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||
</Field>
|
||||
<Field label={t('detp.cqZone')}>
|
||||
@@ -248,7 +329,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||
bearing and distances move to the row below. */}
|
||||
<Field label={t('detp.dxcc')}>
|
||||
<Field label={t('detp.dxcc')} span={2}>
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -257,7 +338,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.azimuthLp')}>
|
||||
{/* The bearing and the two distances are read-only and hold at most
|
||||
"12345 km", so they take a fixed width and the address — the one
|
||||
field here that is never wide enough — gets everything left over. */}
|
||||
<div className="col-span-12 flex gap-2">
|
||||
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -266,7 +351,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceSp')}>
|
||||
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -275,7 +360,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.distanceLp')}>
|
||||
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||
<Input
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
@@ -284,13 +369,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('detp.address')} span={3}>
|
||||
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslMessage')} span={3}>
|
||||
</div>
|
||||
<Field label={t('detp.qslMessage')} span={7}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslVia')} span={2}>
|
||||
<Field label={t('detp.qslVia')} span={5}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
@@ -305,12 +391,39 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
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">
|
||||
{/* Ambiguous candidates: the award allows one reference per QSO and
|
||||
several matched, so none was kept. Click one to settle it while the
|
||||
contact is still in front of you. */}
|
||||
{detected.some((r) => r.ambiguous) && (
|
||||
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
||||
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
|
||||
{detected.filter((r) => r.ambiguous).map((r) => (
|
||||
<button
|
||||
key={`amb-${r.code}@${r.ref}`}
|
||||
type="button"
|
||||
title={r.name ?? ''}
|
||||
onClick={() => {
|
||||
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
|
||||
const cur = details.award_refs ?? '';
|
||||
// Only one of the candidates can be right — replace any
|
||||
// sibling already picked for this award rather than stacking.
|
||||
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
|
||||
onChange({ award_refs: [...kept, entry].join(';') });
|
||||
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
|
||||
}}
|
||||
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detected.some((r) => !r.ambiguous) && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||
{detected.map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||
{detected.filter((r) => !r.ambiguous).map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -352,18 +465,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={t('detp.rig')} span={3}>
|
||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.antenna')} span={3}>
|
||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<Field label={t('detp.satName')} span={3}>
|
||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.satelliteMode')} span={3}>
|
||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mic, Square, X, Radio } from 'lucide-react';
|
||||
import { Mic, Square, X, Radio, Repeat } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -12,14 +12,20 @@ type Props = {
|
||||
onPlay: (slot: number) => void;
|
||||
onStop: () => void;
|
||||
onClose: () => void;
|
||||
autoCq: boolean;
|
||||
autoCqSecs: number;
|
||||
onToggleAutoCq: (on: boolean) => void;
|
||||
onSetAutoCqSecs: (n: number) => void;
|
||||
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||
};
|
||||
|
||||
// Operating panel for the Digital Voice Keyer — transmits the recorded 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) {
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||
const { t } = useI18n();
|
||||
const anyAudio = messages.some((m) => m.has_audio);
|
||||
const recorded = messages.filter((m) => m.has_audio);
|
||||
const anyAudio = recorded.length > 0;
|
||||
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">
|
||||
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{messages.map((m) => (
|
||||
// Only the recorded slots are shown. Two columns filling top-to-bottom
|
||||
// (rows sized to the count) — use the width instead of scrolling, so the
|
||||
// panel keeps the same height as the other reserved-area widgets.
|
||||
<div className="grid grid-flow-col gap-1"
|
||||
style={{ gridTemplateRows: `repeat(${Math.max(1, Math.ceil(recorded.length / 2))}, minmax(0, auto))` }}>
|
||||
{recorded.map((m) => (
|
||||
<button
|
||||
key={m.slot}
|
||||
type="button"
|
||||
disabled={!m.has_audio}
|
||||
disabled={!phoneOk}
|
||||
onClick={() => onPlay(m.slot)}
|
||||
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
|
||||
title={!phoneOk ? t('dvkp.notPhone') : t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) })}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||
m.has_audio
|
||||
phoneOk
|
||||
? '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 ? t('dvkp.message') : '—')}</span>
|
||||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
||||
<span className="text-xs truncate flex-1">{m.label || t('dvkp.message')}</span>
|
||||
{(m.label || '').toUpperCase().includes('CQ') && autoCq && <Repeat className="size-3 text-primary shrink-0" />}
|
||||
<span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a
|
||||
non-phone mode (the DVK won't transmit there). */}
|
||||
<div className="shrink-0 border-t border-border bg-muted/30 px-2 py-1.5 flex items-center gap-2">
|
||||
<label className={cn('flex items-center gap-1.5 text-[11px] cursor-pointer', !phoneOk && 'opacity-50')} title={phoneOk ? t('dvkp.autoCqHint') : t('dvkp.notPhone')}>
|
||||
<input type="checkbox" className="accent-primary" checked={autoCq} disabled={!phoneOk}
|
||||
onChange={(e) => onToggleAutoCq(e.target.checked)} />
|
||||
<Repeat className="size-3" /> {t('dvkp.autoCq')}
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-muted-foreground">{t('dvkp.gap')}</span>
|
||||
<input type="number" min={0} max={120} value={autoCqSecs}
|
||||
onChange={(e) => onSetAutoCqSecs(parseInt(e.target.value, 10))}
|
||||
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" />
|
||||
<span className="text-[10px] text-muted-foreground">s</span>
|
||||
</div>
|
||||
|
||||
{!phoneOk && (
|
||||
<div className="shrink-0 bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center">{t('dvkp.notPhone')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { LogUIError } from '../../wailsjs/go/main/App';
|
||||
|
||||
// A render error used to empty the window and leave nothing behind: no line in
|
||||
// opslog.log, no dialog, no way for the operator to say more than "white screen".
|
||||
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
|
||||
// auto-call — stayed undiagnosed for weeks for exactly that reason.
|
||||
//
|
||||
// So this does two things, in order of importance:
|
||||
//
|
||||
// 1. Writes the error and its stack to the app log through LogUIError, so the
|
||||
// next report arrives WITH the cause instead of a description of a blank
|
||||
// screen.
|
||||
// 2. Shows it, with the text selectable and a button to copy it, because the
|
||||
// person who can act on it is the one looking at the screen.
|
||||
//
|
||||
// It deliberately does NOT try to recover the tree. React cannot guarantee the
|
||||
// state is sane after a render throw, and a half-working logger is worse than
|
||||
// one that says plainly what happened and offers a reload.
|
||||
|
||||
type Props = { children: React.ReactNode };
|
||||
type State = { error: Error | null; info: string };
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null, info: '' };
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
const stack = (info?.componentStack || error.stack || '').trim();
|
||||
this.setState({ info: stack });
|
||||
// Never let the reporting path throw: it runs while the app is already broken.
|
||||
try {
|
||||
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
|
||||
} catch {
|
||||
/* the backend may be gone too — the on-screen copy still works */
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, info } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const report = `${error.name}: ${error.message}\n\n${info}`;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
overflow: 'auto', zIndex: 9999,
|
||||
}}>
|
||||
<div style={{ maxWidth: 820, width: '100%' }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
|
||||
OpsLog hit an error and stopped drawing
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
|
||||
Your log is safe — this is the window, not the database. The details below
|
||||
are already in the app log; send them with your report.
|
||||
</p>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
|
||||
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
|
||||
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
|
||||
}}>{report}</pre>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||
<button
|
||||
onClick={() => { void navigator.clipboard?.writeText(report); }}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
|
||||
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
Copy the details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
|
||||
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
Reload the window
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
|
||||
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
|
||||
// so they leave no trace at all — and an auto-call loop lives entirely in
|
||||
// callbacks, which is precisely where a white screen was reported.
|
||||
export function installGlobalErrorLogging() {
|
||||
const report = (kind: string, message: string, stack?: string) => {
|
||||
try {
|
||||
LogUIError(kind, message, stack || '');
|
||||
} catch {
|
||||
/* nothing more we can do from here */
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('error', (e: ErrorEvent) => {
|
||||
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
|
||||
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||
const r: any = e.reason;
|
||||
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { ADIFFields, ADIFExtraFields } from '@/../wailsjs/go/main/App';
|
||||
import { adif } from '@/../wailsjs/go/models';
|
||||
|
||||
type FieldDef = adif.FieldDef;
|
||||
const PREF_KEY = 'opslog.exportFields';
|
||||
|
||||
// One category card with its checkboxes + All/None. Defined at MODULE scope (not
|
||||
// inside ExportFieldsDialog) so its component identity is stable across renders —
|
||||
// an inner component is re-created every render, remounting the whole subtree and
|
||||
// making the All/None buttons and checkboxes feel dead.
|
||||
function GroupCard({ title, tags, warn, sel, allLabel, noneLabel, onAll, onNone, onToggle }: {
|
||||
title: string; tags: string[]; warn?: boolean; sel: Set<string>;
|
||||
allLabel: string; noneLabel: string;
|
||||
onAll: (tags: string[]) => void; onNone: (tags: string[]) => void; onToggle: (name: string, on: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
|
||||
<div className="flex items-center justify-between mb-1 gap-2">
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
|
||||
<span className="flex gap-1.5 shrink-0">
|
||||
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => onAll(tags)}>{allLabel}</button>
|
||||
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => onNone(tags)}>{noneLabel}</button>
|
||||
</span>
|
||||
</div>
|
||||
{tags.map((name) => (
|
||||
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
|
||||
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => onToggle(name, !!c)} />
|
||||
<span className="font-mono break-all">{name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
|
||||
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
|
||||
// and the OpsLog / non-standard tags actually present in the log's extras. The
|
||||
// chosen set is returned to the parent (which runs the selected/global export)
|
||||
// and persisted so it's the default next time.
|
||||
export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||
open: boolean;
|
||||
count: number; // how many QSOs the pending export covers
|
||||
onExport: (fields: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [official, setOfficial] = useState<FieldDef[]>([]);
|
||||
const [extras, setExtras] = useState<string[]>([]);
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
|
||||
const defaultSet = (flds: FieldDef[]) =>
|
||||
new Set(flds.filter((d) => d.promoted && !d.deprecated).map((d) => d.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [f, e] = await Promise.all([ADIFFields(), ADIFExtraFields()]);
|
||||
if (cancelled) return;
|
||||
const flds = (f ?? []) as FieldDef[];
|
||||
setOfficial(flds);
|
||||
setExtras((e ?? []) as string[]);
|
||||
let saved: string[] | null = null;
|
||||
try { const raw = localStorage.getItem(PREF_KEY); if (raw) saved = JSON.parse(raw); } catch { /* ignore */ }
|
||||
setSel(saved && saved.length ? new Set(saved.map((x) => x.toUpperCase())) : defaultSet(flds));
|
||||
})().catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [open]);
|
||||
|
||||
// Official fields grouped by category (deprecated ones are import-only → hidden).
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, FieldDef[]>();
|
||||
for (const d of official) {
|
||||
if (d.deprecated) continue;
|
||||
const g = d.category || 'Misc';
|
||||
(m.get(g) ?? m.set(g, []).get(g)!).push(d);
|
||||
}
|
||||
return [...m.entries()];
|
||||
}, [official]);
|
||||
|
||||
const toggle = (name: string, on: boolean) =>
|
||||
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||
const setMany = (names: string[], on: boolean) =>
|
||||
setSel((s) => { const n = new Set(s); for (const x of names) { if (on) n.add(x); else n.delete(x); } return n; });
|
||||
|
||||
const doExport = () => {
|
||||
const fields = [...sel];
|
||||
writeUiPref(PREF_KEY, JSON.stringify(fields));
|
||||
onExport(fields);
|
||||
};
|
||||
|
||||
const allLabel = t('exf.all');
|
||||
const noneLabel = t('exf.none');
|
||||
const cardProps = {
|
||||
sel, allLabel, noneLabel,
|
||||
onAll: (tags: string[]) => setMany(tags, true),
|
||||
onNone: (tags: string[]) => setMany(tags, false),
|
||||
onToggle: toggle,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('exf.title')}</DialogTitle>
|
||||
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||
{t('exf.defaults')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
|
||||
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn {...cardProps} />}
|
||||
{groups.map(([g, list]) => (
|
||||
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('exf.cancel')}</Button>
|
||||
<Button disabled={sel.size === 0} onClick={doExport}>{t('exf.export', { n: count })}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,9 @@ export interface QueryFilter {
|
||||
|
||||
// Curated field catalog. `value` MUST match a column in the backend whitelist
|
||||
// (qso.FilterableFields); `type` only drives which operators/value input we show.
|
||||
type FieldType = 'text' | 'number' | 'date';
|
||||
// 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
|
||||
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
|
||||
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
|
||||
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
|
||||
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
|
||||
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
|
||||
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
|
||||
// of them — a paper QSL is not uploaded.
|
||||
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
|
||||
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
|
||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
|
||||
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
|
||||
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
|
||||
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
|
||||
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
|
||||
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
|
||||
{ value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
|
||||
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
|
||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
|
||||
|
||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
|
||||
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
|
||||
// comparison operators (before/after), not the text ones.
|
||||
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
|
||||
return OPS.filter((o) => allow.includes(o.value));
|
||||
}
|
||||
@@ -216,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
<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); }} />
|
||||
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||
fires too late — the menu has already loaded the preset and
|
||||
closed. Intercept pointer-down (preventDefault stops Radix
|
||||
claiming it) and delete there instead. */}
|
||||
<Trash2
|
||||
className="size-3 text-muted-foreground hover:text-destructive"
|
||||
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||
/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -253,12 +278,23 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
// An ADIF date is picked with a calendar but STORED as the
|
||||
// 8-digit form the column holds, so the comparison stays a
|
||||
// plain string one on both sides.
|
||||
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||
className="h-8 flex-1 text-xs"
|
||||
disabled={!needsValue}
|
||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
||||
value={c.value}
|
||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||
// \d, not d: the escape was missing, so the test never matched
|
||||
// an 8-digit ADIF date and the calendar input was handed
|
||||
// "20260728" — which type=date rejects, showing an empty box
|
||||
// over a value that was really there.
|
||||
value={fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
|
||||
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
|
||||
: c.value}
|
||||
onChange={(e) => setCond(i, {
|
||||
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
||||
})}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
|
||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
GetFlexState, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel, FlexSetTXDAX,
|
||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||
@@ -16,6 +20,9 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
import { TunerCard } from '@/components/TunerCard';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
|
||||
type FlexState = {
|
||||
available: boolean; model?: string;
|
||||
@@ -30,6 +37,11 @@ type FlexState = {
|
||||
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||
wnb: boolean; wnb_level: number;
|
||||
// SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them.
|
||||
lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number;
|
||||
speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean;
|
||||
nrf?: boolean; nrf_level?: number; dsp_v4?: boolean;
|
||||
dax_ch?: number; tx_dax?: boolean;
|
||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||
mode?: string;
|
||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||
@@ -113,8 +125,8 @@ function Segmented({ value, options, onChange, disabled }: {
|
||||
}
|
||||
|
||||
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
|
||||
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||
function Chip({ on, onClick, label, disabled, accent = 'emerald', title }: {
|
||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; title?: string;
|
||||
}) {
|
||||
const onCls = {
|
||||
emerald: 'bg-success border-success text-success-foreground',
|
||||
@@ -123,7 +135,7 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
amber: 'bg-warning border-warning text-warning-foreground',
|
||||
}[accent];
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled}
|
||||
<button type="button" onClick={onClick} disabled={disabled} title={title}
|
||||
className={cn(
|
||||
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||||
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||||
@@ -134,6 +146,37 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||
);
|
||||
}
|
||||
|
||||
// SmartSDR reports the tuner as an enum. It is worth decoding rather than
|
||||
// showing raw: TUNE_FAIL and "never tuned" both leave the radio looking normal,
|
||||
// and the operator transmits into a bad match either way.
|
||||
function atuLabel(status: string | undefined, t: (k: string) => string): string {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'TUNE_IN_PROGRESS': return t('flxp.atuTuning');
|
||||
case 'TUNE_SUCCESSFUL':
|
||||
case 'TUNE_OK': return t('flxp.atuOk');
|
||||
case 'TUNE_FAIL':
|
||||
case 'TUNE_FAIL_BYPASS': return t('flxp.atuFail');
|
||||
case 'TUNE_BYPASS':
|
||||
case 'TUNE_MANUAL_BYPASS': return t('flxp.atuBypassed');
|
||||
case 'TUNE_ABORTED': return t('flxp.atuAborted');
|
||||
case 'TUNE_NOT_STARTED':
|
||||
case 'NONE':
|
||||
case '': return t('flxp.atuIdle');
|
||||
default: return status || '';
|
||||
}
|
||||
}
|
||||
|
||||
function atuTone(status: string | undefined): string {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'TUNE_SUCCESSFUL':
|
||||
case 'TUNE_OK': return 'text-success';
|
||||
case 'TUNE_FAIL':
|
||||
case 'TUNE_FAIL_BYPASS': return 'text-danger';
|
||||
case 'TUNE_IN_PROGRESS': return 'text-warning';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
|
||||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
|
||||
@@ -211,56 +254,50 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||
const METER_SEGMENTS = 26;
|
||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||
function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
|
||||
icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
|
||||
open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
|
||||
}) {
|
||||
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);
|
||||
// Collapsible: a chevron in the header hides the body. Uncontrolled by default,
|
||||
// persisting per card (keyed by ckey, falling back to the title); when `open`/
|
||||
// `onToggle` are supplied the parent owns the state (e.g. linked TX/RX cards).
|
||||
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||
const [openState, setOpenState] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||
const controlled = openProp !== undefined;
|
||||
const open = controlled ? openProp : openState;
|
||||
const toggle = controlled
|
||||
? (onToggle ?? (() => {}))
|
||||
: () => setOpenState((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||
return (
|
||||
<div onClick={onClick} title={title}
|
||||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||
{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={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||
{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 && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<button type="button" onClick={toggle}
|
||||
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||
</button>
|
||||
{open && <div className="p-3 space-y-3">{children}</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>
|
||||
);
|
||||
// speMaxW is the amp's rated output, used as the power-bar full scale. Derived from
|
||||
// the model string the amp reports (13K→1.3kW, 15K→1.5kW, 2K/20K→2kW).
|
||||
function speMaxW(model?: string): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
return 1300; // 1.3K-FA default
|
||||
}
|
||||
|
||||
// powerLevelLabel spells out the SPE power-level code (L/M/H) in full.
|
||||
function powerLevelLabel(pl?: string): string {
|
||||
switch ((pl || '').trim().toUpperCase()) {
|
||||
case 'L': return 'Low';
|
||||
case 'M': return 'Mid';
|
||||
case 'H': return 'High';
|
||||
default: return pl || '';
|
||||
}
|
||||
}
|
||||
|
||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||
@@ -268,14 +305,36 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<FlexState>(ZERO);
|
||||
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
||||
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
||||
// NB/NR/ANF stay visible by default. Remembered across sessions.
|
||||
const [dspOpen, setDspOpen] = useState<boolean>(() => localStorage.getItem('opslog.flexDspOpen') === '1');
|
||||
const toggleDsp = () => setDspOpen((o) => { const n = !o; localStorage.setItem('opslog.flexDspOpen', n ? '1' : '0'); return n; });
|
||||
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.
|
||||
// Keep the host's keyer speed (used for CW-length estimates and the keyer panel)
|
||||
// in step with the radio's ACTUAL CW speed — including when it's changed on the
|
||||
// radio / in SmartSDR, not just via the slider below. Notify only on a real change.
|
||||
const lastCwSpeed = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const w = st.cw_speed;
|
||||
if (typeof w === 'number' && w > 0 && w !== lastCwSpeed.current) {
|
||||
lastCwSpeed.current = w;
|
||||
onCWSpeed?.(w);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [st.cw_speed]);
|
||||
// Peak-hold: keep the highest reading for a second so the jittery VITA-49
|
||||
// meters read steadily instead of jumping every poll. One second, not two —
|
||||
// longer and the meters visibly trail the end of a transmission, which reads
|
||||
// as the app lagging rather than as a peak being held.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
|
||||
// live=false throws the held value away at once (e.g. the carrier dropped),
|
||||
// so the meter falls immediately instead of coasting for the window.
|
||||
if (!live) { delete peak.current[key]; return val; }
|
||||
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; }
|
||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
@@ -301,15 +360,60 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}, []);
|
||||
|
||||
// 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 });
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ 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);
|
||||
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// TRANSMIT + RECEIVE share ONE collapse state (they sit side by side, so folding
|
||||
// one folds the other and keeps the row tidy). Persisted like the other cards.
|
||||
const [txrxOpen, setTxrxOpen] = useState(() => localStorage.getItem('opslog.cardOpen.txrx') !== '0');
|
||||
const toggleTxrx = () => setTxrxOpen((o) => { const n = !o; localStorage.setItem('opslog.cardOpen.txrx', n ? '1' : '0'); return n; });
|
||||
|
||||
// Tuner Genius XL direct connection — its own card in the Flex panel (like PGXL).
|
||||
const [tg, setTg] = useState<TGStatus>({ connected: false });
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 500); // fast so meters track TX (see App.tsx)
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
|
||||
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
|
||||
// which, and the choice is remembered per panel.
|
||||
const [ampList, setAmpList] = useState<any[]>([]);
|
||||
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
|
||||
const isSPE = !!selAmp?.spe;
|
||||
const isACOM = !!selAmp?.acom;
|
||||
const spe = selAmp?.spe ?? { connected: false };
|
||||
const acom = selAmp?.acom ?? { connected: false };
|
||||
const ampPicker = ampList.length > 1 && selAmp ? (
|
||||
<select value={selAmp.id}
|
||||
onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
|
||||
title={t('flxp.ampPick')}
|
||||
className="h-8 rounded-md border border-border bg-card px-2 text-xs font-semibold outline-none">
|
||||
{ampList.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
) : null;
|
||||
|
||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||
hold.current[key] = Date.now() + 900;
|
||||
setSt((p) => ({ ...p, [key]: val }));
|
||||
@@ -352,6 +456,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
return () => off();
|
||||
}, [st.rit, st.rit_freq]);
|
||||
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||
// Phone (voice) modes — MIC / COMP meters only make sense here, so they're
|
||||
// hidden in CW and digital.
|
||||
const isPhone = /\b(SSB|USB|LSB|AM|FM|DFM|NFM)\b/i.test(st.mode || '');
|
||||
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];
|
||||
@@ -480,21 +587,22 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
};
|
||||
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`}
|
||||
// dBm sits inline next to the S-value (no separate line below) to save height.
|
||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={`${s.display} | ${dbm.toFixed(0)} dBm`}
|
||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
||||
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||
); })(),
|
||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" value={w} />
|
||||
); })(),
|
||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
|
||||
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
|
||||
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB. Phone modes only.
|
||||
isPhone && mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
|
||||
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||||
// Speech compression (dB of gain reduction).
|
||||
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
|
||||
// Speech compression — original working meter, only the top of the
|
||||
// scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
|
||||
isPhone && comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||
@@ -511,7 +619,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* TX + RX columns */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* TRANSMIT */}
|
||||
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
|
||||
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626" open={txrxOpen} onToggle={toggleTxrx}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
|
||||
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||||
@@ -552,6 +660,33 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ATU — the radio's built-in tuner. Sits under TUNE because that is
|
||||
the order of operations: the tuner needs a carrier to measure
|
||||
against, so TUNE first, then start a tuning cycle. */}
|
||||
<div className="flex items-center gap-2 border-t border-border/60 pt-3">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">ATU</span>
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.atuTuneHint')}
|
||||
onClick={() => FlexATUStart().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
||||
{t('flxp.atuTune')}
|
||||
</button>
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.atuBypassHint')}
|
||||
onClick={() => FlexATUBypass().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
|
||||
{t('flxp.atuBypass')}
|
||||
</button>
|
||||
<Chip on={st.atu_memories} disabled={off} label={t('flxp.atuMem')} accent="cyan"
|
||||
title={t('flxp.atuMemHint')}
|
||||
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
|
||||
{/* The status is the whole point: a tune that FAILED and one that
|
||||
never ran look identical on the radio's own front panel. */}
|
||||
<span className={cn('ml-auto text-[11px] font-mono font-semibold whitespace-nowrap', atuTone(st.atu_status))}>
|
||||
{atuLabel(st.atu_status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{!isCW ? (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
@@ -636,12 +771,24 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RIT/XIT live on the TRANSMIT side to balance the two columns — the
|
||||
RECEIVE card grew tall with the v4 DSP rows. */}
|
||||
<div className="space-y-1.5 border-t border-border/60 pt-3">
|
||||
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* RECEIVE */}
|
||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2" open={txrxOpen} onToggle={toggleTxrx}>
|
||||
{/* Antenna selection sits at the very top of the RX column. */}
|
||||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 pb-3 border-b border-border/60">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground">RX</span>
|
||||
@@ -656,21 +803,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
{/* DAX on/off — SmartSDR's transmit-bar DAX button (TX audio from
|
||||
DAX, e.g. WSJT-X). "transmit set dax=", not the slice channel. */}
|
||||
<button type="button" disabled={off} title={t('flxp.daxHint')}
|
||||
onClick={() => change('tx_dax', !st.tx_dax, () => FlexSetTXDAX(!st.tx_dax))}
|
||||
className={cn('h-7 px-2.5 shrink-0 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-40',
|
||||
st.tx_dax ? 'bg-info text-info-foreground border-info' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
DAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
|
||||
listening (chasing a station that drifts off your transmit frequency),
|
||||
so it belongs with the other things you touch mid-QSO. Both act on the
|
||||
ACTIVE slice and follow slice focus like every control here. */}
|
||||
<div className="space-y-1.5 pb-3 border-b border-border/60">
|
||||
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||
@@ -694,13 +837,28 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
{/* Core noise controls stay visible; WNB + the SmartSDR v4 DSP block
|
||||
hide behind the 'DSP' toggle so the card doesn't tower. When any
|
||||
hidden control is ON we badge the toggle so it's not forgotten. */}
|
||||
{(() => {
|
||||
const hiddenOn = st.wnb || (st.dsp_v4 && (!!st.lms_nr || !!st.speex_nr || !!st.nrf || (!isCW && !!st.lms_anf) || !!st.rnn || (!isCW && !!st.anft)));
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-muted-foreground">{t('flxp.dspNoise')}</span>
|
||||
<button type="button" onClick={toggleDsp} title={t('flxp.dspMore')}
|
||||
className={cn('flex items-center gap-1 h-6 px-2 rounded-md border text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||
hiddenOn && !dspOpen ? 'bg-primary/15 text-primary border-primary/40' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
<SlidersHorizontal className="size-3" />
|
||||
DSP
|
||||
{hiddenOn && !dspOpen && <span className="ml-0.5 size-1.5 rounded-full bg-primary" />}
|
||||
<ChevronDown className={cn('size-3 transition-transform', dspOpen && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<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))} />
|
||||
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
||||
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
||||
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
||||
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(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))} />
|
||||
@@ -710,6 +868,58 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||
)}
|
||||
{/* Advanced / supplementary DSP — collapsed by default. WNB (wideband
|
||||
noise blanker) plus the SmartSDR v4 block (8000/Aurora): NRL/ANFL
|
||||
(legacy LMS), NRS (spectral subtraction), NRF (NR w/ filter), RNN
|
||||
(AI NR), ANFT (FFT notch). The v4 rows only exist when the radio
|
||||
reports those slice keys (older 6000s never do). */}
|
||||
{dspOpen && (
|
||||
<div className="space-y-3 border-t border-dashed border-border/50 pt-3">
|
||||
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
||||
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
||||
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
||||
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
|
||||
{st.dsp_v4 && (
|
||||
<>
|
||||
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
|
||||
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
|
||||
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
|
||||
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
|
||||
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
|
||||
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
|
||||
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
|
||||
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
|
||||
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
|
||||
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
|
||||
{!isCW && (
|
||||
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
|
||||
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
|
||||
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
|
||||
)}
|
||||
{/* RNN and ANFT are on/off only — no level in the API. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
|
||||
<button type="button" disabled={rxOff}
|
||||
title={t('flxp.rnnHint')}
|
||||
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
|
||||
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
RNN
|
||||
</button>
|
||||
{!isCW && (
|
||||
<button type="button" disabled={rxOff}
|
||||
title={t('flxp.anftHint')}
|
||||
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
|
||||
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
ANFT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCW && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
@@ -760,23 +970,130 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||
{st.amp_available && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
{/* SPE Expert amplifier (serial/TCP) — shown when it's the configured amp.
|
||||
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
||||
SPE link rather than the Flex amplifier object. */}
|
||||
{isSPE && (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{ampPicker}
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpOperate(selAmp.id, !spe.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
{/* Output power level: Low / Mid / High. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
||||
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPowerLevel(selAmp.id, lvl).catch(() => {})}
|
||||
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{powerLevelLabel(lvl)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
||||
</span>
|
||||
{spe.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{(spe.warnings || spe.alarms) && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
||||
)}
|
||||
</div>
|
||||
{spe.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
||||
lo={0} hi={speMaxW(spe.model)}
|
||||
display={`${Number(spe.output_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
||||
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
||||
{isACOM && (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{ampPicker}
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => AmpOperate(selAmp.id, !acom.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON is a DTR/RTS pulse — serial only, and it works while the amp
|
||||
is off (the port stays open), so gate on port_open not connected. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
||||
</span>
|
||||
{acom.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{acom.err_text && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
||||
)}
|
||||
</div>
|
||||
{acom.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
||||
lo={0} hi={Number(acom.max_w) || 800}
|
||||
display={`${Number(acom.fwd_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
|
||||
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
|
||||
this Flex-reported card so two amps don't both show. */}
|
||||
{st.amp_available && !isSPE && !isACOM && (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3">
|
||||
{ampPicker}
|
||||
<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-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
{(pg.host || pg.connected) && (
|
||||
{selAmp?.type === 'pgxl' && (pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||
@@ -800,29 +1117,55 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||
and shown compact so they sit with the amp controls. */}
|
||||
{(() => {
|
||||
// Prefer the radio's VITA amp meters when they are flowing (local or
|
||||
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
|
||||
// right temperature sensor). Fall back to the amp's OWN link only when
|
||||
// the radio isn't streaming them (a remote link without VITA).
|
||||
const meters = st.meters || [];
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (off || amp.length === 0) return null;
|
||||
if (off || amp.length === 0) {
|
||||
const P = pg as any;
|
||||
if (!P.connected) return null;
|
||||
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
|
||||
// random points, so peak-hold it (live=txing drops it the instant
|
||||
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
|
||||
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
|
||||
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
|
||||
const idA = txing ? Number(P.id) || 0 : 0;
|
||||
const temp = Number(P.temperature) || 0;
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
|
||||
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
|
||||
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Power comes from the radio's meter stream and nothing else. The
|
||||
// amplifier also reports a "peakfwd", and using it was a mistake
|
||||
// twice over: it is a latched maximum that is never reset, and it
|
||||
// survives in the last-known status after the amp disconnects — so
|
||||
// it claimed 1350 W from an old transmission while the radio was
|
||||
// putting out 10.
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amp.map((m) => {
|
||||
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||
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" />;
|
||||
}
|
||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||
// value itself is fine — only the bar's range was wrong. Give it a
|
||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||
// only override an unusable reported range, not a sane one.
|
||||
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
|
||||
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
|
||||
// unusable reported range but keeping a sane one.
|
||||
let lo = m.lo, hi = m.hi;
|
||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||
lo = 0;
|
||||
hi = m.hi >= 25 ? m.hi : 25;
|
||||
hi = m.hi >= 50 ? m.hi : 50;
|
||||
}
|
||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -830,6 +1173,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tuner Genius XL — 4O3A ATU, its own card when enabled (Settings →
|
||||
Tuner Genius). Same card shown in Station Control. */}
|
||||
{tgEnabled && <TunerCard status={tg} t={t} />}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,12 +47,33 @@ const ZERO: IcomState = {
|
||||
|
||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
||||
const BANDS: { l: string; hz: number }[] = [
|
||||
type Band = { l: string; hz: number };
|
||||
|
||||
const HF_BANDS: Band[] = [
|
||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||
{ l: '6', hz: 50_150_000 },
|
||||
];
|
||||
const B6 = { l: '6', hz: 50_150_000 };
|
||||
const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
||||
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||
|
||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||
// the plain SetFrequency command — no band-stacking codes needed.
|
||||
//
|
||||
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||
// through 6 and none of the bands it actually covers: ten dead buttons, and no
|
||||
// way to change band from here on the only rig where you would want to.
|
||||
function bandsFor(model?: string): Band[] {
|
||||
const m = (model ?? '').toUpperCase();
|
||||
if (m.includes('9700')) return [B2, B70, B23]; // VHF/UHF/SHF only
|
||||
if (m.includes('705')) return [...HF_BANDS, B6, B2, B70];
|
||||
if (m.includes('9100')) return [...HF_BANDS, B6, B2, B70, B23];
|
||||
// 7300 / 7610 / 7100 / unknown: HF + 6 m, the historical list.
|
||||
return [...HF_BANDS, B6];
|
||||
}
|
||||
|
||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||||
// SSB by frequency and the rig's data variant for digital modes.
|
||||
@@ -73,6 +94,27 @@ function attOptions(model?: string): { v: string; l: string }[] {
|
||||
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||||
}
|
||||
|
||||
// bandOfHz names the amateur band a frequency falls in, so the band row can show
|
||||
// where the rig actually is. The buttons only ever SENT a frequency and carried
|
||||
// no active state at all, so nothing was highlighted whatever the rig reported.
|
||||
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
|
||||
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
|
||||
function bandOfHz(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '';
|
||||
const mhz = hz / 1_000_000;
|
||||
const bands: [string, number, number][] = [
|
||||
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
|
||||
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
|
||||
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
|
||||
// Labels must match the band buttons' exactly — this is only used to light
|
||||
// the button for the band the rig is on, and '70' never matched '70cm'.
|
||||
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0],
|
||||
['70cm', 430.0, 450.0], ['23cm', 1240.0, 1300.0],
|
||||
];
|
||||
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||
return '';
|
||||
}
|
||||
|
||||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||
function fmtVFO(hz?: number): string {
|
||||
@@ -731,12 +773,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{BANDS.map((b) => (
|
||||
{bandsFor(st.model).map((b) => {
|
||||
const here = bandOfHz(mainHz) === b.l;
|
||||
return (
|
||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
|
||||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||
here
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
|
||||
: 'border-border bg-card text-foreground hover:bg-muted')}>
|
||||
{b.l}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Row label={t('icmp.antenna')}>
|
||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
||||
|
||||
// LogViewer shows the tail of opslog.log and keeps it current while open.
|
||||
//
|
||||
// This exists because diagnosing a problem used to mean asking the operator to
|
||||
// find a file in their data directory, open it in a text editor, and re-open it
|
||||
// after every attempt. Half the round-trips in a bug report were spent on that.
|
||||
//
|
||||
// Three behaviours matter more than they look:
|
||||
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
|
||||
// moment they scroll up — otherwise the line they are reading is yanked away
|
||||
// a second later, which defeats the one thing this window is for. Scrolling
|
||||
// back to the bottom re-arms it, so following is never a dead end.
|
||||
// • The filter keeps only matching LINES rather than highlighting inside a
|
||||
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||
// the subsystem trace you would have grepped for.
|
||||
// • It polls only while open. A closed dialog costs nothing.
|
||||
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
|
||||
// the oldest fall out of the fetched text entirely, so a small buffer dropped
|
||||
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
|
||||
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
|
||||
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
|
||||
// useful backlog while staying smooth. The backend caps this at 4 MB.
|
||||
const TAIL_BYTES = 512 * 1024;
|
||||
const POLL_MS = 1000;
|
||||
|
||||
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [text, setText] = useState('');
|
||||
const [path, setPath] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// Shown next to the checkbox so a QUIET log still proves it is being polled —
|
||||
// without it there is no way to tell 'nothing new' from 'window is dead'.
|
||||
const [lastPull, setLastPull] = useState('');
|
||||
const boxRef = useRef<HTMLPreElement>(null);
|
||||
const autoRef = useRef(true);
|
||||
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
|
||||
|
||||
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
|
||||
// straight away is not enough while the dialog is still animating in: the box
|
||||
// has no height yet, scrollHeight is wrong, and the scroll silently does
|
||||
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
|
||||
// at lines from hours ago, which looks exactly like a frozen window.
|
||||
const stick = useCallback(() => {
|
||||
if (!autoRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
const el = boxRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pull = useCallback(() => {
|
||||
TailLogFile(TAIL_BYTES).then((s: any) => {
|
||||
setText(String(s ?? ''));
|
||||
setLastPull(new Date().toLocaleTimeString());
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAutoScroll(true);
|
||||
autoRef.current = true;
|
||||
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
|
||||
pull();
|
||||
// Re-assert the bottom after the open animation has settled. A quiet log
|
||||
// never changes, so the content-driven scroll below would never fire again
|
||||
// and the first attempt would be the only one.
|
||||
const t1 = window.setTimeout(stick, 120);
|
||||
const t2 = window.setTimeout(stick, 450);
|
||||
const id = window.setInterval(pull, POLL_MS);
|
||||
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
|
||||
}, [open, pull, stick]);
|
||||
|
||||
const all = useMemo(() => text.split('\n'), [text]);
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((l) => l.toLowerCase().includes(q));
|
||||
}, [all, query]);
|
||||
|
||||
// Stick to the bottom after each refresh, but only while auto-scroll is on.
|
||||
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
|
||||
|
||||
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
|
||||
function onScroll() {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
|
||||
}
|
||||
|
||||
const body = shown.join('\n');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('logview.title')}</DialogTitle>
|
||||
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
|
||||
onChange={(e) => setQuery(e.target.value)} />
|
||||
{query.trim() !== '' && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{t('logview.matches', { n: shown.length, total: all.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre ref={boxRef} onScroll={onScroll}
|
||||
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
|
||||
{body.trim() === '' ? t('logview.empty') : body}
|
||||
</pre>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
|
||||
const on = !!c;
|
||||
setAutoScroll(on);
|
||||
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
|
||||
}} />
|
||||
{t('logview.autoScroll')}
|
||||
</label>
|
||||
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
|
||||
{t('logview.copy')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
|
||||
{t('logview.toEnd')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { nightPolygon } from '../lib/greyline';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
@@ -42,9 +43,20 @@ function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
|
||||
// and their usage policy does not cover a desktop application handed to an
|
||||
// unbounded number of operators — they blocked OpsLog for it, and every user's
|
||||
// map filled with "Access blocked / 403" tiles at once.
|
||||
//
|
||||
// The replacements are tile services whose terms do cover redistributed apps,
|
||||
// with no API key: Carto (whose tiles are OSM-derived, hence the attribution
|
||||
// still credits OpenStreetMap) and Esri. Anything added here later must be
|
||||
// checked the same way — a free tile URL is not the same thing as a tile URL we
|
||||
// are allowed to ship.
|
||||
const ESRI_STREET = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
|
||||
const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, © 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
|
||||
@@ -54,7 +66,7 @@ const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; s
|
||||
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 },
|
||||
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_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}' },
|
||||
@@ -115,6 +127,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// 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');
|
||||
|
||||
// Grey line — the day/night terminator with its twilight band. Off by
|
||||
// default: it is a working tool for the operator who wants it, not decoration
|
||||
// for the one who does not.
|
||||
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||||
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||||
const autoZoomRef = useRef(autoZoom);
|
||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||
|
||||
@@ -152,6 +170,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
}, [basemap]);
|
||||
|
||||
// Draw the grey line, and keep it moving.
|
||||
//
|
||||
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||||
// time, which is invisible, but a map left on all day would otherwise be
|
||||
// silently wrong by evening — and the whole point of the display is knowing
|
||||
// where the line is NOW.
|
||||
//
|
||||
// It lives in its own pane below the overlay pane so the path and beam are
|
||||
// never hidden behind the shading, and is non-interactive so it can never
|
||||
// swallow a click meant for the map.
|
||||
useEffect(() => {
|
||||
const m = worldMap.current;
|
||||
if (!m) return;
|
||||
|
||||
if (!greyline) {
|
||||
greylineLayer.current?.remove();
|
||||
greylineLayer.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m.getPane('greyline')) {
|
||||
const pane = m.createPane('greyline');
|
||||
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||||
pane.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const now = new Date();
|
||||
greylineLayer.current?.remove();
|
||||
const g = L.layerGroup([], { pane: 'greyline' });
|
||||
|
||||
// The rings span −180…+180 once, but this map repeats the world sideways
|
||||
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||||
// single ring therefore ends in a hard vertical edge wherever a copy
|
||||
// begins — the shading looked like a rectangle laid over the planet.
|
||||
//
|
||||
// So each ring is drawn again shifted a full turn either way. Three copies
|
||||
// cover every zoom level this map reaches; they cost nothing, being three
|
||||
// polygons on a canvas layer.
|
||||
const COPIES = [-360, 0, 360];
|
||||
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||||
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||||
|
||||
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||||
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||||
// which is the band operators actually mean by "grey line".
|
||||
const nightRing = nightPolygon(now, 0);
|
||||
const twilightRing = nightPolygon(now, -6);
|
||||
// The terminator itself, so the line is readable at a glance rather than
|
||||
// being inferred from where the shading fades. Dropping the last two
|
||||
// points leaves the terminator alone, without the segment that closes the
|
||||
// polygon along the dark pole.
|
||||
const lineRing = nightRing.slice(0, -2);
|
||||
|
||||
for (const by of COPIES) {
|
||||
L.polygon(shifted(twilightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polygon(shifted(nightRing, by), {
|
||||
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||||
}).addTo(g);
|
||||
L.polyline(shifted(lineRing, by), {
|
||||
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||||
}).addTo(g);
|
||||
}
|
||||
g.addTo(m);
|
||||
greylineLayer.current = g;
|
||||
};
|
||||
|
||||
draw();
|
||||
const id = window.setInterval(draw, 60_000);
|
||||
return () => { window.clearInterval(id); };
|
||||
}, [greyline]);
|
||||
|
||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||
useEffect(() => {
|
||||
const wm = worldMap.current, wo = worldOverlay.current;
|
||||
@@ -293,6 +385,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
>
|
||||
Zoom DX
|
||||
</button>
|
||||
{/* Grey line toggle, under the zoom button. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const v = !greyline;
|
||||
setGreyline(v);
|
||||
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||||
}}
|
||||
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||||
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
Grey line
|
||||
</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
|
||||
@@ -320,7 +427,14 @@ export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
||||
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);
|
||||
// Same restraint as the world map: fetch on idle, not mid-pan, and keep a
|
||||
// single ring of buffer tiles. Requesting tiles as fast as the pointer
|
||||
// moves is what gets an app blocked, and the next provider is under no
|
||||
// more obligation to tolerate it than the last one was.
|
||||
L.tileLayer(CARTO_LIGHT, {
|
||||
attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19,
|
||||
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||
}).addTo(m);
|
||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||
locatorMap.current = m;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MenuItem =
|
||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
|
||||
// accent highlights an item that is an OFFER rather than a command (Donate).
|
||||
// Generic on purpose: a hard-coded label test in the renderer would break the
|
||||
// moment the menu is translated.
|
||||
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
|
||||
| { type: 'separator' };
|
||||
|
||||
export interface Menu {
|
||||
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
disabled={item.disabled}
|
||||
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
|
||||
onSelect={() => onAction(item.action)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// MeterBar is the ONE LED-bar meter used across the FlexRadio panel, the amplifier
|
||||
// cards and the Tuner Genius card, so every meter renders at exactly the same size
|
||||
// (segment count, bar height, padding). Keep it the single source of truth — don't
|
||||
// re-declare a local copy in a panel, or the meters drift out of sync.
|
||||
export const METER_SEGMENTS = 26;
|
||||
|
||||
export function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||
}) {
|
||||
const span = hi - lo;
|
||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||
return (
|
||||
<div onClick={onClick} title={title}
|
||||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||
{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={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||
{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 && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
|
||||
|
||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||
|
||||
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||
type QSOMenuHandlers = {
|
||||
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;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onLogged?: () => void;
|
||||
countries?: string[];
|
||||
bands?: string[];
|
||||
modes?: string[];
|
||||
qsoMenuHandlers?: QSOMenuHandlers;
|
||||
};
|
||||
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [nets, setNets] = useState<Net[]>([]);
|
||||
const [selId, setSelId] = useState<string>('');
|
||||
@@ -61,6 +75,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||
// Programmatic row selection in the on-air grid (auto-advance after logging).
|
||||
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
|
||||
|
||||
// Add/edit-contact dialog.
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
@@ -68,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
const rosterGrid = useRef<any>(null);
|
||||
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||
const onAirApi = useRef<any>(null);
|
||||
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||
const activateRef = useRef<(call: string) => void>(() => {});
|
||||
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||
const wireDropZones = useCallback(() => {
|
||||
const rApi = rosterGrid.current?.api;
|
||||
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||
dropZonesDone.current.roster = true;
|
||||
rApi.addRowDropZone({
|
||||
getContainer: () => onAirWrap.current!,
|
||||
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||
});
|
||||
}
|
||||
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||
dropZonesDone.current.onair = true;
|
||||
onAirApi.current.addRowDropZone({
|
||||
getContainer: () => rosterWrap.current!,
|
||||
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||
const [wbCall, setWbCall] = useState('');
|
||||
@@ -122,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
// ---- Mic pass order ------------------------------------------------------
|
||||
// The on-air grid shows the stations in the order the mic goes around — NOT
|
||||
// by log time. The order is owned here as a list of draft ids: new check-ins
|
||||
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
|
||||
// buttons move the selected station. Persisted per net so a panel remount
|
||||
// (tab switch) keeps the round.
|
||||
const [orderIds, setOrderIds] = useState<number[]>([]);
|
||||
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
|
||||
// Load the saved order when a net opens.
|
||||
useEffect(() => {
|
||||
if (!orderKey) { setOrderIds([]); return; }
|
||||
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
|
||||
catch { setOrderIds([]); }
|
||||
}, [orderKey]);
|
||||
// Reconcile with the live list: keep known ids in their order, append new
|
||||
// ones, drop the gone. Persist.
|
||||
useEffect(() => {
|
||||
setOrderIds((cur) => {
|
||||
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
|
||||
const liveSet = new Set(liveIds);
|
||||
const kept = cur.filter((id) => liveSet.has(id));
|
||||
const keptSet = new Set(kept);
|
||||
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
|
||||
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
|
||||
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||
return next;
|
||||
});
|
||||
}, [active, orderKey]);
|
||||
const activeOrdered = useMemo(() => {
|
||||
const byId = new Map(active.map((a) => [a.id as number, a]));
|
||||
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
|
||||
}, [active, orderIds]);
|
||||
// Move the selected on-air station up/down the queue.
|
||||
const moveSelected = useCallback((dir: -1 | 1) => {
|
||||
const id = selectedActiveIds[0];
|
||||
if (id == null) return;
|
||||
setOrderIds((cur) => {
|
||||
const i = cur.indexOf(id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= cur.length) return cur;
|
||||
const next = [...cur];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||
return next;
|
||||
});
|
||||
}, [selectedActiveIds, orderKey]);
|
||||
|
||||
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||
@@ -177,10 +268,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||
// Then auto-select the NEXT station in the mic-pass order, so the operator
|
||||
// can chain "log → log → log" without re-clicking a row each time.
|
||||
async function deactivate(id?: number) {
|
||||
if (id == null) return;
|
||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||
try {
|
||||
await NetDeactivate(id);
|
||||
await refreshActive();
|
||||
onLogged?.();
|
||||
if (next?.id != null) {
|
||||
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
|
||||
setSelectedActiveIds([next.id as number]);
|
||||
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
|
||||
}
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Keep the drop-zone closures pointed at the CURRENT handlers.
|
||||
activateRef.current = activate;
|
||||
deactivateRef.current = deactivate;
|
||||
|
||||
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||
// exactly as the single-log button would.
|
||||
async function logAll() {
|
||||
if (active.length === 0) return;
|
||||
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
|
||||
try {
|
||||
for (const a of [...active]) {
|
||||
if (a.id != null) await NetDeactivate(a.id as number);
|
||||
}
|
||||
await refreshActive();
|
||||
onLogged?.();
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
|
||||
}
|
||||
|
||||
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||
@@ -232,7 +351,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
}
|
||||
|
||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||
@@ -281,11 +401,15 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
{t('ncp.onAirActive')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||
<RecentQSOsGrid
|
||||
rows={active}
|
||||
total={active.length}
|
||||
rows={activeOrdered}
|
||||
total={activeOrdered.length}
|
||||
storageKey="net.onair"
|
||||
selectRowSignal={selectRow}
|
||||
rowDragCall
|
||||
passOrder
|
||||
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||
onRowSelected={setSelectedActiveIds}
|
||||
@@ -297,6 +421,22 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||
</Button>
|
||||
{/* Mic pass order: move the selected station up/down the round. */}
|
||||
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
|
||||
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
|
||||
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
|
||||
onClick={() => moveSelected(-1)}>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
|
||||
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
|
||||
onClick={() => moveSelected(1)}>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
||||
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -330,6 +470,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
rows={(wb.entries ?? []) as any}
|
||||
total={wb.count}
|
||||
storageKey="net.wb"
|
||||
{...qsoMenuHandlers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -343,7 +484,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
{t('ncp.netUsersRoster')}
|
||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<Station>
|
||||
ref={rosterGrid}
|
||||
@@ -352,6 +493,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
columnDefs={rosterCols}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onGridReady={() => wireDropZones()}
|
||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||
animateRows={false}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||
}, [service]);
|
||||
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
|
||||
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
|
||||
// bleed its log into the next one.
|
||||
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
|
||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||
const [potaErr, setPotaErr] = useState('');
|
||||
@@ -427,6 +431,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
// drives which QSOs the apply-form below updates; a search selects all.
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<RecentQSOsGrid
|
||||
// Its OWN column layout. Without a storageKey this fell back to
|
||||
// the main log's key, so every resize or hidden column here
|
||||
// silently rewrote the Recent QSOs layout, and vice versa.
|
||||
storageKey="qslmgr.paper"
|
||||
rows={paperRows as any}
|
||||
total={paperRows.length}
|
||||
selectAllSignal={paperSelAllSig}
|
||||
@@ -532,6 +540,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
) : (
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<RecentQSOsGrid
|
||||
storageKey="qslmgr.upload"
|
||||
rows={rows as any}
|
||||
total={rows.length}
|
||||
selectAllSignal={uploadSelAllSig}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -15,6 +15,7 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
@@ -35,8 +36,14 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||
// known. Rendering at the cursor first avoids a visible jump for the common
|
||||
// case where it already fits.
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -49,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
};
|
||||
}, [menu, onClose]);
|
||||
|
||||
// Position AFTER measuring.
|
||||
//
|
||||
// The menu used to be clamped against a guessed height — 230 px when the
|
||||
// upload submenu was present, 110 otherwise. It is far taller than that with
|
||||
// a selection, so right-clicking near the bottom of the window cut half the
|
||||
// entries off. Its height also depends on which actions the caller passes, so
|
||||
// no constant can be right for long.
|
||||
//
|
||||
// Measured instead: if it does not fit below the cursor it is placed above,
|
||||
// and failing that pinned to the top with its own scrollbar.
|
||||
useLayoutEffect(() => {
|
||||
if (!menu || !boxRef.current) return;
|
||||
const r = boxRef.current.getBoundingClientRect();
|
||||
const pad = 6;
|
||||
let x = menu.x;
|
||||
let y = menu.y;
|
||||
if (x + r.width > window.innerWidth - pad) {
|
||||
x = Math.max(pad, window.innerWidth - r.width - pad);
|
||||
}
|
||||
if (y + r.height > window.innerHeight - pad) {
|
||||
// Above the cursor, if there is room there.
|
||||
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
||||
}
|
||||
setPos({ x, y });
|
||||
}, [menu, onSendTo]);
|
||||
|
||||
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 }}
|
||||
ref={boxRef}
|
||||
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
@@ -137,6 +168,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportSelectedFields && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportSelectedFields', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportFiltered && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
|
||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||
import type { QSOForm } from '@/types';
|
||||
|
||||
type QSO = QSOForm;
|
||||
@@ -37,7 +38,7 @@ function pfxOf(call: string): string {
|
||||
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
|
||||
}
|
||||
|
||||
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||
// label holds an i18n key (resolved with t() at render time).
|
||||
const QSL_STATUSES = [
|
||||
@@ -72,6 +73,11 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
QSL: 'qedit.confQslPaper',
|
||||
};
|
||||
|
||||
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||
// the channel picker and the status table alongside the rest.
|
||||
const OPSLOG_CONF = 'OPSLOG';
|
||||
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
@@ -148,6 +154,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
|
||||
);
|
||||
}
|
||||
|
||||
// AdifDateInput — a real date picker over an ADIF date.
|
||||
//
|
||||
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
|
||||
// are converted at the edge and the log keeps its ADIF form. The native control
|
||||
// is used on purpose: it brings the OS calendar, the locale's day/month order
|
||||
// and keyboard entry for free, which a hand-rolled popover would have to
|
||||
// reimplement and get wrong.
|
||||
//
|
||||
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
|
||||
// a plain text box instead, so it stays visible and correctable rather than
|
||||
// silently disappearing behind an empty picker.
|
||||
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
|
||||
const raw = (value ?? '').trim();
|
||||
const valid = /^\d{8}$/.test(raw);
|
||||
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
|
||||
const today = () => {
|
||||
const d = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
// UTC: every date in the log is UTC, and near midnight the local day is the
|
||||
// wrong one.
|
||||
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
|
||||
};
|
||||
if (raw !== '' && !valid) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={iso}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value; // "" when cleared
|
||||
onChange(v ? v.replace(/-/g, '') : '');
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
|
||||
onClick={today} title="Today (UTC)">
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -280,19 +336,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
setLooking(true);
|
||||
setLocalErr('');
|
||||
try {
|
||||
const r: any = await LookupCallsign(call);
|
||||
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
|
||||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||
// appeared to do nothing.
|
||||
const r: any = await LookupCallsignFresh(call);
|
||||
// The lookup WINS over what is in the record — that is the point of asking
|
||||
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||
// record with no grid used to wipe the grid that was already there.
|
||||
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
name: r.name ?? d.name,
|
||||
qth: r.qth ?? d.qth,
|
||||
address: r.address ?? (d as any).address,
|
||||
email: r.email ?? (d as any).email,
|
||||
country: r.country ?? d.country,
|
||||
grid: r.grid ?? d.grid,
|
||||
state: r.state ?? d.state,
|
||||
cnty: r.cnty ?? d.cnty,
|
||||
cont: r.cont ?? d.cont,
|
||||
qsl_via: r.qsl_via ?? d.qsl_via,
|
||||
name: keep(r.name, d.name),
|
||||
qth: keep(r.qth, d.qth),
|
||||
address: keep(r.address, (d as any).address),
|
||||
email: keep(r.email, (d as any).email),
|
||||
country: keep(r.country, d.country),
|
||||
grid: keep(r.grid, d.grid),
|
||||
state: keep(r.state, d.state),
|
||||
cnty: keep(r.cnty, d.cnty),
|
||||
cont: keep(r.cont, d.cont),
|
||||
qsl_via: keep(r.qsl_via, d.qsl_via),
|
||||
dxcc: r.dxcc || d.dxcc,
|
||||
cqz: r.cqz || d.cqz,
|
||||
ituz: r.ituz || d.ituz,
|
||||
@@ -369,6 +434,26 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
[draft.extras],
|
||||
);
|
||||
|
||||
// OpsLog QSL "received" marker (ADIF extra). Drives the card's PSE/TNX stamp:
|
||||
// received → TNX QSL (thanks for your card), otherwise PSE QSL (please send one).
|
||||
// Saved with the normal Save via draft.extras.
|
||||
const OPSLOG_QSL_RCVD = 'APP_OPSLOG_QSL_RCVD';
|
||||
const qslReceived = !!String(draft.extras?.[OPSLOG_QSL_RCVD] ?? '').trim();
|
||||
// Sent side, for the confirmations table. Two key names because the marker was
|
||||
// renamed once and old QSOs still carry the first one — same test the Recent
|
||||
// QSOs "OpsLog QSL" column makes.
|
||||
const opslogQslSent = !!(draft.extras?.['APP_OPSLOG_QSL_SENT'] || draft.extras?.['APP_OPSLOG_QSL_CARD_SENT']);
|
||||
const toggleQslReceived = (on: boolean) => {
|
||||
// Reflect immediately in the draft (for the PSE/TNX indicator)…
|
||||
const next = { ...(draft.extras ?? {}) };
|
||||
if (on) next[OPSLOG_QSL_RCVD] = new Date().toISOString();
|
||||
else delete next[OPSLOG_QSL_RCVD];
|
||||
set('extras', next as any);
|
||||
// …and persist right away with a targeted write that reliably sets OR clears
|
||||
// the key (the modal's extras-merge on Save wouldn't clear a removed key).
|
||||
if ((draft as any).id) SetOpsLogQSLReceived((draft as any).id, on).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
@@ -407,14 +492,29 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
{/* Top: Callsign + RST + Fetch */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex items-center">
|
||||
<Label>{t('qedit.callsign')}</Label>
|
||||
{(draft.callsign ?? '').trim() && (
|
||||
<button
|
||||
type="button"
|
||||
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
|
||||
onClick={() => {
|
||||
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
||||
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
|
||||
}}
|
||||
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
|
||||
>
|
||||
QRZ ↗
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col w-20"><Label>S</Label>
|
||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||
<div className="flex flex-col w-20"><Label>R</Label>
|
||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} allowFreeText commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||
title={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
@@ -424,7 +524,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
{/* ── Left column ── */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)}
|
||||
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||
@@ -490,8 +591,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||
</div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
|
||||
onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
|
||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
|
||||
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -512,7 +615,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)}
|
||||
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></div>
|
||||
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||
</div>
|
||||
{/* Right column */}
|
||||
@@ -522,7 +626,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||
</div>
|
||||
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
||||
{/* The OpsLog QSL marker used to sit here, under QSL Msg. It
|
||||
belongs with the other confirmation channels — see the QSL
|
||||
Info tab. */}
|
||||
<div>
|
||||
<Label>{t('qedit.qslMsg')}</Label>
|
||||
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
|
||||
</div>
|
||||
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -570,9 +680,51 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<Label>{t('qedit.manageConf')}</Label>
|
||||
<Select value={confSel} onValueChange={setConfSel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>
|
||||
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
|
||||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
||||
QSO columns, and this channel lives in the ADIF
|
||||
extras. It gets its own editor below rather than the
|
||||
generic sent/received/date grid, which has no field
|
||||
to bind to. */}
|
||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{confSel === OPSLOG_CONF ? (
|
||||
/* OpsLog's own card. "Sent" is stamped when the card
|
||||
actually goes out, so it is shown, not offered: ticking
|
||||
it by hand would record something that never happened.
|
||||
"Received" drives the PSE/TNX stamp printed on the card,
|
||||
which is the whole reason the flag exists — hence the
|
||||
live indicator next to it. It writes immediately rather
|
||||
than on Save, because clearing an extras key would not
|
||||
survive the merge Save does. */
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('qedit.sent')}</Label>
|
||||
<Input disabled value={opslogQslSent ? t('qedit.qslYes') : t('qedit.qslNo')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('qedit.received')}</Label>
|
||||
<label className="flex h-9 items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
|
||||
{t('qedit.qslReceived')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{qslReceived ? 'TNX QSL' : 'PSE QSL'}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('qedit.pseTnxHint')}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('qedit.opslogSentHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label>
|
||||
@@ -580,8 +732,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||
: <Input disabled value="—" />}
|
||||
</div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||
{def.via && (
|
||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
||||
)}
|
||||
@@ -589,6 +741,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: live status grid for every channel.
|
||||
@@ -613,6 +767,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* OpsLog's own card, read from the ADIF extras rather
|
||||
than a QSO column — hence a hand-written row instead
|
||||
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
||||
when the card actually goes out, so it stays
|
||||
read-only here: an operator ticking it by hand would
|
||||
be recording something that never happened. */}
|
||||
<tr className="text-xs">
|
||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { qslStatusCellClass } from '@/lib/qslStatus';
|
||||
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX } from 'lucide-react';
|
||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import {
|
||||
@@ -15,6 +17,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
@@ -27,9 +30,25 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
|
||||
type Props = {
|
||||
rows: QSOForm[];
|
||||
total: number;
|
||||
// Operator's CURRENT locator — fallback for the distance column on older QSOs
|
||||
// that carry no my_grid of their own.
|
||||
myGrid?: string;
|
||||
// Bump this number to programmatically select every row (e.g. after a search
|
||||
// in the QSL Manager, where the default is "all selected").
|
||||
selectAllSignal?: number;
|
||||
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||
// Control auto-advancing to the next on-air station after logging one).
|
||||
selectRowSignal?: { id: number; seq: number };
|
||||
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
||||
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||
// register external drop zones via api.addRowDropZone.
|
||||
rowDragCall?: boolean;
|
||||
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
|
||||
// is the display order — a pinned "#" column numbers it, and all column
|
||||
// sorting is disabled (including saved sorts) so the queue can't be shuffled
|
||||
// by a header click. The parent owns/reorders the array.
|
||||
passOrder?: boolean;
|
||||
onGridApi?: (api: any) => void;
|
||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||
// reuse this grid elsewhere with its OWN independent column config.
|
||||
@@ -37,6 +56,10 @@ type Props = {
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
// The full first selected row — the Stats (F1) panel shows its entity when the
|
||||
// entry form is empty, so browsing the log answers "what have I got with this
|
||||
// one?" without retyping the call.
|
||||
onRowSelectedQso?: (row: QSOForm | null) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
@@ -45,10 +68,15 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||
onFilteredCountChange?: (count: number | null) => 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 }[];
|
||||
@@ -63,23 +91,13 @@ function fmtMhzDots(hz?: number): string {
|
||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
// Both formatters follow the display preference (Settings → General). What is
|
||||
// STORED never changes — see lib/dateFormat.
|
||||
function fmtDateUTC(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
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())}`;
|
||||
return formatDateTimeUTC(s);
|
||||
}
|
||||
function fmtDateOnly(s: any): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
// 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');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
return formatDateOnly(s);
|
||||
}
|
||||
|
||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||
@@ -93,7 +111,26 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
||||
// hooks can't run at module level, so the component calls this with its own t.
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
// qsoDistanceKm returns the great-circle distance for one row, in km.
|
||||
//
|
||||
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
|
||||
// portable outings, so the station the QSO was made from is not necessarily the
|
||||
// one configured today. `myGrid` (the current profile) is only the fallback for
|
||||
// the many older records that carry no my_* fields at all.
|
||||
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
||||
if (!d) return undefined;
|
||||
const here =
|
||||
(d.my_grid && gridToLatLon(d.my_grid)) ||
|
||||
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
|
||||
(myGrid ? gridToLatLon(myGrid) : null);
|
||||
const there =
|
||||
(d.grid && gridToLatLon(d.grid)) ||
|
||||
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
|
||||
if (!here || !there) return undefined;
|
||||
return Math.round(pathBetweenLatLon(here, there).distanceShort);
|
||||
}
|
||||
|
||||
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
// ── QSO basics ──
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
@@ -128,12 +165,17 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||
// Derived, not stored: computed from the two locations at display time, like
|
||||
// the cluster grid's own distance column.
|
||||
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
||||
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||
|
||||
// ── QSL ──
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||
@@ -141,32 +183,34 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||
|
||||
// ── LoTW ──
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── eQSL ──
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
// ── Uploads (online logbooks) ──
|
||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
@@ -238,14 +282,24 @@ const GRP_KEYS: Record<string, string> = {
|
||||
};
|
||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||
|
||||
// Award columns are governed SOLELY by the awardShown code-set, never by AG
|
||||
// Grid's saved column state. Stripping them here (on both save and restore)
|
||||
// stops a stale saved state from re-hiding a shown award column on every
|
||||
// awardCols rebuild — the desync that made award columns vanish mid-session.
|
||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||
// Award-column VISIBILITY is governed SOLELY by the awardShown code-set, never
|
||||
// by AG Grid's saved column state: dropping `hide` here (on both save and
|
||||
// restore) stops a stale saved state from re-hiding a shown award column on
|
||||
// every awardCols rebuild — the desync that made award columns vanish
|
||||
// mid-session.
|
||||
//
|
||||
// Their POSITION, however, has to stay in the state. Filtering the entries out
|
||||
// entirely left applyColumnState({applyOrder:true}) with no place for them, and
|
||||
// AG Grid appends what the state doesn't mention — so a column the operator
|
||||
// dragged to sit AFTER an award column jumped back in front of it on reload.
|
||||
const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
(st ?? []).map((s) => {
|
||||
if (!String(s?.colId ?? '').startsWith('award_')) return s;
|
||||
const { hide: _hide, ...rest } = s as any;
|
||||
return rest;
|
||||
});
|
||||
|
||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -253,9 +307,15 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||
|
||||
// Localized column catalog — rebuilt when the language changes.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
// Rebuild the columns when the format changes: the formatters are captured
|
||||
// inside the column definitions, so nothing else would notice.
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
// 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.
|
||||
@@ -308,17 +368,66 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||
}, []);
|
||||
|
||||
// Award-column WIDTHS are persisted separately too, for the same reason as
|
||||
// visibility: award columns are stripped from AG Grid's saved column-state
|
||||
// round-trip, so their width would otherwise reset to the default on reopen.
|
||||
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
|
||||
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
|
||||
const awardWidthsRef = useRef<Record<string, number>>({});
|
||||
const awardWidthsInit = useRef(false);
|
||||
if (!awardWidthsInit.current) {
|
||||
awardWidthsInit.current = true;
|
||||
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
|
||||
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||
}
|
||||
}
|
||||
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
|
||||
// apply them to any award columns already on screen.
|
||||
useEffect(() => {
|
||||
if (loadLocal(AWARD_WIDTH_KEY)) return;
|
||||
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
|
||||
if (!remote || !remote.length) return;
|
||||
for (const it of remote as any[]) {
|
||||
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||
}
|
||||
seedLocal(AWARD_WIDTH_KEY, remote);
|
||||
const api = gridRef.current?.api;
|
||||
if (api && awardCols?.length) {
|
||||
const ups = awardCols
|
||||
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
|
||||
.filter((u) => !!u.newWidth);
|
||||
if (ups.length) api.setColumnWidths(ups);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||
restoringRef.current = true;
|
||||
const base = COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
||||
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||
col.rowDrag = true;
|
||||
}
|
||||
if (passOrder) {
|
||||
delete (col as any).sort; // e.g. the date column's default 'desc'
|
||||
col.sortable = false;
|
||||
}
|
||||
return col;
|
||||
});
|
||||
if (passOrder) {
|
||||
base.unshift({
|
||||
colId: '__order', headerName: '#', width: 46, pinned: 'left',
|
||||
sortable: false, resizable: false, suppressMovable: true, filter: false,
|
||||
cellClass: 'font-mono text-muted-foreground tabular-nums',
|
||||
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
|
||||
} as ColDef<QSOForm>);
|
||||
}
|
||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||
colId: `award_${a.code}`,
|
||||
headerName: a.code,
|
||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||
width: 110,
|
||||
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
|
||||
cellClass: 'text-[11px]',
|
||||
// Visibility comes from the persisted award-code set, so a column the user
|
||||
// showed reappears on reopen and one they didn't stays hidden.
|
||||
@@ -326,7 +435,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||
}));
|
||||
return [...base, ...awards];
|
||||
}, [awardCols, COL_CATALOG, t, awardShown]);
|
||||
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
|
||||
|
||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||
@@ -334,58 +443,122 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api || !awardCols?.length) return;
|
||||
const widthUps: { key: string; newWidth: number }[] = [];
|
||||
for (const a of awardCols) {
|
||||
const want = awardShown.has(a.code.toUpperCase());
|
||||
const col = api.getColumn(`award_${a.code}`);
|
||||
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||
// Re-apply the saved width — AG Grid keeps an existing column's width across
|
||||
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
|
||||
const w = awardWidthsRef.current[a.code.toUpperCase()];
|
||||
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
|
||||
}
|
||||
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||
}, [awardCols, awardShown]);
|
||||
|
||||
// No column filters here, deliberately. This grid holds only the most recent
|
||||
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
|
||||
// an empty result then reads as "the log does not contain it" rather than "it
|
||||
// is not on this page". An operator hunting an imported CONTEST_ID across
|
||||
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
|
||||
//
|
||||
// The advanced filter queries the whole logbook and is the honest tool for it.
|
||||
// Removing the filter also gives each header back the width of its menu icon.
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
sortable: !passOrder,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
filter: false,
|
||||
suppressMovable: false,
|
||||
}), []);
|
||||
}), [passOrder]);
|
||||
|
||||
// In pass-order mode a saved sort (from before the mode existed, or synced
|
||||
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
|
||||
const sanitizeState = useCallback((state: any[]) => (
|
||||
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
|
||||
), [passOrder]);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
onGridApi?.(e.api);
|
||||
const local = loadLocal(colStateKey);
|
||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
if (local) e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||
// Fall back to the portable DB copy when the local cache is empty
|
||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||
loadRemote(colStateKey).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
||||
e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(remote)) as ColumnState[], applyOrder: true });
|
||||
seedLocal(colStateKey, remote);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||
const api = e?.api ?? gridRef.current?.api;
|
||||
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
|
||||
// row node across a reorder (getRowId by id), so the number is cached and
|
||||
// stale until we force it. Refresh it on every model update (a reorder
|
||||
// fires one). force:true bypasses the value cache.
|
||||
if (passOrder && api?.refreshCells) {
|
||||
api.refreshCells({ columns: ['__order'], force: true });
|
||||
}
|
||||
if (!api || !onFilteredCountChange) return;
|
||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||
}, [onFilteredCountChange, passOrder]);
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||
}, []);
|
||||
if (!state) return;
|
||||
saveState(colStateKey, sanitizeAwardCols(state));
|
||||
// Award columns are stripped above, so persist their widths on the side.
|
||||
let changed = false;
|
||||
for (const s of state) {
|
||||
const id = String((s as any)?.colId ?? '');
|
||||
if (id.startsWith('award_') && (s as any).width) {
|
||||
const code = id.slice('award_'.length).toUpperCase();
|
||||
if (awardWidthsRef.current[code] !== (s as any).width) {
|
||||
awardWidthsRef.current[code] = (s as any).width;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
|
||||
}
|
||||
}, [colStateKey, AWARD_WIDTH_KEY]);
|
||||
|
||||
// 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.
|
||||
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||
useEffect(() => {
|
||||
const api = gridRef.current?.api;
|
||||
const local = loadLocal(colStateKey);
|
||||
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||
if (api && local) api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols]);
|
||||
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
|
||||
// causes here (awardCols/awardShown) missed the others — switching the UI
|
||||
// language rebuilds the memo through `t`, which set restoringRef and left it
|
||||
// set, so every column change for the rest of the session was silently
|
||||
// discarded and the layout reverted on reload. Any future dependency of the
|
||||
// memo is now covered for free.
|
||||
}, [columnDefs]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onSelectionChanged() {
|
||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
const api = gridRef.current?.api;
|
||||
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
setSelCount(sel.length);
|
||||
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||
onRowSelectedQso?.(sel[0] ?? null);
|
||||
}
|
||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||
useEffect(() => {
|
||||
@@ -393,6 +566,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
gridRef.current?.api?.selectAll();
|
||||
}, [selectAllSignal]);
|
||||
|
||||
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
|
||||
// tick so a row-data refresh in the same render settles first.
|
||||
useEffect(() => {
|
||||
if (!selectRowSignal || selectRowSignal.seq === 0) return;
|
||||
const t = window.setTimeout(() => {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.deselectAll();
|
||||
api.forEachNode((n: any) => {
|
||||
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
|
||||
});
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [selectRowSignal?.seq]);
|
||||
|
||||
// ── Column picker (visibility) ──
|
||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||
// state for "which columns are visible" — AG Grid's column state is the
|
||||
@@ -449,6 +637,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||
{/* Live selection count — visible without opening the context menu. */}
|
||||
{selCount > 0 && (
|
||||
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
||||
{t('rqg.selectedCount', { n: selCount })}
|
||||
</span>
|
||||
)}
|
||||
{/* Select every loaded row that passes the active column filters — so a
|
||||
filtered view can be selected in one click (then send to LoTW, bulk
|
||||
edit, export…). Once everything is selected the same button flips to
|
||||
"Unselect all". */}
|
||||
{(() => {
|
||||
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
||||
onClick={() => {
|
||||
const api: any = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
if (allSelected) api.deselectAll();
|
||||
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
||||
else api.selectAll('filtered');
|
||||
}}>
|
||||
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||
title={t('rqg.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||
@@ -467,6 +681,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onGridReady={onGridReady}
|
||||
onFilterChanged={reportFilteredCount}
|
||||
onModelUpdated={reportFilteredCount}
|
||||
onColumnResized={saveColumnState}
|
||||
onColumnMoved={saveColumnState}
|
||||
onColumnPinned={saveColumnState}
|
||||
@@ -495,6 +711,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportFiltered={onExportFiltered}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||
|
||||
@@ -24,6 +24,9 @@ interface Props {
|
||||
centerLat?: number | null; // operator latitude (projection centre)
|
||||
centerLon?: number | null; // operator longitude
|
||||
rotorEnabled?: boolean;
|
||||
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||
activeRotor?: number; // index of the selected rotor (0-based)
|
||||
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||
onGoto?: (az: number) => void; // click-to-turn
|
||||
onClose?: () => void;
|
||||
}
|
||||
@@ -38,7 +41,7 @@ function pt(az: number, radius: number): [number, number] {
|
||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||
}
|
||||
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, 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' } ],
|
||||
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||
{rotors && rotors.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||
{rotors.map((nm, i) => {
|
||||
const active = (activeRotor ?? 0) === i;
|
||||
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelectRotor?.(i)}
|
||||
className={cn(
|
||||
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||
active
|
||||
? 'bg-success text-success-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center p-2 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SpellCheck, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type ScpResult = { partial?: string[]; nplus1?: string[] };
|
||||
|
||||
// ScpPanel — Super Check Partial + N+1 callsign helper, split in two columns.
|
||||
// Left: master calls that CONTAIN what you've typed (spot/correct a call). Right:
|
||||
// calls one edit away (busted-call check). Clicking a suggestion fills the entry.
|
||||
export function ScpPanel({ result, currentCall, count, onPick, onClose }: {
|
||||
result: ScpResult;
|
||||
currentCall: string;
|
||||
count: number; // master-list size (0 = list not downloaded yet)
|
||||
onPick: (call: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const cur = (currentCall || '').trim().toUpperCase();
|
||||
const partial = result.partial ?? [];
|
||||
const nplus1 = result.nplus1 ?? [];
|
||||
|
||||
const Col = ({ title, tone, calls, empty }: { title: string; tone: string; calls: string[]; empty: string }) => (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className={cn('text-[10px] font-bold uppercase tracking-wider px-1.5 py-1 border-b border-border/50', tone)}>{title}</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
|
||||
{calls.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground/70 italic px-1 py-1">{empty}</div>
|
||||
) : calls.map((c) => (
|
||||
<button key={c} type="button" onClick={() => onPick(c)}
|
||||
title={t('scp.fill', { call: c })}
|
||||
className={cn('w-full text-left rounded px-1.5 py-0.5 font-mono text-xs transition-colors',
|
||||
c === cur ? 'bg-success/20 text-success font-bold'
|
||||
: 'hover:bg-primary/15 text-foreground/90')}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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">
|
||||
<SpellCheck className={cn('size-4', count > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">{t('scp.title')}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors" title={t('scp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{count === 0 ? (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center text-[11px] text-muted-foreground italic text-center px-3">
|
||||
{t('scp.noList')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex divide-x divide-border/60">
|
||||
<Col title={t('scp.partial')} tone="text-primary" calls={partial} empty={t('scp.typeMore')} />
|
||||
<Col title="N+1" tone="text-warning" calls={nplus1} empty={t('scp.none')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -8,26 +8,46 @@ import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { AmpCard } from '@/components/AmpCard';
|
||||
import { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
|
||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
||||
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
|
||||
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
|
||||
// packed tighter but left the row edges ragged, which read as untidy on a wide
|
||||
// screen; aligned rows are what an operator expects from a dashboard.
|
||||
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||
const GRID_GAP = 16; // px between cards, both axes
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
|
||||
|
||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||
// the generic USB-serial board, whose channel count the user picks.
|
||||
const chanCount = (d: Device): number =>
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
|
||||
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
|
||||
: (RELAY_COUNT[d.type] ?? 5);
|
||||
|
||||
function blankDevice(): Device {
|
||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||
}
|
||||
|
||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
|
||||
|
||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||
@@ -62,6 +82,9 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
centerLat={centerLat ?? null}
|
||||
centerLon={centerLon ?? null}
|
||||
rotorEnabled={hd.ok}
|
||||
rotors={hd.rotors}
|
||||
activeRotor={hd.active}
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
@@ -199,9 +222,11 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
|
||||
3-element beam only uses the first few. */}
|
||||
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
|
||||
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||
up structural bytes past the real data (varying by band), which
|
||||
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
@@ -253,8 +278,59 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||
});
|
||||
const dragId = useRef<string | null>(null);
|
||||
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||
// row so cards wrap onto further lines even when there's horizontal room.
|
||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||
|
||||
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
|
||||
// cards as the window allows; an explicit count is honoured as asked, even
|
||||
// past that width — the operator can see his own screen.
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
const [colCount, setColCount] = useState(1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
|
||||
const el = gridRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [cols]);
|
||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||
// FlexRadio meters read live; the Flex state rides along because a PGXL's
|
||||
// OPERATE/meters come from the radio, not the direct GSCP link.
|
||||
const [amps, setAmps] = useState<any[]>([]);
|
||||
const [flexState, setFlexState] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => Promise.all([
|
||||
GetAmpStatuses().catch(() => []),
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
|
||||
load();
|
||||
const id = window.setInterval(load, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
// Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
|
||||
// panel still get the controls. Re-read the enabled flag so it appears/hides
|
||||
// without a restart.
|
||||
const [tg, setTg] = useState<TGStatus>({ connected: false });
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
|
||||
};
|
||||
load();
|
||||
const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||
}, []);
|
||||
@@ -340,25 +416,26 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2">
|
||||
{/* Compact one-line relay buttons at a FIXED width so they don't stretch
|
||||
across the whole card — they wrap to fill the available width instead. */}
|
||||
<div className="p-2 flex flex-wrap gap-1.5">
|
||||
{relays.map((r) => {
|
||||
const key = `${dev.id}:${r.number}`;
|
||||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||
return (
|
||||
<button key={r.number} type="button" disabled={!st?.connected}
|
||||
title={label}
|
||||
onClick={() => toggle(dev, r.number, !r.on)}
|
||||
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
|
||||
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
||||
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
|
||||
<span className={cn('flex items-center justify-center size-5 rounded shrink-0',
|
||||
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||
{busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium truncate">{label}</span>
|
||||
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
||||
<span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
|
||||
<span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
|
||||
{r.on ? t('station.on') : t('station.off')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -367,36 +444,38 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
);
|
||||
};
|
||||
|
||||
const widgets: { id: string; node: React.ReactNode }[] = [];
|
||||
// `wide` cards take two grid columns instead of one. The amplifier and tuner
|
||||
// carry meter rows and a channel selector that are unreadable squeezed into a
|
||||
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||
// full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
if (ant.enabled) {
|
||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||
}
|
||||
// One card per configured amplifier (identical to the Flex panel's card).
|
||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||||
// Tuner Genius XL card (identical to the Flex panel's).
|
||||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||
|
||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||
const widgetIds = ordered.map((w) => w.id);
|
||||
|
||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
|
||||
|
||||
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
||||
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||||
// fill however many columns are available.
|
||||
const gridCols: Record<string, string> = {
|
||||
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
|
||||
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
|
||||
};
|
||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||
card can sit on a further line even with horizontal room to spare. */}
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||
{(['auto', '2', '3', '4'] as const).map((c) => (
|
||||
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
|
||||
<button key={c} type="button"
|
||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
@@ -416,14 +495,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
|
||||
{/* Dashboard grid: equal columns, and every card stretches to the height of
|
||||
the tallest one on its row, so the rows line up instead of ending
|
||||
ragged. One column each, two for the amplifier/tuner whose meter rows
|
||||
need the room. Each card has a grip handle (left rail) as the drag
|
||||
initiator, since the body is full of buttons. */}
|
||||
<div ref={gridRef} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||||
gap: `${GRID_GAP}px`,
|
||||
}}>
|
||||
{ordered.map((w) => (
|
||||
<div key={w.id} draggable
|
||||
<div key={w.id}
|
||||
// A wide card takes two columns — but never more than there are, or
|
||||
// the grid would grow a phantom column on a narrow window.
|
||||
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||
{/* h-full down the chain is what makes the card fill the row height
|
||||
rather than sit at its natural size in a taller cell. */}
|
||||
<div className="flex items-stretch h-full">
|
||||
<div draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
||||
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
||||
{w.node}
|
||||
onDragEnd={() => { dragId.current = null; }}
|
||||
title={t('station.dragMove')}
|
||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||
dragId.current === w.id && 'opacity-60')}>
|
||||
<GripVertical className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -445,11 +546,57 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||
const setType = (type: string) => {
|
||||
const n = RELAY_COUNT[type] ?? 5;
|
||||
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||
const n = chanCount({ ...device, type, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, type, labels });
|
||||
onChange({ ...device, type, channels, labels });
|
||||
};
|
||||
const setChannels = (channels: number) => {
|
||||
const n = chanCount({ ...device, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, channels, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
const isDingtian = device.type === 'dingtian';
|
||||
const isDenkovi = device.type === 'denkovi';
|
||||
const isUsbRelay = device.type === 'usbrelay';
|
||||
// COM ports for the generic USB-serial relay picker.
|
||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!isUsbRelay) return;
|
||||
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||
}, [isUsbRelay]);
|
||||
// Detected FTDI serials for the Denkovi picker.
|
||||
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectMsg, setDetectMsg] = useState('');
|
||||
const detectDenkovi = async () => {
|
||||
setDetecting(true);
|
||||
setDetectMsg('');
|
||||
try {
|
||||
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||
setDenkoviSerials(list);
|
||||
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
|
||||
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
|
||||
finally { setDetecting(false); }
|
||||
};
|
||||
// Connection test result for the current device config.
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const testDevice = async () => {
|
||||
setTesting(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const r: any = await TestStationDevice(device as any);
|
||||
setTestMsg(r?.ok
|
||||
? { ok: true, text: t('station.testOk', { n: r.relays }) }
|
||||
: { ok: false, text: r?.error || t('station.testFail') });
|
||||
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
|
||||
finally { setTesting(false); }
|
||||
};
|
||||
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||
return (
|
||||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||
@@ -461,6 +608,9 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<SelectContent>
|
||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
|
||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -469,11 +619,78 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||
{(isDenkovi || isUsbRelay || isDingtian) && (
|
||||
<div className="space-y-1 max-w-[10rem]">
|
||||
<Label>{t('station.channels')}</Label>
|
||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isDenkovi ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.ftdiSerial')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||
list="denkovi-serials"
|
||||
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
<datalist id="denkovi-serials">
|
||||
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
{t('station.detect')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
|
||||
</div>
|
||||
) : isUsbRelay ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.comPort')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||||
<Label>{t('station.host')}</Label>
|
||||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
|
||||
</div>
|
||||
{/* Dingtian: both are OFF on a factory board — the session ID only when
|
||||
"HTTP Session" is enabled in its web page, the password only when a
|
||||
relay password is set. */}
|
||||
{isDingtian && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.dtSession')}</Label>
|
||||
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
|
||||
onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.dtPwd')}</Label>
|
||||
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
|
||||
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isKM && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
@@ -487,6 +704,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.labels')}</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
@@ -496,10 +714,22 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{testMsg && (
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||
{testMsg.text}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||
{t('station.test')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,14 +23,20 @@ import { cn } from '@/lib/utils';
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Bucket = { key: string; count: number };
|
||||
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
|
||||
type Gap = { start: string; end: string; minutes: number };
|
||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||
type Stats = {
|
||||
total: number; unique_calls: number; entities: number; continents: number;
|
||||
first_qso: string; last_qso: string;
|
||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||
// Chronological series across the SELECTED period, one per bucket size; empty
|
||||
// when that bucket would be too fine for the period (see the Go side).
|
||||
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
|
||||
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
|
||||
// Period / contest metrics.
|
||||
window_start: string; window_end: string; window_hours: number;
|
||||
avg_per_hour: number; avg_per_active: number;
|
||||
@@ -52,11 +58,14 @@ const nf = (n: number) => n.toLocaleString('en-US');
|
||||
|
||||
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
||||
function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
|
||||
return (
|
||||
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
||||
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
||||
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
|
||||
{title}
|
||||
</h3>
|
||||
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||
</header>
|
||||
{children}
|
||||
@@ -64,15 +73,23 @@ function Card({ title, sub, children, className }: { title: string; sub?: string
|
||||
);
|
||||
}
|
||||
|
||||
// A headline number IS the chart — a one-bar bar chart would be noise.
|
||||
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
|
||||
// carries an accent (a categorical chart hue or a semantic token): a soft tint
|
||||
// wash + a left accent bar give it a colourful identity, while the number itself
|
||||
// stays in high-contrast foreground ink so it reads on every theme.
|
||||
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||
<div className="relative">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +97,7 @@ function StatTile({ label, value, sub }: { label: string; value: string; sub?: s
|
||||
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||
// on a tooltip to get a number.
|
||||
|
||||
function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string }) {
|
||||
function HBars({ data, max, empty, share, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
|
||||
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||
@@ -90,13 +107,13 @@ function HBars({ data, max, empty, share, labelWidth = 'w-20' }: { data: Bucket[
|
||||
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{top.map((d) => (
|
||||
{top.map((d, i) => (
|
||||
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
||||
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||
<div
|
||||
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||
@@ -146,6 +163,47 @@ function VBars({ data, empty, height = 150, showValues, colorful }: { data: Buck
|
||||
);
|
||||
}
|
||||
|
||||
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||
// categories are the subject → categorical colour in a fixed order, matching the
|
||||
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||
const BAND_SPLIT = [
|
||||
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||
] as const;
|
||||
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||
{data.map((d) => (
|
||||
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||
{BAND_SPLIT.map((s) => {
|
||||
const v = d[s.key];
|
||||
if (!v) return null;
|
||||
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||
})}
|
||||
</div>
|
||||
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||
{BAND_SPLIT.map((s) => (
|
||||
<span key={s.key} className="inline-flex items-center gap-1">
|
||||
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||
@@ -451,6 +509,88 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||
}
|
||||
}
|
||||
|
||||
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
|
||||
// panel — not a window of its own. It used to be four fixed rolling windows
|
||||
// anchored on the newest QSO, which ignored the period filter entirely: picking
|
||||
// a year up top left this chart showing the last 7 days.
|
||||
const ACT_GRAN = [
|
||||
{ key: 'day', tkey: 'stats.granDay' },
|
||||
{ key: 'week', tkey: 'stats.granWeek' },
|
||||
{ key: 'month', tkey: 'stats.granMonth' },
|
||||
{ key: 'year', tkey: 'stats.granYear' },
|
||||
] as const;
|
||||
type Gran = typeof ACT_GRAN[number]['key'];
|
||||
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
|
||||
|
||||
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||
const [g, setG] = useState<Gran | 'auto'>('auto');
|
||||
const seriesFor = (k: Gran): Bucket[] => (
|
||||
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
|
||||
);
|
||||
|
||||
// Auto picks the finest bucket that stays readable: the backend leaves a
|
||||
// series empty when it would be too fine for the period, so "the first
|
||||
// non-empty one" is exactly the right rule and needs no thresholds here.
|
||||
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
|
||||
// A bucket the user forces but that is too fine for the period falls back to
|
||||
// the next coarser one rather than showing an empty chart.
|
||||
let shown: Gran = g === 'auto' ? auto : g;
|
||||
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
|
||||
const series = seriesFor(shown);
|
||||
const steppedUp = g !== 'auto' && shown !== g;
|
||||
|
||||
return (
|
||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||
<div className="mb-2 flex flex-wrap gap-1 items-center">
|
||||
<button type="button" onClick={() => setG('auto')}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t('stats.granAuto')}
|
||||
</button>
|
||||
{ACT_GRAN.map((o) => (
|
||||
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t(o.tkey)}
|
||||
</button>
|
||||
))}
|
||||
{steppedUp && (
|
||||
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Many buckets read better as a filled trend than as a forest of bars. */}
|
||||
{series.length > 60
|
||||
? <AreaTrend data={series} empty={empty} />
|
||||
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// RhythmCard — WHEN the operator is on the air over the selected period, which is
|
||||
// a different question from "how much lately" and so gets its own card. The hour
|
||||
// histogram used to cover a single day, too little to read anything from.
|
||||
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||
const [g, setG] = useState<'hour' | 'weekday'>('hour');
|
||||
return (
|
||||
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
|
||||
<button key={k} type="button" onClick={() => setG(k)}
|
||||
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{t(tk)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||
|
||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||
@@ -513,9 +653,12 @@ export function StatsPanel() {
|
||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||
setStats({
|
||||
...raw,
|
||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
|
||||
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
|
||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||
} as Stats);
|
||||
@@ -629,12 +772,12 @@ export function StatsPanel() {
|
||||
|
||||
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||
</div>
|
||||
|
||||
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||
@@ -723,43 +866,42 @@ export function StatsPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
||||
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
|
||||
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')}>
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
||||
<AreaTrend data={stats.by_month} empty={empty} />
|
||||
</Card>
|
||||
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||
<RhythmCard stats={stats} t={t} empty={empty} />
|
||||
|
||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||
instead of truncating. */}
|
||||
<Card title={t('stats.byOperator')}>
|
||||
<Card title={t('stats.byOperator')} accent="var(--chart-5)">
|
||||
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_operator} empty={empty} share />
|
||||
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
||||
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
|
||||
<Donut data={stats.by_continent} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<Card title={t('stats.topEntities')}>
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" />
|
||||
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2.5 min-w-0">
|
||||
<Card title={t('stats.confirmations')} className="flex-1">
|
||||
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
|
||||
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title={t('stats.byStation')}>
|
||||
<Card title={t('stats.byStation')} accent="var(--chart-8)">
|
||||
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||
<HBars data={stats.by_station} empty={empty} share />
|
||||
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
|
||||
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
|
||||
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
|
||||
// mirrors the native app's two channels (A / B) with their source, frequency and
|
||||
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
|
||||
// drives the backend directly (no local state) — the caller's ~1.5s poll
|
||||
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
|
||||
// so they're the exact same size as the Flex/amp meters.
|
||||
|
||||
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<button type="button" onClick={toggle}
|
||||
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||
</button>
|
||||
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
|
||||
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
|
||||
// antenna, matching the two rows of the native 4O3A app.
|
||||
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
|
||||
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
|
||||
onSelect: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const cls = ptt
|
||||
? '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)]'
|
||||
: active
|
||||
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
|
||||
: 'bg-card text-foreground/80 border-border hover:bg-muted';
|
||||
const src = ch.mode_str || '—';
|
||||
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
|
||||
return (
|
||||
<button type="button" onClick={onSelect}
|
||||
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-extrabold text-sm">{letter}</span>
|
||||
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
|
||||
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
|
||||
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
|
||||
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
|
||||
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
|
||||
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
|
||||
// between syllables as often as on a peak, so the raw reading collapses to zero
|
||||
// several times a second mid-transmission and reads as a dropped link. The
|
||||
// amplifier looked steady next to it only because it already smoothed this way.
|
||||
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
|
||||
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 > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
const connected = !!status.connected;
|
||||
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
// Prefer the device's own peak detector over the sampled reading; the JS hold
|
||||
// below still smooths what is left between polls.
|
||||
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
|
||||
const fwdW = peakHold('fwd', rawFwdW);
|
||||
|
||||
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||
// meaningless 1.00, which would wipe the figure the operator actually wants —
|
||||
// the one measured while transmitting. So show the live value during TX, keep it
|
||||
// briefly afterwards so it can be read, then let it go.
|
||||
//
|
||||
// That expiry is the point: the first version of this held the last TX value
|
||||
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||
// Same one-second window as the power peak above, so the two meters clear
|
||||
// together.
|
||||
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||
const heldSwr = swrHold.current;
|
||||
const vswr = rawFwdW >= 1
|
||||
? rawVswr
|
||||
: heldSwr && Date.now() - heldSwr.t < 1000
|
||||
? heldSwr.v
|
||||
: undefined;
|
||||
const active = status.active ?? 1;
|
||||
const a: TGChannel = status.a ?? {};
|
||||
const b: TGChannel = status.b ?? {};
|
||||
|
||||
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
|
||||
return (
|
||||
<Card icon={Gauge} title={title} accent="#f59e0b">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{status.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusAutotune().catch(() => {})}
|
||||
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
|
||||
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||
</button>
|
||||
<button type="button" disabled={!connected}
|
||||
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
|
||||
{t('tgp.bypass')}
|
||||
</button>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
|
||||
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{status.message && (
|
||||
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold">⚠ {status.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connected && (
|
||||
<>
|
||||
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
|
||||
<div className="flex items-stretch gap-2">
|
||||
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
|
||||
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
|
||||
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
|
||||
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
|
||||
</div>
|
||||
|
||||
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
|
||||
3-wide because it shows three meters; copying it here left the tuner's
|
||||
two meters in the first two of three columns, with a third of the card
|
||||
empty to the right of SWR. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
|
||||
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Gauge, X, Power, Radio } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type TGChannel = {
|
||||
ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string;
|
||||
freq_mhz?: number; bypass?: boolean; antenna?: number;
|
||||
};
|
||||
export type TGStatus = {
|
||||
connected: boolean; host?: string; last_error?: string;
|
||||
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||
antenna?: number; three_way?: boolean; message?: string;
|
||||
a?: TGChannel; b?: TGChannel;
|
||||
relay_c1?: number; relay_l?: number; relay_c2?: number;
|
||||
};
|
||||
|
||||
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
|
||||
// red above (the usual "safe / caution / high" ATU thresholds).
|
||||
function swrColour(vswr?: number): string {
|
||||
if (!vswr || vswr <= 0) return 'text-muted-foreground';
|
||||
if (vswr <= 1.5) return 'text-success';
|
||||
if (vswr <= 2.0) return 'text-warning';
|
||||
return 'text-danger';
|
||||
}
|
||||
|
||||
// ChanRow — a compact A/B channel line in the docked widget. Highlights the
|
||||
// active channel (green) or TX (red), shows the source + frequency + antenna,
|
||||
// and clicking it makes that channel active.
|
||||
function ChanRow({ letter, ch, active, onSelect, t }: {
|
||||
letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void;
|
||||
t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const ptt = !!ch.ptt;
|
||||
const cls = ptt
|
||||
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40'
|
||||
: active
|
||||
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40'
|
||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||
const src = ch.mode_str || '—';
|
||||
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—';
|
||||
return (
|
||||
<button type="button" onClick={onSelect}
|
||||
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||
className={cn('w-full flex items-center gap-1.5 rounded-lg border px-2 py-1 text-left transition-all active:scale-[0.98]', cls)}>
|
||||
<span className="font-extrabold text-xs w-3 shrink-0">{letter}</span>
|
||||
<span className="text-[10px] font-semibold truncate opacity-90 w-12 shrink-0">{src}</span>
|
||||
<span className="font-mono text-[10px] tabular-nums truncate flex-1">{freq}</span>
|
||||
{ch.antenna != null && ch.antenna > 0 && (
|
||||
<span className="text-[9px] font-semibold whitespace-nowrap opacity-90 shrink-0">{t('tgp.ant')}{ch.antenna}</span>
|
||||
)}
|
||||
{ptt && <span className="text-[9px] font-bold uppercase shrink-0">TX</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows
|
||||
// the live SWR / forward power, the two RF channels (A / B, click to activate),
|
||||
// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio
|
||||
// panel and Station Control.
|
||||
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: {
|
||||
status: TGStatus;
|
||||
onTune: () => void;
|
||||
onBypass: (on: boolean) => void;
|
||||
onOperate: (on: boolean) => void;
|
||||
onActivate: (ch: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const active = status.active ?? 1;
|
||||
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
|
||||
// between syllables as often as on a peak, so the plain figure showed a few
|
||||
// hundred watts on a kilowatt transmission.
|
||||
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
|
||||
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">
|
||||
<Gauge className={cn('size-4', status.connected ? 'text-success 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">Tuner 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-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('tgp.online') : t('tgp.offline')}</span>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!status.connected ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
|
||||
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
|
||||
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
|
||||
{/* SWR + forward power readouts */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.swr')}</div>
|
||||
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
|
||||
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel A / B — click to activate */}
|
||||
<div className="space-y-1">
|
||||
<ChanRow letter="A" ch={status.a ?? {}} active={active === 1} onSelect={() => onActivate(1)} t={t} />
|
||||
<ChanRow letter="B" ch={status.b ?? {}} active={active === 2} onSelect={() => onActivate(2)} t={t} />
|
||||
</div>
|
||||
|
||||
{status.message && (
|
||||
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tune */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTune}
|
||||
disabled={status.tuning}
|
||||
title={t('tgp.tuneHint')}
|
||||
className={cn(
|
||||
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
|
||||
status.tuning
|
||||
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
|
||||
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<Radio className="size-4" />
|
||||
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Bypass + Operate toggles */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBypass(!status.bypass)}
|
||||
title={t('tgp.bypassHint')}
|
||||
className={cn(
|
||||
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||
status.bypass
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{t('tgp.bypass')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOperate(!status.operate)}
|
||||
title={t('tgp.operateHint')}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||
status.operate
|
||||
? '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-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Power className="size-3.5" />
|
||||
{status.operate ? t('tgp.operate') : t('tgp.standby')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Upload, FolderOpen, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
GetWebPublishConfig, SaveWebPublishConfig, WebPublishColumns,
|
||||
TestWebPublishFTP, PublishLogNow, GetWebPublishStatus, PickBackupFolder,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
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 { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Cfg = {
|
||||
enabled: boolean; format: string; folder: string; file_name: string; title: string;
|
||||
count: number; interval_min: number; columns: string[];
|
||||
ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string;
|
||||
ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string;
|
||||
};
|
||||
type Col = { key: string; header: string };
|
||||
|
||||
export function WebPublishPanel() {
|
||||
const { t } = useI18n();
|
||||
const [cfg, setCfg] = useState<Cfg | null>(null);
|
||||
const [cols, setCols] = useState<Col[]>([]);
|
||||
const [busy, setBusy] = useState<'' | 'test' | 'publish'>('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
// Raw text for the two numeric boxes: bound straight to the number they could
|
||||
// not be cleared, the same trap as the Recent-QSOs Max field.
|
||||
const [countText, setCountText] = useState('');
|
||||
const [everyText, setEveryText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [c, k, st] = await Promise.all([GetWebPublishConfig(), WebPublishColumns(), GetWebPublishStatus()]);
|
||||
setCfg(c as any);
|
||||
setCols((k ?? []) as Col[]);
|
||||
setCountText(String((c as any).count ?? 100));
|
||||
setEveryText(String((c as any).interval_min ?? 0));
|
||||
const s: any = st;
|
||||
if (s?.last_err) setErr(s.last_err);
|
||||
else if (s?.last_run) setMsg(t('wpub.lastRun') + ' ' + s.last_run);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!cfg) return <p className="text-xs text-muted-foreground">…</p>;
|
||||
const set = (p: Partial<Cfg>) => setCfg((c) => (c ? { ...c, ...p } : c));
|
||||
|
||||
// Every control saves immediately: this panel has no Save button of its own,
|
||||
// matching the other "saved instantly" panels.
|
||||
const save = async (next: Cfg) => {
|
||||
setCfg(next);
|
||||
try { await SaveWebPublishConfig(next as any); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
};
|
||||
const patch = (p: Partial<Cfg>) => save({ ...cfg, ...p });
|
||||
|
||||
const toggleCol = (key: string) => {
|
||||
const has = cfg.columns?.includes(key);
|
||||
patch({ columns: has ? cfg.columns.filter((c) => c !== key) : [...(cfg.columns ?? []), key] });
|
||||
};
|
||||
|
||||
const run = async (what: 'test' | 'publish') => {
|
||||
setBusy(what); setMsg(''); setErr('');
|
||||
try {
|
||||
const r = what === 'test' ? await TestWebPublishFTP(cfg as any) : await PublishLogNow();
|
||||
setMsg(String(r));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setBusy(''); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t('wpub.hint')}</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} />
|
||||
{t('wpub.enable')}
|
||||
</label>
|
||||
|
||||
{cfg.enabled && (<>
|
||||
{/* ── The file ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Label className="text-xs font-semibold">{t('wpub.fileSection')}</Label>
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">{t('wpub.format')}</Label>
|
||||
<Select value={cfg.format} onValueChange={(v) => patch({ format: v })}>
|
||||
<SelectTrigger className="h-8 w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="html">{t('wpub.formatHtml')}</SelectItem>
|
||||
<SelectItem value="csv">{t('wpub.formatCsv')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Label className="text-sm">{t('wpub.folder')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input className="h-8 font-mono text-xs flex-1" value={cfg.folder}
|
||||
onChange={(e) => set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} />
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={async () => {
|
||||
try { const p = await PickBackupFolder(); if (p) patch({ folder: p }); } catch { /* cancelled */ }
|
||||
}}><FolderOpen className="size-3.5" /> {t('wpub.browse')}</Button>
|
||||
</div>
|
||||
|
||||
<Label className="text-sm">{t('wpub.fileName')}</Label>
|
||||
<Input className="h-8 w-56 font-mono text-xs" value={cfg.file_name}
|
||||
onChange={(e) => set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.title')}</Label>
|
||||
<Input className="h-8" placeholder={t('wpub.titlePh')} value={cfg.title}
|
||||
onChange={(e) => set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.count')}</Label>
|
||||
<Input type="number" min={1} max={100000} className="h-8 w-28 font-mono text-xs" value={countText}
|
||||
onChange={(e) => setCountText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const n = Math.floor(Number(countText));
|
||||
if (Number.isFinite(n) && n > 0) patch({ count: n }); else setCountText(String(cfg.count));
|
||||
}} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.every')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="number" min={0} max={1440} className="h-8 w-28 font-mono text-xs" value={everyText}
|
||||
onChange={(e) => setEveryText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const n = Math.floor(Number(everyText));
|
||||
if (Number.isFinite(n) && n >= 0) patch({ interval_min: n }); else setEveryText(String(cfg.interval_min));
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">{t('wpub.everyHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Columns ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cols.map((c) => {
|
||||
const on = cfg.columns?.includes(c.key);
|
||||
return (
|
||||
<button key={c.key} type="button" onClick={() => toggleCol(c.key)}
|
||||
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
|
||||
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{c.header}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Upload ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.ftp_enabled} onCheckedChange={(c) => patch({ ftp_enabled: !!c })} />
|
||||
{t('wpub.ftpEnable')}
|
||||
</label>
|
||||
{cfg.ftp_enabled && (
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">{t('wpub.ftpHost')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input className="h-8 flex-1" placeholder="ftp.example.com" value={cfg.ftp_host}
|
||||
onChange={(e) => set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} />
|
||||
<Input type="number" className="h-8 w-24 font-mono text-xs" value={cfg.ftp_port}
|
||||
onChange={(e) => set({ ftp_port: Number(e.target.value) || 21 })}
|
||||
onBlur={() => patch({ ftp_port: cfg.ftp_port })} />
|
||||
</div>
|
||||
<Label className="text-sm">{t('wpub.ftpUser')}</Label>
|
||||
<Input className="h-8" value={cfg.ftp_user}
|
||||
onChange={(e) => set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} />
|
||||
<Label className="text-sm">{t('wpub.ftpPassword')}</Label>
|
||||
<Input type="password" className="h-8" value={cfg.ftp_password}
|
||||
onChange={(e) => set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} />
|
||||
<Label className="text-sm">{t('wpub.ftpFolder')}</Label>
|
||||
<Input className="h-8 font-mono text-xs" placeholder="/www/log" value={cfg.ftp_folder}
|
||||
onChange={(e) => set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} />
|
||||
<Label className="text-sm">{t('wpub.ftpFileName')}</Label>
|
||||
<Input className="h-8 w-56 font-mono text-xs" value={cfg.ftp_file_name}
|
||||
onChange={(e) => set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} />
|
||||
<span />
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.ftp_tls} onCheckedChange={(c) => patch({ ftp_tls: !!c })} />
|
||||
{t('wpub.ftpTls')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border/60 pt-3 flex-wrap">
|
||||
<Button size="sm" onClick={() => run('publish')} disabled={busy !== ''}>
|
||||
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
||||
{t('wpub.publishNow')}
|
||||
</Button>
|
||||
{cfg.ftp_enabled && (
|
||||
<Button variant="outline" size="sm" onClick={() => run('test')} disabled={busy !== ''}>
|
||||
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{t('wpub.testFtp')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{msg && <p className="text-xs text-success break-all">{msg}</p>}
|
||||
{err && <p className="text-xs text-danger break-all">{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
@@ -101,14 +101,18 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' ? (
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: source === 'yaesu' || source === 'kenwood'
|
||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
) : !connected ? (
|
||||
<>
|
||||
@@ -183,7 +187,7 @@ export function WinkeyerPanel({
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||
{t('wkp.cwText')}
|
||||
{source === 'winkeyer' && (
|
||||
{(source === 'winkeyer' || source === 'flex') && (
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
@@ -228,9 +232,14 @@ export function WinkeyerPanel({
|
||||
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short.
|
||||
Empty macros (no label AND no text) are hidden, like the voice keyer;
|
||||
the F-number stays tied to the macro's real index so shortcuts match. */}
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{macros.map((m, i) => (
|
||||
{macros
|
||||
.map((m, i) => ({ m, i }))
|
||||
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
|
||||
.map(({ m, i }) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
|
||||
@@ -14,8 +14,9 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
@@ -25,6 +26,8 @@ const hamlogTheme = hamlogGridTheme;
|
||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||
|
||||
type Props = {
|
||||
// Operator's CURRENT locator — fallback for the distance column (see catalog).
|
||||
myGrid?: string;
|
||||
wb: WorkedBeforeView | null;
|
||||
busy: boolean;
|
||||
currentCall: string;
|
||||
@@ -35,6 +38,10 @@ type Props = {
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award (cell = the reference this QSO counts for).
|
||||
awardCols?: { code: string; name: string }[];
|
||||
@@ -50,14 +57,16 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
|
||||
// Localized column catalog (shared with the Recent QSOs grid).
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
@@ -107,15 +116,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||
}), []);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
// Restore AFTER the profile scope is known: this grid has no key= remount to
|
||||
// save it from reading the wrong (unscoped) cache key at first paint, so it
|
||||
// used to miss the cache every time and then save under the scoped key.
|
||||
async function onGridReady(e: GridReadyEvent) {
|
||||
await whenGridPrefsReady();
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
const remote = await loadRemote(COL_STATE_KEY);
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
});
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||
@@ -131,7 +143,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [awardCols]);
|
||||
// columnDefs itself, not the reasons it was rebuilt — see the same note in
|
||||
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
|
||||
}, [columnDefs]);
|
||||
|
||||
function isColVisible(colId: string): boolean {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
@@ -247,6 +261,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
|
||||
mean "the whole logbook under the active column filters", but this grid
|
||||
is a per-callsign view rather than a filter over the log, so the entry
|
||||
would quietly export everything — not what it would appear to do here.
|
||||
The selection-based exports and bulk edit apply unchanged. */}
|
||||
<QSOContextMenu
|
||||
menu={menu}
|
||||
onClose={() => setMenu(null)}
|
||||
@@ -256,6 +275,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
|
||||
import {
|
||||
GetYaesuState, RefreshYaesuPanel,
|
||||
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
|
||||
type YaesuState = {
|
||||
available: boolean; model?: string; mode?: string; raw_mode?: string;
|
||||
transmitting: boolean; split: boolean;
|
||||
s_meter: number; power_meter: number; swr_meter: number;
|
||||
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||
agc?: string; preamp: number; att: number;
|
||||
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
||||
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
||||
};
|
||||
|
||||
const ZERO: YaesuState = {
|
||||
available: false, transmitting: false, split: false,
|
||||
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||
};
|
||||
|
||||
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
|
||||
// the radio's own band keys do. That is why these are band names, not Hz.
|
||||
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||
|
||||
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
|
||||
// the operator is the one who knows which they want, so each shows its sideband
|
||||
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
|
||||
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
|
||||
// the band plan dictates, and AM/FM have none.
|
||||
//
|
||||
// PSK rides on the rig's DATA mode, like the other digital modes — the button
|
||||
// exists because the operator thinks in modes, not in what the radio calls them.
|
||||
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
|
||||
const MODES: ModeBtn[] = [
|
||||
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
|
||||
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
|
||||
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
|
||||
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
|
||||
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
|
||||
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
|
||||
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
|
||||
];
|
||||
|
||||
// Which button the rig's current raw mode belongs to, and on which sideband.
|
||||
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
||||
switch ((raw || '').toUpperCase()) {
|
||||
case 'USB': return { id: 'SSB', side: 'U' };
|
||||
case 'LSB': return { id: 'SSB', side: 'L' };
|
||||
case 'CW-U': return { id: 'CW', side: 'U' };
|
||||
case 'CW-L': return { id: 'CW', side: 'L' };
|
||||
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
|
||||
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
|
||||
case 'DATA-U': return { id: 'DIGI', side: 'U' };
|
||||
case 'DATA-L': return { id: 'DIGI', side: 'L' };
|
||||
case 'AM': return { id: 'AM', side: 'U' };
|
||||
case 'FM': return { id: 'FM', side: 'U' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
|
||||
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
||||
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
||||
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
||||
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
||||
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
||||
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||
|
||||
function fmtVFO(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '––.–––.––';
|
||||
const mhz = Math.floor(hz / 1_000_000);
|
||||
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||||
const h2 = Math.floor((hz % 1000) / 10);
|
||||
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function bandOfHz(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '';
|
||||
const mhz = hz / 1_000_000;
|
||||
const bands: [string, number, number][] = [
|
||||
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
|
||||
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
|
||||
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
|
||||
];
|
||||
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
// Split the 0-100 S-meter reading into S units + dB over S9.
|
||||
//
|
||||
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
|
||||
// falls; the front panel puts it at roughly half travel, which is the 50 used
|
||||
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
|
||||
// unit off on a radio, this is the number to correct, not the RST helper.
|
||||
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
|
||||
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
|
||||
|
||||
function sParts(v: number): { s: number; over: number; label: string } {
|
||||
if (v >= S9_PCT) {
|
||||
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
|
||||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||
}
|
||||
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
|
||||
return { s, over: 0, label: `S${s}` };
|
||||
}
|
||||
|
||||
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
|
||||
// through the S9+ range, red once the signal is strong enough to be reported as
|
||||
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
|
||||
// change always lands exactly where the numbers say it should — if the S9 point
|
||||
// is ever corrected, the colours follow on their own.
|
||||
const RED_OVER_DB = 20;
|
||||
function sSegColor(frac: number): string {
|
||||
const pct = frac * 100;
|
||||
if (pct < S9_PCT) return '#16a34a';
|
||||
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
|
||||
return '#dc2626';
|
||||
}
|
||||
|
||||
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
|
||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
|
||||
}) {
|
||||
const v = Math.max(min, Math.min(max, value));
|
||||
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
// React's onWheel is passive, so preventDefault is ignored there — attach a
|
||||
// native non-passive listener, and read live values through refs so the
|
||||
// handler never closes over a stale value.
|
||||
const valRef = useRef(value); valRef.current = value;
|
||||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||
const disRef = useRef(disabled); disRef.current = disabled;
|
||||
const minRef = useRef(min); minRef.current = min;
|
||||
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 nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
||||
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={min} max={max} value={v} disabled={disabled}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
|
||||
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
|
||||
// The filled side was invisible against the dark theme: --muted is barely
|
||||
// lighter than the card it sits on, so the whole track read as one bar.
|
||||
// An explicit translucent track keeps both halves distinct in either theme.
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
|
||||
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, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} title={title}
|
||||
className={cn('shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||
onReportRST?: (rst: string) => void;
|
||||
// Told whenever the operator moves the CW speed here, so the app can keep the
|
||||
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
|
||||
// the timing and the rig's internal keyer speed changes nothing audible — the
|
||||
// slider looked broken because it was driving the wrong keyer.
|
||||
onKeySpeed?: (wpm: number) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<YaesuState>(ZERO);
|
||||
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
|
||||
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
|
||||
// main display showed the operator the frequency they transmit on and an
|
||||
// offset of 0 kHz against itself.
|
||||
const [freqHz, setFreqHz] = useState(0);
|
||||
const [txHz, setTxHz] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
// Optimistic local values for the sliders. Without them a drag fights the
|
||||
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
|
||||
const [local, setLocal] = useState<Partial<YaesuState>>({});
|
||||
const localAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = (await GetYaesuState()) as YaesuState;
|
||||
const c = await GetCATState();
|
||||
if (!alive) return;
|
||||
setSt(s);
|
||||
const cs = c as any;
|
||||
const tx = cs?.freq_hz ?? 0;
|
||||
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
|
||||
setFreqHz(rx);
|
||||
setTxHz(tx);
|
||||
// Drop the optimistic overlay once the rig has had time to answer with
|
||||
// the new value — 1.2 s covers the slow-beat settings read.
|
||||
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||
setErr('');
|
||||
} catch (e: any) {
|
||||
if (alive) setErr(String(e?.message ?? e));
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 400);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const view = { ...st, ...local };
|
||||
|
||||
// Every setter follows the same shape: show the value at once, remember when,
|
||||
// and let the poll take over. A rejected command surfaces as an error rather
|
||||
// than as a control that silently springs back.
|
||||
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
|
||||
setLocal((l) => ({ ...l, [key]: value }));
|
||||
localAtRef.current = Date.now();
|
||||
fn().catch((e) => setErr(String(e?.message ?? e)));
|
||||
}
|
||||
|
||||
const band = bandOfHz(freqHz);
|
||||
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
|
||||
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
|
||||
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
|
||||
|
||||
if (!st.available) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center p-6 text-center">
|
||||
<div className="space-y-1">
|
||||
<Radio className="size-8 mx-auto text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
|
||||
{err && <p className="text-xs text-destructive">{err}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
|
||||
it without mx-auto left the console pinned to the left edge with a
|
||||
window of empty space beside it. */}
|
||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||
{/* VFO + status */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{st.model || 'Yaesu'}</div>
|
||||
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
|
||||
{view.split && txHz > 0 && (
|
||||
<div className="text-[11px] font-mono tabular-nums text-warning">
|
||||
{t('yaesu.txOn')} {fmtVFO(txHz)}
|
||||
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{st.transmitting && (
|
||||
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
|
||||
)}
|
||||
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
|
||||
{/* The usual pile-up offsets. Which one is idiomatic depends on the
|
||||
mode — up 5 on phone, up 1 on CW — so both are offered rather than
|
||||
guessed, and each turns split on in the same action. */}
|
||||
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.splitUpHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
|
||||
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.splitUpHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
|
||||
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.tuneHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
TUNE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-xs text-destructive px-1">{err}</p>}
|
||||
|
||||
{/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three
|
||||
consoles read alike instead of each having its own instrument style. */}
|
||||
<Card icon={Activity} title={t('yaesu.meters')}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
|
||||
display={sParts(view.s_meter).label}
|
||||
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
|
||||
title={onReportRST ? t('yaesu.sToRst') : undefined} />
|
||||
{/* Watts as MEASURED, not the power setting scaled by a percentage:
|
||||
the setting says what was asked for, the meter says what left. */}
|
||||
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
|
||||
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
|
||||
{/* The RATIO, as the rig shows it — a percentage of meter travel is
|
||||
not something an operator can act on. The bar keeps the travel. */}
|
||||
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
|
||||
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bands + modes */}
|
||||
<Card icon={Antenna} title={t('yaesu.bandMode')}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{BANDS.map((b) => (
|
||||
<button key={b} type="button"
|
||||
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{b.replace('m', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{MODES.map((m) => {
|
||||
const act = activeMode(view.raw_mode);
|
||||
const on = act?.id === m.id;
|
||||
// The sideband shown is the rig's when this mode is active, else the
|
||||
// one the band plan implies — so a button says what pressing it will
|
||||
// actually do rather than a stale letter.
|
||||
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
|
||||
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
|
||||
return (
|
||||
<button key={m.id} type="button"
|
||||
onClick={() => {
|
||||
// Already on this mode → the click means "the other sideband".
|
||||
const target = m.id === 'SSB'
|
||||
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
|
||||
: m.rig(on && m.sideband ? flip : side);
|
||||
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
|
||||
}}
|
||||
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
|
||||
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{m.sideband ? m.label + '-' + side : m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Receive */}
|
||||
<Card icon={AudioLines} title={t('yaesu.receive')}>
|
||||
<Row label="AF">
|
||||
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
|
||||
</Row>
|
||||
<Row label="RF">
|
||||
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
|
||||
</Row>
|
||||
<Row label="SQL">
|
||||
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
|
||||
</Row>
|
||||
<Row label="AGC">
|
||||
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
|
||||
</Row>
|
||||
<Row label="FRONT">
|
||||
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
<Row label="ATT">
|
||||
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Noise + filter */}
|
||||
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
||||
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
||||
</div>
|
||||
<Row label="DNR">
|
||||
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||
the radio's own display counts 1-15, and matching it is what makes
|
||||
the panel readable next to the front panel. */}
|
||||
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
|
||||
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Transmit */}
|
||||
<Card icon={Mic} title={t('yaesu.transmit')}>
|
||||
<Row label="PWR">
|
||||
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
||||
percentage would be a second unit to reconcile every time. */}
|
||||
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
|
||||
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
||||
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||
</Row>
|
||||
{/* Microphone gain and VOX are meaningless in CW — the rig ignores both
|
||||
— so they are hidden rather than shown as dead controls. */}
|
||||
{!isCW && (
|
||||
<>
|
||||
<Row label="MIC">
|
||||
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
|
||||
</Row>
|
||||
<Row label="VOX">
|
||||
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
<Row label="">
|
||||
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
{t('yaesu.refresh')}
|
||||
</button>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* CW — only in CW, where these replace the phone controls above. */}
|
||||
{isCW && (
|
||||
<Card icon={Radio} title={t('yaesu.cw')}>
|
||||
<Row label="SPEED">
|
||||
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
|
||||
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
|
||||
</Row>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
|
||||
label="BK-IN" title={t('yaesu.breakInHint')} />
|
||||
{/* ZIN is a one-shot: the rig retunes so the station being received
|
||||
lands on the operator's own CW pitch. Not a toggle, so it is a
|
||||
plain button rather than a chip that would look latched. */}
|
||||
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('yaesu.zinHint')}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||
ZIN
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,17 @@ export function Combobox({
|
||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||
// open the list (so tabbing in doesn't pop the dropdown).
|
||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setQuery(v);
|
||||
setOpen(true);
|
||||
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||
// without leaving the field uses what was just typed). With free text that's
|
||||
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||
// that's actually in the list, so a half-typed or invalid report never
|
||||
// becomes the committed value — blur then reverts the leftover text.
|
||||
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||
|
||||
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||
// No focus ring on the panel itself. Radix makes the content focusable and
|
||||
// moves focus into it when a tab is selected, so clicking anywhere in the
|
||||
// lower half drew a full-width orange rule under the tab strip. The panel is
|
||||
// a container — what it holds carries its own focus styling.
|
||||
className={cn('mt-2 focus-visible:outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||
//
|
||||
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||
// upload. This is the reading layer only, because an operator reading
|
||||
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||
// and that is a display problem, not a data one.
|
||||
//
|
||||
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||
// preference would become unreadable the day the preference changed.
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||
|
||||
const KEY = 'opslog.dateFormat';
|
||||
|
||||
function read(): DateFormat {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||
}
|
||||
|
||||
let current: DateFormat = read();
|
||||
|
||||
// A minimal store so every grid and panel re-renders the moment the format
|
||||
// changes, rather than showing the old one until the next restart.
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getDateFormat(): DateFormat {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setDateFormat(f: DateFormat): void {
|
||||
current = f;
|
||||
writeUiPref(KEY, f);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeDateFormat(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||
// have been synced from the database at startup, which happens AFTER this
|
||||
// module's first read.
|
||||
export function reloadDateFormat(): void {
|
||||
const v = read();
|
||||
if (v !== current) {
|
||||
current = v;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||
// in the French one would be conventional but it also invites the reader to
|
||||
// take it for a US date, which is exactly the confusion being fixed.
|
||||
function order(y: number, m: number, d: number): string {
|
||||
switch (current) {
|
||||
case 'fr':
|
||||
return `${pad(d)}-${pad(m)}-${y}`;
|
||||
case 'us':
|
||||
return `${pad(m)}-${pad(d)}-${y}`;
|
||||
}
|
||||
return `${y}-${pad(m)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||
//
|
||||
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||
// twice a year.
|
||||
export function formatDateTimeUTC(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(String(s));
|
||||
if (isNaN(d.getTime())) return String(s);
|
||||
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||
export function formatDateOnly(s: unknown): string {
|
||||
if (!s) return '';
|
||||
const t = String(s).trim();
|
||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
const d = new Date(t);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Grey line — the day/night terminator, and the twilight band around it where
|
||||
// HF propagation briefly does things it does at no other time.
|
||||
//
|
||||
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||
// this has to work in a field shack with no internet.
|
||||
//
|
||||
// Everything here is pure so it can be tested against known positions; the
|
||||
// drawing lives in the map component.
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// julianDay for a date, including the fraction of the day.
|
||||
export function julianDay(d: Date): number {
|
||||
return d.getTime() / 86400000 + 2440587.5;
|
||||
}
|
||||
|
||||
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||
//
|
||||
// Returned together because they are computed from the same intermediates and
|
||||
// every caller needs both — splitting them into two exported functions would
|
||||
// double the work at each call for the sake of a tidier signature.
|
||||
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||
const jd = julianDay(date);
|
||||
const n = jd - 2451545.0; // days from J2000.0
|
||||
|
||||
// Mean longitude and mean anomaly of the Sun.
|
||||
const L = (280.460 + 0.9856474 * n) % 360;
|
||||
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||
|
||||
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||
|
||||
// Obliquity of the ecliptic, slowly decreasing.
|
||||
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||
|
||||
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||
|
||||
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||
// terminator about the prime meridian. Six months of a plausible-looking
|
||||
// wrong answer is exactly the kind of bug that survives review.
|
||||
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||
if (ra < 0) ra += 360;
|
||||
|
||||
// Greenwich mean sidereal time, in degrees.
|
||||
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||
|
||||
let gha = gmst - ra;
|
||||
gha = ((gha % 360) + 360) % 360;
|
||||
return { dec, gha };
|
||||
}
|
||||
|
||||
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||
// sits at `altitude` degrees above (or below) the horizon.
|
||||
//
|
||||
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||
// of the grey line as operators use the term.
|
||||
//
|
||||
// Returns null where no such latitude exists at that longitude — inside the
|
||||
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const h = altitude * DEG;
|
||||
|
||||
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||
//
|
||||
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||
// local in December came out as daylight because the branch chosen put the
|
||||
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||
// entirely plausible and is exactly wrong.
|
||||
//
|
||||
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||
// which is the physical requirement — the twilight curve is a small offset
|
||||
// from the terminator, never on the other side of the planet.
|
||||
const A = Math.sin(d);
|
||||
const B = Math.cos(d) * Math.cos(ha);
|
||||
const R = Math.hypot(A, B);
|
||||
if (R === 0) return null;
|
||||
const s = Math.sin(h) / R;
|
||||
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||
|
||||
// The classical terminator. Division by zero is deliberate and correct here:
|
||||
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||
// exactly what atan(±Infinity) = ±90° expresses.
|
||||
const t = Math.tan(d);
|
||||
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||
|
||||
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||
const fold = (x: number) => {
|
||||
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||
if (v > 90) v = 180 - v;
|
||||
if (v < -90) v = -180 - v;
|
||||
return v;
|
||||
};
|
||||
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||
}
|
||||
|
||||
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||
//
|
||||
// The ring runs along the terminator from west to east and closes along
|
||||
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||
// getting it wrong shades the lit half of the planet.
|
||||
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ring: [number, number][] = [];
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||
// ring stays closed rather than tearing open across the map.
|
||||
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||
}
|
||||
|
||||
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||
const pole = dec > 0 ? -90 : 90;
|
||||
ring.push([pole, 180]);
|
||||
ring.push([pole, -180]);
|
||||
return ring;
|
||||
}
|
||||
@@ -6,11 +6,52 @@
|
||||
// back to the DB copy and re-seed the cache.
|
||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||
|
||||
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||
// the active profile id). The localStorage cache, however, is one namespace for
|
||||
// the whole WebView, so without scoping it too a profile switch would keep
|
||||
// serving the previous profile's cached layout and the correct per-profile DB
|
||||
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||
// once the active profile is known and updated on every profile switch.
|
||||
let lsScope = '';
|
||||
|
||||
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||
// the grids read their state (at startup) and again whenever the active profile
|
||||
// changes so each profile keeps its own column layout / widths.
|
||||
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||
markReady();
|
||||
}
|
||||
|
||||
// The active profile is only known after an async call, but the grids mount and
|
||||
// read their state on the first paint. A grid that read before the scope was set
|
||||
// looked up the UNSCOPED cache key (always a miss) and then saved under the
|
||||
// scoped one — so its layout could never be restored, only rewritten. Grids now
|
||||
// wait for this instead of racing it.
|
||||
//
|
||||
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
|
||||
// still come up with whatever the unscoped cache holds rather than hang with no
|
||||
// columns restored.
|
||||
let markReady: () => void = () => {};
|
||||
const gridPrefsReady = new Promise<void>((resolve) => {
|
||||
markReady = resolve;
|
||||
setTimeout(resolve, 5000);
|
||||
});
|
||||
|
||||
export function whenGridPrefsReady(): Promise<void> {
|
||||
return gridPrefsReady;
|
||||
}
|
||||
|
||||
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||
function lsKey(key: string): string {
|
||||
return lsScope + key;
|
||||
}
|
||||
|
||||
// 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 raw = localStorage.getItem(lsKey(key));
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
return Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
@@ -19,25 +60,70 @@ export function loadLocal(key: string): any[] | null {
|
||||
}
|
||||
|
||||
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||
export async function loadRemote(key: string): Promise<any[] | null> {
|
||||
//
|
||||
// GetUIPref returns an ERROR — not "" — while the settings store is still
|
||||
// coming up and not yet scoped to the active profile. Treating that as "no
|
||||
// preference" was the silent data-loss path: the grid rendered defaults and the
|
||||
// first column event then wrote those defaults over the good saved copy. So
|
||||
// retry for a few seconds instead, and only give up on a real absence.
|
||||
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const v = await GetUIPref(key);
|
||||
const parsed = v ? JSON.parse(v) : null;
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
// Not ready yet (or a parse failure on a corrupt value — one more read
|
||||
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
||||
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
|
||||
// frame, so dragging one border used to fire dozens of settings writes; the
|
||||
// cache write stays synchronous so nothing is lost if the window closes.
|
||||
const pendingDB = new Map<string, string>();
|
||||
const dbTimers = new Map<string, number>();
|
||||
|
||||
export function saveState(key: string, state: any[]) {
|
||||
const json = JSON.stringify(state);
|
||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||
pendingDB.set(key, json);
|
||||
const prev = dbTimers.get(key);
|
||||
if (prev) clearTimeout(prev);
|
||||
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
|
||||
}
|
||||
|
||||
function flushOne(key: string) {
|
||||
const json = pendingDB.get(key);
|
||||
dbTimers.delete(key);
|
||||
if (json == null) return;
|
||||
pendingDB.delete(key);
|
||||
SetUIPref(key, json).catch(() => {
|
||||
// The store may not be scoped yet at startup. Keep the value and try once
|
||||
// more shortly — dropping it here is how a fresh profile ended up with no
|
||||
// portable copy at all.
|
||||
pendingDB.set(key, json);
|
||||
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
|
||||
});
|
||||
}
|
||||
|
||||
// flushGridPrefs writes any debounced state immediately. Call it when the app is
|
||||
// about to close so a resize made in the last moments still reaches the DB.
|
||||
export function flushGridPrefs() {
|
||||
for (const key of Array.from(pendingDB.keys())) {
|
||||
const t = dbTimers.get(key);
|
||||
if (t) clearTimeout(t);
|
||||
flushOne(key);
|
||||
}
|
||||
}
|
||||
|
||||
// seedLocal writes a value into the cache without touching the DB (used after
|
||||
// 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 */ }
|
||||
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
+352
-84
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
||||
// Colouring for QSL / confirmation status letters.
|
||||
//
|
||||
// These columns are read by COLOUR before they are read as letters: an operator
|
||||
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
|
||||
// hundred times.
|
||||
//
|
||||
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
|
||||
// and a column of coloured boxes would shout louder than the callsign it
|
||||
// belongs to.
|
||||
//
|
||||
// The classes are semantic tokens, not fixed colours, so all four themes follow
|
||||
// and the contrast stays right on the light ones.
|
||||
|
||||
// ADIF confirmation values, and what they mean to the operator:
|
||||
// Y confirmed / sent
|
||||
// N not sent, not received
|
||||
// R requested — queued, waiting on the other station or the service
|
||||
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
|
||||
// good news nor bad, and colouring it would put it in one camp or the
|
||||
// other.
|
||||
//
|
||||
// Anything else — a blank, or a status some other logger wrote — is left alone
|
||||
// rather than guessed at.
|
||||
export function qslStatusClass(value: unknown): string {
|
||||
switch (String(value ?? '').trim().toUpperCase()) {
|
||||
case 'Y':
|
||||
return 'text-success';
|
||||
case 'N':
|
||||
return 'text-destructive';
|
||||
case 'R':
|
||||
return 'text-info';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
|
||||
// status columns already used so the letters stay in a straight column.
|
||||
export function qslStatusCellClass(p: { value?: unknown }): string {
|
||||
const c = qslStatusClass(p?.value);
|
||||
return c ? 'font-mono ' + c : 'font-mono';
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// The "extra" spot markers — the ones ORTHOGONAL to the entity status.
|
||||
//
|
||||
// A spot's entity status (new DXCC / new band / new slot / worked) answers one
|
||||
// question. These answer others that can be true at the same time: the park is
|
||||
// new even if the entity is worked, the county is new even if the band is not.
|
||||
// Both the DX-cluster list and the band map show them, and they have to agree —
|
||||
// the same fact must not be green in one panel and violet in the next, so the
|
||||
// colours live HERE and nowhere else. This is also the table a per-marker colour
|
||||
// setting will drive, which is why it is a table rather than three constants.
|
||||
//
|
||||
// Colour choices:
|
||||
// POTA green — parks; the association is worth keeping
|
||||
// county violet — a chart hue, because every semantic token was already
|
||||
// spoken for: red, orange and yellow are the entity
|
||||
// statuses, blue is "callsign worked", green is POTA
|
||||
// worked blue — matches the WKD-CALL badge the cluster list has always used
|
||||
// prefix yellow — unchanged
|
||||
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call';
|
||||
|
||||
export type SpotMarker = {
|
||||
key: SpotMarkerKey;
|
||||
colour: string; // a CSS colour — a var() reference, so it follows the theme
|
||||
labelKey: string; // i18n key, short form (badge / legend)
|
||||
};
|
||||
|
||||
// Order is the display order, in both panels.
|
||||
export const SPOT_MARKERS: SpotMarker[] = [
|
||||
{ key: 'new_pota', colour: 'var(--success)', labelKey: 'clg2.newPota' },
|
||||
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
||||
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
||||
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
||||
];
|
||||
|
||||
export const markerColour = (key: SpotMarkerKey): string =>
|
||||
SPOT_MARKERS.find((m) => m.key === key)?.colour ?? 'var(--muted-foreground)';
|
||||
|
||||
// activeMarkers returns the markers set on a status entry, in display order.
|
||||
export function activeMarkers(e: Record<string, unknown> | undefined): SpotMarker[] {
|
||||
if (!e) return [];
|
||||
return SPOT_MARKERS.filter((m) => !!e[m.key]);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Sunrise / sunset for a station, in UTC.
|
||||
//
|
||||
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
||||
// the solar equations: the map's terminator and the entry strip's sunrise must
|
||||
// never disagree, and there is a whole comment block over there about a sign
|
||||
// error that produced six months of plausible-looking wrong answers. One source
|
||||
// of astronomy, one place to be wrong.
|
||||
//
|
||||
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
||||
// time would raise the question "local to whom — me or the DX?".
|
||||
import { sunPosition } from './greyline';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
||||
// the horizon plus atmospheric refraction.
|
||||
const H0 = -0.833;
|
||||
|
||||
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
||||
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
||||
const { dec, gha } = sunPosition(date);
|
||||
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||
const d = dec * DEG;
|
||||
const lat = latDeg * DEG;
|
||||
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
||||
}
|
||||
|
||||
export type SunTimes = {
|
||||
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
||||
set: string;
|
||||
polarDay: boolean; // Sun stays up all day
|
||||
polarNight: boolean; // Sun never comes up
|
||||
};
|
||||
|
||||
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
||||
// crosses the rise/set altitude.
|
||||
//
|
||||
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
||||
// special-casing for the polar cases and for days where the declination shifts
|
||||
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
||||
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
||||
// crossing found means no sunrise, which is a real answer above the Arctic
|
||||
// circle. The crossing minute is then refined by linear interpolation, so the
|
||||
// result is accurate to well under a minute despite the coarse scan.
|
||||
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
||||
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
||||
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
||||
|
||||
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
const minute = 60000;
|
||||
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
||||
const anyUp = prev > H0;
|
||||
let anyDown = prev <= H0;
|
||||
let sawUp = anyUp;
|
||||
|
||||
for (let i = 1; i <= 1440; i++) {
|
||||
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
||||
if (alt > H0) sawUp = true; else anyDown = true;
|
||||
if (prev <= H0 && alt > H0 && !out.rise) {
|
||||
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
} else if (prev > H0 && alt <= H0 && !out.set) {
|
||||
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||
}
|
||||
prev = alt;
|
||||
}
|
||||
if (!out.rise && !out.set) {
|
||||
out.polarDay = sawUp && !anyDown;
|
||||
out.polarNight = !sawUp;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// frac is where between two samples the altitude crosses H0.
|
||||
function frac(a: number, b: number): number {
|
||||
const d = b - a;
|
||||
return d === 0 ? 0 : (H0 - a) / d;
|
||||
}
|
||||
|
||||
function fmt(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Capitalisation helpers for the free-text QSO fields (Name / QTH / Comment /
|
||||
// Note).
|
||||
//
|
||||
// The same station reaches the log SHOUTED by QRZ, lower-cased by a hurried
|
||||
// operator, and in whatever case the other logger stored it in an imported
|
||||
// ADIF — so one callsign ends up as "JEAN", "jean" and "Jean" across a log,
|
||||
// and sorting or reading a QTH column becomes a mess.
|
||||
//
|
||||
// Both helpers are meant to run on BLUR, never per keystroke: rewriting the
|
||||
// value while the operator is still typing a word fights them mid-word (see
|
||||
// the controlled-input note in CLAUDE.md).
|
||||
|
||||
// titleCase upper-cases the first letter of every word and lower-cases the
|
||||
// rest. Separators are kept, so "SAINT-JULIEN" → "Saint-Julien" and
|
||||
// "o'brien" → "O'Brien".
|
||||
export function titleCase(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/(^|[\s\-'’/.])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toUpperCase());
|
||||
}
|
||||
|
||||
// sentenceCase upper-cases the first letter and leaves everything after it as
|
||||
// typed. A comment routinely carries callsigns, modes and abbreviations
|
||||
// ("TNX QSO F5ABC, FT8 59") that lower-casing would quietly destroy — which is
|
||||
// why this is NOT titleCase.
|
||||
export function sentenceCase(s: string): string {
|
||||
return s.replace(/^(\s*)(\p{L})/u, (_m, sp: string, ch: string) => sp + ch.toUpperCase());
|
||||
}
|
||||
@@ -6,15 +6,24 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
|
||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||
// travels with the data/ folder like the language).
|
||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic'
|
||||
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
|
||||
|
||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
||||
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
||||
// then darks, with high-contrast last — it is an accessibility choice, not a
|
||||
// taste one, and listing it among the moods buries it.
|
||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
|
||||
'light-warm', 'light-cool', 'light-sage', 'light-nordic',
|
||||
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
|
||||
'high-contrast',
|
||||
];
|
||||
|
||||
export const LS_KEY = 'opslog.theme';
|
||||
const DEFAULT: ThemeChoice = 'light-warm';
|
||||
// A fresh install starts DARK. A shack is usually a dim room and the screen is
|
||||
// looked at for hours; every other logger defaults the same way. Graphite
|
||||
// specifically, because that is what 'auto' already resolves to for a dark
|
||||
// system — so the two paths agree instead of landing on different darks.
|
||||
const DEFAULT: ThemeChoice = 'dark-graphite';
|
||||
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||
|
||||
function systemDark(): boolean {
|
||||
@@ -73,16 +82,24 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const load = () => {
|
||||
tries += 1;
|
||||
GetUIPref(LS_KEY).then((raw) => {
|
||||
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
|
||||
// empty/invalid one means the theme is genuinely unset → keep the default.
|
||||
// Either way we're done — do NOT retry (retrying only matters while the
|
||||
// backend is still starting, which now surfaces as a rejected promise).
|
||||
if (cancelled || userPicked.current) return;
|
||||
const v = raw as ThemeChoice;
|
||||
if (v && ALL.includes(v)) {
|
||||
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||
setThemeState(v);
|
||||
return; // restored
|
||||
}
|
||||
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
|
||||
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
|
||||
}).catch(() => {
|
||||
// Settings store not ready yet — a brief startup window before the backend
|
||||
// has opened the local DB and built the store (the frontend can query it
|
||||
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
|
||||
// lost to the light default after an update cleared localStorage.
|
||||
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
|
||||
});
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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';
|
||||
import { GetUIPref, SetUIPref, LogUIError } 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).
|
||||
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
|
||||
'opslog.autofocusWB', // auto-focus Worked-before
|
||||
'hamlog.filterPresets', // Filter Builder saved presets
|
||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
||||
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||
@@ -25,14 +27,24 @@ const PORTABLE_KEYS = [
|
||||
'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.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||
// Cluster filter selections — restored on reopen.
|
||||
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
// through this global path would fight that per-profile scoping.
|
||||
];
|
||||
|
||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||
@@ -58,5 +70,11 @@ export async function syncPortablePrefs(): Promise<void> {
|
||||
// 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 */ });
|
||||
SetUIPref(key, value).catch((e: any) => {
|
||||
// The cache still holds it, so the interface behaves — but the BACKEND
|
||||
// reads some of these keys too (digital-mode grouping decides how the
|
||||
// cluster judges a slot). A write that reaches localStorage and not the
|
||||
// database makes the two disagree, and used to do so in silence.
|
||||
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import App from './App'
|
||||
import { syncPortablePrefs } from './lib/uiPref'
|
||||
import { I18nProvider } from './lib/i18n'
|
||||
import { ThemeProvider, initTheme } from './lib/theme'
|
||||
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
|
||||
import { reloadDateFormat } from './lib/dateFormat'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
@@ -17,13 +19,23 @@ syncPortablePrefs().finally(() => {
|
||||
// Stamp the persisted theme onto <html> before render so the correct
|
||||
// palette is applied immediately (portable pref hydrated just above).
|
||||
initTheme()
|
||||
// The date format module read localStorage at import time, which is BEFORE
|
||||
// the portable prefs were pulled from the database. Re-read it now, or a
|
||||
// copied data/ folder would show ISO until the next launch.
|
||||
reloadDateFormat()
|
||||
// Catch what a boundary cannot: errors thrown from timers, event handlers and
|
||||
// rejected promises. Installed before the first render so a fault during
|
||||
// startup is reported too.
|
||||
installGlobalErrorLogging()
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
})
|
||||
|
||||
+321
-2
@@ -495,6 +495,286 @@
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 5: Indigo — deep blue-violet, electric accent ----------------
|
||||
The first seven themes are all neutral (beige, slate, graphite, black) with an
|
||||
orange accent, so "another dark theme" only ever meant another grey. These
|
||||
four carry a real hue in the SURFACES, not just in the accent, and each takes
|
||||
a different primary — violet, cyan, pink, indigo — so they are told apart at a
|
||||
glance in the picker.
|
||||
|
||||
Every one keeps the same contract: the semantic colours (success / warning /
|
||||
caution / danger / info) must stay recognisable as themselves. A logger is
|
||||
read for hours and "red = a problem" cannot become a decorative choice, so the
|
||||
hue budget is spent on the surfaces and the primary, never on the meanings. */
|
||||
[data-theme="dark-indigo"] {
|
||||
--background: #0e0f1f;
|
||||
--foreground: #e4e5f2;
|
||||
--card: #181a30;
|
||||
--card-foreground: #e4e5f2;
|
||||
--popover: #181a30;
|
||||
--popover-foreground: #e4e5f2;
|
||||
--primary: #7c6cff; /* electric violet */
|
||||
--primary-foreground: #0b0a1a;
|
||||
--secondary: #222542;
|
||||
--secondary-foreground: #e4e5f2;
|
||||
--muted: #1e2038;
|
||||
--muted-foreground: #9d9fbe;
|
||||
--accent: #2a2d51;
|
||||
--accent-foreground: #c7c9ee;
|
||||
--destructive: #f2555a;
|
||||
--destructive-foreground: #12030a;
|
||||
--destructive-muted: #34131c;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #2a2d4c;
|
||||
--input: #2a2d4c;
|
||||
--ring: #9b8dff;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #052018;
|
||||
--success-muted: #0e2f27;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #165241;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #221803;
|
||||
--warning-muted: #33280f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3e15;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #221e04;
|
||||
--caution-muted: #322d0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4b4315;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #250912;
|
||||
--danger-muted: #37151f;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #562533;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #051d29;
|
||||
--info-muted: #0b2a3a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #12475c;
|
||||
|
||||
/* The entity ramp moves to CYAN here: violet is the primary, and a violet
|
||||
"entity confirmed" cell would read as a button. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2c7a52;
|
||||
--mx-dx-conf: #22d3ee;
|
||||
--mx-dx-work: #1b6b7c;
|
||||
--mx-none: #2c2f4d;
|
||||
|
||||
--scrollbar-thumb: #383c63;
|
||||
--scrollbar-thumb-hover: #4c5182;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(228, 229, 242, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 6: Ocean — deep teal, cyan accent --------------------------- */
|
||||
[data-theme="dark-teal"] {
|
||||
--background: #061c21;
|
||||
--foreground: #dcecee;
|
||||
--card: #0d2c33;
|
||||
--card-foreground: #dcecee;
|
||||
--popover: #0d2c33;
|
||||
--popover-foreground: #dcecee;
|
||||
--primary: #22d3ee; /* cyan */
|
||||
--primary-foreground: #041d24;
|
||||
--secondary: #143b44;
|
||||
--secondary-foreground: #dcecee;
|
||||
--muted: #10333b;
|
||||
--muted-foreground: #92b4ba;
|
||||
--accent: #15424c;
|
||||
--accent-foreground: #a5d8e0;
|
||||
--destructive: #f2555a;
|
||||
--destructive-foreground: #12030a;
|
||||
--destructive-muted: #35161a;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #1a4652;
|
||||
--input: #1a4652;
|
||||
--ring: #5fe3f5;
|
||||
|
||||
--success: #4ade80;
|
||||
--success-foreground: #04220f;
|
||||
--success-muted: #0e3020;
|
||||
--success-muted-foreground: #86efac;
|
||||
--success-border: #17593a;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #221803;
|
||||
--warning-muted: #33280f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3e15;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #221e04;
|
||||
--caution-muted: #312d0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4a4315;
|
||||
|
||||
--danger: #fb7185;
|
||||
--danger-foreground: #250912;
|
||||
--danger-muted: #38161f;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #572634;
|
||||
|
||||
/* Info moves to a BLUE: a cyan "info" would be the primary colour again. */
|
||||
--info: #7aa2f7;
|
||||
--info-foreground: #04122e;
|
||||
--info-muted: #16233f;
|
||||
--info-muted-foreground: #a9c2fb;
|
||||
--info-border: #2b3d66;
|
||||
|
||||
--mx-call-conf: #4ade80;
|
||||
--mx-call-work: #2b7a4d;
|
||||
--mx-dx-conf: #a78bfa;
|
||||
--mx-dx-work: #5b4a9c;
|
||||
--mx-none: #1c4550;
|
||||
|
||||
--scrollbar-thumb: #235a68;
|
||||
--scrollbar-thumb-hover: #31788a;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(220, 236, 238, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 7: Plum — aubergine, magenta accent ------------------------- */
|
||||
[data-theme="dark-plum"] {
|
||||
--background: #180f1e;
|
||||
--foreground: #eee0f0;
|
||||
--card: #251830;
|
||||
--card-foreground: #eee0f0;
|
||||
--popover: #251830;
|
||||
--popover-foreground: #eee0f0;
|
||||
--primary: #f472b6; /* magenta-pink */
|
||||
--primary-foreground: #250518;
|
||||
--secondary: #33203f;
|
||||
--secondary-foreground: #eee0f0;
|
||||
--muted: #2b1b36;
|
||||
--muted-foreground: #b39dbd;
|
||||
--accent: #3b2648;
|
||||
--accent-foreground: #dcc2e6;
|
||||
--destructive: #f2555a;
|
||||
--destructive-foreground: #12030a;
|
||||
--destructive-muted: #3a1620;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #3a2547;
|
||||
--input: #3a2547;
|
||||
--ring: #f9a8d4;
|
||||
|
||||
--success: #34d399;
|
||||
--success-foreground: #052018;
|
||||
--success-muted: #10302a;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #1a5745;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-foreground: #221803;
|
||||
--warning-muted: #352a12;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #524019;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #221e04;
|
||||
--caution-muted: #342e12;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4e4519;
|
||||
|
||||
/* Danger goes RED here, not rose: a rose "danger" beside a pink primary is
|
||||
indistinguishable, and this is the one colour that must never be missed. */
|
||||
--danger: #f43f3f;
|
||||
--danger-foreground: #2a0606;
|
||||
--danger-muted: #3d1418;
|
||||
--danger-muted-foreground: #fca5a5;
|
||||
--danger-border: #5c2128;
|
||||
|
||||
--info: #38bdf8;
|
||||
--info-foreground: #051d29;
|
||||
--info-muted: #0f2b3a;
|
||||
--info-muted-foreground: #7dd3fc;
|
||||
--info-border: #16485d;
|
||||
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2f7d55;
|
||||
--mx-dx-conf: #60a5fa;
|
||||
--mx-dx-work: #35538c;
|
||||
--mx-none: #3a2647;
|
||||
|
||||
--scrollbar-thumb: #4a3159;
|
||||
--scrollbar-thumb-hover: #644176;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(238, 224, 240, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 8: Nordic light — crisp cool white, indigo accent ----------- */
|
||||
[data-theme="light-nordic"] {
|
||||
--background: #eef1f7;
|
||||
--foreground: #1b2130;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1b2130;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1b2130;
|
||||
--primary: #4f46e5; /* indigo */
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #dfe4ee;
|
||||
--secondary-foreground: #1b2130;
|
||||
--muted: #e2e7f0;
|
||||
--muted-foreground: #4a5468;
|
||||
--accent: #dcdcfb;
|
||||
--accent-foreground: #312a91;
|
||||
--destructive: #b91c1c;
|
||||
--destructive-foreground: #ffffff;
|
||||
--destructive-muted: #fef2f2;
|
||||
--destructive-muted-foreground: #b91c1c;
|
||||
--border: #c9d1e0;
|
||||
--input: #c9d1e0;
|
||||
--ring: #6366f1;
|
||||
|
||||
--success: #15803d;
|
||||
--success-foreground: #ffffff;
|
||||
--success-muted: #dcfce7;
|
||||
--success-muted-foreground: #14532d;
|
||||
--success-border: #86efac;
|
||||
|
||||
--warning: #b45309;
|
||||
--warning-foreground: #ffffff;
|
||||
--warning-muted: #fef3c7;
|
||||
--warning-muted-foreground: #92400e;
|
||||
--warning-border: #fcd34d;
|
||||
|
||||
--caution: #a16207;
|
||||
--caution-foreground: #ffffff;
|
||||
--caution-muted: #fef9c3;
|
||||
--caution-muted-foreground: #854d0e;
|
||||
--caution-border: #fde047;
|
||||
|
||||
--danger: #dc2626;
|
||||
--danger-foreground: #ffffff;
|
||||
--danger-muted: #fee2e2;
|
||||
--danger-muted-foreground: #991b1b;
|
||||
--danger-border: #fca5a5;
|
||||
|
||||
--info: #0369a1;
|
||||
--info-foreground: #ffffff;
|
||||
--info-muted: #e0f2fe;
|
||||
--info-muted-foreground: #075985;
|
||||
--info-border: #7dd3fc;
|
||||
|
||||
/* Entity ramp shifts to TEAL: indigo is the primary here. */
|
||||
--mx-call-conf: #15803d;
|
||||
--mx-call-work: #86efac;
|
||||
--mx-dx-conf: #0f766e;
|
||||
--mx-dx-work: #99f6e4;
|
||||
--mx-none: #dde3ec;
|
||||
|
||||
--scrollbar-thumb: #b6c0d2;
|
||||
--scrollbar-thumb-hover: #93a0b8;
|
||||
--card-shadow: 0 1px 2px rgba(27, 33, 48, 0.06), 0 0 0 1px rgba(27, 33, 48, 0.03);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||
@@ -512,7 +792,8 @@
|
||||
:root,
|
||||
[data-theme="light-warm"],
|
||||
[data-theme="light-cool"],
|
||||
[data-theme="light-sage"] {
|
||||
[data-theme="light-sage"],
|
||||
[data-theme="light-nordic"] {
|
||||
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||
--chart-2: #1baf7a; /* aqua */
|
||||
--chart-3: #eda100; /* yellow */
|
||||
@@ -533,7 +814,10 @@
|
||||
[data-theme="dim-slate"],
|
||||
[data-theme="dark-warm"],
|
||||
[data-theme="dark-graphite"],
|
||||
[data-theme="high-contrast"] {
|
||||
[data-theme="high-contrast"],
|
||||
[data-theme="dark-indigo"],
|
||||
[data-theme="dark-teal"],
|
||||
[data-theme="dark-plum"] {
|
||||
--chart-1: #3987e5;
|
||||
--chart-2: #199e70;
|
||||
--chart-3: #c98500;
|
||||
@@ -652,3 +936,38 @@
|
||||
border: 2px solid var(--background);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||
|
||||
/* ── Frameless window: drag region ──────────────────────────────────────────
|
||||
The OS title bar is gone (main.go, Frameless), so the app header IS the title
|
||||
bar and must be draggable. Everything interactive inside it opts OUT: a drag
|
||||
region swallows clicks, and without this the menus and toolbar buttons would
|
||||
move the window instead of doing their job. Opting out by ROLE rather than
|
||||
listing each child keeps a future button working without anyone remembering
|
||||
this rule. */
|
||||
.app-dragregion {
|
||||
--wails-draggable: drag;
|
||||
}
|
||||
.app-dragregion button,
|
||||
.app-dragregion a,
|
||||
.app-dragregion input,
|
||||
.app-dragregion select,
|
||||
.app-dragregion textarea,
|
||||
.app-dragregion nav,
|
||||
.app-dragregion [role='button'],
|
||||
.app-dragregion [role='menu'],
|
||||
.app-dragregion [data-no-drag] {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
|
||||
disappears on the dark themes. Invert it there — the control itself is worth
|
||||
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
|
||||
help. */
|
||||
Every temporal input carries the same glyph, so the rule names them all:
|
||||
datetime-local was left out at first and its icon stayed invisible. */
|
||||
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'],
|
||||
[data-theme='dark-indigo'], [data-theme='dark-teal'], [data-theme='dark-plum'])
|
||||
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(1.8);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.19.9';
|
||||
export const APP_VERSION = '0.24.2';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+275
-11
@@ -5,13 +5,18 @@ import {qso} from '../models';
|
||||
import {main} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {acom} from '../models';
|
||||
import {catemu} from '../models';
|
||||
import {antgenius} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
import {spe} from '../models';
|
||||
import {solar} from '../models';
|
||||
import {tunergenius} from '../models';
|
||||
import {webpub} from '../models';
|
||||
import {winkeyer} from '../models';
|
||||
import {alerts} from '../models';
|
||||
import {audio} from '../models';
|
||||
@@ -21,6 +26,13 @@ import {udp} from '../models';
|
||||
import {lotwusers} from '../models';
|
||||
import {lookup} from '../models';
|
||||
import {netctl} from '../models';
|
||||
import {scp} from '../models';
|
||||
|
||||
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
||||
|
||||
export function ADIFExtraFields():Promise<Array<string>>;
|
||||
|
||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||
|
||||
@@ -30,6 +42,14 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||
|
||||
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||
@@ -42,6 +62,8 @@ export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||
|
||||
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AudioMonitorActive():Promise<boolean>;
|
||||
|
||||
export function AudioStartMonitor():Promise<void>;
|
||||
@@ -54,7 +76,7 @@ export function AudioStopTX():Promise<void>;
|
||||
|
||||
export function AudioTXActive():Promise<boolean>;
|
||||
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function AwardFields():Promise<Array<string>>;
|
||||
|
||||
@@ -66,14 +88,20 @@ export function AwardsFolder():Promise<string>;
|
||||
|
||||
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||
|
||||
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
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 CIVTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function CWDecoderRunning():Promise<boolean>;
|
||||
|
||||
export function CancelConfirmations():Promise<void>;
|
||||
|
||||
export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
@@ -146,26 +174,34 @@ export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||
|
||||
export function DownloadLoTWUsers():Promise<number>;
|
||||
|
||||
export function DownloadScp():Promise<number>;
|
||||
|
||||
export function DownloadULSCounties():Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
|
||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||
|
||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportAward(arg1:string):Promise<string>;
|
||||
|
||||
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||
|
||||
export function ExportAwards():Promise<string>;
|
||||
|
||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||
@@ -188,8 +224,14 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||
@@ -198,6 +240,8 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetANFT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||
@@ -218,8 +262,20 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetDAX(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetLMSANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetLMSANFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetLMSNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetLMSNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMic(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||
@@ -236,6 +292,10 @@ export function FlexSetNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNRF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetNRFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetPower(arg1:number):Promise<void>;
|
||||
@@ -248,14 +308,22 @@ export function FlexSetRIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetRNN(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetSpeexNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetSpeexNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||
@@ -276,12 +344,24 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexStopCW():Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetACOMStatus():Promise<acom.Status>;
|
||||
|
||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAlertEmailTo():Promise<string>;
|
||||
|
||||
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
|
||||
|
||||
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||
|
||||
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||
|
||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||
|
||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||
@@ -290,7 +370,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||
|
||||
export function GetAward(arg1:string):Promise<award.Result>;
|
||||
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
||||
|
||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||
|
||||
@@ -314,10 +394,14 @@ export function GetCWDecoderPitch():Promise<number>;
|
||||
|
||||
export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
|
||||
|
||||
export function GetClusterAutoConnect():Promise<boolean>;
|
||||
|
||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
@@ -344,13 +428,15 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||
|
||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||
|
||||
@@ -384,12 +470,20 @@ export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
|
||||
export function GetQSORate():Promise<main.QSORate>;
|
||||
|
||||
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||
|
||||
export function GetSPEStatus():Promise<spe.Status>;
|
||||
|
||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||
|
||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||
|
||||
export function GetSelfSpotSettings():Promise<main.SelfSpotSettings>;
|
||||
|
||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
@@ -404,16 +498,32 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetTrackedAwards():Promise<Array<string>>;
|
||||
|
||||
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
|
||||
|
||||
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
|
||||
|
||||
export function GetUIPref(arg1:string):Promise<string>;
|
||||
|
||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||
|
||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||
|
||||
export function GetWebPublishConfig():Promise<webpub.Config>;
|
||||
|
||||
export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
||||
|
||||
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function GetWorkedCallVariants():Promise<boolean>;
|
||||
|
||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IcomRefresh():Promise<void>;
|
||||
@@ -502,7 +612,7 @@ export function IcomStopCW():Promise<void>;
|
||||
|
||||
export function IcomTune():Promise<void>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
@@ -510,6 +620,12 @@ export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||
|
||||
export function KenwoodSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function KenwoodStopCW():Promise<void>;
|
||||
|
||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||
|
||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||
@@ -528,6 +644,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
@@ -542,12 +660,18 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
||||
|
||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||
|
||||
export function LiveLastQSOAgeSec():Promise<number>;
|
||||
|
||||
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
|
||||
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorReadElements():Promise<Array<number>>;
|
||||
|
||||
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||
@@ -600,6 +724,14 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
||||
|
||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function PTTHotkeyDown():Promise<void>;
|
||||
|
||||
export function PTTHotkeyUp():Promise<void>;
|
||||
|
||||
export function PickADIFMonitorFile():Promise<string>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
@@ -610,6 +742,8 @@ export function PickSaveDatabase():Promise<string>;
|
||||
|
||||
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
||||
|
||||
export function PublishLogNow():Promise<string>;
|
||||
|
||||
export function QSLDefaultTemplateID():Promise<number>;
|
||||
|
||||
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
||||
@@ -648,20 +782,38 @@ export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QSOAudioManualReady():Promise<boolean>;
|
||||
|
||||
export function QSOAudioManualStart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioPlayOnAir():Promise<void>;
|
||||
|
||||
export function QSOAudioResetClock():Promise<boolean>;
|
||||
|
||||
export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QSOAudioResume():Promise<boolean>;
|
||||
|
||||
export function QSOAudioStop():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RecomputeAllAwardRefs():Promise<number>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function RefreshSolar():Promise<void>;
|
||||
|
||||
export function RefreshYaesuPanel():Promise<void>;
|
||||
|
||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameLogbook(arg1:string):Promise<void>;
|
||||
|
||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||
|
||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||
@@ -680,6 +832,8 @@ export function RestartQSORecorder():Promise<void>;
|
||||
|
||||
export function RetryOfflineSync():Promise<number>;
|
||||
|
||||
export function RevealDataFolder():Promise<void>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
@@ -688,10 +842,22 @@ export function RotatorStop():Promise<void>;
|
||||
|
||||
export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SMTPConfigured():Promise<boolean>;
|
||||
|
||||
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SPESetPower(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SPESetPowerLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||
|
||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||
|
||||
export function SaveAmplifiers(arg1:Array<main.AmpConfig>):Promise<void>;
|
||||
|
||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||
@@ -716,6 +882,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
|
||||
|
||||
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||
|
||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
@@ -734,18 +902,30 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
|
||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||
|
||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
|
||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||
|
||||
export function SaveTrackedAwards(arg1:Array<string>):Promise<void>;
|
||||
|
||||
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
|
||||
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function SaveWebPublishConfig(arg1:webpub.Config):Promise<void>;
|
||||
|
||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||
|
||||
export function ScpLookup(arg1:string):Promise<scp.Result>;
|
||||
|
||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||
|
||||
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||
@@ -756,34 +936,88 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<voi
|
||||
|
||||
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SendLogToDeveloper():Promise<void>;
|
||||
|
||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||
|
||||
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||
|
||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(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 SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):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 SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuAtt(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuBand(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuBreakIn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuMicGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuModeRaw(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuNarrow(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuPower(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuPreamp(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuRFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuSplitOffset(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuSquelch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
@@ -794,6 +1028,10 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TailLogFile(arg1:number):Promise<string>;
|
||||
|
||||
export function TestCloudlogUpload():Promise<string>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
|
||||
export function TestEQSLUpload():Promise<string>;
|
||||
@@ -812,10 +1050,26 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||
|
||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||
|
||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function TestWebPublishFTP(arg1:webpub.Config):Promise<string>;
|
||||
|
||||
export function TuneYaesuATU():Promise<void>;
|
||||
|
||||
export function TunerGeniusActivate(arg1:number):Promise<void>;
|
||||
|
||||
export function TunerGeniusAutotune():Promise<void>;
|
||||
|
||||
export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
|
||||
|
||||
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function UILog(arg1:string):Promise<void>;
|
||||
|
||||
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||
|
||||
export function UltrabeamRetract():Promise<void>;
|
||||
@@ -836,6 +1090,8 @@ export function UploadCallsign(arg1:string):Promise<string>;
|
||||
|
||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||
|
||||
export function WebPublishColumns():Promise<Array<Record<string, string>>>;
|
||||
|
||||
export function WinkeyerBackspace():Promise<void>;
|
||||
|
||||
export function WinkeyerConnect():Promise<void>;
|
||||
@@ -848,4 +1104,12 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function WinkeyerStop():Promise<void>;
|
||||
|
||||
export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function YaesuStopCW():Promise<void>;
|
||||
|
||||
export function YaesuZeroIn():Promise<void>;
|
||||
|
||||
+538
-22
@@ -2,6 +2,18 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function ACOMSetOperate(arg1) {
|
||||
return window['go']['main']['App']['ACOMSetOperate'](arg1);
|
||||
}
|
||||
|
||||
export function ACOMSetPower(arg1) {
|
||||
return window['go']['main']['App']['ACOMSetPower'](arg1);
|
||||
}
|
||||
|
||||
export function ADIFExtraFields() {
|
||||
return window['go']['main']['App']['ADIFExtraFields']();
|
||||
}
|
||||
|
||||
export function ADIFFields() {
|
||||
return window['go']['main']['App']['ADIFFields']();
|
||||
}
|
||||
@@ -18,6 +30,22 @@ export function AddQSO(arg1) {
|
||||
return window['go']['main']['App']['AddQSO'](arg1);
|
||||
}
|
||||
|
||||
export function AmpFanMode(arg1, arg2) {
|
||||
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AmpOperate(arg1, arg2) {
|
||||
return window['go']['main']['App']['AmpOperate'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AmpPower(arg1, arg2) {
|
||||
return window['go']['main']['App']['AmpPower'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AmpPowerLevel(arg1, arg2) {
|
||||
return window['go']['main']['App']['AmpPowerLevel'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AntGeniusActivate(arg1, arg2) {
|
||||
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
||||
}
|
||||
@@ -42,6 +70,10 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function AudioApplyLevels(arg1, arg2) {
|
||||
return window['go']['main']['App']['AudioApplyLevels'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AudioMonitorActive() {
|
||||
return window['go']['main']['App']['AudioMonitorActive']();
|
||||
}
|
||||
@@ -66,8 +98,8 @@ export function AudioTXActive() {
|
||||
return window['go']['main']['App']['AudioTXActive']();
|
||||
}
|
||||
|
||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||
export function AwardCellQSOs(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function AwardFields() {
|
||||
@@ -90,6 +122,10 @@ export function BackfillUSCounties() {
|
||||
return window['go']['main']['App']['BackfillUSCounties']();
|
||||
}
|
||||
|
||||
export function BandSlotQSOs(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['BandSlotQSOs'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function BrowseExecutable() {
|
||||
return window['go']['main']['App']['BrowseExecutable']();
|
||||
}
|
||||
@@ -102,10 +138,18 @@ export function BulkUpdateQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CIVTraceEnabled() {
|
||||
return window['go']['main']['App']['CIVTraceEnabled']();
|
||||
}
|
||||
|
||||
export function CWDecoderRunning() {
|
||||
return window['go']['main']['App']['CWDecoderRunning']();
|
||||
}
|
||||
|
||||
export function CancelConfirmations() {
|
||||
return window['go']['main']['App']['CancelConfirmations']();
|
||||
}
|
||||
|
||||
export function ChatAvailable() {
|
||||
return window['go']['main']['App']['ChatAvailable']();
|
||||
}
|
||||
@@ -250,10 +294,18 @@ export function DownloadAllReferenceLists() {
|
||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||
}
|
||||
|
||||
export function DownloadAndApplyUpdate(arg1) {
|
||||
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadClublogCty() {
|
||||
return window['go']['main']['App']['DownloadClublogCty']();
|
||||
}
|
||||
|
||||
export function DownloadClublogMostWanted() {
|
||||
return window['go']['main']['App']['DownloadClublogMostWanted']();
|
||||
}
|
||||
|
||||
export function DownloadConfirmations(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -262,6 +314,10 @@ export function DownloadLoTWUsers() {
|
||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||
}
|
||||
|
||||
export function DownloadScp() {
|
||||
return window['go']['main']['App']['DownloadScp']();
|
||||
}
|
||||
|
||||
export function DownloadULSCounties() {
|
||||
return window['go']['main']['App']['DownloadULSCounties']();
|
||||
}
|
||||
@@ -274,22 +330,26 @@ export function ExplainAward(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportADIF(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
||||
export function ExportADIF(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ExportADIFFiltered(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3);
|
||||
export function ExportADIFFiltered(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ExportADIFSelected(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
||||
export function ExportADIFSelected(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ExportAward(arg1) {
|
||||
return window['go']['main']['App']['ExportAward'](arg1);
|
||||
}
|
||||
|
||||
export function ExportAwardForCatalog(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportAwards() {
|
||||
return window['go']['main']['App']['ExportAwards']();
|
||||
}
|
||||
@@ -334,10 +394,22 @@ export function FlexApplyBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexApplyBandPower(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexApplyBandPower'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexBackspaceCW(arg1) {
|
||||
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||
}
|
||||
|
||||
export function FlexMox(arg1) {
|
||||
return window['go']['main']['App']['FlexMox'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSendCW(arg1) {
|
||||
return window['go']['main']['App']['FlexSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAGCMode(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||
}
|
||||
@@ -354,6 +426,10 @@ export function FlexSetANFLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetANFT(arg1) {
|
||||
return window['go']['main']['App']['FlexSetANFT'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAPF(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
||||
}
|
||||
@@ -394,10 +470,34 @@ export function FlexSetCWSpeed(arg1) {
|
||||
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetDAX(arg1) {
|
||||
return window['go']['main']['App']['FlexSetDAX'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetFilter(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexSetKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetLMSANF(arg1) {
|
||||
return window['go']['main']['App']['FlexSetLMSANF'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetLMSANFLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetLMSANFLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetLMSNR(arg1) {
|
||||
return window['go']['main']['App']['FlexSetLMSNR'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetLMSNRLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetLMSNRLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetMic(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||
}
|
||||
@@ -430,6 +530,14 @@ export function FlexSetNR(arg1) {
|
||||
return window['go']['main']['App']['FlexSetNR'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetNRF(arg1) {
|
||||
return window['go']['main']['App']['FlexSetNRF'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetNRFLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetNRFLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetNRLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
||||
}
|
||||
@@ -454,6 +562,10 @@ export function FlexSetRITFreq(arg1) {
|
||||
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetRNN(arg1) {
|
||||
return window['go']['main']['App']['FlexSetRNN'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetRXAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||
}
|
||||
@@ -462,6 +574,14 @@ export function FlexSetSidetoneLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetSpeexNR(arg1) {
|
||||
return window['go']['main']['App']['FlexSetSpeexNR'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetSpeexNRLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetSpeexNRLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetSplit(arg1) {
|
||||
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
||||
}
|
||||
@@ -470,6 +590,10 @@ export function FlexSetTXAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetTXDAX(arg1) {
|
||||
return window['go']['main']['App']['FlexSetTXDAX'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetTXFilter(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
||||
}
|
||||
@@ -510,10 +634,22 @@ export function FlexSetXITFreq(arg1) {
|
||||
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||
}
|
||||
|
||||
export function FlexStopCW() {
|
||||
return window['go']['main']['App']['FlexStopCW']();
|
||||
}
|
||||
|
||||
export function FlexTune(arg1) {
|
||||
return window['go']['main']['App']['FlexTune'](arg1);
|
||||
}
|
||||
|
||||
export function GetACOMStatus() {
|
||||
return window['go']['main']['App']['GetACOMStatus']();
|
||||
}
|
||||
|
||||
export function GetADIFMonitor() {
|
||||
return window['go']['main']['App']['GetADIFMonitor']();
|
||||
}
|
||||
|
||||
export function GetActiveProfile() {
|
||||
return window['go']['main']['App']['GetActiveProfile']();
|
||||
}
|
||||
@@ -522,6 +658,18 @@ export function GetAlertEmailTo() {
|
||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||
}
|
||||
|
||||
export function GetAmpBandFollowStatus() {
|
||||
return window['go']['main']['App']['GetAmpBandFollowStatus']();
|
||||
}
|
||||
|
||||
export function GetAmpStatuses() {
|
||||
return window['go']['main']['App']['GetAmpStatuses']();
|
||||
}
|
||||
|
||||
export function GetAmplifiers() {
|
||||
return window['go']['main']['App']['GetAmplifiers']();
|
||||
}
|
||||
|
||||
export function GetAntGeniusSettings() {
|
||||
return window['go']['main']['App']['GetAntGeniusSettings']();
|
||||
}
|
||||
@@ -538,8 +686,8 @@ export function GetAutostartPrograms() {
|
||||
return window['go']['main']['App']['GetAutostartPrograms']();
|
||||
}
|
||||
|
||||
export function GetAward(arg1) {
|
||||
return window['go']['main']['App']['GetAward'](arg1);
|
||||
export function GetAward(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetAward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetAwardDefs() {
|
||||
@@ -586,6 +734,10 @@ export function GetCatalogCodes() {
|
||||
return window['go']['main']['App']['GetCatalogCodes']();
|
||||
}
|
||||
|
||||
export function GetChangelog() {
|
||||
return window['go']['main']['App']['GetChangelog']();
|
||||
}
|
||||
|
||||
export function GetChatHistory(arg1) {
|
||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||
}
|
||||
@@ -594,6 +746,10 @@ export function GetClublogCtyInfo() {
|
||||
return window['go']['main']['App']['GetClublogCtyInfo']();
|
||||
}
|
||||
|
||||
export function GetClublogMostWantedInfo() {
|
||||
return window['go']['main']['App']['GetClublogMostWantedInfo']();
|
||||
}
|
||||
|
||||
export function GetClusterAutoConnect() {
|
||||
return window['go']['main']['App']['GetClusterAutoConnect']();
|
||||
}
|
||||
@@ -646,6 +802,10 @@ export function GetFlexBandAntennas() {
|
||||
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||
}
|
||||
|
||||
export function GetFlexBandPower() {
|
||||
return window['go']['main']['App']['GetFlexBandPower']();
|
||||
}
|
||||
|
||||
export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
@@ -658,8 +818,8 @@ export function GetListsSettings() {
|
||||
return window['go']['main']['App']['GetListsSettings']();
|
||||
}
|
||||
|
||||
export function GetLiveStatusEnabled() {
|
||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||
export function GetLiveStations() {
|
||||
return window['go']['main']['App']['GetLiveStations']();
|
||||
}
|
||||
|
||||
export function GetLoTWUsersStatus() {
|
||||
@@ -726,18 +886,34 @@ export function GetQSORate() {
|
||||
return window['go']['main']['App']['GetQSORate']();
|
||||
}
|
||||
|
||||
export function GetRelayAuto() {
|
||||
return window['go']['main']['App']['GetRelayAuto']();
|
||||
}
|
||||
|
||||
export function GetRotatorHeading() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
|
||||
export function GetRotatorSettings() {
|
||||
return window['go']['main']['App']['GetRotatorSettings']();
|
||||
export function GetRotators() {
|
||||
return window['go']['main']['App']['GetRotators']();
|
||||
}
|
||||
|
||||
export function GetSPEStatus() {
|
||||
return window['go']['main']['App']['GetSPEStatus']();
|
||||
}
|
||||
|
||||
export function GetScpStatus() {
|
||||
return window['go']['main']['App']['GetScpStatus']();
|
||||
}
|
||||
|
||||
export function GetSecretStatus() {
|
||||
return window['go']['main']['App']['GetSecretStatus']();
|
||||
}
|
||||
|
||||
export function GetSelfSpotSettings() {
|
||||
return window['go']['main']['App']['GetSelfSpotSettings']();
|
||||
}
|
||||
|
||||
export function GetSlotStats() {
|
||||
return window['go']['main']['App']['GetSlotStats']();
|
||||
}
|
||||
@@ -766,6 +942,18 @@ export function GetTelemetryEnabled() {
|
||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||
}
|
||||
|
||||
export function GetTrackedAwards() {
|
||||
return window['go']['main']['App']['GetTrackedAwards']();
|
||||
}
|
||||
|
||||
export function GetTunerGeniusSettings() {
|
||||
return window['go']['main']['App']['GetTunerGeniusSettings']();
|
||||
}
|
||||
|
||||
export function GetTunerGeniusStatus() {
|
||||
return window['go']['main']['App']['GetTunerGeniusStatus']();
|
||||
}
|
||||
|
||||
export function GetUIPref(arg1) {
|
||||
return window['go']['main']['App']['GetUIPref'](arg1);
|
||||
}
|
||||
@@ -778,6 +966,18 @@ export function GetUltrabeamStatus() {
|
||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||
}
|
||||
|
||||
export function GetWebPublishConfig() {
|
||||
return window['go']['main']['App']['GetWebPublishConfig']();
|
||||
}
|
||||
|
||||
export function GetWebPublishStatus() {
|
||||
return window['go']['main']['App']['GetWebPublishStatus']();
|
||||
}
|
||||
|
||||
export function GetWhatsNew() {
|
||||
return window['go']['main']['App']['GetWhatsNew']();
|
||||
}
|
||||
|
||||
export function GetWinkeyerSettings() {
|
||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||
}
|
||||
@@ -786,6 +986,14 @@ export function GetWinkeyerStatus() {
|
||||
return window['go']['main']['App']['GetWinkeyerStatus']();
|
||||
}
|
||||
|
||||
export function GetWorkedCallVariants() {
|
||||
return window['go']['main']['App']['GetWorkedCallVariants']();
|
||||
}
|
||||
|
||||
export function GetYaesuState() {
|
||||
return window['go']['main']['App']['GetYaesuState']();
|
||||
}
|
||||
|
||||
export function HasBuiltinReferences(arg1) {
|
||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||
}
|
||||
@@ -962,8 +1170,8 @@ export function IcomTune() {
|
||||
return window['go']['main']['App']['IcomTune']();
|
||||
}
|
||||
|
||||
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||
export function ImportADIF(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function ImportAwardReferencesText(arg1, arg2) {
|
||||
@@ -978,6 +1186,18 @@ export function InspectAwardImport() {
|
||||
return window['go']['main']['App']['InspectAwardImport']();
|
||||
}
|
||||
|
||||
export function IsNewUSCounty(arg1, arg2) {
|
||||
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function KenwoodSendCW(arg1) {
|
||||
return window['go']['main']['App']['KenwoodSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function KenwoodStopCW() {
|
||||
return window['go']['main']['App']['KenwoodStopCW']();
|
||||
}
|
||||
|
||||
export function LaunchAutostartProgram(arg1) {
|
||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||
}
|
||||
@@ -1014,6 +1234,10 @@ export function ListCountries() {
|
||||
return window['go']['main']['App']['ListCountries']();
|
||||
}
|
||||
|
||||
export function ListDenkoviDevices() {
|
||||
return window['go']['main']['App']['ListDenkoviDevices']();
|
||||
}
|
||||
|
||||
export function ListOperatingTree() {
|
||||
return window['go']['main']['App']['ListOperatingTree']();
|
||||
}
|
||||
@@ -1042,6 +1266,10 @@ export function ListUDPIntegrations() {
|
||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||
}
|
||||
|
||||
export function LiveLastQSOAgeSec() {
|
||||
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||
}
|
||||
|
||||
export function LoTWUserInfo(arg1) {
|
||||
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||
}
|
||||
@@ -1050,10 +1278,18 @@ export function LogUDPLoggedADIF(arg1) {
|
||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||
}
|
||||
|
||||
export function LogUIError(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function LookupCallsign(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function LookupCallsignFresh(arg1) {
|
||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
|
||||
}
|
||||
|
||||
export function MotorReadElements() {
|
||||
return window['go']['main']['App']['MotorReadElements']();
|
||||
}
|
||||
@@ -1158,6 +1394,22 @@ export function PGXLSetFanMode(arg1) {
|
||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||
}
|
||||
|
||||
export function PGXLSetOperate(arg1) {
|
||||
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||
}
|
||||
|
||||
export function PTTHotkeyDown() {
|
||||
return window['go']['main']['App']['PTTHotkeyDown']();
|
||||
}
|
||||
|
||||
export function PTTHotkeyUp() {
|
||||
return window['go']['main']['App']['PTTHotkeyUp']();
|
||||
}
|
||||
|
||||
export function PickADIFMonitorFile() {
|
||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||
}
|
||||
|
||||
export function PickAudioFolder() {
|
||||
return window['go']['main']['App']['PickAudioFolder']();
|
||||
}
|
||||
@@ -1178,6 +1430,10 @@ export function PopulateBuiltinReferences(arg1) {
|
||||
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
|
||||
}
|
||||
|
||||
export function PublishLogNow() {
|
||||
return window['go']['main']['App']['PublishLogNow']();
|
||||
}
|
||||
|
||||
export function QSLDefaultTemplateID() {
|
||||
return window['go']['main']['App']['QSLDefaultTemplateID']();
|
||||
}
|
||||
@@ -1254,6 +1510,18 @@ export function QSOAudioCancel() {
|
||||
return window['go']['main']['App']['QSOAudioCancel']();
|
||||
}
|
||||
|
||||
export function QSOAudioManualReady() {
|
||||
return window['go']['main']['App']['QSOAudioManualReady']();
|
||||
}
|
||||
|
||||
export function QSOAudioManualStart() {
|
||||
return window['go']['main']['App']['QSOAudioManualStart']();
|
||||
}
|
||||
|
||||
export function QSOAudioPlayOnAir() {
|
||||
return window['go']['main']['App']['QSOAudioPlayOnAir']();
|
||||
}
|
||||
|
||||
export function QSOAudioResetClock() {
|
||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||
}
|
||||
@@ -1262,10 +1530,22 @@ export function QSOAudioRestart() {
|
||||
return window['go']['main']['App']['QSOAudioRestart']();
|
||||
}
|
||||
|
||||
export function QSOAudioResume() {
|
||||
return window['go']['main']['App']['QSOAudioResume']();
|
||||
}
|
||||
|
||||
export function QSOAudioStop() {
|
||||
return window['go']['main']['App']['QSOAudioStop']();
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
|
||||
export function RecomputeAllAwardRefs() {
|
||||
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||
}
|
||||
|
||||
export function RefreshCtyDat() {
|
||||
return window['go']['main']['App']['RefreshCtyDat']();
|
||||
}
|
||||
@@ -1274,6 +1554,10 @@ export function RefreshSolar() {
|
||||
return window['go']['main']['App']['RefreshSolar']();
|
||||
}
|
||||
|
||||
export function RefreshYaesuPanel() {
|
||||
return window['go']['main']['App']['RefreshYaesuPanel']();
|
||||
}
|
||||
|
||||
export function ReloadUDPIntegrations() {
|
||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||
}
|
||||
@@ -1282,6 +1566,14 @@ export function RemovePassphrase(arg1) {
|
||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function RenameDatabase(arg1) {
|
||||
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function RenameLogbook(arg1) {
|
||||
return window['go']['main']['App']['RenameLogbook'](arg1);
|
||||
}
|
||||
|
||||
export function RenderEQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||
}
|
||||
@@ -1318,6 +1610,10 @@ export function RetryOfflineSync() {
|
||||
return window['go']['main']['App']['RetryOfflineSync']();
|
||||
}
|
||||
|
||||
export function RevealDataFolder() {
|
||||
return window['go']['main']['App']['RevealDataFolder']();
|
||||
}
|
||||
|
||||
export function RotatorGoTo(arg1, arg2) {
|
||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||
}
|
||||
@@ -1334,14 +1630,38 @@ export function RunBackupNow() {
|
||||
return window['go']['main']['App']['RunBackupNow']();
|
||||
}
|
||||
|
||||
export function SMTPConfigured() {
|
||||
return window['go']['main']['App']['SMTPConfigured']();
|
||||
}
|
||||
|
||||
export function SPESetOperate(arg1) {
|
||||
return window['go']['main']['App']['SPESetOperate'](arg1);
|
||||
}
|
||||
|
||||
export function SPESetPower(arg1) {
|
||||
return window['go']['main']['App']['SPESetPower'](arg1);
|
||||
}
|
||||
|
||||
export function SPESetPowerLevel(arg1) {
|
||||
return window['go']['main']['App']['SPESetPowerLevel'](arg1);
|
||||
}
|
||||
|
||||
export function SaveADIFFile() {
|
||||
return window['go']['main']['App']['SaveADIFFile']();
|
||||
}
|
||||
|
||||
export function SaveADIFMonitor(arg1) {
|
||||
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAlertRule(arg1) {
|
||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAmplifiers(arg1) {
|
||||
return window['go']['main']['App']['SaveAmplifiers'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAntGeniusSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
||||
}
|
||||
@@ -1390,6 +1710,10 @@ export function SaveFlexBandAntennas(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexBandPower(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||
}
|
||||
|
||||
export function SaveListsSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||
}
|
||||
@@ -1426,8 +1750,16 @@ export function SaveQSLDefaults(arg1) {
|
||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotatorSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||
export function SaveRelayAuto(arg1) {
|
||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotators(arg1) {
|
||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||
}
|
||||
|
||||
export function SaveSelfSpotSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveStationDevices(arg1) {
|
||||
@@ -1438,6 +1770,14 @@ export function SaveStationSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveTrackedAwards(arg1) {
|
||||
return window['go']['main']['App']['SaveTrackedAwards'](arg1);
|
||||
}
|
||||
|
||||
export function SaveTunerGeniusSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveUDPIntegration(arg1) {
|
||||
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
||||
}
|
||||
@@ -1446,10 +1786,18 @@ export function SaveUltrabeamSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveUltrabeamSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveWebPublishConfig(arg1) {
|
||||
return window['go']['main']['App']['SaveWebPublishConfig'](arg1);
|
||||
}
|
||||
|
||||
export function SaveWinkeyerSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
||||
}
|
||||
|
||||
export function ScpLookup(arg1) {
|
||||
return window['go']['main']['App']['ScpLookup'](arg1);
|
||||
}
|
||||
|
||||
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -1470,10 +1818,18 @@ export function SendEQSL(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SendLogToDeveloper() {
|
||||
return window['go']['main']['App']['SendLogToDeveloper']();
|
||||
}
|
||||
|
||||
export function SendQSORecordingEmail(arg1) {
|
||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||
}
|
||||
|
||||
export function SetActiveRotor(arg1) {
|
||||
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||
}
|
||||
|
||||
export function SetAlertEmailTo(arg1) {
|
||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||
}
|
||||
@@ -1486,6 +1842,10 @@ export function SetCATMode(arg1) {
|
||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetCIVTrace(arg1) {
|
||||
return window['go']['main']['App']['SetCIVTrace'](arg1);
|
||||
}
|
||||
|
||||
export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
@@ -1494,6 +1854,10 @@ export function SetClublogCtyEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetClusterAutoConnect(arg1) {
|
||||
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||
}
|
||||
@@ -1506,14 +1870,22 @@ export function SetDVKLabel(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetLiveStatusEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
|
||||
export function SetKenwoodKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function SetOpsLogQSLReceived(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function SetScpEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetTelemetryEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||
}
|
||||
@@ -1526,6 +1898,90 @@ export function SetUltrabeamDirection(arg1) {
|
||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||
}
|
||||
|
||||
export function SetWinkeyerTrace(arg1) {
|
||||
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
|
||||
}
|
||||
|
||||
export function SetWorkedCallVariants(arg1) {
|
||||
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAFGain(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAGC(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAGC'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAtt(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAtt'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuBand(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuBand'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuBreakIn(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuBreakIn'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuMicGain(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuMicGain'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuModeRaw(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuModeRaw'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuNB(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuNB'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuNR(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuNR'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuNRLevel(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuNRLevel'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuNarrow(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuNarrow'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuPower(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuPower'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuPreamp(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuPreamp'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuRFGain(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuRFGain'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuSplit(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuSplit'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuSplitOffset(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuSplitOffset'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuSquelch(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuSquelch'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuVOX(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuVOX'](arg1);
|
||||
}
|
||||
|
||||
export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
@@ -1546,6 +2002,14 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TailLogFile(arg1) {
|
||||
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||
}
|
||||
|
||||
export function TestCloudlogUpload() {
|
||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||
}
|
||||
|
||||
export function TestClublogUpload() {
|
||||
return window['go']['main']['App']['TestClublogUpload']();
|
||||
}
|
||||
@@ -1582,14 +2046,46 @@ export function TestQRZUpload() {
|
||||
return window['go']['main']['App']['TestQRZUpload']();
|
||||
}
|
||||
|
||||
export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
export function TestRotatorDevice(arg1, arg2) {
|
||||
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TestStationDevice(arg1) {
|
||||
return window['go']['main']['App']['TestStationDevice'](arg1);
|
||||
}
|
||||
|
||||
export function TestUltrabeam(arg1) {
|
||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||
}
|
||||
|
||||
export function TestWebPublishFTP(arg1) {
|
||||
return window['go']['main']['App']['TestWebPublishFTP'](arg1);
|
||||
}
|
||||
|
||||
export function TuneYaesuATU() {
|
||||
return window['go']['main']['App']['TuneYaesuATU']();
|
||||
}
|
||||
|
||||
export function TunerGeniusActivate(arg1) {
|
||||
return window['go']['main']['App']['TunerGeniusActivate'](arg1);
|
||||
}
|
||||
|
||||
export function TunerGeniusAutotune() {
|
||||
return window['go']['main']['App']['TunerGeniusAutotune']();
|
||||
}
|
||||
|
||||
export function TunerGeniusSetBypass(arg1) {
|
||||
return window['go']['main']['App']['TunerGeniusSetBypass'](arg1);
|
||||
}
|
||||
|
||||
export function TunerGeniusSetOperate(arg1) {
|
||||
return window['go']['main']['App']['TunerGeniusSetOperate'](arg1);
|
||||
}
|
||||
|
||||
export function UILog(arg1) {
|
||||
return window['go']['main']['App']['UILog'](arg1);
|
||||
}
|
||||
|
||||
export function ULSStatus() {
|
||||
return window['go']['main']['App']['ULSStatus']();
|
||||
}
|
||||
@@ -1630,6 +2126,10 @@ export function UploadQSOsManual(arg1, arg2) {
|
||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function WebPublishColumns() {
|
||||
return window['go']['main']['App']['WebPublishColumns']();
|
||||
}
|
||||
|
||||
export function WinkeyerBackspace() {
|
||||
return window['go']['main']['App']['WinkeyerBackspace']();
|
||||
}
|
||||
@@ -1654,6 +2154,22 @@ export function WinkeyerStop() {
|
||||
return window['go']['main']['App']['WinkeyerStop']();
|
||||
}
|
||||
|
||||
export function WinkeyerTraceEnabled() {
|
||||
return window['go']['main']['App']['WinkeyerTraceEnabled']();
|
||||
}
|
||||
|
||||
export function WorkedBefore(arg1, arg2) {
|
||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function YaesuSendCW(arg1) {
|
||||
return window['go']['main']['App']['YaesuSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function YaesuStopCW() {
|
||||
return window['go']['main']['App']['YaesuStopCW']();
|
||||
}
|
||||
|
||||
export function YaesuZeroIn() {
|
||||
return window['go']['main']['App']['YaesuZeroIn']();
|
||||
}
|
||||
|
||||
+891
-12
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ require (
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jlaffaye/ftp v0.2.2
|
||||
github.com/moutend/go-wca v0.3.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/wneessen/go-mail v0.7.3
|
||||
|
||||
@@ -25,6 +25,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/jlaffaye/ftp v0.2.2 h1:JwjrXCAIjN9ZYrF1/8qlmHFXDteh9MHYaiEIh/Oqtd8=
|
||||
github.com/jlaffaye/ftp v0.2.2/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
@@ -64,8 +66,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// The Icom model picker in the settings and civ.ModelName are two hand-kept
|
||||
// copies of the same table, and they had drifted: the UI offered the IC-7700 at
|
||||
// 0x88 and the IC-7800 at 0x80, which are the IC-7100's and the IC-7410's
|
||||
// factory addresses. Picking either set an address the rig never answers on —
|
||||
// the symptom is a rig that simply never replies — and the backend then named it
|
||||
// as the other model.
|
||||
//
|
||||
// The list is read out of the .tsx itself, so a model added on one side alone
|
||||
// fails here rather than on someone's radio.
|
||||
func TestIcomModelAddressesMatch(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/SettingsModal.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
re := regexp.MustCompile(`\{ name: '(IC-[A-Za-z0-9-]+)', addr: 0x([0-9A-Fa-f]{2}) \}`)
|
||||
ms := re.FindAllStringSubmatch(string(src), -1)
|
||||
if len(ms) < 5 {
|
||||
t.Fatalf("only %d models found — the parse is wrong, not the data", len(ms))
|
||||
}
|
||||
for _, m := range ms {
|
||||
name := m[1]
|
||||
addr, err := strconv.ParseUint(m[2], 16, 8)
|
||||
if err != nil {
|
||||
t.Fatalf("bad address %q for %s", m[2], name)
|
||||
}
|
||||
if got := civ.ModelName(byte(addr)); got != name {
|
||||
t.Errorf("settings offer %s at 0x%02X, but civ.ModelName(0x%02X) = %q",
|
||||
name, addr, addr, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// The confirmation statuses an import fills in, and the ones it must not touch.
|
||||
//
|
||||
// An operator asked for this feature believing it missing — it has been there
|
||||
// since v0.14, but the option's description never mentioned it, so nobody could
|
||||
// know. The behaviour is pinned here so a future edit cannot quietly drop what
|
||||
// the description now promises.
|
||||
//
|
||||
// The rule is fill-if-empty: a WSJT-X log carries almost no QSL fields, while a
|
||||
// log exported from another program carries real ones that must survive.
|
||||
func TestQSLDefaultsFillOnlyEmptyFields(t *testing.T) {
|
||||
defaults := QSLDefaults{
|
||||
QSLSent: "N", QSLRcvd: "N",
|
||||
LOTWSent: "N", LOTWRcvd: "N",
|
||||
EQSLSent: "N", EQSLRcvd: "N",
|
||||
ClublogStatus: "N", HRDLogStatus: "N",
|
||||
QRZComStatus: "N", QRZComCfm: "N",
|
||||
}
|
||||
|
||||
// A record as WSJT-X writes it: no confirmation fields at all.
|
||||
bare := &qso.QSO{Callsign: "F4XYZ"}
|
||||
applyQSLDefaultsTo(bare, defaults)
|
||||
for name, got := range map[string]string{
|
||||
"qsl_sent": bare.QSLSent, "qsl_rcvd": bare.QSLRcvd,
|
||||
"lotw_sent": bare.LOTWSent, "lotw_rcvd": bare.LOTWRcvd,
|
||||
"eqsl_sent": bare.EQSLSent, "eqsl_rcvd": bare.EQSLRcvd,
|
||||
"clublog": bare.ClublogUploadStatus, "hrdlog": bare.HRDLogUploadStatus,
|
||||
"qrz_sent": bare.QRZComUploadStatus, "qrz_rcvd": bare.QRZComDownloadStatus,
|
||||
} {
|
||||
if got != "N" {
|
||||
t.Errorf("%s = %q on an empty record, want the default \"N\"", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A record that already carries confirmations — from a real logbook export.
|
||||
// Overwriting these would erase QSLs the operator has actually received.
|
||||
kept := &qso.QSO{
|
||||
Callsign: "F4XYZ",
|
||||
QSLRcvd: "Y", LOTWRcvd: "Y", EQSLSent: "Y",
|
||||
ClublogUploadStatus: "Y", QRZComDownloadStatus: "Y",
|
||||
}
|
||||
applyQSLDefaultsTo(kept, defaults)
|
||||
for name, got := range map[string]string{
|
||||
"qsl_rcvd": kept.QSLRcvd, "lotw_rcvd": kept.LOTWRcvd, "eqsl_sent": kept.EQSLSent,
|
||||
"clublog": kept.ClublogUploadStatus, "qrz_rcvd": kept.QRZComDownloadStatus,
|
||||
} {
|
||||
if got != "Y" {
|
||||
t.Errorf("%s = %q — an existing confirmation was overwritten", name, got)
|
||||
}
|
||||
}
|
||||
// …while its empty ones still get the default.
|
||||
if kept.QSLSent != "N" || kept.LOTWSent != "N" {
|
||||
t.Errorf("empty fields on a partly-filled record were not defaulted: qsl_sent=%q lotw_sent=%q",
|
||||
kept.QSLSent, kept.LOTWSent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
|
||||
|
||||
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
|
||||
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
|
||||
cannot be started over the serial link, and the attempt to work around that was
|
||||
abandoned.**
|
||||
|
||||
## The tuner is PA state 8
|
||||
|
||||
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
|
||||
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
|
||||
twice, front-panel TUNE, with the byte-level trace running:
|
||||
|
||||
```
|
||||
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
|
||||
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
|
||||
14:47:24.006 state 8 step 2
|
||||
14:47:25.201 state 8 step 4
|
||||
14:47:27.457 state 8 step 5
|
||||
14:47:30.092 state 5 STANDBY ← accord finished
|
||||
```
|
||||
|
||||
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
|
||||
Step 0 means *armed, waiting for RF* — in the first of the two runs the
|
||||
amplifier sat there **30 seconds** untouched before the operator transmitted.
|
||||
|
||||
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
|
||||
|
||||
## The command does not exist in the state-command family
|
||||
|
||||
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
|
||||
**verified** STANDBY (an earlier sweep was worthless because its second code
|
||||
parked the amp in SERVICE, where it silently refuses everything — 17 false
|
||||
negatives). Result:
|
||||
|
||||
| byte | effect |
|
||||
|------|--------|
|
||||
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
|
||||
| 0x04 | → SERVICE (state 4) |
|
||||
| 0x05 | → STANDBY (state 5) |
|
||||
| 0x06 | → OPERATE (state 6) |
|
||||
| 0x0A | → OFF (state 10) |
|
||||
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
|
||||
|
||||
The action byte **is** the target PA state — four independent confirmations.
|
||||
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
|
||||
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
|
||||
|
||||
Going further would mean varying the other bytes of the frame. That is a large
|
||||
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
|
||||
it.
|
||||
|
||||
## The workaround that was built and removed
|
||||
|
||||
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
|
||||
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
|
||||
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
|
||||
Settings → Amplifier) and **removed** after the first hardware test:
|
||||
|
||||
- the power change did not take effect, and
|
||||
- **the PTT was not released when the tune finished.**
|
||||
|
||||
A logger that can leave a transceiver keyed is not acceptable, and the whole
|
||||
thing was scaffolding around a protocol we do not actually control. Removed at
|
||||
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
|
||||
bidouille."*
|
||||
|
||||
If it is ever revisited, the unexplained part is why the tune-finished detection
|
||||
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
|
||||
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
|
||||
|
||||
## Loose ends worth knowing
|
||||
|
||||
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
|
||||
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
|
||||
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
|
||||
tuner flag for a while purely because of this — it toggles on its own several
|
||||
times a minute. Do not read tail bytes without checking byte 6.
|
||||
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
|
||||
not a command acknowledgement.
|
||||
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
|
||||
tune and during transmission.** `errText` renders those as "ERROR — see
|
||||
display" and "Remove drive power", so an ACOM operator probably sees a false
|
||||
alarm. This was never confirmed with the amplifier in OPERATE (it was in
|
||||
STANDBY throughout, where drive genuinely is an error) — worth a look with a
|
||||
clean log before touching the table.
|
||||
@@ -0,0 +1,509 @@
|
||||
// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S /
|
||||
// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by
|
||||
// the ACOM-Controller project (bjornekelund, C#). The amp is reached either
|
||||
// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet
|
||||
// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE
|
||||
// backend.
|
||||
//
|
||||
// Wire format (host → amp): fixed raw byte strings, validated by the same rule as
|
||||
// telemetry (sum of ALL frame bytes ≡ 0 mod 256):
|
||||
//
|
||||
// enable telemetry: 55 92 04 15
|
||||
// disable telemetry: 55 91 04 16
|
||||
// OPERATE: 55 81 08 02 00 06 00 1A
|
||||
// STANDBY: 55 81 08 02 00 05 00 1B
|
||||
// OFF (power down): 55 81 08 02 00 0A 00 16
|
||||
//
|
||||
// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS
|
||||
// lines (the amp's remote power-on pins) — serial transport only, and only if the
|
||||
// cable wires those pins (a telemetry-only cable uses just RX/TX/GND).
|
||||
//
|
||||
// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them
|
||||
// permanently blocks the amplifier's front-panel power button.
|
||||
//
|
||||
// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting
|
||||
// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are
|
||||
// decoded in decodeFrame below.
|
||||
package acom
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdEnableTelemetry = []byte{0x55, 0x92, 0x04, 0x15}
|
||||
cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16}
|
||||
cmdOperate = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A}
|
||||
cmdStandby = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B}
|
||||
cmdOff = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16}
|
||||
)
|
||||
|
||||
const (
|
||||
frameLen = 72
|
||||
sync0 = 0x55
|
||||
sync1 = 0x2F
|
||||
dialTimeout = 5 * time.Second
|
||||
ioTimeout = 3 * time.Second
|
||||
// The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid
|
||||
// arrives for this long the link (or the amp) is considered down.
|
||||
staleAfter = 5 * time.Second
|
||||
)
|
||||
|
||||
// model carries the per-model constants: the temperature word offset and the
|
||||
// nominal/max forward power (for UI bar scaling).
|
||||
type model struct {
|
||||
Name string
|
||||
TempOffset int
|
||||
NominalW int
|
||||
MaxW int
|
||||
}
|
||||
|
||||
var models = map[string]model{
|
||||
"500S": {"500S", 282, 500, 600},
|
||||
"600S": {"600S", 273, 600, 700},
|
||||
"700S": {"700S", 282, 700, 800},
|
||||
"1200S": {"1200S", 281, 1200, 1400},
|
||||
"2020S": {"2020S", 282, 1800, 2000},
|
||||
}
|
||||
|
||||
// paStatusNames maps the PAstatus nibble to a display string.
|
||||
// 8 is absent from the state list the ACOM-Controller project recovered; it is
|
||||
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
|
||||
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
|
||||
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
|
||||
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
|
||||
var paStatusNames = map[int]string{
|
||||
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
||||
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
|
||||
}
|
||||
|
||||
// acomBands maps the band nibble to a band label.
|
||||
var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"}
|
||||
|
||||
// errText translates the error code (frame byte 66) shown on the amp's display.
|
||||
// 0xFF means NO error — everything else, including 0x00, is a fault/warning.
|
||||
func errText(code int) string {
|
||||
switch code {
|
||||
case 0xFF:
|
||||
return ""
|
||||
case 0x00, 0x08:
|
||||
return "Hot switching"
|
||||
case 0x03:
|
||||
return "Drive power at wrong time"
|
||||
case 0x04, 0x05:
|
||||
return "Reflected power warning"
|
||||
case 0x06, 0x07:
|
||||
return "Drive power too high"
|
||||
case 0x0C:
|
||||
return "RF power at wrong time"
|
||||
case 0x0E:
|
||||
return "Stop transmission first"
|
||||
case 0x0F:
|
||||
return "Remove drive power"
|
||||
case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59:
|
||||
return "Excessive PAM current"
|
||||
case 0x70:
|
||||
return "CAT error"
|
||||
default:
|
||||
return "ERROR — see display"
|
||||
}
|
||||
}
|
||||
|
||||
// Status is the decoded amplifier state for the UI.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"` // valid telemetry is flowing
|
||||
PortOpen bool `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off
|
||||
Transport string `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line)
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
State string `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / …
|
||||
Operate bool `json:"operate"` // RECEIVE or TRANSMIT (vs STANDBY)
|
||||
TX bool `json:"tx"`
|
||||
FwdW int `json:"fwd_w"` // forward/output power
|
||||
ReflW int `json:"refl_w"` // reflected power
|
||||
SWR float64 `json:"swr"`
|
||||
DriveW int `json:"drive_w"`
|
||||
DCPowerW int `json:"dc_w"`
|
||||
TempC int `json:"temp_c"` // PA temperature
|
||||
Band string `json:"band,omitempty"`
|
||||
FanLevel int `json:"fan"` // 1..4
|
||||
ErrCode int `json:"err_code"` // raw code from the frame; 0xFF = none
|
||||
ErrText string `json:"err_text,omitempty"` // human message; empty = no error
|
||||
NominalW int `json:"nominal_w"`
|
||||
MaxW int `json:"max_w"`
|
||||
}
|
||||
|
||||
// Config selects the transport and amplifier model.
|
||||
type Config struct {
|
||||
Model string // "500S" | "600S" | "700S" | "1200S" | "2020S"
|
||||
Transport string // "serial" | "tcp"
|
||||
ComPort string // serial
|
||||
Baud int // serial (the amp is fixed 9600 8N1)
|
||||
Host string // tcp (RS232-to-Ethernet bridge)
|
||||
Port int // tcp
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
mdl model
|
||||
|
||||
mu sync.Mutex // serialises access to the connection
|
||||
conn io.ReadWriteCloser
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
|
||||
// Diagnostics: raw byte count + first-bytes capture, so a hardware session log
|
||||
// tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid
|
||||
// frame" (framing/checksum) without a serial sniffer.
|
||||
rawSeen int64
|
||||
dbgBytes []byte
|
||||
dbgLogged bool
|
||||
ckFails int
|
||||
frameLogged bool
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.Baud <= 0 {
|
||||
cfg.Baud = 9600
|
||||
}
|
||||
mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))]
|
||||
if !ok {
|
||||
mdl = models["700S"]
|
||||
}
|
||||
c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})}
|
||||
c.status.Model = mdl.Name
|
||||
c.status.Transport = cfg.Transport
|
||||
c.status.NominalW = mdl.NominalW
|
||||
c.status.MaxW = mdl.MaxW
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
c.running = true
|
||||
go c.readLoop()
|
||||
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.Write(cmdDisableTelemetry) // best effort: stop the stream
|
||||
}
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() Status {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
return c.status
|
||||
}
|
||||
|
||||
func (c *Client) setErr(msg string) {
|
||||
c.statusMu.Lock()
|
||||
c.status.Connected = false
|
||||
c.status.LastError = msg
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's
|
||||
// single toggle key, the ACOM protocol has explicit commands for each state.
|
||||
func (c *Client) Operate(on bool) error {
|
||||
if on {
|
||||
return c.send(cmdOperate)
|
||||
}
|
||||
return c.send(cmdStandby)
|
||||
}
|
||||
|
||||
// PowerOff sends the power-down command (same as pressing OFF on the amp).
|
||||
func (c *Client) PowerOff() error { return c.send(cmdOff) }
|
||||
|
||||
// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired
|
||||
// like a press of the front-panel power button. Hardware line ⇒ serial transport
|
||||
// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have
|
||||
// those pins connected. Pulse length to be confirmed on real hardware.
|
||||
func (c *Client) PowerOn() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
sp, ok := c.conn.(serial.Port)
|
||||
if !ok {
|
||||
return fmt.Errorf("power-on needs the serial DTR/RTS lines — not available over a network bridge")
|
||||
}
|
||||
if err := sp.SetDTR(true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sp.SetRTS(true); err != nil {
|
||||
_ = sp.SetDTR(false)
|
||||
return err
|
||||
}
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
err1 := sp.SetDTR(false)
|
||||
err2 := sp.SetRTS(false)
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
return err2
|
||||
}
|
||||
|
||||
func (c *Client) send(cmd []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
if nc, ok := c.conn.(net.Conn); ok {
|
||||
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||
}
|
||||
_, err := c.conn.Write(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// readLoop keeps the link up and consumes the telemetry stream. When no valid
|
||||
// frame arrives for a while it re-arms telemetry (the amp forgets the enable
|
||||
// across a power cycle) and, on TCP, reconnects. A serial port is kept open even
|
||||
// with the amp off, so the DTR power-on pulse stays available.
|
||||
func (c *Client) readLoop() {
|
||||
var lastFrame time.Time
|
||||
var lastEnable time.Time
|
||||
for {
|
||||
select {
|
||||
case <-c.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := c.ensureConn(); err != nil {
|
||||
c.setErr("connect: " + err.Error())
|
||||
select {
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
frame, err := c.readFrame()
|
||||
now := time.Now()
|
||||
if err == nil {
|
||||
lastFrame = now
|
||||
if !c.frameLogged {
|
||||
c.frameLogged = true
|
||||
applog.Printf("acom: telemetry up — first valid frame received")
|
||||
}
|
||||
c.decodeFrame(frame)
|
||||
continue
|
||||
}
|
||||
// No valid frame. Re-send the telemetry enable briskly — there is no way to
|
||||
// know whether the amp has it on (the reference controller re-sends every
|
||||
// 200ms until frames flow), and it revives the stream after a power cycle.
|
||||
if now.Sub(lastEnable) >= 500*time.Millisecond {
|
||||
lastEnable = now
|
||||
_ = c.send(cmdEnableTelemetry)
|
||||
}
|
||||
if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter {
|
||||
if c.cfg.Transport == "tcp" {
|
||||
// The bridge socket may be dead — force a reconnect.
|
||||
c.mu.Lock()
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
if prev := c.GetStatus(); prev.Connected || prev.LastError == "" {
|
||||
applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails)
|
||||
}
|
||||
c.setErr("no telemetry — amplifier off or cable/bridge issue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ensureConn() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
if c.cfg.Transport == "tcp" {
|
||||
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.conn = nc
|
||||
} else {
|
||||
sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while
|
||||
// asserted the amp's front-panel power button is blocked (they are its
|
||||
// remote power-on lines).
|
||||
_ = sp.SetDTR(false)
|
||||
_ = sp.SetRTS(false)
|
||||
// Short read timeout so the frame reader can poll the stop channel and
|
||||
// detect staleness rather than blocking forever.
|
||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||
c.conn = sp
|
||||
}
|
||||
c.statusMu.Lock()
|
||||
c.status.PortOpen = true
|
||||
c.statusMu.Unlock()
|
||||
applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport)
|
||||
_, _ = c.conn.Write(cmdEnableTelemetry)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) dropLocked() {
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.statusMu.Lock()
|
||||
c.status.PortOpen = false
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// readByte reads a single byte, honouring the transport's short timeout. A serial
|
||||
// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error
|
||||
// here so callers can distinguish "no data yet".
|
||||
var errNoData = fmt.Errorf("no data")
|
||||
|
||||
func (c *Client) readByte(deadline time.Time) (byte, error) {
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
if conn == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
if nc, ok := conn.(net.Conn); ok {
|
||||
_ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||
}
|
||||
n, err := conn.Read(buf)
|
||||
if n == 1 {
|
||||
c.rawSeen++
|
||||
// Capture the first 144 raw bytes (~2 frames) once, so a session log shows
|
||||
// what the wire actually carries when frames won't validate.
|
||||
if !c.dbgLogged {
|
||||
c.dbgBytes = append(c.dbgBytes, buf[0])
|
||||
if len(c.dbgBytes) >= 144 {
|
||||
c.dbgLogged = true
|
||||
applog.Printf("acom: first raw bytes: % X", c.dbgBytes)
|
||||
c.dbgBytes = nil
|
||||
}
|
||||
}
|
||||
return buf[0], nil
|
||||
}
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
err = nil // treat like the serial short-timeout: just no data yet
|
||||
} else {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-c.stop:
|
||||
return 0, fmt.Errorf("stopped")
|
||||
default:
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return 0, errNoData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies
|
||||
// the mod-256 checksum (sum of ALL 72 bytes ≡ 0).
|
||||
func (c *Client) readFrame() ([]byte, error) {
|
||||
deadline := time.Now().Add(ioTimeout)
|
||||
// Sync: hunt for 0x55 followed by 0x2F.
|
||||
for {
|
||||
b, err := c.readByte(deadline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b != sync0 {
|
||||
continue
|
||||
}
|
||||
b2, err := c.readByte(deadline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b2 == sync1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
frame := make([]byte, frameLen)
|
||||
frame[0], frame[1] = sync0, sync1
|
||||
for i := 2; i < frameLen; i++ {
|
||||
b, err := c.readByte(deadline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frame[i] = b
|
||||
}
|
||||
var sum int
|
||||
for _, b := range frame {
|
||||
sum += int(b)
|
||||
}
|
||||
if sum%256 != 0 {
|
||||
c.ckFails++
|
||||
if c.ckFails <= 3 {
|
||||
applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame)
|
||||
}
|
||||
return nil, fmt.Errorf("bad checksum")
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
// decodeFrame extracts the telemetry fields (see the package comment for the
|
||||
// reverse-engineered layout; 16-bit values are little-endian lo + hi*256).
|
||||
func (c *Client) decodeFrame(f []byte) {
|
||||
u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 }
|
||||
paStatus := int(f[3]&0xF0) >> 4
|
||||
state := paStatusNames[paStatus]
|
||||
if state == "" {
|
||||
state = fmt.Sprintf("?%d", paStatus)
|
||||
}
|
||||
bandIdx := int(f[69] & 0x0F)
|
||||
band := ""
|
||||
if bandIdx > 0 && bandIdx < len(acomBands) {
|
||||
band = acomBands[bandIdx]
|
||||
}
|
||||
|
||||
c.statusMu.Lock()
|
||||
defer c.statusMu.Unlock()
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
c.status.State = state
|
||||
c.status.Operate = paStatus == 6 || paStatus == 7
|
||||
c.status.TX = paStatus == 7
|
||||
c.status.DCPowerW = u16(8) / 10
|
||||
c.status.TempC = u16(16) - c.mdl.TempOffset
|
||||
c.status.DriveW = u16(20)
|
||||
c.status.FwdW = u16(22)
|
||||
c.status.ReflW = u16(24)
|
||||
c.status.SWR = float64(u16(26)) / 100
|
||||
c.status.ErrCode = int(f[66])
|
||||
c.status.ErrText = errText(int(f[66]))
|
||||
c.status.Band = band
|
||||
c.status.FanLevel = int(f[69]&0xF0) >> 4
|
||||
}
|
||||
+162
-131
@@ -34,6 +34,12 @@ type Exporter struct {
|
||||
// export destined for another logger; set true for a full OpsLog→OpsLog
|
||||
// round-trip that preserves everything.
|
||||
IncludeAppFields bool
|
||||
|
||||
// Fields, when non-nil, restricts the export to exactly these ADIF tags
|
||||
// (uppercase) — the "choose which fields to export" mode. nil = write every
|
||||
// field (the standard/full behaviour above). When set it overrides
|
||||
// IncludeAppFields: an APP_/vendor tag is written iff it's in the set.
|
||||
Fields map[string]bool
|
||||
}
|
||||
|
||||
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
||||
@@ -100,7 +106,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
|
||||
|
||||
count := 0
|
||||
err := iter(ctx, func(q qso.QSO) error {
|
||||
writeRecord(bw, q, e.IncludeAppFields)
|
||||
writeRecord(bw, q, e.IncludeAppFields, e.Fields)
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
@@ -115,7 +121,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
||||
var b strings.Builder
|
||||
bw := bufio.NewWriter(&b)
|
||||
// Uploads target other services — keep it standard (no app-specific tags).
|
||||
writeRecord(bw, q, false)
|
||||
writeRecord(bw, q, false, nil)
|
||||
bw.Flush()
|
||||
return b.String()
|
||||
}
|
||||
@@ -127,7 +133,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
||||
func FullRecordADIF(q qso.QSO) string {
|
||||
var b strings.Builder
|
||||
bw := bufio.NewWriter(&b)
|
||||
writeRecord(bw, q, true)
|
||||
writeRecord(bw, q, true, nil)
|
||||
bw.Flush()
|
||||
return b.String()
|
||||
}
|
||||
@@ -155,161 +161,181 @@ func BatchRecordsADIF(records []string) string {
|
||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
|
||||
// allow == nil → write every promoted field (standard/full behaviour).
|
||||
// Otherwise a promoted tag is written only when it's in the chosen set.
|
||||
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
||||
// stay one-liners.
|
||||
ok := func(tag string) bool { return allow == nil || allow[tag] }
|
||||
w := func(tag, v string) {
|
||||
if ok(tag) {
|
||||
writeField(bw, tag, v)
|
||||
}
|
||||
}
|
||||
wi := func(tag string, p *int) {
|
||||
if ok(tag) {
|
||||
writeIntPtr(bw, tag, p)
|
||||
}
|
||||
}
|
||||
wf := func(tag string, p *float64, decimals int) {
|
||||
if ok(tag) {
|
||||
writeFloatPtr(bw, tag, p, decimals)
|
||||
}
|
||||
}
|
||||
// --- Core ---
|
||||
writeField(bw, "CALL", q.Callsign)
|
||||
w("CALL", q.Callsign)
|
||||
|
||||
if !q.QSODate.IsZero() {
|
||||
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
||||
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405"))
|
||||
w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
||||
w("TIME_ON", q.QSODate.UTC().Format("150405"))
|
||||
}
|
||||
if !q.QSODateOff.IsZero() {
|
||||
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
||||
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||
w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
||||
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||
}
|
||||
writeField(bw, "BAND", q.Band)
|
||||
writeField(bw, "BAND_RX", q.BandRX)
|
||||
w("BAND", q.Band)
|
||||
w("BAND_RX", q.BandRX)
|
||||
|
||||
mode, submode := modeForExport(q.Mode, q.Submode)
|
||||
writeField(bw, "MODE", mode)
|
||||
writeField(bw, "SUBMODE", submode)
|
||||
w("MODE", mode)
|
||||
w("SUBMODE", submode)
|
||||
|
||||
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||
}
|
||||
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
||||
writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
||||
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
||||
}
|
||||
|
||||
writeField(bw, "RST_SENT", q.RSTSent)
|
||||
writeField(bw, "RST_RCVD", q.RSTRcvd)
|
||||
w("RST_SENT", q.RSTSent)
|
||||
w("RST_RCVD", q.RSTRcvd)
|
||||
|
||||
// --- Contacted ---
|
||||
writeField(bw, "NAME", q.Name)
|
||||
writeField(bw, "QTH", q.QTH)
|
||||
writeField(bw, "ADDRESS", q.Address)
|
||||
writeField(bw, "EMAIL", q.Email)
|
||||
writeField(bw, "WEB", q.Web)
|
||||
writeField(bw, "GRIDSQUARE", q.Grid)
|
||||
writeField(bw, "GRIDSQUARE_EXT", q.GridExt)
|
||||
writeField(bw, "VUCC_GRIDS", q.VUCCGrids)
|
||||
writeField(bw, "COUNTRY", q.Country)
|
||||
writeField(bw, "STATE", q.State)
|
||||
writeField(bw, "CNTY", q.County)
|
||||
writeIntPtr(bw, "DXCC", q.DXCC)
|
||||
writeField(bw, "CONT", q.Continent)
|
||||
writeIntPtr(bw, "CQZ", q.CQZ)
|
||||
writeIntPtr(bw, "ITUZ", q.ITUZ)
|
||||
writeField(bw, "IOTA", q.IOTA)
|
||||
writeField(bw, "SOTA_REF", q.SOTARef)
|
||||
writeField(bw, "POTA_REF", q.POTARef)
|
||||
writeIntPtr(bw, "AGE", q.Age)
|
||||
writeFloatPtr(bw, "LAT", q.Lat, 6)
|
||||
writeFloatPtr(bw, "LON", q.Lon, 6)
|
||||
writeField(bw, "RIG", q.Rig)
|
||||
writeField(bw, "ANT", q.Ant)
|
||||
w("NAME", q.Name)
|
||||
w("QTH", q.QTH)
|
||||
w("ADDRESS", q.Address)
|
||||
w("EMAIL", q.Email)
|
||||
w("WEB", q.Web)
|
||||
w("GRIDSQUARE", q.Grid)
|
||||
w("GRIDSQUARE_EXT", q.GridExt)
|
||||
w("VUCC_GRIDS", q.VUCCGrids)
|
||||
w("COUNTRY", q.Country)
|
||||
w("STATE", q.State)
|
||||
w("CNTY", q.County)
|
||||
wi("DXCC", q.DXCC)
|
||||
w("CONT", q.Continent)
|
||||
wi("CQZ", q.CQZ)
|
||||
wi("ITUZ", q.ITUZ)
|
||||
w("IOTA", q.IOTA)
|
||||
w("SOTA_REF", q.SOTARef)
|
||||
w("POTA_REF", q.POTARef)
|
||||
wi("AGE", q.Age)
|
||||
wf("LAT", q.Lat, 6)
|
||||
wf("LON", q.Lon, 6)
|
||||
w("RIG", q.Rig)
|
||||
w("ANT", q.Ant)
|
||||
|
||||
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
||||
writeField(bw, "QSL_SENT", q.QSLSent)
|
||||
writeField(bw, "QSL_RCVD", q.QSLRcvd)
|
||||
writeField(bw, "QSLSDATE", q.QSLSentDate)
|
||||
writeField(bw, "QSLRDATE", q.QSLRcvdDate)
|
||||
writeField(bw, "QSL_VIA", q.QSLVia)
|
||||
writeField(bw, "QSLMSG", q.QSLMsg)
|
||||
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd)
|
||||
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent)
|
||||
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd)
|
||||
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate)
|
||||
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate)
|
||||
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent)
|
||||
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd)
|
||||
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate)
|
||||
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate)
|
||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
||||
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
||||
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
||||
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||
w("QSL_SENT", q.QSLSent)
|
||||
w("QSL_RCVD", q.QSLRcvd)
|
||||
w("QSLSDATE", q.QSLSentDate)
|
||||
w("QSLRDATE", q.QSLRcvdDate)
|
||||
w("QSL_VIA", q.QSLVia)
|
||||
w("QSLMSG", q.QSLMsg)
|
||||
w("QSLMSG_RCVD", q.QSLMsgRcvd)
|
||||
w("LOTW_QSL_SENT", q.LOTWSent)
|
||||
w("LOTW_QSL_RCVD", q.LOTWRcvd)
|
||||
w("LOTW_QSLSDATE", q.LOTWSentDate)
|
||||
w("LOTW_QSLRDATE", q.LOTWRcvdDate)
|
||||
w("EQSL_QSL_SENT", q.EQSLSent)
|
||||
w("EQSL_QSL_RCVD", q.EQSLRcvd)
|
||||
w("EQSL_QSLSDATE", q.EQSLSentDate)
|
||||
w("EQSL_QSLRDATE", q.EQSLRcvdDate)
|
||||
w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
||||
w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
||||
w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
||||
w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||
w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
||||
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||
|
||||
// --- Contest ---
|
||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
||||
writeIntPtr(bw, "SRX", q.SRX)
|
||||
writeIntPtr(bw, "STX", q.STX)
|
||||
writeField(bw, "SRX_STRING", q.SRXString)
|
||||
writeField(bw, "STX_STRING", q.STXString)
|
||||
writeField(bw, "CHECK", q.Check)
|
||||
writeField(bw, "PRECEDENCE", q.Precedence)
|
||||
writeField(bw, "ARRL_SECT", q.ARRLSect)
|
||||
w("CONTEST_ID", q.ContestID)
|
||||
wi("SRX", q.SRX)
|
||||
wi("STX", q.STX)
|
||||
w("SRX_STRING", q.SRXString)
|
||||
w("STX_STRING", q.STXString)
|
||||
w("CHECK", q.Check)
|
||||
w("PRECEDENCE", q.Precedence)
|
||||
w("ARRL_SECT", q.ARRLSect)
|
||||
|
||||
// --- Satellite / propagation ---
|
||||
writeField(bw, "PROP_MODE", q.PropMode)
|
||||
writeField(bw, "SAT_NAME", q.SatName)
|
||||
writeField(bw, "SAT_MODE", q.SatMode)
|
||||
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1)
|
||||
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1)
|
||||
writeField(bw, "ANT_PATH", q.AntPath)
|
||||
w("PROP_MODE", q.PropMode)
|
||||
w("SAT_NAME", q.SatName)
|
||||
w("SAT_MODE", q.SatMode)
|
||||
wf("ANT_AZ", q.AntAz, 1)
|
||||
wf("ANT_EL", q.AntEl, 1)
|
||||
w("ANT_PATH", q.AntPath)
|
||||
|
||||
// --- My station / operator ---
|
||||
writeField(bw, "STATION_CALLSIGN", q.StationCallsign)
|
||||
writeField(bw, "OPERATOR", q.Operator)
|
||||
writeField(bw, "MY_GRIDSQUARE", q.MyGrid)
|
||||
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||
writeField(bw, "MY_COUNTRY", q.MyCountry)
|
||||
writeField(bw, "MY_STATE", q.MyState)
|
||||
writeField(bw, "MY_CNTY", q.MyCounty)
|
||||
writeField(bw, "MY_IOTA", q.MyIOTA)
|
||||
writeField(bw, "MY_SOTA_REF", q.MySOTARef)
|
||||
writeField(bw, "MY_POTA_REF", q.MyPOTARef)
|
||||
writeIntPtr(bw, "MY_DXCC", q.MyDXCC)
|
||||
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone)
|
||||
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone)
|
||||
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6)
|
||||
writeFloatPtr(bw, "MY_LON", q.MyLon, 6)
|
||||
writeField(bw, "MY_STREET", q.MyStreet)
|
||||
writeField(bw, "MY_CITY", q.MyCity)
|
||||
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode)
|
||||
writeField(bw, "MY_RIG", q.MyRig)
|
||||
writeField(bw, "MY_ANTENNA", q.MyAntenna)
|
||||
w("STATION_CALLSIGN", q.StationCallsign)
|
||||
w("OPERATOR", q.Operator)
|
||||
w("MY_GRIDSQUARE", q.MyGrid)
|
||||
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||
w("MY_COUNTRY", q.MyCountry)
|
||||
w("MY_STATE", q.MyState)
|
||||
w("MY_CNTY", q.MyCounty)
|
||||
w("MY_IOTA", q.MyIOTA)
|
||||
w("MY_SOTA_REF", q.MySOTARef)
|
||||
w("MY_POTA_REF", q.MyPOTARef)
|
||||
wi("MY_DXCC", q.MyDXCC)
|
||||
wi("MY_CQ_ZONE", q.MyCQZone)
|
||||
wi("MY_ITU_ZONE", q.MyITUZone)
|
||||
wf("MY_LAT", q.MyLat, 6)
|
||||
wf("MY_LON", q.MyLon, 6)
|
||||
w("MY_STREET", q.MyStreet)
|
||||
w("MY_CITY", q.MyCity)
|
||||
w("MY_POSTAL_CODE", q.MyPostalCode)
|
||||
w("MY_RIG", q.MyRig)
|
||||
w("MY_ANTENNA", q.MyAntenna)
|
||||
|
||||
// --- Misc ---
|
||||
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
|
||||
writeField(bw, "COMMENT", q.Comment)
|
||||
writeField(bw, "NOTES", q.Notes)
|
||||
wf("TX_PWR", q.TXPower, 1)
|
||||
w("COMMENT", q.Comment)
|
||||
w("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)
|
||||
w("SIG", q.SIG)
|
||||
w("SIG_INFO", q.SIGInfo)
|
||||
w("MY_SIG", q.MySIG)
|
||||
w("MY_SIG_INFO", q.MySIGInfo)
|
||||
w("WWFF_REF", q.WWFFRef)
|
||||
w("MY_WWFF_REF", q.MyWWFFRef)
|
||||
wf("DISTANCE", q.Distance, 1)
|
||||
wf("RX_PWR", q.RXPower, 1)
|
||||
wf("A_INDEX", q.AIndex, 0)
|
||||
wf("K_INDEX", q.KIndex, 0)
|
||||
wf("SFI", q.SFI, 0)
|
||||
w("SKCC", q.SKCC)
|
||||
w("FISTS", q.FISTS)
|
||||
w("TEN_TEN", q.TenTen)
|
||||
w("CONTACTED_OP", q.ContactedOp)
|
||||
w("EQ_CALL", q.EqCall)
|
||||
w("PFX", q.PFX)
|
||||
w("MY_NAME", q.MyName)
|
||||
w("CLASS", q.Class)
|
||||
w("DARC_DOK", q.DarcDOK)
|
||||
w("MY_DARC_DOK", q.MyDarcDOK)
|
||||
w("REGION", q.Region)
|
||||
w("SILENT_KEY", q.SilentKey)
|
||||
w("SWL", q.SWL)
|
||||
w("QSO_COMPLETE", q.QSOComplete)
|
||||
w("QSO_RANDOM", q.QSORandom)
|
||||
w("CREDIT_GRANTED", q.CreditGranted)
|
||||
w("CREDIT_SUBMITTED", q.CreditSubmitted)
|
||||
w("MY_ARRL_SECT", q.MyARRLSect)
|
||||
w("MY_VUCC_GRIDS", q.MyVUCCGrids)
|
||||
|
||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
||||
@@ -318,7 +344,12 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
||||
// extra for a lossless OpsLog round-trip.
|
||||
for k, v := range q.Extras {
|
||||
tag := strings.ToUpper(k)
|
||||
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
||||
if allow != nil {
|
||||
// Chosen-fields mode: the set alone decides (incl. APP_/vendor tags).
|
||||
if !allow[tag] {
|
||||
continue
|
||||
}
|
||||
} else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
||||
continue
|
||||
}
|
||||
writeField(bw, tag, v)
|
||||
|
||||
@@ -46,6 +46,21 @@ type Importer struct {
|
||||
// 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)
|
||||
// FieldMap moves ADIF fields around before a record is converted, keyed
|
||||
// lowercase source → lowercase destination.
|
||||
//
|
||||
// Contest software puts things where the operator, not the standard, expects
|
||||
// them. The RSGB IOTA contest is the case that prompted this: N1MM and
|
||||
// friends export the island reference in STATE, because that is the column
|
||||
// their contest module uses for the received exchange. Imported verbatim,
|
||||
// every QSO gets a US-state field holding "EU005" and the IOTA award sees
|
||||
// nothing at all.
|
||||
//
|
||||
// Applied to the RAW record, so it works for promoted columns and Extras
|
||||
// alike, and so the destination is parsed exactly as if the file had carried
|
||||
// it there in the first place. A destination that already has a value is
|
||||
// never overwritten — the file's own IOTA tag outranks a guess about STATE.
|
||||
FieldMap map[string]string
|
||||
// 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.
|
||||
@@ -161,6 +176,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
|
||||
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
|
||||
res.Total++
|
||||
reportProgress(false)
|
||||
applyFieldMap(rec, im.FieldMap)
|
||||
q, ok := recordToQSO(rec)
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
@@ -303,6 +319,49 @@ func stringSet(items ...string) map[string]struct{} {
|
||||
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
||||
// can convert a freshly received ADIF record into a QSO and then enrich
|
||||
// it with lookup + operating data before inserting.
|
||||
// applyFieldMap moves values between tags of a record, in place.
|
||||
//
|
||||
// The source is CLEARED after the move: a reference that belongs in IOTA should
|
||||
// not also be left behind in STATE, where it would show up as the contacted
|
||||
// station's US state in the grid and in every export.
|
||||
func applyFieldMap(rec Record, m map[string]string) {
|
||||
if len(m) == 0 || rec == nil {
|
||||
return
|
||||
}
|
||||
// Read every source first. Applied one at a time, a swap (a→b, b→a) would
|
||||
// read back what the previous move had just written.
|
||||
orig := make(map[string]string, len(rec))
|
||||
for k, v := range rec {
|
||||
orig[k] = v
|
||||
}
|
||||
vals := make(map[string]string, len(m))
|
||||
for from := range m {
|
||||
if v := strings.TrimSpace(rec[from]); v != "" {
|
||||
vals[from] = v
|
||||
}
|
||||
}
|
||||
for from := range m {
|
||||
delete(rec, from)
|
||||
}
|
||||
for from, to := range m {
|
||||
v, ok := vals[from]
|
||||
if !ok || from == to || to == "" {
|
||||
continue
|
||||
}
|
||||
// Whether the destination was already filled is judged against the record
|
||||
// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
|
||||
// depend on Go's map iteration order, which is deliberately random.
|
||||
//
|
||||
// A destination that is itself a mapping source is an explicit swap, and
|
||||
// overwriting it is the whole point; anything else keeps what the file
|
||||
// carried, because the file's own tag outranks a guess about where a
|
||||
// reference might have been put.
|
||||
if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
|
||||
rec[to] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
||||
|
||||
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package adif_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// End to end: an operator's own record, through a real SQLite logbook and back.
|
||||
//
|
||||
// Written because CONTEST_ID was reported as "not imported". It is imported, and
|
||||
// this pins the whole path — parse, remap, insert, read back — rather than the
|
||||
// conversion step alone, which was already green while the report stood. A field
|
||||
// can be lost at five places between the file and the grid; only the round trip
|
||||
// says which.
|
||||
func TestContestIDSurvivesImport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "logbook.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
const file = "<CALL:6>2E0CVN <QSO_DATE:8>20260725 <TIME_ON:6>120039 <STATE:5>EU005 <BAND:3>20M <FREQ:6>14.014 <MODE:2>CW <CONTEST_ID:9>RSGB-IOTA <RST_RCVD:3>599 <RST_SENT:3>599 <SRX_STRING:8>001EU005 <EOR>\n"
|
||||
path := filepath.Join(dir, "in.adi")
|
||||
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo := qso.NewRepo(conn)
|
||||
im := &adif.Importer{Repo: repo, FieldMap: map[string]string{"state": "iota"}}
|
||||
res, err := im.ImportFile(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
t.Logf("result: %+v", res)
|
||||
|
||||
rows, err := conn.Query("SELECT callsign, contest_id, iota, state FROM qso")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var call, contest, iota, state any
|
||||
if err := rows.Scan(&call, &contest, &iota, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n++
|
||||
t.Logf("stored: call=%v contest_id=%v iota=%v state=%v", call, contest, iota, state)
|
||||
if s := strings.TrimSpace(toS(contest)); s != "RSGB-IOTA" {
|
||||
t.Errorf("contest_id in the DATABASE = %q, want RSGB-IOTA", s)
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("nothing was stored")
|
||||
}
|
||||
}
|
||||
|
||||
func toS(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []byte:
|
||||
return string(x)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package adif
|
||||
|
||||
import "testing"
|
||||
|
||||
// Field mapping, from the RSGB IOTA contest case.
|
||||
//
|
||||
// N1MM and similar export the island reference in STATE, because that is the
|
||||
// column their contest module uses for the received exchange. Imported
|
||||
// verbatim, every QSO gets a US-state field reading "EU005" and the IOTA award
|
||||
// sees nothing — the reference is in the log but not where anything looks.
|
||||
func TestApplyFieldMap(t *testing.T) {
|
||||
// The real record, from an operator's TM2Q log.
|
||||
rec := Record{
|
||||
"call": "2E0CVN", "band": "20m", "mode": "CW",
|
||||
"state": "EU005", "srx_string": "001EU005",
|
||||
}
|
||||
applyFieldMap(rec, map[string]string{"state": "iota"})
|
||||
if rec["iota"] != "EU005" {
|
||||
t.Errorf("iota = %q, want EU005", rec["iota"])
|
||||
}
|
||||
// Cleared, not copied: a reference left behind in STATE would show up as the
|
||||
// contacted station's US state in the grid and in every export.
|
||||
if _, ok := rec["state"]; ok {
|
||||
t.Errorf("state survived the move as %q", rec["state"])
|
||||
}
|
||||
|
||||
// A destination the file already filled is never overwritten — the file's own
|
||||
// tag outranks a guess about where the reference might be.
|
||||
rec2 := Record{"call": "UT0RM", "state": "EU005", "iota": "EU030"}
|
||||
applyFieldMap(rec2, map[string]string{"state": "iota"})
|
||||
if rec2["iota"] != "EU030" {
|
||||
t.Errorf("iota = %q — the file's own value must win", rec2["iota"])
|
||||
}
|
||||
|
||||
// An empty source moves nothing (the second QSO of that log has no STATE).
|
||||
rec3 := Record{"call": "UT0RM", "state": " "}
|
||||
applyFieldMap(rec3, map[string]string{"state": "iota"})
|
||||
if v, ok := rec3["iota"]; ok {
|
||||
t.Errorf("an empty source created iota=%q", v)
|
||||
}
|
||||
|
||||
// A swap reads both sources before writing either.
|
||||
rec4 := Record{"state": "EU005", "iota": "NA001"}
|
||||
applyFieldMap(rec4, map[string]string{"state": "iota", "iota": "state"})
|
||||
if rec4["iota"] != "EU005" || rec4["state"] != "NA001" {
|
||||
t.Errorf("swap gave iota=%q state=%q, want EU005 / NA001", rec4["iota"], rec4["state"])
|
||||
}
|
||||
|
||||
// No mapping, no change.
|
||||
rec5 := Record{"call": "F4BPO", "state": "EU005"}
|
||||
applyFieldMap(rec5, nil)
|
||||
if rec5["state"] != "EU005" {
|
||||
t.Error("a nil mapping must leave the record alone")
|
||||
}
|
||||
}
|
||||
+23
-7
@@ -22,9 +22,11 @@ import (
|
||||
// Record is a single ADIF record. Keys are lowercased field names.
|
||||
type Record map[string]string
|
||||
|
||||
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
|
||||
// Parse reads an ADI stream and invokes fn for each record.
|
||||
// Returning a non-nil error from fn stops parsing and is propagated.
|
||||
// The header (text before <EOH>) is silently discarded.
|
||||
//
|
||||
// The header (text before <EOH>) is silently discarded. A file with NO header is
|
||||
// read as records from the first tag — hand-assembled files often have none.
|
||||
func Parse(r io.Reader, fn func(Record) error) error {
|
||||
return parseWith(r, nil, fn)
|
||||
}
|
||||
@@ -44,7 +46,6 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
br := bufio.NewReaderSize(r, 64*1024)
|
||||
|
||||
rec := Record{}
|
||||
headerDone := false
|
||||
|
||||
for {
|
||||
// Seek next '<'. Bytes before it are either header text or
|
||||
@@ -65,11 +66,19 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
name, length := parseSpec(spec)
|
||||
switch name {
|
||||
case "eoh":
|
||||
headerDone = true
|
||||
rec = Record{}
|
||||
rec = Record{} // everything collected so far was the header
|
||||
continue
|
||||
case "eor":
|
||||
if headerDone && len(rec) > 0 {
|
||||
// An <eor> proves we are in the record section, whether or not an <eoh>
|
||||
// came first. Files pasted together by hand — a few lines lifted out of
|
||||
// wsjtx_log.adi into a new file — have no header at all, and requiring
|
||||
// one made every record invisible: the import reported 0 imported,
|
||||
// 0 ignored, 0 total, which reads as "the file is empty" rather than
|
||||
// "there was no header".
|
||||
//
|
||||
// A real header cannot contain <eor>, so accepting it costs nothing for
|
||||
// well-formed files: theirs ends at <eoh> long before any record.
|
||||
if len(rec) > 0 {
|
||||
if err := fn(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,7 +104,14 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
if decodeValue == nil && !utf8.Valid(val) {
|
||||
val = extendToRunes(br, val, length)
|
||||
}
|
||||
if headerDone && name != "" {
|
||||
// Fields are collected ALWAYS, and thrown away at <eoh>.
|
||||
//
|
||||
// Collecting them only after the header meant a headerless file lost its
|
||||
// FIRST record whole: its fields were discarded as header text, and the
|
||||
// <eor> that followed found nothing to emit. Discarding at <eoh> instead
|
||||
// keeps real headers out of the data just as well — that tag is exactly
|
||||
// what says "everything before this was the header".
|
||||
if name != "" {
|
||||
if decodeValue != nil {
|
||||
rec[name] = decodeValue(val)
|
||||
} else {
|
||||
|
||||
@@ -51,19 +51,28 @@ func TestParseValueWithAngleBracket(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseNoHeader(t *testing.T) {
|
||||
// Some loggers omit the header entirely — records before <EOH> are
|
||||
// discarded by design. Verify nothing is emitted in that case.
|
||||
// A file without a header is IMPORTED, not discarded.
|
||||
//
|
||||
// This test asserted the opposite until 2026-07-29, when an operator pasted a
|
||||
// dozen lines out of wsjtx_log.adi into a new .adi and got "0 imported,
|
||||
// 0 ignored, 0 total". Discarding records before <EOH> was a defensible
|
||||
// reading of the spec, but the file it rejects is one people really make, and
|
||||
// the rejection is silent: the count says the file was empty, not that a tag
|
||||
// was missing. An <eor> is proof enough that a record ended.
|
||||
src := `<CALL:5>F4XYZ<EOR>`
|
||||
var got int
|
||||
var got []Record
|
||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||
got++
|
||||
got = append(got, r)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("expected 0 records without <EOH>, got %d", got)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected the record to be imported without <EOH>, got %d", len(got))
|
||||
}
|
||||
if got[0]["call"] != "F4XYZ" {
|
||||
t.Errorf("call = %q, want F4XYZ", got[0]["call"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +91,57 @@ func TestParseTypedField(t *testing.T) {
|
||||
t.Errorf("freq mismatch: %q", got["freq"])
|
||||
}
|
||||
}
|
||||
|
||||
// A file with NO header must still import.
|
||||
//
|
||||
// Reported from the field: an operator lifted a dozen lines out of wsjtx_log.adi
|
||||
// into a new .adi and got "0 imported, 0 ignored, 0 total" — which reads as an
|
||||
// empty file rather than as a missing header. Records were only emitted after
|
||||
// <EOH>, so a hand-assembled file was invisible in its entirety.
|
||||
func TestParseHeaderlessFile(t *testing.T) {
|
||||
// The operator's exact shape: records straight from a WSJT-X log, no header.
|
||||
in := "<call:5>PY2VE <gridsquare:4>GG67 <mode:3>FT8 <rst_sent:3>-18 <rst_rcvd:3>-15 " +
|
||||
"<qso_date:8>20260728 <time_on:6>194200 <band:3>15m <eor>\n" +
|
||||
"<call:5>W8OBX <gridsquare:4>FM25 <mode:3>FT8 <rst_sent:3>-19 <rst_rcvd:3>-12 " +
|
||||
"<qso_date:8>20260728 <time_on:6>194500 <band:3>15m <eor>\n"
|
||||
|
||||
var got []Record
|
||||
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d records from a headerless file, want 2", len(got))
|
||||
}
|
||||
if got[0]["call"] != "PY2VE" || got[1]["call"] != "W8OBX" {
|
||||
t.Errorf("callsigns = %q, %q — want PY2VE, W8OBX", got[0]["call"], got[1]["call"])
|
||||
}
|
||||
if got[0]["gridsquare"] != "GG67" {
|
||||
t.Errorf("gridsquare = %q, want GG67", got[0]["gridsquare"])
|
||||
}
|
||||
}
|
||||
|
||||
// And a normal file must be unaffected: its header is still discarded, so header
|
||||
// fields never leak into the first record.
|
||||
func TestParseHeaderStillDiscarded(t *testing.T) {
|
||||
in := "Generated by SomeLogger\n<adif_ver:5>3.1.4 <programid:6>OpsLog <eoh>\n" +
|
||||
"<call:4>F4AA <mode:2>CW <eor>\n"
|
||||
var got []Record
|
||||
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("parsed %d records, want 1", len(got))
|
||||
}
|
||||
if _, leaked := got[0]["programid"]; leaked {
|
||||
t.Error("a header field leaked into the record")
|
||||
}
|
||||
if got[0]["call"] != "F4AA" {
|
||||
t.Errorf("call = %q, want F4AA", got[0]["call"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
bw := bufio.NewWriter(&buf)
|
||||
bw.WriteString("<EOH>\n")
|
||||
writeRecord(bw, in, true)
|
||||
writeRecord(bw, in, true, nil)
|
||||
bw.Flush()
|
||||
|
||||
var rec Record
|
||||
@@ -115,7 +115,7 @@ func TestStandardExportDropsNonStandard(t *testing.T) {
|
||||
func renderRecord(q qso.QSO, includeApp bool) string {
|
||||
var buf bytes.Buffer
|
||||
bw := bufio.NewWriter(&buf)
|
||||
writeRecord(bw, q, includeApp)
|
||||
writeRecord(bw, q, includeApp, nil)
|
||||
bw.Flush()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ type Client struct {
|
||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
||||
antRawN int // one-shot: how many raw antenna lines we've logged
|
||||
lastShown map[int]int // port id → antenna last reported, so only CHANGES are logged
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
@@ -87,6 +88,7 @@ func New(host string, port int, password string) *Client {
|
||||
stop: make(chan struct{}),
|
||||
antennas: map[int]string{},
|
||||
antBands: map[int]int{},
|
||||
lastShown: map[int]int{},
|
||||
status: Status{Host: host},
|
||||
}
|
||||
}
|
||||
@@ -398,9 +400,27 @@ func (c *Client) parsePort(msg string) {
|
||||
}
|
||||
tx := kvInt(msg, "txant")
|
||||
rx := kvInt(msg, "rxant")
|
||||
active := tx
|
||||
// The RX antenna is what identifies a port's selection, and it is the only
|
||||
// thing Activate sets. Preferring the TX antenna made both ports converge on
|
||||
// the same row a few seconds after a change: on an 8x2 only ONE port can hold
|
||||
// the transmit antenna, so the device reports the same txant on both, and a
|
||||
// port A genuinely on the 80 m vertical was redrawn as the beam that port B
|
||||
// transmits through.
|
||||
//
|
||||
// TX remains the fallback for a port that reports no RX antenna at all.
|
||||
active := rx
|
||||
if active == 0 {
|
||||
active = rx
|
||||
active = tx
|
||||
}
|
||||
// Logged only when the shown antenna CHANGES: the device pushes port state
|
||||
// every few seconds, and logging each one would bury everything else — but
|
||||
// without any trace, "port A jumped to the wrong antenna" is unfalsifiable.
|
||||
c.statusMu.Lock()
|
||||
prev, seen := c.lastShown[id]
|
||||
c.lastShown[id] = active
|
||||
c.statusMu.Unlock()
|
||||
if !seen || prev != active {
|
||||
applog.Printf("antgenius: port %d → antenna %d (rxant=%d txant=%d) raw=%q", id, active, rx, tx, msg)
|
||||
}
|
||||
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
|
||||
c.setStatus(func(s *Status) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package antgenius
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which antenna a port SHOWS.
|
||||
//
|
||||
// Reported on an 8x2: port A was correctly on the 80 m vertical, then a few
|
||||
// seconds later both A and B were drawn on the same beam. The cause is that only
|
||||
// ONE port can hold the transmit antenna, so the device reports the same txant
|
||||
// on both — and the display preferred txant. The RX antenna is the per-port
|
||||
// selection, and the only thing Activate sets, so it has to win.
|
||||
func TestPortShowsRXAntenna(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg string
|
||||
wantPort int
|
||||
wantAnt int
|
||||
}{
|
||||
{
|
||||
// The reported case: port A listening on antenna 2 while the shared TX
|
||||
// antenna is 1. Before the fix this drew port A on antenna 1.
|
||||
name: "rx and tx disagree — rx wins",
|
||||
msg: "port 1 rxant=2 txant=1 tx=0",
|
||||
wantPort: 1, wantAnt: 2,
|
||||
},
|
||||
{
|
||||
name: "they agree",
|
||||
msg: "port 2 rxant=3 txant=3 tx=0",
|
||||
wantPort: 2, wantAnt: 3,
|
||||
},
|
||||
{
|
||||
// A port with no RX antenna still shows something meaningful.
|
||||
name: "no rx antenna — fall back to tx",
|
||||
msg: "port 1 rxant=0 txant=4 tx=1",
|
||||
wantPort: 1, wantAnt: 4,
|
||||
},
|
||||
{
|
||||
name: "nothing selected",
|
||||
msg: "port 2 rxant=0 txant=0 tx=0",
|
||||
wantPort: 2, wantAnt: 0,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cl := New("host", 0, "")
|
||||
cl.parsePort(c.msg)
|
||||
st := cl.GetStatus()
|
||||
got := st.PortA
|
||||
if c.wantPort == 2 {
|
||||
got = st.PortB
|
||||
}
|
||||
if got != c.wantAnt {
|
||||
t.Errorf("%s: port %d shows antenna %d, want %d (%q)", c.name, c.wantPort, got, c.wantAnt, c.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The two ports must stay independent: a message about one must never move the
|
||||
// other, which is the shape the operator actually saw on screen.
|
||||
func TestPortsAreIndependent(t *testing.T) {
|
||||
c := New("host", 0, "")
|
||||
c.parsePort("port 1 rxant=2 txant=0 tx=0")
|
||||
c.parsePort("port 2 rxant=1 txant=1 tx=1")
|
||||
st := c.GetStatus()
|
||||
if st.PortA != 2 {
|
||||
t.Errorf("port A = %d, want 2 — port B's message moved it", st.PortA)
|
||||
}
|
||||
if st.PortB != 1 {
|
||||
t.Errorf("port B = %d, want 1", st.PortB)
|
||||
}
|
||||
if !st.TxB || st.TxA {
|
||||
t.Errorf("transmit flags crossed: TxA=%v TxB=%v", st.TxA, st.TxB)
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,15 @@ func Init(dataDir string) (string, error) {
|
||||
_ = os.Rename(oldLog, logPath)
|
||||
}
|
||||
}
|
||||
// Truncate if the file grew past ~5MB so we don't accumulate logs
|
||||
// forever. We keep one file — simple and adequate for diagnostics.
|
||||
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
|
||||
_ = os.Remove(logPath)
|
||||
// Rotate (don't delete) once the file grows past ~10MB: rename it to
|
||||
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
|
||||
// to erase exactly the diagnostics we needed when a user reported an issue from
|
||||
// the session that just ended. One generation kept — enough, without unbounded growth.
|
||||
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
|
||||
_ = os.Remove(logPath + ".1") // drop the older generation
|
||||
if err := os.Rename(logPath, logPath+".1"); err != nil {
|
||||
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
|
||||
}
|
||||
}
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
|
||||
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// DeviceName resolves an endpoint id to its friendly name.
|
||||
//
|
||||
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
|
||||
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
|
||||
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
|
||||
// straight at the DAX panel.
|
||||
//
|
||||
// Falls back to the id when the endpoint cannot be found, which is itself worth
|
||||
// seeing: a device that has disappeared explains an empty recording too.
|
||||
func DeviceName(id string) string {
|
||||
if id == "" {
|
||||
return "(none)"
|
||||
}
|
||||
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
|
||||
devs, err := list()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, d := range devs {
|
||||
if d.ID == id {
|
||||
return d.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -80,7 +80,17 @@ func pcmFormat() *wca.WAVEFORMATEX {
|
||||
}
|
||||
}
|
||||
|
||||
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
|
||||
// autoConvert lets WASAPI resample between the device format and ours.
|
||||
//
|
||||
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
|
||||
// it selects the CHEAP converter, meant for cases where quality does not matter.
|
||||
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
|
||||
// 8 kHz back into the audio band — and a receiver hiss is mostly high
|
||||
// frequencies. The result was a recording where the noise sat ON TOP of the
|
||||
// voice, while the same audio heard live had the voice well clear of it.
|
||||
//
|
||||
// Without the flag Windows uses its normal converter, which filters first.
|
||||
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
||||
|
||||
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
||||
// stop channel is closed.
|
||||
|
||||
+115
-7
@@ -5,6 +5,7 @@ package audio
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
||||
@@ -14,7 +15,18 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
recStop chan struct{}
|
||||
recDone chan recResult
|
||||
// monGainPct scales what the RX monitor plays, 100 = as captured.
|
||||
//
|
||||
// The "From radio" slider used to reach only the QSO recorder, so an
|
||||
// operator listening through OpsLog heard no difference between 10% and
|
||||
// 150% — the setting looked broken because it was, for the thing they were
|
||||
// listening to.
|
||||
monGainPct int
|
||||
playStop chan struct{}
|
||||
// playDone closes when the playback goroutine has returned and the audio
|
||||
// device is free again. Without it, the next Play raced the old one for the
|
||||
// device and lost.
|
||||
playDone chan struct{}
|
||||
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||
txStop chan struct{} // TX audio passthrough (mic → rig)
|
||||
@@ -104,23 +116,59 @@ func (m *Manager) IsPlaying() bool {
|
||||
|
||||
// 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 {
|
||||
// Play sends a recorded message to a device, amplified by gainPct (100 = as
|
||||
// recorded).
|
||||
//
|
||||
// The gain exists because nothing else could raise the level: the message went
|
||||
// out exactly as captured, so a mic recorded quietly drove the rig quietly and
|
||||
// the operator had no control anywhere in OpsLog — only the radio's own USB
|
||||
// input level, buried in its menus, and the Windows mixer.
|
||||
func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
||||
pcm, rate, ch, bits, err := readWAV(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gainPct > 0 && gainPct != 100 && bits == 16 {
|
||||
g := float64(gainPct) / 100
|
||||
// In place: the buffer is this call's own copy of the file.
|
||||
for i := 0; i+1 < len(pcm); i += 2 {
|
||||
v := int16(uint16(pcm[i]) | uint16(pcm[i+1])<<8)
|
||||
v = scalePCM(v, g)
|
||||
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||
}
|
||||
}
|
||||
// Waits for any previous playback to have RELEASED THE DEVICE.
|
||||
//
|
||||
// It used to only signal the old one to stop and start a new one at once.
|
||||
// The old goroutine still held the WASAPI render client for a moment, so the
|
||||
// new client could not start: it returned immediately, the PTT was keyed and
|
||||
// released a tenth of a second later, and nothing came out. On the air that
|
||||
// looked like "press play again and you must wait the whole length of the
|
||||
// message before it will play at all".
|
||||
m.StopPlayback()
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
m.mu.Lock()
|
||||
m.playStop = stop
|
||||
m.playDone = done
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
|
||||
// The error was discarded. A device that refuses to start returns here
|
||||
// instantly, the PTT is released 120 ms later, and NOTHING says why —
|
||||
// which is exactly what a station heard as "it plays once, then never
|
||||
// again": the call succeeded, the sound did not.
|
||||
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
|
||||
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.playStop == stop {
|
||||
m.playStop = nil
|
||||
}
|
||||
if m.playDone == done {
|
||||
m.playDone = nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
close(done) // the device is free from here
|
||||
m.notify()
|
||||
}()
|
||||
m.notify()
|
||||
@@ -131,12 +179,24 @@ func (m *Manager) Play(deviceID, path string) error {
|
||||
func (m *Manager) StopPlayback() {
|
||||
m.mu.Lock()
|
||||
stop := m.playStop
|
||||
done := m.playDone
|
||||
m.playStop = nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
m.notify()
|
||||
if stop == nil {
|
||||
return
|
||||
}
|
||||
close(stop)
|
||||
// Wait for the goroutine to release the device — that is the whole point of
|
||||
// stopping before starting again. Bounded: a wedged WASAPI call must not
|
||||
// freeze the caller, which here is the operator clicking a button.
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
LogSink("audio: previous playback did not release the device within 1.5 s")
|
||||
}
|
||||
}
|
||||
m.notify()
|
||||
}
|
||||
|
||||
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
||||
@@ -172,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
if capture {
|
||||
// Producer: capture the rig's USB audio into the ring.
|
||||
// Producer: capture the rig's USB audio into the ring, at the level the
|
||||
// operator set. Applied HERE rather than on the render side so the
|
||||
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
|
||||
// that stream is already scaled by the radio.
|
||||
go func() {
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
|
||||
}()
|
||||
}
|
||||
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||
@@ -185,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
|
||||
// Takes effect on the next captured chunk — no need to restart the monitor.
|
||||
func (m *Manager) SetMonitorGain(pct int) {
|
||||
if pct <= 0 {
|
||||
pct = 100
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.monGainPct = pct
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// scaleMonitor applies the monitor level to one captured chunk, returning a
|
||||
// buffer the ring may keep. At unity it hands the chunk straight back: the
|
||||
// common case must not pay for a copy 30 times a second.
|
||||
func (m *Manager) scaleMonitor(chunk []byte) []byte {
|
||||
m.mu.Lock()
|
||||
pct := m.monGainPct
|
||||
m.mu.Unlock()
|
||||
if pct == 0 || pct == 100 {
|
||||
return chunk
|
||||
}
|
||||
g := float64(pct) / 100
|
||||
out := make([]byte, len(chunk))
|
||||
copy(out, chunk)
|
||||
for i := 0; i+1 < len(out); i += 2 {
|
||||
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
|
||||
v = scalePCM(v, g)
|
||||
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StopMonitor stops the RX monitor passthrough.
|
||||
func (m *Manager) StopMonitor() {
|
||||
m.mu.Lock()
|
||||
@@ -265,3 +360,16 @@ func (m *Manager) TXAudioActive() bool {
|
||||
defer m.mu.Unlock()
|
||||
return m.txStop != nil
|
||||
}
|
||||
|
||||
// scalePCM applies a gain to one sample, clamping rather than wrapping — an
|
||||
// overflow that wraps turns loud speech into a burst of noise on the air.
|
||||
func scalePCM(s int16, g float64) int16 {
|
||||
v := float64(s) * g
|
||||
if v > 32767 {
|
||||
return 32767
|
||||
}
|
||||
if v < -32768 {
|
||||
return -32768
|
||||
}
|
||||
return int16(v)
|
||||
}
|
||||
|
||||
+70
-7
@@ -3,6 +3,7 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||||
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
|
||||
return enc.Write(f, stereo)
|
||||
}
|
||||
|
||||
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
|
||||
// upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
|
||||
// filter that makes the operation legitimate.
|
||||
//
|
||||
// It used to interpolate linearly — each new sample the average of its
|
||||
// neighbours. That is an upsampler in name only: every component at f is
|
||||
// mirrored to 16 kHz − f, measured just 7 dB down (see the test). On speech it
|
||||
// adds brightness; on receiver noise, which is broadband, it lays a second
|
||||
// noise floor across the top of the band. Operators heard it as a hiss louder
|
||||
// than the voice, present in the MP3 and absent from the WAV of the very same
|
||||
// audio.
|
||||
//
|
||||
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
|
||||
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
|
||||
// microseconds of work on an eight-second recording — nothing, next to the MP3
|
||||
// encode that follows.
|
||||
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]
|
||||
half := len(upsampleFIR) / 2
|
||||
for i := range out {
|
||||
var acc float64
|
||||
// The zero-stuffed signal is non-zero only at even indices, so only
|
||||
// every other tap contributes — the loop skips the zeros rather than
|
||||
// multiplying by them.
|
||||
start := (i - half + 1) &^ 1 // first even index in the window
|
||||
for j := start; j <= i+half; j += 2 {
|
||||
k := i - j + half
|
||||
if k < 0 || k >= len(upsampleFIR) {
|
||||
continue
|
||||
}
|
||||
src := j / 2
|
||||
if src < 0 || src >= len(in) {
|
||||
continue
|
||||
}
|
||||
acc += float64(in[src]) * upsampleFIR[k]
|
||||
}
|
||||
// ×2 for the energy lost to zero-stuffing, then clamp.
|
||||
v := acc * 2
|
||||
if v > 32767 {
|
||||
v = 32767
|
||||
} else if v < -32768 {
|
||||
v = -32768
|
||||
}
|
||||
out[i] = int16(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
|
||||
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
|
||||
// images begin.
|
||||
var upsampleFIR = func() []float64 {
|
||||
const n = 63
|
||||
const fc = 0.25 // cycles/sample at the new rate
|
||||
h := make([]float64, n)
|
||||
mid := (n - 1) / 2
|
||||
var sum float64
|
||||
for i := 0; i < n; i++ {
|
||||
m := float64(i - mid)
|
||||
var v float64
|
||||
if m == 0 {
|
||||
v = 2 * fc
|
||||
} else {
|
||||
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
|
||||
}
|
||||
// Hamming window: a rectangular one would ring and put the stopband
|
||||
// only ~21 dB down, which is not enough to be worth the filter.
|
||||
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
|
||||
h[i] = v
|
||||
sum += v
|
||||
}
|
||||
for i := range h {
|
||||
h[i] /= sum // unity gain at DC
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Playing a stopped take must not consume it.
|
||||
//
|
||||
// Reported on the air: the recording plays once, then nothing — "as if it threw
|
||||
// the sound away after playing it". PeekQSO is what the playback reads, and it
|
||||
// is meant to COPY; TakeQSO is the one that clears.
|
||||
func TestPeekDoesNotConsumeTheTake(t *testing.T) {
|
||||
r := &Recorder{running: true, active: true, paused: true, prerollSamples: sampleRate}
|
||||
r.acc = make([]int16, 4000)
|
||||
for i := range r.acc {
|
||||
r.acc[i] = int16(i % 500)
|
||||
}
|
||||
|
||||
first, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("first peek: %v", err)
|
||||
}
|
||||
second, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("second peek: %v — the take was consumed by the first", err)
|
||||
}
|
||||
if len(first) != len(second) {
|
||||
t.Errorf("second peek returned %d bytes, first %d", len(second), len(first))
|
||||
}
|
||||
|
||||
// And the copy must be independent: the caller holds these bytes while the
|
||||
// recorder may still be appending to its own slice.
|
||||
if len(first) > 0 {
|
||||
first[0] = ^first[0]
|
||||
again, _ := r.PeekQSO()
|
||||
if len(again) > 0 && again[0] == first[0] {
|
||||
t.Error("PeekQSO handed out its internal buffer, not a copy")
|
||||
}
|
||||
}
|
||||
|
||||
// Resuming and stopping again keeps everything captured so far.
|
||||
r.ResumeQSO()
|
||||
r.mu.Lock()
|
||||
r.acc = append(r.acc, make([]int16, 1000)...)
|
||||
r.mu.Unlock()
|
||||
r.PauseQSO()
|
||||
third, err := r.PeekQSO()
|
||||
if err != nil {
|
||||
t.Fatalf("peek after resume: %v", err)
|
||||
}
|
||||
if len(third) <= len(second) {
|
||||
t.Errorf("after resuming, the take is %d bytes — it should have grown past %d", len(third), len(second))
|
||||
}
|
||||
_ = time.Now
|
||||
}
|
||||
+244
-13
@@ -15,6 +15,14 @@ import (
|
||||
// Defaults to a no-op so the package is usable without wiring.
|
||||
var LogSink = func(string, ...any) {}
|
||||
|
||||
// AlertSink receives the few audio problems an operator must see WHILE they are
|
||||
// operating, not afterwards in a log file — a capture device that opens but
|
||||
// never streams being the one that matters: the recording is silently empty and
|
||||
// nothing says so until the QSO is logged and gone.
|
||||
//
|
||||
// Set to a toast emitter at startup; a no-op keeps the package standalone.
|
||||
var AlertSink = 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
|
||||
@@ -45,6 +53,16 @@ type Recorder struct {
|
||||
srcMu sync.Mutex
|
||||
bufA []int16 // From Radio
|
||||
bufB []int16 // mic
|
||||
// When each source last delivered samples. A configured device that never
|
||||
// produces anything is not an error anywhere — the capture call just sits
|
||||
// there — so the only way to notice is to watch the clock.
|
||||
lastA, lastB time.Time
|
||||
// startedAt is when capture began — the reference for a source that has not
|
||||
// delivered anything at all yet.
|
||||
startedAt time.Time
|
||||
// deadB (deadA) latches once a source has been declared silent, so the
|
||||
// warning is logged once rather than 25 times a second.
|
||||
deadA, deadB bool
|
||||
twoSrc bool
|
||||
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
|
||||
gainB float64 // mic gain
|
||||
@@ -52,6 +70,11 @@ type Recorder struct {
|
||||
// Mixed output state (guarded by mu).
|
||||
ring []int16 // last prerollSamples of mixed audio
|
||||
active bool
|
||||
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
|
||||
// captured so far is kept and the take still ends normally when the QSO is
|
||||
// logged. It is what lets an operator stop, play the recording back on the
|
||||
// air to the station they are working, and still have it saved with the QSO.
|
||||
paused bool
|
||||
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
|
||||
}
|
||||
|
||||
@@ -110,11 +133,37 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
if prerollSec < 0 {
|
||||
prerollSec = 0
|
||||
}
|
||||
// Does the configured endpoint still EXIST?
|
||||
//
|
||||
// Endpoint ids are stored, and a DAX channel that is reconfigured, disabled
|
||||
// or removed comes back with a different id. The old one then opens without
|
||||
// complaint on some drivers and simply never streams — which is
|
||||
// indistinguishable from a quiet band until the recording turns out empty.
|
||||
// Checking the list takes milliseconds and answers it outright.
|
||||
if devs, derr := ListInputDevices(); derr == nil {
|
||||
known := func(id string) bool {
|
||||
for _, d := range devs {
|
||||
if d.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if fromDev != "" && !known(fromDev) {
|
||||
LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev)
|
||||
AlertSink("The configured radio audio input no longer exists — re-select it in Settings")
|
||||
}
|
||||
if micDev != "" && micDev != fromDev && !known(micDev) {
|
||||
LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev)
|
||||
}
|
||||
}
|
||||
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
|
||||
r.startedAt = time.Now()
|
||||
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
|
||||
r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false
|
||||
stop := r.stopCh
|
||||
twoSrc := r.twoSrc
|
||||
r.mu.Unlock()
|
||||
@@ -124,27 +173,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (radio)")
|
||||
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
||||
// The error was discarded here. A device that cannot be opened — renamed,
|
||||
// unplugged, held by another program — then looked exactly like a device
|
||||
// that is merely quiet, and the recording came out empty with nothing in
|
||||
// the log to say why.
|
||||
if err := captureStream(fromDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufA = append(r.bufA, s...)
|
||||
r.lastA = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
|
||||
}
|
||||
}()
|
||||
if twoSrc {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder capture (mic)")
|
||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
||||
if err := captureStream(micDev, stop, func(chunk []byte) {
|
||||
s := bytesToInt16(chunk)
|
||||
r.srcMu.Lock()
|
||||
r.bufB = append(r.bufB, s...)
|
||||
r.lastB = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
})
|
||||
}); err != nil {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Name the devices being recorded FROM, once, at the start.
|
||||
//
|
||||
// A station with two radios has two sets of endpoints, and the recorder will
|
||||
// happily capture the one that is not being listened to: the result is a
|
||||
// file full of hiss with no relation to what the operator hears, and nothing
|
||||
// anywhere said which receiver it came from.
|
||||
if twoSrc {
|
||||
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
|
||||
} else {
|
||||
LogSink("recorder: capturing %q", DeviceName(fromDev))
|
||||
}
|
||||
|
||||
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
|
||||
// all — a DAX channel with no stream behind it does exactly that, and it is
|
||||
// indistinguishable from silence until the recording turns out to be empty
|
||||
// at the end of a QSO. Say it once, three seconds in, while there is still
|
||||
// time to fix the setup.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer recoverGoroutine("recorder watchdog")
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
r.srcMu.Lock()
|
||||
aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
|
||||
r.srcMu.Unlock()
|
||||
if aQuiet {
|
||||
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev))
|
||||
AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev))
|
||||
}
|
||||
// The mic is only worth a warning when the RADIO is silent too. On CW the
|
||||
// mic channel legitimately delivers nothing, and the mixer has already
|
||||
// said "recording the radio alone" — repeating it as an alarm made the log
|
||||
// read as if something were broken while the recording was going fine.
|
||||
if twoSrc && bQuiet && aQuiet {
|
||||
LogSink("recorder: no audio at all from %q either", DeviceName(micDev))
|
||||
}
|
||||
}()
|
||||
|
||||
// Mixer goroutine.
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
@@ -166,15 +267,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
|
||||
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||
// ring + active accumulation.
|
||||
// deadSourceAfter is how long a source may deliver nothing before the recorder
|
||||
// carries on without it. Long enough not to trip on a scheduling hiccup, short
|
||||
// enough that almost nothing is lost from the source that IS working.
|
||||
const deadSourceAfter = 1500 * time.Millisecond
|
||||
|
||||
func (r *Recorder) mixTick() {
|
||||
r.srcMu.Lock()
|
||||
var mixed []int16
|
||||
if r.twoSrc {
|
||||
// A source that has been silent for a while is treated as ABSENT and the
|
||||
// other one is recorded alone.
|
||||
//
|
||||
// Two sources used to mean min(len(A), len(B)) samples: if one device
|
||||
// delivered nothing, NOTHING was recorded, and the drift guard below
|
||||
// then threw the live source away a second at a time. That is exactly
|
||||
// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
|
||||
// path is not running — and the operator got "recording was empty" after
|
||||
// a whole QSO. Half a recording is worth having; silence is not.
|
||||
now := time.Now()
|
||||
// A source is dry when it has been quiet for too long — and a source that
|
||||
// has NEVER delivered is measured from when capture started, because it
|
||||
// has no last-delivery time of its own.
|
||||
//
|
||||
// The first version required a source to have spoken at least once. That
|
||||
// covers a device that stops, but not the one that actually happens: a
|
||||
// DAX Mic channel that is simply switched off never delivers a single
|
||||
// sample, so it stayed "not yet dry" forever and took the whole recording
|
||||
// down with it. Three empty CW recordings, with the radio audio streaming
|
||||
// perfectly the entire time.
|
||||
dry := func(last time.Time) bool {
|
||||
if last.IsZero() {
|
||||
return now.Sub(r.startedAt) > deadSourceAfter
|
||||
}
|
||||
return now.Sub(last) > deadSourceAfter
|
||||
}
|
||||
aDry, bDry := dry(r.lastA), dry(r.lastB)
|
||||
if bDry && !aDry && len(r.bufA) > 0 {
|
||||
if !r.deadB {
|
||||
r.deadB = true
|
||||
LogSink("recorder: the second audio source is silent — recording the radio alone")
|
||||
}
|
||||
mixed = make([]int16, len(r.bufA))
|
||||
for i, v := range r.bufA {
|
||||
mixed[i] = scaleSample(v, r.gainA)
|
||||
}
|
||||
r.bufA = r.bufA[:0]
|
||||
r.bufB = r.bufB[:0]
|
||||
r.srcMu.Unlock()
|
||||
r.store(mixed)
|
||||
return
|
||||
}
|
||||
if aDry && !bDry && len(r.bufB) > 0 {
|
||||
if !r.deadA {
|
||||
r.deadA = true
|
||||
LogSink("recorder: the radio audio source is silent — recording the microphone alone")
|
||||
}
|
||||
mixed = make([]int16, len(r.bufB))
|
||||
for i, v := range r.bufB {
|
||||
mixed[i] = scaleSample(v, r.gainB)
|
||||
}
|
||||
r.bufA = r.bufA[:0]
|
||||
r.bufB = r.bufB[:0]
|
||||
r.srcMu.Unlock()
|
||||
r.store(mixed)
|
||||
return
|
||||
}
|
||||
n := len(r.bufA)
|
||||
if len(r.bufB) < n {
|
||||
n = len(r.bufB)
|
||||
}
|
||||
if n > 0 {
|
||||
// Both alive again after one was written off.
|
||||
r.deadA, r.deadB = false, false
|
||||
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))
|
||||
@@ -182,13 +347,21 @@ func (r *Recorder) mixTick() {
|
||||
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).
|
||||
// Drift guard: two sound cards run on their own clocks, so drop the
|
||||
// excess to keep them within a second of each other.
|
||||
//
|
||||
// ONLY while both are actually running. A starved source is not drift: it
|
||||
// made this guard throw away the radio audio a second at a time during
|
||||
// the grace period, so the opening of every recording was lost even
|
||||
// though it had been captured. Waiting costs nothing now — whatever is
|
||||
// buffered is written whole the moment the silent source is written off.
|
||||
if len(r.bufA) > 0 && len(r.bufB) > 0 {
|
||||
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 {
|
||||
@@ -198,6 +371,12 @@ func (r *Recorder) mixTick() {
|
||||
}
|
||||
r.srcMu.Unlock()
|
||||
|
||||
r.store(mixed)
|
||||
}
|
||||
|
||||
// store appends mixed samples to the pre-roll ring and, when a take is running,
|
||||
// to the take itself.
|
||||
func (r *Recorder) store(mixed []int16) {
|
||||
if len(mixed) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -206,7 +385,7 @@ func (r *Recorder) mixTick() {
|
||||
if len(r.ring) > r.prerollSamples {
|
||||
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
|
||||
}
|
||||
if r.active {
|
||||
if r.active && !r.paused {
|
||||
r.acc = append(r.acc, mixed...)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
@@ -221,7 +400,7 @@ func (r *Recorder) BeginQSO() {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
r.active, r.paused = true, false
|
||||
}
|
||||
|
||||
// RestartQSO begins a fresh accumulation even if one is already active —
|
||||
@@ -236,7 +415,7 @@ func (r *Recorder) RestartQSO() {
|
||||
return
|
||||
}
|
||||
r.acc = append([]int16(nil), r.ring...)
|
||||
r.active = true
|
||||
r.active, r.paused = true, false
|
||||
}
|
||||
|
||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||
@@ -266,7 +445,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
|
||||
return nil, fmt.Errorf("no active recording")
|
||||
}
|
||||
samples := r.acc
|
||||
r.acc, r.active = nil, false
|
||||
r.acc, r.active, r.paused = nil, false, false
|
||||
r.mu.Unlock()
|
||||
if len(samples) == 0 {
|
||||
return nil, fmt.Errorf("recording was empty")
|
||||
@@ -295,7 +474,7 @@ func (r *Recorder) SaveQSO(path string) error {
|
||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||
func (r *Recorder) DiscardQSO() {
|
||||
r.mu.Lock()
|
||||
r.acc, r.active = nil, false
|
||||
r.acc, r.active, r.paused = nil, false, false
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -313,7 +492,7 @@ func (r *Recorder) Stop() {
|
||||
close(stop)
|
||||
r.wg.Wait()
|
||||
r.mu.Lock()
|
||||
r.ring, r.acc, r.active = nil, nil, false
|
||||
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
|
||||
r.mu.Unlock()
|
||||
r.srcMu.Lock()
|
||||
r.bufA, r.bufB = nil, nil
|
||||
@@ -346,3 +525,55 @@ func int16sToBytes(s []int16) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// PauseQSO freezes the active take without ending it: nothing more is recorded,
|
||||
// nothing is thrown away, and logging the QSO still writes the file.
|
||||
//
|
||||
// This exists for one situation, which is common enough on the bands to be
|
||||
// worth the state: you are working a station, you have been recording, and they
|
||||
// ask to hear it. You stop, you play it back to them on the air, and the
|
||||
// recording is still saved with the QSO afterwards.
|
||||
func (r *Recorder) PauseQSO() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return false
|
||||
}
|
||||
r.paused = true
|
||||
return true
|
||||
}
|
||||
|
||||
// ResumeQSO continues an interrupted take, appending to what is already there.
|
||||
func (r *Recorder) ResumeQSO() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return false
|
||||
}
|
||||
r.paused = false
|
||||
return true
|
||||
}
|
||||
|
||||
// Paused reports whether the active take is frozen.
|
||||
func (r *Recorder) Paused() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.active && r.paused
|
||||
}
|
||||
|
||||
// PeekQSO returns what has been captured so far WITHOUT ending the take.
|
||||
//
|
||||
// Unlike TakeQSO this keeps the audio, because the take is going to be played
|
||||
// back and then still saved with the QSO. The copy is deliberate: the caller
|
||||
// gets bytes it can hold while the recorder keeps appending to its own slice.
|
||||
func (r *Recorder) PeekQSO() ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return nil, fmt.Errorf("no active recording")
|
||||
}
|
||||
if len(r.acc) == 0 {
|
||||
return nil, fmt.Errorf("recording is empty")
|
||||
}
|
||||
return int16sToBytes(append([]int16(nil), r.acc...)), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A silent second source must not silence the recording.
|
||||
//
|
||||
// From a real session: a Flex with "From Radio" on DAX RX and "Recording mic"
|
||||
// on DAX Mic. In CW the mic path does not run, so DAX Mic delivered nothing —
|
||||
// and because the mixer took min(len(A), len(B)) samples, nothing at all was
|
||||
// recorded. The whole QSO ended in "recording was empty".
|
||||
func TestMixerSurvivesASilentSource(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
|
||||
// The radio has been talking; the mic has not spoken for well over the
|
||||
// grace period.
|
||||
r.bufA = make([]int16, 800)
|
||||
for i := range r.bufA {
|
||||
r.bufA[i] = 1000
|
||||
}
|
||||
r.lastA = time.Now()
|
||||
r.lastB = time.Now().Add(-5 * time.Second)
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) == 0 {
|
||||
t.Fatal("nothing was recorded — a silent mic must not stop the radio being captured")
|
||||
}
|
||||
if r.acc[0] != 1000 {
|
||||
t.Errorf("sample = %d, want 1000 — the live source must pass through unchanged", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// While BOTH sources are alive the two are mixed, as before.
|
||||
func TestMixerStillMixesBothSources(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.bufA = []int16{100, 100, 100}
|
||||
r.bufB = []int16{50, 50, 50}
|
||||
now := time.Now()
|
||||
r.lastA, r.lastB = now, now
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 3 {
|
||||
t.Fatalf("recorded %d samples, want 3", len(r.acc))
|
||||
}
|
||||
if r.acc[0] != 150 {
|
||||
t.Errorf("sample = %d, want 150 — both sources should be summed", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Just after capture starts, a source that has not delivered yet is NOT written
|
||||
// off: the first samples take a moment to arrive, and declaring the mic dead at
|
||||
// once would drop the opening of every recording.
|
||||
func TestMixerWaitsBrieflyAtStartup(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now()
|
||||
r.bufA = []int16{100, 100}
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 0 {
|
||||
t.Errorf("recorded %d samples immediately — the second source deserves a moment to start", len(r.acc))
|
||||
}
|
||||
}
|
||||
|
||||
// A source that has NEVER delivered is written off once enough time has passed.
|
||||
//
|
||||
// This is the case that actually happens, and the one the first version missed:
|
||||
// a DAX Mic channel switched off delivers not one sample, so it had no
|
||||
// last-delivery time and stayed forever "not yet dry" — taking the whole
|
||||
// recording down with it while the radio audio streamed perfectly.
|
||||
func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||
r.bufA = []int16{700, 700, 700}
|
||||
r.lastA = time.Now()
|
||||
// lastB stays zero: the mic has never produced anything at all.
|
||||
|
||||
r.mixTick()
|
||||
|
||||
if len(r.acc) != 3 {
|
||||
t.Fatalf("recorded %d samples, want 3 — the radio was streaming the whole time", len(r.acc))
|
||||
}
|
||||
if r.acc[0] != 700 {
|
||||
t.Errorf("sample = %d, want 700", r.acc[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing captured during the grace period is thrown away.
|
||||
//
|
||||
// The drift guard exists for two sound cards running on their own clocks. A
|
||||
// STARVED source is not drift, and treating it as such discarded the radio
|
||||
// audio a second at a time while the mixer was still waiting for the mic — so
|
||||
// the opening of every CW recording was lost although it had been captured.
|
||||
func TestGracePeriodKeepsWhatWasCaptured(t *testing.T) {
|
||||
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
|
||||
r.startedAt = time.Now()
|
||||
|
||||
// Three seconds of radio audio arrive while the mic says nothing at all.
|
||||
r.bufA = make([]int16, 3*sampleRate)
|
||||
for i := range r.bufA {
|
||||
r.bufA[i] = 500
|
||||
}
|
||||
r.lastA = time.Now()
|
||||
r.mixTick() // still inside the grace period: nothing is written yet…
|
||||
|
||||
if len(r.acc) != 0 {
|
||||
t.Fatalf("wrote %d samples before the mic was written off", len(r.acc))
|
||||
}
|
||||
if len(r.bufA) != 3*sampleRate {
|
||||
t.Fatalf("buffered audio was trimmed to %d samples — it must be kept, not dropped", len(r.bufA))
|
||||
}
|
||||
|
||||
// …and once the mic is written off, everything captured comes through.
|
||||
r.startedAt = time.Now().Add(-5 * time.Second)
|
||||
r.mixTick()
|
||||
if len(r.acc) != 3*sampleRate {
|
||||
t.Errorf("recorded %d samples, want the full %d captured during the wait", len(r.acc), 3*sampleRate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
|
||||
// much energy is at 10 kHz" without pulling in an FFT.
|
||||
func goertzel(x []int16, fs, freq float64) float64 {
|
||||
w := 2 * math.Pi * freq / fs
|
||||
c := 2 * math.Cos(w)
|
||||
var s1, s2 float64
|
||||
for _, v := range x {
|
||||
s := float64(v) + c*s1 - s2
|
||||
s2, s1 = s1, s
|
||||
}
|
||||
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
|
||||
}
|
||||
|
||||
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
|
||||
//
|
||||
// MP3 recordings came back with a hiss that is not on the air and is not in the
|
||||
// WAV of the same audio — "louder than the voice", from a station that hears the
|
||||
// voice well clear of the noise. The MP3 path is the only one that resamples,
|
||||
// and it did so by linear interpolation: every component at f is mirrored to
|
||||
// 16 kHz − f, which for broadband receiver noise means a second noise floor
|
||||
// spread across the top of the band.
|
||||
func TestUpsampleSuppressesTheImage(t *testing.T) {
|
||||
const fs = 16000.0
|
||||
const tone = 6000.0 // its image lands at 16000 − 6000 = 10 kHz
|
||||
|
||||
in := make([]int16, 4096)
|
||||
for i := range in {
|
||||
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
|
||||
}
|
||||
|
||||
out := upsample2(in)
|
||||
if len(out) != 2*len(in) {
|
||||
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
|
||||
}
|
||||
|
||||
wanted := goertzel(out, 2*fs, tone)
|
||||
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
|
||||
if wanted == 0 {
|
||||
t.Fatal("the tone itself did not survive upsampling")
|
||||
}
|
||||
db := 20 * math.Log10(image/wanted)
|
||||
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
|
||||
if db > -35 {
|
||||
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
|
||||
}
|
||||
}
|
||||
|
||||
// The filter must not change the level, or every existing recording would come
|
||||
// back quieter (or clipped) after the fix — a second surprise on top of the one
|
||||
// being repaired.
|
||||
func TestUpsampleKeepsTheLevel(t *testing.T) {
|
||||
const fs = 16000.0
|
||||
in := make([]int16, 4096)
|
||||
for i := range in {
|
||||
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
|
||||
}
|
||||
rms := func(x []int16) float64 {
|
||||
var acc float64
|
||||
for _, v := range x {
|
||||
acc += float64(v) * float64(v)
|
||||
}
|
||||
return math.Sqrt(acc / float64(len(x)))
|
||||
}
|
||||
// Skip the filter's warm-up and tail, where the window is only partly fed.
|
||||
out := upsample2(in)
|
||||
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
|
||||
ratio := got / want
|
||||
t.Logf("level after upsampling: ×%.3f", ratio)
|
||||
if ratio < 0.9 || ratio > 1.1 {
|
||||
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
|
||||
}
|
||||
}
|
||||
+190
-14
@@ -6,6 +6,7 @@
|
||||
// QSO may yield several references (e.g. a Note holding "D74 D73").
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// DXCC : field "dxcc" (no pattern) → entity number
|
||||
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
||||
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
||||
@@ -73,11 +74,24 @@ type Def struct {
|
||||
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
|
||||
Prefix string `json:"prefix,omitempty"` // prepended to each found reference (e.g. captured "01" + "D" → "D01"), same as OrRule.Prefix
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
// NOTE: there is no "one reference per QSO" switch, and there was never any
|
||||
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
||||
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
||||
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
||||
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
|
||||
//
|
||||
// This is not the old `Multi` flag, which was removed for good reason: a QSO
|
||||
// always yields every reference its FIELD holds — an n-fer POTA activation
|
||||
// ("US-6544,US-0680"), a VUCC contact on a grid line. That is several
|
||||
// references, correctly.
|
||||
//
|
||||
// The case here is the opposite. The field holds ONE value and two entries in
|
||||
// the reference list carry it: two German DOKs are both called "Gießen",
|
||||
// because two clubs share the town. The matcher is not wrong, the data is
|
||||
// ambiguous — and picking one would write a reference into the log that may
|
||||
// well be the other. So on an award where a QSO can only ever count for one
|
||||
// reference, an ambiguous match assigns NONE and the contact appears under
|
||||
// "missing references", where the operator picks. Their choice then sticks:
|
||||
// a manual reference overrides the matcher.
|
||||
OneRefPerQSO bool `json:"one_ref_per_qso,omitempty"`
|
||||
|
||||
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||
@@ -97,6 +111,18 @@ type Def struct {
|
||||
// --- Confirmation ---
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
// The "custom" source, for confirmations OpsLog has no dedicated column for:
|
||||
// ConfirmField names a QSO field or an ADIF extras key, ConfirmValue the
|
||||
// value(s) that count (comma-separated, case-insensitive).
|
||||
//
|
||||
// An EMPTY ConfirmValue means "any non-empty value confirms" — which is what
|
||||
// the OpsLog card marker needs: APP_OPSLOG_QSL_RCVD stores the timestamp of
|
||||
// the day the card arrived, not a Y/N flag. The same shape serves a key
|
||||
// stamped by an outside source (a club's CSV imported into an extras field),
|
||||
// so one mechanism covers every "confirmed somewhere else" case instead of a
|
||||
// new checkbox per site.
|
||||
ConfirmField string `json:"confirm_field,omitempty"`
|
||||
ConfirmValue string `json:"confirm_value,omitempty"`
|
||||
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
|
||||
// controls have been removed from the editor — a checkbox that quietly does
|
||||
@@ -340,6 +366,12 @@ type Ref struct {
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
|
||||
// "DIGI". An operator asking "which entities have I worked on CW but not
|
||||
// confirmed" does not care whether it was FT8 or RTTY on the digital side,
|
||||
// and a list of twenty mode names would not answer the question they asked.
|
||||
Modes []string `json:"modes"`
|
||||
ConfirmedModes []string `json:"confirmed_modes"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
ValidatedBands []string `json:"validated_bands"`
|
||||
@@ -359,11 +391,33 @@ type Result struct {
|
||||
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
||||
}
|
||||
|
||||
// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
|
||||
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
|
||||
// under any class — better than inventing one, since these classes drive a
|
||||
// filter that decides what the operator is shown.
|
||||
func ModeClass(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW", "CWR":
|
||||
return "CW"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
|
||||
return "PHONE"
|
||||
case "":
|
||||
return ""
|
||||
}
|
||||
// Everything else that a logbook actually carries is a data mode: FT8, FT4,
|
||||
// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
|
||||
// would mean a new mode silently vanishing from the filter the day it
|
||||
// appears, which is worse than treating an unknown data mode as data.
|
||||
return "DIGI"
|
||||
}
|
||||
|
||||
// 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{}
|
||||
modes map[string]struct{}
|
||||
confirmedModes map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
validatedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
@@ -480,22 +534,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
continue
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
isVal := confirmed(q, d.Validate)
|
||||
modeClass := ModeClass(q.Mode)
|
||||
isConf := confirmed(q, d.Confirm, d)
|
||||
isVal := confirmed(q, d.Validate, d)
|
||||
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{}{}}
|
||||
a = &refAgg{
|
||||
bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
|
||||
validatedBands: map[string]struct{}{},
|
||||
modes: map[string]struct{}{}, confirmedModes: map[string]struct{}{},
|
||||
}
|
||||
agg[i][ref] = a
|
||||
}
|
||||
if band != "" {
|
||||
a.bands[band] = struct{}{}
|
||||
}
|
||||
if modeClass != "" {
|
||||
a.modes[modeClass] = struct{}{}
|
||||
}
|
||||
if isConf {
|
||||
a.anyConfirmed = true
|
||||
if band != "" {
|
||||
a.confirmedBands[band] = struct{}{}
|
||||
}
|
||||
if modeClass != "" {
|
||||
a.confirmedModes[modeClass] = struct{}{}
|
||||
}
|
||||
}
|
||||
if isVal {
|
||||
a.anyValidated = true
|
||||
@@ -524,6 +589,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
r.Validated++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||
Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
|
||||
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)
|
||||
@@ -631,8 +697,9 @@ func manualRefs(q *qso.QSO, code string) []string {
|
||||
}
|
||||
|
||||
// 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) }
|
||||
// sources (lotw|qsl|eqsl|qrzcom|custom). Exported for the statistics view.
|
||||
// The Def is needed for the "custom" source, which reads the field IT names.
|
||||
func Confirmed(q *qso.QSO, d Def, sources []string) bool { return confirmed(q, sources, &d) }
|
||||
|
||||
// InScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||
// bands, modes, emission, dates) — independent of whether a reference was
|
||||
@@ -726,6 +793,10 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
||||
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
|
||||
|
||||
var found []string
|
||||
// codesAreFinal: this branch produced references straight from the award's
|
||||
// LIST, so they are already whole codes. The blanket prefix at the end must
|
||||
// leave them alone — prefixing a code that is already "D74" yields "DD74".
|
||||
codesAreFinal := false
|
||||
switch {
|
||||
case re != nil:
|
||||
// Award-level regex: capture group 1 (or whole match) for each hit.
|
||||
@@ -734,6 +805,7 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
||||
// 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.
|
||||
codesAreFinal = true
|
||||
up := strings.ToUpper(raw)
|
||||
for _, nc := range rl.names {
|
||||
if exact {
|
||||
@@ -756,9 +828,23 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
||||
// "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.
|
||||
codesAreFinal = true
|
||||
for _, tok := range tokenize(raw) {
|
||||
if _, ok := rl.byCode[tok]; ok {
|
||||
found = append(found, tok)
|
||||
continue
|
||||
}
|
||||
// The field may carry the reference WITHOUT the award's letter: a French
|
||||
// operator writes "74" in STATE while the DDFM codes are "D74". Prefix is
|
||||
// exactly what that case is for, so try the prefixed form HERE — applying
|
||||
// it after the lookup (as the blanket pass below used to) could never
|
||||
// help, because the lookup is the step that failed. Without this, the
|
||||
// only way to match a bare department was to write a regex, in a mode
|
||||
// where the operator had chosen "code" and not "pattern".
|
||||
if prefix != "" {
|
||||
if _, ok := rl.byCode[prefix+tok]; ok {
|
||||
found = append(found, prefix+tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, code := range rl.withPattern {
|
||||
@@ -772,7 +858,7 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
||||
// counts each reference separately.
|
||||
found = splitRefs(raw)
|
||||
}
|
||||
if prefix != "" {
|
||||
if prefix != "" && !codesAreFinal {
|
||||
for i := range found {
|
||||
found[i] = prefix + found[i]
|
||||
}
|
||||
@@ -818,6 +904,14 @@ type Explanation struct {
|
||||
RefCount int `json:"ref_count"` // size of that list
|
||||
Steps []Step `json:"steps"`
|
||||
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||
// Superseded is what the matcher had found before the operator's choice
|
||||
// replaced it — shown in the trace so a correction is explainable rather
|
||||
// than mysterious.
|
||||
Superseded []string `json:"superseded,omitempty"`
|
||||
// Ambiguous lists the references that matched when the award allows only one
|
||||
// — none of them was kept. Shown so the operator sees WHICH ones to choose
|
||||
// between rather than an unexplained blank.
|
||||
Ambiguous []string `json:"ambiguous,omitempty"`
|
||||
Result []string `json:"result"` // what the QSO finally counts for
|
||||
}
|
||||
|
||||
@@ -899,7 +993,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
// Testing the raw candidate would call that a hit, skip every fallback, and
|
||||
// only then drop it as unlisted — leaving the QSO unmatched even though the
|
||||
// next rule ("find the code inside the QTH") would have found BG.
|
||||
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
|
||||
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, d.Prefix)
|
||||
for i := range d.OrRules {
|
||||
r := &d.OrRules[i]
|
||||
label := fmt.Sprintf("OR %d", i+1)
|
||||
@@ -936,8 +1030,31 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
if ex != nil {
|
||||
ex.Manual = manual
|
||||
}
|
||||
found = append(found, manual...)
|
||||
// An operator's choice REPLACES what the matcher found. It does not join it.
|
||||
//
|
||||
// Adding to it was the behaviour, and it made a correction impossible: a
|
||||
// German address matching two or three DOKs kept them all, so deleting the
|
||||
// wrong ones and assigning the right one lasted exactly until the next
|
||||
// recompute, which put them straight back. The operator was arguing with
|
||||
// the matcher and could not win.
|
||||
//
|
||||
// One manual entry is stored per award (setOverrideRef drops the previous
|
||||
// one), so this is an override in the storage as well as in the name.
|
||||
if len(manual) > 0 {
|
||||
if ex != nil {
|
||||
ex.Superseded = dedupe(found)
|
||||
}
|
||||
found = manual
|
||||
}
|
||||
out := dedupe(found)
|
||||
// Ambiguity is not a result. See OneRefPerQSO.
|
||||
if d.OneRefPerQSO && len(manual) == 0 && len(out) > 1 {
|
||||
if ex != nil {
|
||||
ex.Ambiguous = out
|
||||
ex.Result = []string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ex != nil {
|
||||
ex.Result = out
|
||||
}
|
||||
@@ -1289,7 +1406,7 @@ func dxccAllowed(dxcc *int, filter []int) bool {
|
||||
|
||||
// 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 {
|
||||
func confirmed(q *qso.QSO, sources []string, d *Def) bool {
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "lotw":
|
||||
@@ -1304,11 +1421,66 @@ func confirmed(q *qso.QSO, sources []string) bool {
|
||||
if isYes(q.EQSLRcvd) {
|
||||
return true
|
||||
}
|
||||
case "qrzcom":
|
||||
// The DOWNLOAD status, not the upload one: uploading a QSO to QRZ is
|
||||
// us telling them, not them confirming it back.
|
||||
if isYes(q.QRZComDownloadStatus) {
|
||||
return true
|
||||
}
|
||||
case "custom":
|
||||
if customConfirmed(q, d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// customConfirmed answers the operator-defined confirmation source: the field
|
||||
// named by ConfirmField, optionally required to hold one of ConfirmValue's
|
||||
// comma-separated values.
|
||||
//
|
||||
// A source that names no field confirms NOTHING. That is deliberate: ticking
|
||||
// "custom" without saying what it means used to mark every QSO as unconfirmed
|
||||
// anyway, and silently marking them all CONFIRMED instead would be far worse.
|
||||
func customConfirmed(q *qso.QSO, d *Def) bool {
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
field := strings.TrimSpace(d.ConfirmField)
|
||||
if field == "" {
|
||||
return false
|
||||
}
|
||||
got := strings.TrimSpace(customFieldValue(q, field))
|
||||
if got == "" {
|
||||
return false
|
||||
}
|
||||
want := strings.TrimSpace(d.ConfirmValue)
|
||||
if want == "" {
|
||||
return true // any value at all counts — see ConfirmField's doc
|
||||
}
|
||||
for _, w := range strings.Split(want, ",") {
|
||||
if w = strings.TrimSpace(w); w != "" && strings.EqualFold(w, got) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// customFieldValue resolves the field a custom confirmation names: one of the
|
||||
// engine's known QSO fields first, then the ADIF extras — which is where the
|
||||
// OpsLog card marker (APP_OPSLOG_QSL_RCVD) and anything stamped by an outside
|
||||
// import actually live.
|
||||
func customFieldValue(q *qso.QSO, field string) string {
|
||||
if v := strings.TrimSpace(fieldRaw(field, q)); v != "" {
|
||||
return v
|
||||
}
|
||||
if q.Extras == nil {
|
||||
return ""
|
||||
}
|
||||
return q.Extras[strings.ToUpper(strings.TrimSpace(field))]
|
||||
}
|
||||
|
||||
func isYes(v string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(v)) {
|
||||
case "Y", "V":
|
||||
@@ -1326,7 +1498,11 @@ func setToSorted(m map[string]struct{}) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
|
||||
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
|
||||
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
|
||||
// Microwave. A band missing from this order sorts to the end of an award's
|
||||
// band columns rather than in frequency order — and 3 cm was missing.
|
||||
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
|
||||
|
||||
func sortedBands(m map[string]int) []string {
|
||||
idx := map[string]int{}
|
||||
|
||||
@@ -4,11 +4,27 @@
|
||||
"name": "Départements Français Métropolitains",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"ref_display": "name",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "note",
|
||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "address",
|
||||
"match_by": "code",
|
||||
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||
"prefix": "D"
|
||||
},
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "code",
|
||||
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||
"prefix": "D"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
227
|
||||
227,
|
||||
214
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
@@ -18,6 +34,777 @@
|
||||
"lotw"
|
||||
],
|
||||
"total": 96,
|
||||
"builtin": true
|
||||
"builtin": true,
|
||||
"version": 2
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "D01",
|
||||
"name": "Ain",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D02",
|
||||
"name": "Aisne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D03",
|
||||
"name": "Allier",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D04",
|
||||
"name": "Alpes-de-Haute-Provence",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D05",
|
||||
"name": "Hautes-Alpes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D06",
|
||||
"name": "Alpes-Maritimes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D07",
|
||||
"name": "Ardèche",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D08",
|
||||
"name": "Ardennes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D09",
|
||||
"name": "Ariège",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D10",
|
||||
"name": "Aube",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D11",
|
||||
"name": "Aude",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D12",
|
||||
"name": "Aveyron",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D13",
|
||||
"name": "Bouches-du-Rhône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D14",
|
||||
"name": "Calvados",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D15",
|
||||
"name": "Cantal",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D16",
|
||||
"name": "Charente",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D17",
|
||||
"name": "Charente-Maritime",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D18",
|
||||
"name": "Cher",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D19",
|
||||
"name": "Corrèze",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D21",
|
||||
"name": "Côte-d'Or",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D22",
|
||||
"name": "Côtes-d'Armor",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D23",
|
||||
"name": "Creuse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D24",
|
||||
"name": "Dordogne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D25",
|
||||
"name": "Doubs",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D26",
|
||||
"name": "Drôme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D27",
|
||||
"name": "Eure",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D28",
|
||||
"name": "Eure-et-Loir",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D29",
|
||||
"name": "Finistère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D2A",
|
||||
"name": "Corse-du-Sud",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D2B",
|
||||
"name": "Haute-Corse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D30",
|
||||
"name": "Gard",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D31",
|
||||
"name": "Haute-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D32",
|
||||
"name": "Gers",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D33",
|
||||
"name": "Gironde",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D34",
|
||||
"name": "Hérault",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D35",
|
||||
"name": "Ille-et-Vilaine",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D36",
|
||||
"name": "Indre",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D37",
|
||||
"name": "Indre-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D38",
|
||||
"name": "Isère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D39",
|
||||
"name": "Jura",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D40",
|
||||
"name": "Landes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D41",
|
||||
"name": "Loir-et-Cher",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D42",
|
||||
"name": "Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D43",
|
||||
"name": "Haute-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D44",
|
||||
"name": "Loire-Atlantique",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D45",
|
||||
"name": "Loiret",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D46",
|
||||
"name": "Lot",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D47",
|
||||
"name": "Lot-et-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D48",
|
||||
"name": "Lozère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D49",
|
||||
"name": "Maine-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D50",
|
||||
"name": "Manche",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D51",
|
||||
"name": "Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D52",
|
||||
"name": "Haute-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D53",
|
||||
"name": "Mayenne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D54",
|
||||
"name": "Meurthe-et-Moselle",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D55",
|
||||
"name": "Meuse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D56",
|
||||
"name": "Morbihan",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D57",
|
||||
"name": "Moselle",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D58",
|
||||
"name": "Nièvre",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D59",
|
||||
"name": "Nord",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D60",
|
||||
"name": "Oise",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D61",
|
||||
"name": "Orne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D62",
|
||||
"name": "Pas-de-Calais",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D63",
|
||||
"name": "Puy-de-Dôme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D64",
|
||||
"name": "Pyrénées-Atlantiques",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D65",
|
||||
"name": "Hautes-Pyrénées",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D66",
|
||||
"name": "Pyrénées-Orientales",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D67",
|
||||
"name": "Bas-Rhin",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D68",
|
||||
"name": "Haut-Rhin",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D69",
|
||||
"name": "Rhône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D70",
|
||||
"name": "Haute-Saône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D71",
|
||||
"name": "Saône-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D72",
|
||||
"name": "Sarthe",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D73",
|
||||
"name": "Savoie",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D74",
|
||||
"name": "Haute-Savoie",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D75",
|
||||
"name": "Paris",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D76",
|
||||
"name": "Seine-Maritime",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D77",
|
||||
"name": "Seine-et-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D78",
|
||||
"name": "Yvelines",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D79",
|
||||
"name": "Deux-Sèvres",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D80",
|
||||
"name": "Somme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D81",
|
||||
"name": "Tarn",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D82",
|
||||
"name": "Tarn-et-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D83",
|
||||
"name": "Var",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D84",
|
||||
"name": "Vaucluse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D85",
|
||||
"name": "Vendée",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D86",
|
||||
"name": "Vienne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D87",
|
||||
"name": "Haute-Vienne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D88",
|
||||
"name": "Vosges",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D89",
|
||||
"name": "Yonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D90",
|
||||
"name": "Territoire de Belfort",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D91",
|
||||
"name": "Essonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D92",
|
||||
"name": "Hauts-de-Seine",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D93",
|
||||
"name": "Seine-Saint-Denis",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D94",
|
||||
"name": "Val-de-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D95",
|
||||
"name": "Val-d'Oise",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user