Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9f2e515e1 | |||
| 260172cd6d | |||
| 4b5e2e0b72 | |||
| 4a6ea45665 | |||
| 95d37da3bb | |||
| e1b3f0faf3 | |||
| 6379e2cd1f | |||
| 2228816057 | |||
| 32878c17be | |||
| 079d0c32df | |||
| 45d081ac0c | |||
| 183db7ac2b | |||
| 4d074de27e | |||
| 679e8f8d39 | |||
| dd2deee939 | |||
| cdd71b17c8 | |||
| e8eedcc1dc | |||
| 3c47366f56 | |||
| bd11bb4763 | |||
| 40e95e6a16 | |||
| cc0f9ffc64 | |||
| e1f1ab4922 | |||
| b6d991b799 | |||
| 59f1775fcd |
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go:*)",
|
||||||
|
"Bash(gofmt:*)",
|
||||||
|
"Bash(/c/Users/legre/go/bin/wails:*)",
|
||||||
|
"Bash(wails:*)",
|
||||||
|
"Bash(npm:*)",
|
||||||
|
"Bash(npx:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(rg:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(head:*)",
|
||||||
|
"Bash(tail:*)",
|
||||||
|
"Bash(awk:*)",
|
||||||
|
"Bash(sed:*)",
|
||||||
|
"Bash(sort:*)",
|
||||||
|
"Bash(uniq:*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(xargs:*)",
|
||||||
|
"Bash(for f in *)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git show:*)",
|
||||||
|
"Bash(curl -sI --max-time 10 https://raw.githubusercontent.com/google/fonts/main/ofl/archivoblack/ArchivoBlack-Regular.ttf)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ desktop.ini
|
|||||||
hamlog.db*
|
hamlog.db*
|
||||||
cty.dat
|
cty.dat
|
||||||
cat.log
|
cat.log
|
||||||
|
*.adi
|
||||||
|
|
||||||
# --- Secrets ---
|
# --- Secrets ---
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -1,19 +1,125 @@
|
|||||||
# README
|
# OpsLog
|
||||||
|
|
||||||
## About
|
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||||
|
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
|
||||||
|
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||||
|
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||||
|
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||||
|
|
||||||
This is the official Wails Svelte-TS template.
|
Developed by **F4BPO**.
|
||||||
|
|
||||||
## Live Development
|
---
|
||||||
|
|
||||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
## Building / developing
|
||||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
|
||||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
|
||||||
to this in your browser, and you can call your Go code from devtools.
|
|
||||||
|
|
||||||
## Building
|
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
|
||||||
|
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
|
||||||
|
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
|
||||||
|
`wails generate module`.
|
||||||
|
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* →
|
||||||
|
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
|
||||||
|
and publishes it to Gitea + GitHub releases.
|
||||||
|
|
||||||
To build a redistributable, production mode package, use `wails build`.
|
---
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||||
|
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
||||||
|
**flag** is shown large next to the RST fields.
|
||||||
|
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||||
|
and the QRZ.com tab.
|
||||||
|
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||||
|
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
|
||||||
|
date overrides.
|
||||||
|
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||||
|
from cty/QRZ/ClubLog, bulk send to QSL services.
|
||||||
|
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
||||||
|
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
||||||
|
|
||||||
|
## Maps & antenna
|
||||||
|
|
||||||
|
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||||
|
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||||
|
worked-before grid, or the **FlexRadio controls**.
|
||||||
|
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||||
|
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||||
|
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||||
|
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
|
||||||
|
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
||||||
|
direction is shown in green and the **mechanical boom** in grey, on both the
|
||||||
|
compass and the map, so you never lose track of where the antenna points.
|
||||||
|
|
||||||
|
## DX Cluster
|
||||||
|
|
||||||
|
- Multiple cluster servers with auto-reconnect, a master for commands.
|
||||||
|
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
|
||||||
|
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||||
|
pane, with a show/hide toggle.
|
||||||
|
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||||
|
rig, and a multi-band **Band Map** (panadapter-style strips).
|
||||||
|
|
||||||
|
## CAT control
|
||||||
|
|
||||||
|
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
|
||||||
|
backend over the radio's TCP API — real-time slice freq/mode/split, auto
|
||||||
|
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
|
||||||
|
Flex display, click → fill the call).
|
||||||
|
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
|
||||||
|
from the frequency.
|
||||||
|
|
||||||
|
### FlexRadio control tab (SmartSDR-style)
|
||||||
|
|
||||||
|
Shown only when the CAT backend is a FlexRadio:
|
||||||
|
|
||||||
|
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
||||||
|
VOX (+ level + delay), monitor (+ level), mic gain.
|
||||||
|
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
||||||
|
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||||
|
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
||||||
|
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||||
|
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||||
|
|
||||||
|
## Keyers & audio
|
||||||
|
|
||||||
|
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
|
||||||
|
- **Digital Voice Keyer** (DVK) message playback.
|
||||||
|
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
|
||||||
|
audio in CW).
|
||||||
|
|
||||||
|
## QSL & awards
|
||||||
|
|
||||||
|
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||||
|
across profiles), worked/confirmed/validated by band & mode, OR rules and
|
||||||
|
manual reference assignment, live reference detection on call entry, and a
|
||||||
|
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
|
||||||
|
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||||
|
and **confirmation download** (which auto-refreshes the award stats).
|
||||||
|
- **QSL Card Designer** (see below).
|
||||||
|
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
||||||
|
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||||
|
password, or a Microsoft app password.)
|
||||||
|
|
||||||
|
## Multi-operator live status (special events)
|
||||||
|
|
||||||
|
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
||||||
|
Settings → General → *Publish live operator status*. Each OpsLog instance
|
||||||
|
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||||
|
`live_status` table every ~15 s. A small PHP renderer
|
||||||
|
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
||||||
|
own web server reads that table and produces a live page/image you can embed on
|
||||||
|
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||||
|
only writes to the DB — it is not a web server.
|
||||||
|
|
||||||
|
## Other
|
||||||
|
|
||||||
|
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||||
|
OpsLog startup, skipping any already running.
|
||||||
|
- **Update check** at startup with a toast (toggleable).
|
||||||
|
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||||
|
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## QSL Card Designer
|
## QSL Card Designer
|
||||||
|
|
||||||
@@ -40,3 +146,13 @@ Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
|
|||||||
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
||||||
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
||||||
commonly-worked DXCC entities.
|
commonly-worked DXCC entities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data & storage
|
||||||
|
|
||||||
|
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
||||||
|
award lists, QSL templates) always lives in the local SQLite file under
|
||||||
|
`data/` — instant even when the logbook is on a far-away MySQL.
|
||||||
|
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
||||||
|
file or a per-profile shared **MySQL** database.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"hamlog/internal/backup"
|
"hamlog/internal/backup"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
|
"hamlog/internal/cwdecode"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
@@ -190,13 +191,25 @@ const (
|
|||||||
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
|
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
|
||||||
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
|
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
|
||||||
|
|
||||||
|
keyExtHRDLogCallsign = "extsvc.hrdlog.callsign"
|
||||||
|
keyExtHRDLogCode = "extsvc.hrdlog.code" // HRDLog account upload code
|
||||||
|
keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
|
||||||
|
keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
|
||||||
|
|
||||||
|
keyExtEQSLUsername = "extsvc.eqsl.username"
|
||||||
|
keyExtEQSLPassword = "extsvc.eqsl.password"
|
||||||
|
keyExtEQSLQTHNick = "extsvc.eqsl.qth_nickname"
|
||||||
|
keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
|
||||||
|
keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"
|
||||||
|
|
||||||
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
||||||
|
|
||||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||||
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
||||||
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
|
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
|
||||||
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
|
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
|
||||||
|
keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
|
||||||
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||||||
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
||||||
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
|
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
|
||||||
@@ -366,6 +379,11 @@ type App struct {
|
|||||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||||
|
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||||
|
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||||
|
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||||
|
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
|
||||||
|
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
@@ -523,9 +541,23 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
||||||
// before opening it.
|
// before opening it.
|
||||||
if custom := readDBPointer(dataDir); custom != "" {
|
if custom := readDBPointer(dataDir); custom != "" {
|
||||||
|
// Portability guard: a pointer that is merely ANOTHER folder's default DB
|
||||||
|
// location ("…/<other>/data/opslog.db") means the portable folder was
|
||||||
|
// renamed or copied — its config.json still points at the original. Ignore
|
||||||
|
// it and use THIS folder's own data (and clear the stale pointer so it
|
||||||
|
// stops happening). A genuine custom location — another drive, a different
|
||||||
|
// filename — is NOT default-style, so it's still honoured.
|
||||||
|
stale := strings.EqualFold(filepath.Base(custom), "opslog.db") &&
|
||||||
|
strings.EqualFold(filepath.Base(filepath.Dir(custom)), "data") &&
|
||||||
|
!strings.EqualFold(filepath.Clean(filepath.Dir(custom)), filepath.Clean(dataDir))
|
||||||
|
if stale {
|
||||||
|
fmt.Printf("OpsLog: ignoring stale DB pointer %q (folder moved) — using %s\n", custom, a.dbPath)
|
||||||
|
_ = writeDBPointer(dataDir, "")
|
||||||
|
} else {
|
||||||
a.dbPath = custom
|
a.dbPath = custom
|
||||||
usingDefault = false
|
usingDefault = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||||
a.startupErr = "cannot create db folder: " + err.Error()
|
a.startupErr = "cannot create db folder: " + err.Error()
|
||||||
fmt.Println("OpsLog:", a.startupErr)
|
fmt.Println("OpsLog:", a.startupErr)
|
||||||
@@ -582,6 +614,22 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("OpsLog: EnsureDefault profile:", err)
|
fmt.Println("OpsLog: EnsureDefault profile:", err)
|
||||||
}
|
}
|
||||||
|
// A "--profile <name>" command-line argument selects which profile to start
|
||||||
|
// on (so a desktop shortcut can launch OpsLog straight into F4BPO or TM2Q).
|
||||||
|
// Match by name, case-insensitive; activate it before any per-profile wiring.
|
||||||
|
if want := strings.TrimSpace(a.startupProfile); want != "" {
|
||||||
|
if list, lerr := a.profiles.List(a.ctx); lerr == nil {
|
||||||
|
for _, p := range list {
|
||||||
|
if strings.EqualFold(p.Name, want) {
|
||||||
|
if serr := a.profiles.SetActive(a.ctx, p.ID); serr == nil {
|
||||||
|
active = p
|
||||||
|
fmt.Printf("OpsLog: started on profile %q (from --profile)\n", p.Name)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
a.settings.SetProfile(active.ID)
|
a.settings.SetProfile(active.ID)
|
||||||
a.awardRefs = awardref.NewRepo(conn)
|
a.awardRefs = awardref.NewRepo(conn)
|
||||||
a.qslTemplates = qslcard.NewRepo(conn)
|
a.qslTemplates = qslcard.NewRepo(conn)
|
||||||
@@ -715,6 +763,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
NotifyError: a.notifyExtError,
|
NotifyError: a.notifyExtError,
|
||||||
ShouldUpload: a.extShouldUpload,
|
ShouldUpload: a.extShouldUpload,
|
||||||
StationCallOf: a.stationCallOf,
|
StationCallOf: a.stationCallOf,
|
||||||
|
CloseUploadIDs: a.closeUploadIDs,
|
||||||
Logf: applog.Printf,
|
Logf: applog.Printf,
|
||||||
})
|
})
|
||||||
a.extsvc.SetConfig(a.loadExternalServices())
|
a.extsvc.SetConfig(a.loadExternalServices())
|
||||||
@@ -774,6 +823,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// when disabled in Preferences or until the PostHog key is configured.
|
// when disabled in Preferences or until the PostHog key is configured.
|
||||||
go a.sendTelemetryHeartbeat()
|
go a.sendTelemetryHeartbeat()
|
||||||
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
|
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
|
||||||
|
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
|
||||||
|
|
||||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||||
}
|
}
|
||||||
@@ -841,7 +891,7 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
if n := a.extsvc.PendingCount(); n > 0 {
|
if n := a.extsvc.CloseUploadCount(); n > 0 {
|
||||||
out = append(out, shutdownStep{
|
out = append(out, shutdownStep{
|
||||||
ID: "extsvc-upload",
|
ID: "extsvc-upload",
|
||||||
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
|
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
|
||||||
@@ -1452,7 +1502,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
a.applyStationDefaults(&q)
|
a.applyStationDefaults(&q, true)
|
||||||
a.applyDXCCNumber(&q)
|
a.applyDXCCNumber(&q)
|
||||||
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
@@ -1575,7 +1625,7 @@ func (a *App) refineDistrictZones(q *qso.QSO) {
|
|||||||
// currently-active profile's values. Multi-profile support means a user
|
// currently-active profile's values. Multi-profile support means a user
|
||||||
// can be /P with a different callsign + grid + SOTA ref than home — the
|
// can be /P with a different callsign + grid + SOTA ref than home — the
|
||||||
// QSO carries whichever profile was selected at log time.
|
// QSO carries whichever profile was selected at log time.
|
||||||
func (a *App) applyStationDefaults(q *qso.QSO) {
|
func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
|
||||||
if a.profiles == nil {
|
if a.profiles == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1583,15 +1633,17 @@ func (a *App) applyStationDefaults(q *qso.QSO) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if q.StationCallsign == "" {
|
// STATION_CALLSIGN drives upload routing, so only stamp it on NEW QSOs — on
|
||||||
|
// import backfill, stamping the active call onto a QSO that lacked one could
|
||||||
|
// misroute it in a mixed-call log.
|
||||||
|
if includeIdentity && q.StationCallsign == "" {
|
||||||
q.StationCallsign = p.Callsign
|
q.StationCallsign = p.Callsign
|
||||||
}
|
}
|
||||||
|
// OPERATOR and OWNER_CALLSIGN are descriptive (not used for routing), so fill
|
||||||
|
// them whenever empty — including on import.
|
||||||
if q.Operator == "" {
|
if q.Operator == "" {
|
||||||
q.Operator = p.Operator
|
q.Operator = p.Operator
|
||||||
}
|
}
|
||||||
// OWNER_CALLSIGN is a valid ADIF field but not a promoted column, so it
|
|
||||||
// lives in Extras (exported verbatim, round-trips, and is filterable via
|
|
||||||
// json_extract). Stamp it from the active profile when set.
|
|
||||||
if strings.TrimSpace(p.OwnerCallsign) != "" {
|
if strings.TrimSpace(p.OwnerCallsign) != "" {
|
||||||
if q.Extras == nil {
|
if q.Extras == nil {
|
||||||
q.Extras = map[string]string{}
|
q.Extras = map[string]string{}
|
||||||
@@ -2691,6 +2743,64 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AwardRefsForQSOs returns, per QSO id, a map of award code → the reference(s)
|
||||||
|
// that QSO contributes to (joined when several). Powers the per-award columns in
|
||||||
|
// the Recent QSOs / Worked-before grids. The reference metadata is computed ONCE
|
||||||
|
// for the whole batch so a page of QSOs stays cheap.
|
||||||
|
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
|
||||||
|
out := map[int64]map[string]string{}
|
||||||
|
if a.qso == nil || len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
defs := a.awardDefs()
|
||||||
|
metas := a.awardRefMetas(defs)
|
||||||
|
fieldByCode := map[string]string{}
|
||||||
|
for _, d := range defs {
|
||||||
|
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||||||
|
}
|
||||||
|
nameOf := func(field, ref string) string {
|
||||||
|
switch field {
|
||||||
|
case "dxcc":
|
||||||
|
if n, err := strconv.Atoi(ref); err == nil {
|
||||||
|
return dxcc.NameForDXCC(n)
|
||||||
|
}
|
||||||
|
case "cont":
|
||||||
|
return continentName(ref)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||||
|
a.enrichQSOForAwards(&q)
|
||||||
|
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf)
|
||||||
|
m := map[string]string{}
|
||||||
|
for i := range results {
|
||||||
|
r := &results[i]
|
||||||
|
code := strings.ToUpper(r.Code)
|
||||||
|
dxccField := fieldByCode[code] == "dxcc"
|
||||||
|
var refs []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if !rf.Worked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// DXCC's ref is a number → show the country name instead.
|
||||||
|
label := rf.Ref
|
||||||
|
if dxccField && rf.Name != "" {
|
||||||
|
label = rf.Name
|
||||||
|
}
|
||||||
|
refs = append(refs, label)
|
||||||
|
}
|
||||||
|
if len(refs) > 0 {
|
||||||
|
m[code] = strings.Join(refs, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m) > 0 {
|
||||||
|
out[q.ID] = m
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// AwardRefMeta describes a reference list's state for the UI.
|
// AwardRefMeta describes a reference list's state for the UI.
|
||||||
type AwardRefMeta struct {
|
type AwardRefMeta struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -3174,6 +3284,15 @@ func (a *App) DeleteQSO(id int64) error {
|
|||||||
return a.qso.Delete(a.ctx, id)
|
return a.qso.Delete(a.ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
||||||
|
// number actually deleted.
|
||||||
|
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
return a.qso.DeleteMany(a.ctx, ids)
|
||||||
|
}
|
||||||
|
|
||||||
// QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty
|
// QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty
|
||||||
// string leaves that field unchanged (so you can set only "received = Y + date"
|
// string leaves that field unchanged (so you can set only "received = Y + date"
|
||||||
// without touching the sent side).
|
// without touching the sent side).
|
||||||
@@ -3227,6 +3346,70 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bulkFieldColumns maps the UI field ids to their QSO column. Kept in the app
|
||||||
|
// layer so the frontend works with stable ids, not raw column names.
|
||||||
|
var bulkFieldColumns = map[string]string{
|
||||||
|
// QSL / upload status
|
||||||
|
"lotw_sent": "lotw_sent",
|
||||||
|
"lotw_rcvd": "lotw_rcvd",
|
||||||
|
"eqsl_sent": "eqsl_sent",
|
||||||
|
"eqsl_rcvd": "eqsl_rcvd",
|
||||||
|
"qsl_sent": "qsl_sent",
|
||||||
|
"qsl_rcvd": "qsl_rcvd",
|
||||||
|
"qsl_via": "qsl_via",
|
||||||
|
"qrz_upload": "qrzcom_qso_upload_status",
|
||||||
|
"clublog_upload": "clublog_qso_upload_status",
|
||||||
|
"hrdlog_upload": "hrdlog_qso_upload_status",
|
||||||
|
// My station / operator
|
||||||
|
"station_callsign": "station_callsign",
|
||||||
|
"operator": "operator",
|
||||||
|
"my_grid": "my_grid",
|
||||||
|
"my_country": "my_country",
|
||||||
|
"my_state": "my_state",
|
||||||
|
"my_cnty": "my_cnty",
|
||||||
|
"my_iota": "my_iota",
|
||||||
|
"my_sota_ref": "my_sota_ref",
|
||||||
|
"my_pota_ref": "my_pota_ref",
|
||||||
|
"my_wwff_ref": "my_wwff_ref",
|
||||||
|
"my_street": "my_street",
|
||||||
|
"my_city": "my_city",
|
||||||
|
"my_postal_code": "my_postal_code",
|
||||||
|
"my_rig": "my_rig",
|
||||||
|
"my_antenna": "my_antenna",
|
||||||
|
"my_sig": "my_sig",
|
||||||
|
"my_sig_info": "my_sig_info",
|
||||||
|
// Misc text
|
||||||
|
"comment": "comment",
|
||||||
|
"notes": "notes",
|
||||||
|
"rig": "rig",
|
||||||
|
"ant": "ant",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkUpdateField sets one QSL/upload status field to value on the given QSOs
|
||||||
|
// (e.g. flip a filtered set from N to R so they upload). field is one of the
|
||||||
|
// ids in bulkFieldColumns; value is a status code (Y/N/R/I) or "" to clear.
|
||||||
|
// Returns how many rows were updated.
|
||||||
|
func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
col, ok := bulkFieldColumns[field]
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("unknown field %q", field)
|
||||||
|
}
|
||||||
|
// Trim only — do NOT force case here: status codes arrive already upper from
|
||||||
|
// the UI, while free-text fields (address, antenna, comment…) must keep
|
||||||
|
// their case. Callsign/grid uppercasing is handled in the UI.
|
||||||
|
n, err := a.qso.BulkSetField(a.ctx, ids, col, strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
a.invalidateAwardStats()
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// WorkedBefore returns prior contacts with the given callsign at both
|
// WorkedBefore returns prior contacts with the given callsign at both
|
||||||
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
||||||
// will infer it from past QSOs with the same call when possible.
|
// will infer it from past QSOs with the same call when possible.
|
||||||
@@ -3317,17 +3500,19 @@ func (a *App) OpenADIFFile() (string, error) {
|
|||||||
// cty.dat for every record, overriding what the file carries — corrects the
|
// cty.dat for every record, overriding what the file carries — corrects the
|
||||||
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
|
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
|
||||||
// Russia). Everything else in the ADIF is still preserved verbatim.
|
// Russia). Everything else in the ADIF is still preserved verbatim.
|
||||||
func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.ImportResult, error) {
|
func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool) (adif.ImportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ImportResult{}, fmt.Errorf("empty path")
|
return adif.ImportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
// Import preserves the ADIF verbatim — NO station / confirmation defaults
|
// Import preserves the ADIF verbatim by default — confirmation/sent-status
|
||||||
// are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log);
|
// defaults are NEVER applied (they'd flag old QSOs "LoTW requested" and try to
|
||||||
// stamping them on a historical import would, e.g., flag old QSOs as
|
// re-upload). When applyStation is on, we DO backfill empty MY_* station
|
||||||
// "LoTW requested" and try to re-upload them.
|
// fields (grid/rig/antenna/QTH/address…) from the active profile — those are
|
||||||
|
// descriptive metadata and safe to fill (identity fields are still left
|
||||||
|
// alone, see applyStationDefaults).
|
||||||
im := &adif.Importer{Repo: a.qso}
|
im := &adif.Importer{Repo: a.qso}
|
||||||
switch dupMode {
|
switch dupMode {
|
||||||
case "update":
|
case "update":
|
||||||
@@ -3348,13 +3533,20 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor
|
|||||||
_ = a.clublog.EnsureLoaded()
|
_ = a.clublog.EnsureLoaded()
|
||||||
}
|
}
|
||||||
clLoaded := a.clublog != nil && a.clublog.Loaded()
|
clLoaded := a.clublog != nil && a.clublog.Loaded()
|
||||||
if applyCty {
|
if applyCty || applyStation {
|
||||||
im.Enrich = func(q *qso.QSO) {
|
im.Enrich = func(q *qso.QSO) {
|
||||||
|
if applyCty {
|
||||||
a.enrichContactedFromCtyForce(q)
|
a.enrichContactedFromCtyForce(q)
|
||||||
if clLoaded {
|
if clLoaded {
|
||||||
a.applyClublogException(q, true) // force: explicit import-time correction
|
a.applyClublogException(q, true) // force: explicit import-time correction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if applyStation {
|
||||||
|
// Backfill empty MY_* descriptive fields from the active profile
|
||||||
|
// (identity fields left alone to keep mixed-call routing intact).
|
||||||
|
a.applyStationDefaults(q, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
im.OnProgress = func(processed, total int) {
|
im.OnProgress = func(processed, total int) {
|
||||||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
||||||
@@ -4799,6 +4991,31 @@ func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, e
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseUploadFlags resolves the LoTW "treat as unsent" status set: prefer the
|
||||||
|
// CSV (new multi-select), fall back to the legacy single flag, and default to
|
||||||
|
// N+R when nothing is configured (covers an imported ADIF still marked unsent).
|
||||||
|
func parseUploadFlags(csv, legacy string) []string {
|
||||||
|
add := func(dst []string, seen map[string]bool, raw string) []string {
|
||||||
|
for _, p := range strings.Split(raw, ",") {
|
||||||
|
f := strings.ToUpper(strings.TrimSpace(p))
|
||||||
|
if (f == "N" || f == "R") && !seen[f] {
|
||||||
|
seen[f] = true
|
||||||
|
dst = append(dst, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := add(nil, seen, csv)
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = add(out, seen, legacy)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return []string{"N", "R"}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||||
var out extsvc.ExternalServices
|
var out extsvc.ExternalServices
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
@@ -4815,9 +5032,11 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||||||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
||||||
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
||||||
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
||||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||||
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
||||||
|
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||||
|
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -4847,18 +5066,46 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
StationLocation: m[keyExtLoTWStationLoc],
|
StationLocation: m[keyExtLoTWStationLoc],
|
||||||
ForceStationCallsign: m[keyExtLoTWForceCall],
|
ForceStationCallsign: m[keyExtLoTWForceCall],
|
||||||
KeyPassword: m[keyExtLoTWKeyPassword],
|
KeyPassword: m[keyExtLoTWKeyPassword],
|
||||||
UploadFlag: m[keyExtLoTWUploadFlag],
|
UploadFlags: parseUploadFlags(m[keyExtLoTWUploadFlags], m[keyExtLoTWUploadFlag]),
|
||||||
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
||||||
Username: m[keyExtLoTWUsername],
|
Username: m[keyExtLoTWUsername],
|
||||||
Password: m[keyExtLoTWWebPassword],
|
Password: m[keyExtLoTWWebPassword],
|
||||||
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
|
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
|
||||||
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
|
// LoTW only ever uploads as an on-close batch (ARRL discourages per-QSO
|
||||||
|
// uploads), so the UI offers no other timing. Force it here so configs
|
||||||
|
// saved by older builds — which stored "immediate" — still batch at close.
|
||||||
|
UploadMode: extsvc.ModeOnClose,
|
||||||
}
|
}
|
||||||
// Default the TQSL path to the standard install location when unset, so
|
// Default the TQSL path to the standard install location when unset, so
|
||||||
// the field is pre-populated if TQSL is present.
|
// the field is pre-populated if TQSL is present.
|
||||||
if out.LoTW.TQSLPath == "" {
|
if out.LoTW.TQSLPath == "" {
|
||||||
out.LoTW.TQSLPath = extsvc.DefaultTQSLPath()
|
out.LoTW.TQSLPath = extsvc.DefaultTQSLPath()
|
||||||
}
|
}
|
||||||
|
out.HRDLog = extsvc.ServiceConfig{
|
||||||
|
Callsign: m[keyExtHRDLogCallsign],
|
||||||
|
Code: m[keyExtHRDLogCode],
|
||||||
|
AutoUpload: m[keyExtHRDLogAutoUpload] == "1",
|
||||||
|
UploadMode: extsvc.UploadMode(m[keyExtHRDLogUploadMode]),
|
||||||
|
}
|
||||||
|
// Default the HRDLog callsign to the active profile's call when unset.
|
||||||
|
if out.HRDLog.Callsign == "" && a.profiles != nil {
|
||||||
|
if p, perr := a.profiles.Active(a.ctx); perr == nil {
|
||||||
|
out.HRDLog.Callsign = p.Callsign
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.EQSL = extsvc.ServiceConfig{
|
||||||
|
Username: m[keyExtEQSLUsername],
|
||||||
|
Password: m[keyExtEQSLPassword],
|
||||||
|
QTHNickname: m[keyExtEQSLQTHNick],
|
||||||
|
AutoUpload: m[keyExtEQSLAutoUpload] == "1",
|
||||||
|
UploadMode: extsvc.UploadMode(m[keyExtEQSLUploadMode]),
|
||||||
|
}
|
||||||
|
// Default the eQSL username to the active profile's call when unset.
|
||||||
|
if out.EQSL.Username == "" && a.profiles != nil {
|
||||||
|
if p, perr := a.profiles.Active(a.ctx); perr == nil {
|
||||||
|
out.EQSL.Username = p.Callsign
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4873,38 +5120,49 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
mode := string(extsvc.ModeImmediate)
|
// Preserve the chosen upload timing — including "on_close", which the LoTW
|
||||||
if cfg.QRZ.UploadMode == extsvc.ModeDelayed {
|
// batch flush at shutdown depends on. (A previous version collapsed anything
|
||||||
mode = string(extsvc.ModeDelayed)
|
// that wasn't "delayed" to "immediate", silently disabling on-close upload.)
|
||||||
|
modeOf := func(m extsvc.UploadMode) string {
|
||||||
|
switch m {
|
||||||
|
case extsvc.ModeDelayed:
|
||||||
|
return string(extsvc.ModeDelayed)
|
||||||
|
case extsvc.ModeOnClose:
|
||||||
|
return string(extsvc.ModeOnClose)
|
||||||
|
default:
|
||||||
|
return string(extsvc.ModeImmediate)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
mode := modeOf(cfg.QRZ.UploadMode)
|
||||||
auto := "0"
|
auto := "0"
|
||||||
if cfg.QRZ.AutoUpload {
|
if cfg.QRZ.AutoUpload {
|
||||||
auto = "1"
|
auto = "1"
|
||||||
}
|
}
|
||||||
clMode := string(extsvc.ModeImmediate)
|
clMode := modeOf(cfg.Clublog.UploadMode)
|
||||||
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
|
|
||||||
clMode = string(extsvc.ModeDelayed)
|
|
||||||
}
|
|
||||||
clAuto := "0"
|
clAuto := "0"
|
||||||
if cfg.Clublog.AutoUpload {
|
if cfg.Clublog.AutoUpload {
|
||||||
clAuto = "1"
|
clAuto = "1"
|
||||||
}
|
}
|
||||||
ltMode := string(extsvc.ModeImmediate)
|
ltMode := modeOf(cfg.LoTW.UploadMode)
|
||||||
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
|
|
||||||
ltMode = string(extsvc.ModeDelayed)
|
|
||||||
}
|
|
||||||
ltAuto := "0"
|
ltAuto := "0"
|
||||||
if cfg.LoTW.AutoUpload {
|
if cfg.LoTW.AutoUpload {
|
||||||
ltAuto = "1"
|
ltAuto = "1"
|
||||||
}
|
}
|
||||||
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag))
|
ltFlags := strings.Join(parseUploadFlags(strings.Join(cfg.LoTW.UploadFlags, ","), ""), ",")
|
||||||
if ltFlag != "N" && ltFlag != "R" {
|
|
||||||
ltFlag = "R"
|
|
||||||
}
|
|
||||||
ltWriteLog := "0"
|
ltWriteLog := "0"
|
||||||
if cfg.LoTW.WriteLog {
|
if cfg.LoTW.WriteLog {
|
||||||
ltWriteLog = "1"
|
ltWriteLog = "1"
|
||||||
}
|
}
|
||||||
|
hlMode := modeOf(cfg.HRDLog.UploadMode)
|
||||||
|
hlAuto := "0"
|
||||||
|
if cfg.HRDLog.AutoUpload {
|
||||||
|
hlAuto = "1"
|
||||||
|
}
|
||||||
|
eqMode := modeOf(cfg.EQSL.UploadMode)
|
||||||
|
eqAuto := "0"
|
||||||
|
if cfg.EQSL.AutoUpload {
|
||||||
|
eqAuto = "1"
|
||||||
|
}
|
||||||
scope := a.profileScope() // write under the active profile's prefix
|
scope := a.profileScope() // write under the active profile's prefix
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||||
@@ -4923,12 +5181,23 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
||||||
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
||||||
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
||||||
keyExtLoTWUploadFlag: ltFlag,
|
keyExtLoTWUploadFlags: ltFlags,
|
||||||
keyExtLoTWWriteLog: ltWriteLog,
|
keyExtLoTWWriteLog: ltWriteLog,
|
||||||
keyExtLoTWAutoUpload: ltAuto,
|
keyExtLoTWAutoUpload: ltAuto,
|
||||||
keyExtLoTWUploadMode: ltMode,
|
keyExtLoTWUploadMode: ltMode,
|
||||||
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
|
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
|
||||||
keyExtLoTWWebPassword: cfg.LoTW.Password,
|
keyExtLoTWWebPassword: cfg.LoTW.Password,
|
||||||
|
|
||||||
|
keyExtHRDLogCallsign: strings.ToUpper(strings.TrimSpace(cfg.HRDLog.Callsign)),
|
||||||
|
keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code),
|
||||||
|
keyExtHRDLogAutoUpload: hlAuto,
|
||||||
|
keyExtHRDLogUploadMode: hlMode,
|
||||||
|
|
||||||
|
keyExtEQSLUsername: strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)),
|
||||||
|
keyExtEQSLPassword: cfg.EQSL.Password,
|
||||||
|
keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname),
|
||||||
|
keyExtEQSLAutoUpload: eqAuto,
|
||||||
|
keyExtEQSLUploadMode: eqMode,
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -4957,6 +5226,16 @@ func (a *App) TestClublogUpload() (string, error) {
|
|||||||
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHRDLogUpload validates that the HRDLog credentials are complete.
|
||||||
|
func (a *App) TestHRDLogUpload() (string, error) {
|
||||||
|
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEQSLUpload validates the eQSL credentials with a real (no-op) request.
|
||||||
|
func (a *App) TestEQSLUpload() (string, error) {
|
||||||
|
return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
|
||||||
|
}
|
||||||
|
|
||||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||||
|
|
||||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||||
@@ -4968,6 +5247,10 @@ func uploadColumnFor(service string) string {
|
|||||||
return "clublog_qso_upload_status"
|
return "clublog_qso_upload_status"
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
return "lotw_sent"
|
return "lotw_sent"
|
||||||
|
case extsvc.ServiceHRDLog:
|
||||||
|
return "hrdlog_qso_upload_status"
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
return "eqsl_sent"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -5032,49 +5315,56 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
}
|
}
|
||||||
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
|
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
|
||||||
}
|
}
|
||||||
} else if svc == extsvc.ServiceClublog {
|
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog {
|
||||||
// Club Log accepts a whole ADIF file (putlogs.php) and dedupes
|
statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date"
|
||||||
// server-side, so upload in chunks instead of one realtime.php request
|
if svc == extsvc.ServiceHRDLog {
|
||||||
// per QSO. Chunked so a single failure doesn't lose the whole run and
|
statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date"
|
||||||
// the user sees progress.
|
}
|
||||||
const clublogChunk = 100
|
|
||||||
type item struct {
|
type item struct {
|
||||||
id int64
|
id int64
|
||||||
rec string
|
rec string
|
||||||
call string
|
call string
|
||||||
}
|
}
|
||||||
|
// Fetch the selected QSOs in BULK (chunked IN queries) instead of one
|
||||||
|
// GetByID per QSO — on a remote MySQL, 25k individual SELECTs is what made
|
||||||
|
// this crawl.
|
||||||
var items []item
|
var items []item
|
||||||
for _, id := range ids {
|
const fetchChunk = 1000
|
||||||
q, gerr := a.qso.GetByID(ctx, id)
|
for s := 0; s < len(ids); s += fetchChunk {
|
||||||
call := ""
|
e := s + fetchChunk
|
||||||
if gerr == nil {
|
if e > len(ids) {
|
||||||
call = q.Callsign
|
e = len(ids)
|
||||||
}
|
}
|
||||||
rec, ok := a.buildUploadADIF(id, "")
|
_ = a.qso.IterateByIDs(ctx, ids[s:e], func(q qso.QSO) error {
|
||||||
if !ok {
|
items = append(items, item{id: q.ID, rec: adif.SingleRecordADIF(q), call: q.Callsign})
|
||||||
emit(call + " — skipped (no record)")
|
return nil
|
||||||
continue
|
})
|
||||||
}
|
}
|
||||||
items = append(items, item{id: id, rec: rec, call: call})
|
date := time.Now().UTC().Format("20060102")
|
||||||
}
|
|
||||||
emit(fmt.Sprintf("Club Log: uploading %d QSO(s) in batches of %d…", len(items), clublogChunk))
|
if svc == extsvc.ServiceClublog {
|
||||||
for start := 0; start < len(items); start += clublogChunk {
|
// Club Log accepts a whole ADIF file (putlogs.php) and dedupes
|
||||||
end := start + clublogChunk
|
// server-side → upload in chunks, one HTTP request per 100 QSOs.
|
||||||
|
const chunk = 100
|
||||||
|
emit(fmt.Sprintf("Club Log: uploading %d QSO(s) in batches of %d…", len(items), chunk))
|
||||||
|
for start := 0; start < len(items); start += chunk {
|
||||||
|
end := start + chunk
|
||||||
if end > len(items) {
|
if end > len(items) {
|
||||||
end = len(items)
|
end = len(items)
|
||||||
}
|
}
|
||||||
batch := items[start:end]
|
batch := items[start:end]
|
||||||
recs := make([]string, len(batch))
|
recs := make([]string, len(batch))
|
||||||
|
batchIDs := make([]int64, len(batch))
|
||||||
for i, it := range batch {
|
for i, it := range batch {
|
||||||
recs[i] = it.rec
|
recs[i] = it.rec
|
||||||
|
batchIDs[i] = it.id
|
||||||
}
|
}
|
||||||
doc := adif.BatchRecordsADIF(recs)
|
res, err := extsvc.UploadClublogADIF(ctx, nil, cfg.Clublog, adif.BatchRecordsADIF(recs))
|
||||||
res, err := extsvc.UploadClublogADIF(ctx, nil, cfg.Clublog, doc)
|
|
||||||
if err == nil && res.OK {
|
if err == nil && res.OK {
|
||||||
for _, it := range batch {
|
if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, batchIDs); merr != nil {
|
||||||
a.markExtUploaded(svc, it.id, "")
|
applog.Printf("extsvc: Club Log batch mark: %v", merr)
|
||||||
uploaded++
|
|
||||||
}
|
}
|
||||||
|
uploaded += len(batch)
|
||||||
emit(fmt.Sprintf("Club Log: %d/%d uploaded", end, len(items)))
|
emit(fmt.Sprintf("Club Log: %d/%d uploaded", end, len(items)))
|
||||||
} else {
|
} else {
|
||||||
msg := res.Message
|
msg := res.Message
|
||||||
@@ -5084,6 +5374,42 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
|
emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
|
||||||
|
// record ADIF, so upload ONE record per request. The DB stays cheap:
|
||||||
|
// bulk fetch above + the marks flushed in batches (not one per QSO).
|
||||||
|
emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items)))
|
||||||
|
var doneIDs []int64
|
||||||
|
flush := func() {
|
||||||
|
if len(doneIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, doneIDs); merr != nil {
|
||||||
|
applog.Printf("extsvc: HRDLog batch mark: %v", merr)
|
||||||
|
}
|
||||||
|
doneIDs = doneIDs[:0]
|
||||||
|
}
|
||||||
|
for i, it := range items {
|
||||||
|
res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec)
|
||||||
|
if err == nil && res.OK {
|
||||||
|
doneIDs = append(doneIDs, it.id)
|
||||||
|
uploaded++
|
||||||
|
} else {
|
||||||
|
msg := res.Message
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
emit(it.call + " — FAILED: " + msg)
|
||||||
|
}
|
||||||
|
if len(doneIDs) >= 200 {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
if (i+1)%50 == 0 || i+1 == len(items) {
|
||||||
|
emit(fmt.Sprintf("HRDLog: %d/%d uploaded", uploaded, len(items)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// QRZ.com: one record per request (its logbook API has no batch upload).
|
// QRZ.com: one record per request (its logbook API has no batch upload).
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
@@ -5106,6 +5432,10 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
switch svc {
|
switch svc {
|
||||||
case extsvc.ServiceQRZ:
|
case extsvc.ServiceQRZ:
|
||||||
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||||
|
case extsvc.ServiceHRDLog:
|
||||||
|
res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec)
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
res, err = extsvc.UploadEQSL(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, rec)
|
||||||
default:
|
default:
|
||||||
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
||||||
}
|
}
|
||||||
@@ -5189,12 +5519,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
switch svc {
|
switch svc {
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
sinceDate := resolveSince(keyExtLoTWLastDownload)
|
sinceDate := resolveSince(keyExtLoTWLastDownload)
|
||||||
if sinceDate != "" {
|
ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
|
||||||
emit("Downloading LoTW confirmations received since " + sinceDate + "…")
|
callLabel := ownCall
|
||||||
} else {
|
if callLabel == "" {
|
||||||
emit("Downloading all LoTW confirmations…")
|
callLabel = "all callsigns"
|
||||||
}
|
}
|
||||||
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate)
|
if sinceDate != "" {
|
||||||
|
emit(fmt.Sprintf("Downloading LoTW confirmations for %s received since %s…", callLabel, sinceDate))
|
||||||
|
} else {
|
||||||
|
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
|
||||||
|
}
|
||||||
|
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
emit("Download failed: " + err.Error())
|
emit("Download failed: " + err.Error())
|
||||||
done(matched, total)
|
done(matched, total)
|
||||||
@@ -5659,6 +5994,88 @@ func (a *App) stationCallOf(id int64) string {
|
|||||||
return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
|
return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uploadOwnerCall returns the callsign OpsLog signs/uploads/downloads as for a
|
||||||
|
// service in the active profile: the configured Force/owner callsign, else the
|
||||||
|
// active profile's callsign. "" when nothing is known (don't scope by call).
|
||||||
|
func (a *App) uploadOwnerCall(svc extsvc.Service) string {
|
||||||
|
cfg := a.loadExternalServices()
|
||||||
|
owner := ""
|
||||||
|
switch svc {
|
||||||
|
case extsvc.ServiceLoTW:
|
||||||
|
owner = cfg.LoTW.ForceStationCallsign
|
||||||
|
case extsvc.ServiceQRZ:
|
||||||
|
owner = cfg.QRZ.ForceStationCallsign
|
||||||
|
case extsvc.ServiceClublog:
|
||||||
|
owner = cfg.Clublog.Callsign
|
||||||
|
case extsvc.ServiceHRDLog:
|
||||||
|
owner = cfg.HRDLog.Callsign
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
owner = cfg.EQSL.Username
|
||||||
|
}
|
||||||
|
owner = strings.ToUpper(strings.TrimSpace(owner))
|
||||||
|
if owner == "" && a.profiles != nil {
|
||||||
|
if p, perr := a.profiles.Active(a.ctx); perr == nil {
|
||||||
|
owner = strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadCallsign exposes uploadOwnerCall to the UI so the QSL Manager can show
|
||||||
|
// which of the operator's callsigns a download/upload targets in this profile.
|
||||||
|
func (a *App) UploadCallsign(service string) string {
|
||||||
|
return a.uploadOwnerCall(extsvc.Service(service))
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeUploadIDs returns the QSO ids to upload to a service at app close,
|
||||||
|
// scanning the whole logbook: LoTW matches the configured sent-status set
|
||||||
|
// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an
|
||||||
|
// imported ADIF (old QSOs still unsent) flush on close.
|
||||||
|
func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
|
||||||
|
if a.qso == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
col := uploadColumnFor(string(svc))
|
||||||
|
if col == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// owner is the callsign this logbook signs/uploads as. Each external
|
||||||
|
// logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q)
|
||||||
|
// we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would
|
||||||
|
// be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log
|
||||||
|
// logbook.
|
||||||
|
var statuses []string
|
||||||
|
if svc == extsvc.ServiceLoTW {
|
||||||
|
statuses = a.loadExternalServices().LoTW.UploadFlags
|
||||||
|
if len(statuses) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner := a.uploadOwnerCall(svc)
|
||||||
|
|
||||||
|
cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("extsvc: close-upload candidate scan for %s failed: %v", svc, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]int64, 0, len(cands))
|
||||||
|
skipped := 0
|
||||||
|
for _, c := range cands {
|
||||||
|
// Keep QSOs that belong to this logbook's call. A blank STATION_CALLSIGN
|
||||||
|
// is assumed to be ours (it gets signed/labelled as owner on upload),
|
||||||
|
// mirroring the per-QSO guard in extsvc.upload.
|
||||||
|
if owner == "" || c.StationCallsign == "" || extsvc.SameBaseCall(c.StationCallsign, owner) {
|
||||||
|
out = append(out, c.ID)
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
applog.Printf("extsvc: %s close-upload skipped %d QSO(s) not matching logbook callsign %q", svc, skipped, owner)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// extShouldUpload reports whether a QSO is eligible for upload to a service,
|
// extShouldUpload reports whether a QSO is eligible for upload to a service,
|
||||||
// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW
|
// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW
|
||||||
// uploads only QSOs whose lotw_sent matches the configured Upload flag
|
// uploads only QSOs whose lotw_sent matches the configured Upload flag
|
||||||
@@ -5684,16 +6101,25 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
case extsvc.ServiceHRDLog:
|
||||||
|
if strings.EqualFold(q.HRDLogUploadStatus, "Y") {
|
||||||
|
applog.Printf("extsvc: QSO %d not eligible for hrdlog — HRDLogUploadStatus already %q (set Confirmations default to N to upload)", id, q.HRDLogUploadStatus)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
if strings.EqualFold(q.EQSLSent, "Y") {
|
||||||
|
applog.Printf("extsvc: QSO %d not eligible for eqsl — EQSLSent already %q (set Confirmations default to N to upload)", id, q.EQSLSent)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
flag := "R"
|
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||||
if a.settings != nil {
|
if strings.EqualFold(q.LOTWSent, f) {
|
||||||
if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil {
|
return true
|
||||||
if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" {
|
|
||||||
flag = v
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return false
|
||||||
return strings.EqualFold(q.LOTWSent, flag)
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -5702,25 +6128,31 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
|||||||
// tells the frontend to refresh that row's confirmation columns.
|
// tells the frontend to refresh that row's confirmation columns.
|
||||||
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||||
date := time.Now().UTC().Format("20060102")
|
date := time.Now().UTC().Format("20060102")
|
||||||
|
// Use a fresh background context, NOT a.ctx: this stamp often runs during
|
||||||
|
// the on-close upload, and a.ctx is cancelled as the app shuts down — which
|
||||||
|
// would silently abort the UPDATE and leave the QSO at "R" forever despite a
|
||||||
|
// successful upload.
|
||||||
|
ctx := context.Background()
|
||||||
|
if a.qso == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
switch svc {
|
switch svc {
|
||||||
case extsvc.ServiceQRZ:
|
case extsvc.ServiceQRZ:
|
||||||
if a.qso != nil {
|
err = a.qso.MarkQRZUploaded(ctx, id, date)
|
||||||
if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil {
|
|
||||||
applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case extsvc.ServiceClublog:
|
case extsvc.ServiceClublog:
|
||||||
if a.qso != nil {
|
err = a.qso.MarkClublogUploaded(ctx, id, date)
|
||||||
if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil {
|
|
||||||
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
if a.qso != nil {
|
err = a.qso.MarkLoTWUploaded(ctx, id, date)
|
||||||
if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil {
|
case extsvc.ServiceHRDLog:
|
||||||
applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err)
|
err = a.qso.MarkHRDLogUploaded(ctx, id, date)
|
||||||
}
|
case extsvc.ServiceEQSL:
|
||||||
|
err = a.qso.MarkEQSLSent(ctx, id, date)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
|
||||||
|
} else {
|
||||||
|
applog.Printf("extsvc: marked %s QSO %d as sent", svc, id)
|
||||||
}
|
}
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
||||||
@@ -5928,7 +6360,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
// (station callsign, grid, country, zones, and the profile's default
|
// (station callsign, grid, country, zones, and the profile's default
|
||||||
// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP /
|
// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP /
|
||||||
// WSJT-X auto-logged QSO carried none of the operator's own data.
|
// WSJT-X auto-logged QSO carried none of the operator's own data.
|
||||||
a.applyStationDefaults(&q)
|
a.applyStationDefaults(&q, true)
|
||||||
|
|
||||||
// ── DXCC# + QSL defaults ──
|
// ── DXCC# + QSL defaults ──
|
||||||
// applyDXCCNumber stamps the contacted-station DXCC# from the
|
// applyDXCCNumber stamps the contacted-station DXCC# from the
|
||||||
@@ -6510,6 +6942,55 @@ func (a *App) FlexSetANFLevel(l int) error {
|
|||||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFLevel(l) })
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFLevel(l) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetAPF(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPF(on) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetAPFLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPFLevel(l) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetCWSpeed(wpm int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetCWPitch(hz int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWPitch(hz) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetCWBreakInDelay(ms int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWBreakInDelay(ms) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetCWSidetone(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSidetone(on) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetSidetoneLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSidetoneLevel(l) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetCWFilter(bw int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWFilter(bw) })
|
||||||
|
}
|
||||||
|
|
||||||
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
||||||
// requiring a trip through the full Settings panel. Persists the choice
|
// requiring a trip through the full Settings panel. Persists the choice
|
||||||
// so it survives restart.
|
// so it survives restart.
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
"hamlog/internal/cwdecode"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
||||||
|
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
||||||
|
// only by the frontend, and only while the entry mode is CW.
|
||||||
|
//
|
||||||
|
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
||||||
|
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
||||||
|
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
||||||
|
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
||||||
|
// back to auto-search otherwise.
|
||||||
|
|
||||||
|
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
||||||
|
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
||||||
|
func (a *App) cwTargetPitch() int {
|
||||||
|
if a.cwPitchHz > 0 {
|
||||||
|
return a.cwPitchHz
|
||||||
|
}
|
||||||
|
if a.cat != nil {
|
||||||
|
if st, ok := a.cat.FlexState(); ok && st.Available {
|
||||||
|
// Only trust the radio's pitch when it's actually in CW.
|
||||||
|
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
||||||
|
if st.CWPitch > 0 {
|
||||||
|
return st.CWPitch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
||||||
|
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
||||||
|
// call repeatedly; a second call is a no-op while already running.
|
||||||
|
func (a *App) StartCWDecoder() error {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
if a.cwStop != nil {
|
||||||
|
return nil // already running
|
||||||
|
}
|
||||||
|
dev := ""
|
||||||
|
if a.settings != nil {
|
||||||
|
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
||||||
|
}
|
||||||
|
if dev == "" {
|
||||||
|
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := cwdecode.New(audio.SampleRate,
|
||||||
|
func(text string) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func(st cwdecode.Status) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
a.cwDecoder = dec
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
a.cwStop = stop
|
||||||
|
go func() {
|
||||||
|
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
||||||
|
applog.Printf("cw: capture failed: %v", err)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cwMu.Lock()
|
||||||
|
if a.cwStop == stop {
|
||||||
|
a.cwStop = nil
|
||||||
|
a.cwDecoder = nil
|
||||||
|
}
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
}()
|
||||||
|
// Follow the radio's CW pitch live (every second) while this run is active.
|
||||||
|
go a.cwFollowPitch(stop, dec)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
||||||
|
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
||||||
|
t := time.NewTicker(time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCWDecoder halts the CW decoder if running.
|
||||||
|
func (a *App) StopCWDecoder() {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
stop := a.cwStop
|
||||||
|
a.cwStop = nil
|
||||||
|
a.cwDecoder = nil
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CWDecoderRunning reports whether the decoder is currently capturing.
|
||||||
|
func (a *App) CWDecoderRunning() bool {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
return a.cwStop != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
||||||
|
// the Flex CW pitch, or search). Applies live to a running decoder.
|
||||||
|
func (a *App) SetCWDecoderPitch(hz int) {
|
||||||
|
if hz < 0 {
|
||||||
|
hz = 0
|
||||||
|
}
|
||||||
|
a.cwMu.Lock()
|
||||||
|
a.cwPitchHz = hz
|
||||||
|
dec := a.cwDecoder
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
if dec != nil {
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
||||||
|
func (a *App) GetCWDecoderPitch() int {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
return a.cwPitchHz
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtClublogPassword: true,
|
keyExtClublogPassword: true,
|
||||||
keyExtLoTWKeyPassword: true,
|
keyExtLoTWKeyPassword: true,
|
||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
|
keyExtHRDLogCode: true,
|
||||||
|
keyExtEQSLPassword: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multi-operator chat over the SHARED MySQL logbook. The database is the message
|
||||||
|
// bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so
|
||||||
|
// operators on the same shared log (e.g. a special-event call) can talk. No
|
||||||
|
// extra server. Presence is a lightweight heartbeat into chat_presence. Chat is
|
||||||
|
// only available on a MySQL logbook (SQLite/solo has no one else to talk to).
|
||||||
|
|
||||||
|
const (
|
||||||
|
chatPollInterval = 3 * time.Second
|
||||||
|
chatPresenceEvery = 20 * time.Second
|
||||||
|
chatRetentionDays = 7
|
||||||
|
chatHistoryDefault = 80
|
||||||
|
chatPresenceStaleSecs = 120 // a presence row older than this = offline
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatMessage is one chat line.
|
||||||
|
type ChatMessage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
Station string `json:"station"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CreatedAt string `json:"created_at"` // ISO UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPresence is one operator currently online (recent heartbeat).
|
||||||
|
type ChatPresence struct {
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
Station string `json:"station"`
|
||||||
|
AgoSecs int `json:"ago_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatActive reports whether chat can run (shared MySQL logbook).
|
||||||
|
func (a *App) chatActive() bool {
|
||||||
|
return a.logDb != nil && a.dbBackend == "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatAvailable lets the UI show/hide the chat icon (only on a shared log).
|
||||||
|
func (a *App) ChatAvailable() bool { return a.chatActive() }
|
||||||
|
|
||||||
|
func (a *App) ensureChatTables() error {
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS chat_messages ("+
|
||||||
|
"id BIGINT AUTO_INCREMENT PRIMARY KEY, "+
|
||||||
|
"operator VARCHAR(32), station VARCHAR(32), "+
|
||||||
|
"message TEXT, created_at DATETIME)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS chat_presence ("+
|
||||||
|
"operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChatMessage posts a message to the shared chat and returns the stored row
|
||||||
|
// (with its id) so the UI can show it immediately; the poll loop dedupes by id.
|
||||||
|
func (a *App) SendChatMessage(text string) (ChatMessage, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ChatMessage{}, nil
|
||||||
|
}
|
||||||
|
if len(text) > 1000 {
|
||||||
|
text = text[:1000]
|
||||||
|
}
|
||||||
|
if !a.chatActive() {
|
||||||
|
return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook")
|
||||||
|
}
|
||||||
|
op, station := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first")
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
res, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())",
|
||||||
|
op, station, text)
|
||||||
|
if err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return ChatMessage{ID: id, Operator: op, Station: station, Message: text,
|
||||||
|
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChatHistory returns the most recent messages (oldest first) for the panel.
|
||||||
|
func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) {
|
||||||
|
if !a.chatActive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = chatHistoryDefault
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var m ChatMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
// Reverse to chronological order (we queried newest-first to honour LIMIT).
|
||||||
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnlineOperators lists operators with a recent presence heartbeat.
|
||||||
|
func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
|
||||||
|
if !a.chatActive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+
|
||||||
|
"FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator",
|
||||||
|
chatPresenceStaleSecs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatPresence
|
||||||
|
for rows.Next() {
|
||||||
|
var p ChatPresence
|
||||||
|
if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatLoop polls for new messages and heartbeats presence while on a shared
|
||||||
|
// MySQL logbook. Started once at startup; a cheap no-op otherwise.
|
||||||
|
func (a *App) chatLoop() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
var lastID int64 = -1 // -1 = not yet baselined
|
||||||
|
lastPresence := time.Time{}
|
||||||
|
lastPurge := time.Time{}
|
||||||
|
t := time.NewTicker(chatPollInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if !a.chatActive() {
|
||||||
|
lastID = -1 // re-baseline if the backend changes
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
// Presence heartbeat.
|
||||||
|
if now.Sub(lastPresence) >= chatPresenceEvery {
|
||||||
|
if op, station := a.liveStatusOperator(); op != "" {
|
||||||
|
_, _ = a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+
|
||||||
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station)
|
||||||
|
}
|
||||||
|
lastPresence = now
|
||||||
|
}
|
||||||
|
// Baseline on first run so existing history isn't replayed as "new"
|
||||||
|
// (the panel loads it via GetChatHistory).
|
||||||
|
if lastID < 0 {
|
||||||
|
row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages")
|
||||||
|
_ = row.Scan(&lastID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Emit new messages.
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var m ChatMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.ID > lastID {
|
||||||
|
lastID = m.ID
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "chat:message", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
// Purge old messages occasionally (hourly).
|
||||||
|
if now.Sub(lastPurge) >= time.Hour {
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("chat: purge failed: %v", err)
|
||||||
|
}
|
||||||
|
lastPurge = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+319
-38
@@ -1,13 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Eraser, Hash, Loader2, Lock,
|
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus, CheckForUpdate,
|
GetStartupStatus, CheckForUpdate,
|
||||||
@@ -29,10 +29,13 @@ import {
|
|||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||||
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref,
|
GetUIPref,
|
||||||
ReportLiveActivity,
|
ReportLiveActivity,
|
||||||
|
AwardRefsForQSOs,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -60,6 +63,8 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
|||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
|
import { BulkEditModal } from '@/components/BulkEditModal';
|
||||||
|
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
||||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
@@ -588,6 +593,92 @@ export default function App() {
|
|||||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||||
|
|
||||||
// === Digital Voice Keyer (DVK) ===
|
// === Digital Voice Keyer (DVK) ===
|
||||||
|
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
|
||||||
|
// mode is CW. The decoded text appears in a strip above the tabs.
|
||||||
|
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
|
||||||
|
const [cwText, setCwText] = useState('');
|
||||||
|
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
|
||||||
|
const cwOn = cwEnabled && mode === 'CW';
|
||||||
|
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
|
||||||
|
const cwScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
|
||||||
|
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
|
||||||
|
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
|
||||||
|
useEffect(() => {
|
||||||
|
const hz = parseInt(cwPitch, 10);
|
||||||
|
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
|
||||||
|
localStorage.setItem('opslog.cwPitch', cwPitch);
|
||||||
|
}, [cwPitch, cwOn]);
|
||||||
|
useEffect(() => {
|
||||||
|
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
|
||||||
|
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
||||||
|
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
|
||||||
|
return () => { offT?.(); offS?.(); offE?.(); };
|
||||||
|
}, []);
|
||||||
|
// Start/stop the backend decoder as the (enabled, mode) combination changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
|
||||||
|
else { StopCWDecoder().catch(() => {}); }
|
||||||
|
}, [cwOn]);
|
||||||
|
function toggleCwDecoder() {
|
||||||
|
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
||||||
|
const [chatAvailable, setChatAvailable] = useState(false);
|
||||||
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
|
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>([]);
|
||||||
|
const [chatOnline, setChatOnline] = useState<ChatPresence[]>([]);
|
||||||
|
const [chatUnread, setChatUnread] = useState(0);
|
||||||
|
const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen;
|
||||||
|
const chatSeen = useRef<Set<number>>(new Set());
|
||||||
|
// Availability (only on a shared MySQL logbook; re-checked as profiles switch).
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const chk = () => ChatAvailable().then((v) => alive && setChatAvailable(!!v)).catch(() => {});
|
||||||
|
chk();
|
||||||
|
const id = window.setInterval(chk, 10000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
// Incoming messages — append + bump unread when the panel is closed.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('chat:message', (m: ChatMsg) => {
|
||||||
|
if (!m || chatSeen.current.has(m.id)) return;
|
||||||
|
chatSeen.current.add(m.id);
|
||||||
|
setChatMsgs((p) => [...p, m].slice(-300));
|
||||||
|
if (!chatOpenRef.current) setChatUnread((u) => u + 1);
|
||||||
|
});
|
||||||
|
return () => { off?.(); };
|
||||||
|
}, []);
|
||||||
|
// On open: clear unread, load history + the online list (refreshed).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatOpen) return;
|
||||||
|
setChatUnread(0);
|
||||||
|
GetChatHistory(80).then((h: any) => {
|
||||||
|
const list = (h ?? []) as ChatMsg[];
|
||||||
|
list.forEach((m) => chatSeen.current.add(m.id));
|
||||||
|
setChatMsgs((prev) => {
|
||||||
|
const byId = new Map<number, ChatMsg>();
|
||||||
|
[...list, ...prev].forEach((m) => byId.set(m.id, m));
|
||||||
|
return Array.from(byId.values()).sort((a, b) => a.id - b.id).slice(-300);
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
const lo = () => GetOnlineOperators().then((o: any) => setChatOnline((o ?? []) as ChatPresence[])).catch(() => {});
|
||||||
|
lo();
|
||||||
|
const id = window.setInterval(lo, 15000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [chatOpen]);
|
||||||
|
async function chatSend(t: string) {
|
||||||
|
try {
|
||||||
|
const m = (await SendChatMessage(t)) as any as ChatMsg;
|
||||||
|
if (m && m.id && !chatSeen.current.has(m.id)) {
|
||||||
|
chatSeen.current.add(m.id);
|
||||||
|
setChatMsgs((p) => [...p, m].slice(-300));
|
||||||
|
}
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
const chatShown = chatOpen && chatAvailable;
|
||||||
|
|
||||||
const [dvkEnabled, setDvkEnabled] = useState(false);
|
const [dvkEnabled, setDvkEnabled] = useState(false);
|
||||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
@@ -651,7 +742,12 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
// Single band map docked beside the table (toggled by the toolbar button,
|
// Single band map docked beside the table (toggled by the toolbar button,
|
||||||
// visible across tabs). Independent of the multi-band "Band Map" tab.
|
// visible across tabs). Independent of the multi-band "Band Map" tab.
|
||||||
const [showBandMap, setShowBandMap] = useState(false);
|
const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1');
|
||||||
|
// Persist the Band Map open/closed state (portable) so it survives a restart.
|
||||||
|
const setBandMapShown = useCallback((v: boolean) => {
|
||||||
|
setShowBandMap(v);
|
||||||
|
writeUiPref('bandmap.show', v ? '1' : '0');
|
||||||
|
}, []);
|
||||||
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
|
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
|
||||||
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
|
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
|
||||||
);
|
);
|
||||||
@@ -701,8 +797,12 @@ export default function App() {
|
|||||||
|
|
||||||
// === Modals ===
|
// === Modals ===
|
||||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||||
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
|
// QSOs queued for the delete confirm (1 or many — multi-row selection).
|
||||||
|
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||||
|
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||||
@@ -738,6 +838,7 @@ export default function App() {
|
|||||||
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
|
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
|
||||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||||
|
const [importApplyStation, setImportApplyStation] = useState(false);
|
||||||
// QRZ profile photo lightbox (full-size, in-app — not the browser).
|
// QRZ profile photo lightbox (full-size, in-app — not the browser).
|
||||||
const [photoModal, setPhotoModal] = useState<string | null>(null);
|
const [photoModal, setPhotoModal] = useState<string | null>(null);
|
||||||
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
|
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
|
||||||
@@ -759,6 +860,40 @@ export default function App() {
|
|||||||
const wbTimerRef = useRef<number | null>(null);
|
const wbTimerRef = useRef<number | null>(null);
|
||||||
const [wb, setWb] = useState<WB | null>(null);
|
const [wb, setWb] = useState<WB | null>(null);
|
||||||
const [wbBusy, setWbBusy] = useState(false);
|
const [wbBusy, setWbBusy] = useState(false);
|
||||||
|
|
||||||
|
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
|
||||||
|
// list once, then compute each shown QSO's reference per award and attach it
|
||||||
|
// to the rows (the grids render one hideable column per award).
|
||||||
|
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
GetAwardDefs().then((defs: any[]) =>
|
||||||
|
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
||||||
|
).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
||||||
|
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; }
|
||||||
|
let alive = true;
|
||||||
|
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [qsos, awardCols.length]);
|
||||||
|
const qsosWithAwards = useMemo(
|
||||||
|
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
||||||
|
[qsos, qsoAwardRefs],
|
||||||
|
);
|
||||||
|
const [wbAwardRefs, setWbAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean);
|
||||||
|
if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; }
|
||||||
|
let alive = true;
|
||||||
|
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [wb, awardCols.length]);
|
||||||
|
const wbWithAwards = useMemo(
|
||||||
|
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
||||||
|
[wb, wbAwardRefs],
|
||||||
|
);
|
||||||
// Always-current copy of the entry callsign, so the UDP event handlers
|
// Always-current copy of the entry callsign, so the UDP event handlers
|
||||||
// (which live in a []-deps effect with a stale `callsign` closure) can
|
// (which live in a []-deps effect with a stale `callsign` closure) can
|
||||||
// tell whether an incoming DX call actually changed anything.
|
// tell whether an incoming DX call actually changed anything.
|
||||||
@@ -1414,8 +1549,16 @@ export default function App() {
|
|||||||
function wkSendMacro(i: number) {
|
function wkSendMacro(i: number) {
|
||||||
const m = wkMacros[i];
|
const m = wkMacros[i];
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
if (wkAutoCallRef.current) runAutoCall(i); // loop this macro until a reply / stop
|
// Auto-call only loops CQ-type macros. Sending any other macro (e.g. a
|
||||||
else wkSend(m.text);
|
// report once someone answers) sends ONCE and cancels a running loop —
|
||||||
|
// otherwise a report would keep repeating.
|
||||||
|
const isCQ = (m.text || '').toUpperCase().includes('CQ');
|
||||||
|
if (wkAutoCallRef.current && isCQ) {
|
||||||
|
runAutoCall(i); // loop this CQ until a reply is sent / Stop / ESC
|
||||||
|
} else {
|
||||||
|
stopAutoCall();
|
||||||
|
wkSend(m.text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wkSendMacroRef.current = wkSendMacro;
|
wkSendMacroRef.current = wkSendMacro;
|
||||||
function wkToggleAutoCall(on: boolean) {
|
function wkToggleAutoCall(on: boolean) {
|
||||||
@@ -1584,8 +1727,8 @@ export default function App() {
|
|||||||
} catch (err: any) { setError(String(err?.message ?? err)); }
|
} catch (err: any) { setError(String(err?.message ?? err)); }
|
||||||
}
|
}
|
||||||
function onModalDelete(id: number) {
|
function onModalDelete(id: number) {
|
||||||
const q = editingQSO; setEditingQSO(null);
|
setEditingQSO(null);
|
||||||
if (q) setDeletingQSO(q); else askDelete(id);
|
setDeletingIds([id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk grid actions (right-click menu). Recompute country/zones from
|
// Bulk grid actions (right-click menu). Recompute country/zones from
|
||||||
@@ -1596,6 +1739,11 @@ export default function App() {
|
|||||||
if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
|
if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
|
||||||
showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`);
|
showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`);
|
||||||
}
|
}
|
||||||
|
function openBulkEdit(ids: number[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setBulkEditIds(ids);
|
||||||
|
setBulkEditOpen(true);
|
||||||
|
}
|
||||||
async function bulkUpdateFromCty(ids: number[]) {
|
async function bulkUpdateFromCty(ids: number[]) {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); }
|
try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); }
|
||||||
@@ -1654,20 +1802,25 @@ export default function App() {
|
|||||||
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
function askDelete(id: number) {
|
function askDelete(id: number) { setDeletingIds([id]); }
|
||||||
const q = qsos.find((x) => x.id === id);
|
// Delete the whole multi-row selection (Edit menu / Delete key).
|
||||||
if (q) setDeletingQSO(q);
|
function askDeleteSelected() {
|
||||||
|
if (selectedIds.length > 0) setDeletingIds(selectedIds);
|
||||||
|
else if (selectedId != null) setDeletingIds([selectedId]);
|
||||||
}
|
}
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!deletingQSO) return;
|
if (deletingIds.length === 0) return;
|
||||||
|
const ids = deletingIds;
|
||||||
try {
|
try {
|
||||||
await DeleteQSO(deletingQSO.id);
|
if (ids.length === 1) await DeleteQSO(ids[0]);
|
||||||
if (selectedId === deletingQSO.id) setSelectedId(null);
|
else await DeleteQSOs(ids as any);
|
||||||
setDeletingQSO(null);
|
setDeletingIds([]);
|
||||||
|
setSelectedId(null);
|
||||||
|
setSelectedIds([]);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(String(err?.message ?? err));
|
setError(String(err?.message ?? err));
|
||||||
setDeletingQSO(null);
|
setDeletingIds([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function confirmDeleteAll() {
|
async function confirmDeleteAll() {
|
||||||
@@ -1856,7 +2009,7 @@ export default function App() {
|
|||||||
setImportErrorsOpen(false);
|
setImportErrorsOpen(false);
|
||||||
setImportDupsOpen(false);
|
setImportDupsOpen(false);
|
||||||
try {
|
try {
|
||||||
const res = await ImportADIF(path, importDupMode, importApplyCty);
|
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
|
||||||
setImportResult(res);
|
setImportResult(res);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -1878,7 +2031,8 @@ export default function App() {
|
|||||||
]},
|
]},
|
||||||
{ name: 'edit', label: 'Edit', items: [
|
{ name: 'edit', label: 'Edit', items: [
|
||||||
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
||||||
{ type: 'item', label: 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
|
{ type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
|
||||||
|
{ type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
|
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
|
||||||
]},
|
]},
|
||||||
@@ -1892,6 +2046,7 @@ export default function App() {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
||||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||||
|
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
// Maintenance — bumped here while we only have one entry. Will move
|
// Maintenance — bumped here while we only have one entry. Will move
|
||||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||||
@@ -1901,7 +2056,7 @@ export default function App() {
|
|||||||
{ name: 'help', label: 'Help', items: [
|
{ name: 'help', label: 'Help', items: [
|
||||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -1911,12 +2066,14 @@ export default function App() {
|
|||||||
case 'view.refresh': refresh(); break;
|
case 'view.refresh': refresh(); break;
|
||||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
case 'edit.delete': askDeleteSelected(); break;
|
||||||
|
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
|
||||||
case 'edit.prefs': setShowSettings(true); break;
|
case 'edit.prefs': setShowSettings(true); break;
|
||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
|
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||||
case 'tools.downloadRefs': downloadRefs(); break;
|
case 'tools.downloadRefs': downloadRefs(); break;
|
||||||
case 'help.about': setShowAbout(true); break;
|
case 'help.about': setShowAbout(true); break;
|
||||||
@@ -1970,7 +2127,9 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const keyerLive = wkActiveRef.current;
|
const keyerLive = wkActiveRef.current;
|
||||||
if (keyerLive) WinkeyerStop().catch(() => {});
|
// ESC aborts the current CW transmission AND the auto-call loop, so it
|
||||||
|
// won't resend after the gap — you must click a CQ macro to restart it.
|
||||||
|
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
|
||||||
if (!keyerLive || wkEscClearsRef.current) {
|
if (!keyerLive || wkEscClearsRef.current) {
|
||||||
resetEntry();
|
resetEntry();
|
||||||
callsignRef.current?.focus();
|
callsignRef.current?.focus();
|
||||||
@@ -2008,14 +2167,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (typing) return;
|
if (typing) return;
|
||||||
if (selectedId !== null) {
|
if (selectedId !== null) {
|
||||||
if (e.key === 'Delete') { e.preventDefault(); askDelete(selectedId); return; }
|
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
|
||||||
if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; }
|
if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedId, refresh]);
|
}, [selectedId, selectedIds, refresh]);
|
||||||
|
|
||||||
// ── Entry-field blocks ─────────────────────────────────────────────────
|
// ── Entry-field blocks ─────────────────────────────────────────────────
|
||||||
// Each field is defined once here, then composed into either the compact
|
// Each field is defined once here, then composed into either the compact
|
||||||
@@ -2309,8 +2468,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (clusterStatusFilter.size > 0) {
|
if (clusterStatusFilter.size > 0) {
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
const st = spotStatus[k]?.status || '';
|
const e = spotStatus[k];
|
||||||
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
|
const st = (e?.status || '') as SpotStatusKey;
|
||||||
|
// A previously-worked call counts as WORKED for filtering even when its
|
||||||
|
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
|
||||||
|
// matching the "Hide worked" toggle. Additive: it still matches its own
|
||||||
|
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
|
||||||
|
const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked'));
|
||||||
|
if (!matches) return false;
|
||||||
}
|
}
|
||||||
if (clusterHideWorked) {
|
if (clusterHideWorked) {
|
||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
@@ -2519,7 +2684,7 @@ export default function App() {
|
|||||||
case 'worked':
|
case 'worked':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2718,6 +2883,24 @@ export default function App() {
|
|||||||
<Zap className="size-4" />
|
<Zap className="size-4" />
|
||||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />}
|
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleCwDecoder}
|
||||||
|
title={
|
||||||
|
cwEnabled
|
||||||
|
? (mode === 'CW' ? 'CW decoder — on (decoding) · click to disable' : 'CW decoder — on, idle until CW mode · click to disable')
|
||||||
|
: 'CW decoder · click to enable (decodes RX audio in CW mode)'
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
cwOn && cwStatus.active ? 'border-emerald-400 bg-emerald-100 text-emerald-800'
|
||||||
|
: cwEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Ear className="size-4" />
|
||||||
|
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500 animate-pulse" />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||||
@@ -2730,6 +2913,23 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Compass className="size-4" />
|
<Compass className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
{chatAvailable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setChatOpen((o) => !o)}
|
||||||
|
title={`Multi-op chat${chatUnread > 0 ? ` — ${chatUnread} new` : ''}`}
|
||||||
|
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
chatShown ? 'border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted')}
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-4" />
|
||||||
|
{chatUnread > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-rose-500 text-white text-[9px] font-bold leading-[14px] text-center">
|
||||||
|
{chatUnread > 9 ? '9+' : chatUnread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||||
@@ -2758,7 +2958,7 @@ export default function App() {
|
|||||||
<Button
|
<Button
|
||||||
variant={showBandMap ? 'default' : 'outline'}
|
variant={showBandMap ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowBandMap((v) => !v)}
|
onClick={() => setBandMapShown(!showBandMap)}
|
||||||
title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
|
title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
@@ -3006,8 +3206,14 @@ export default function App() {
|
|||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
otherwise it shows the QRZ profile photo. */}
|
||||||
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
|
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||||
|
{chatShown && (
|
||||||
|
<div className="w-[280px] shrink-0 min-h-0">
|
||||||
|
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
|
||||||
|
onSend={chatSend} onClose={() => setChatOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||||
rotator is configured or a DX bearing exists. */}
|
rotator is configured or a DX bearing exists. */}
|
||||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||||
@@ -3090,6 +3296,54 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>{/* /entry + aside row */}
|
</div>{/* /entry + aside row */}
|
||||||
|
|
||||||
|
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||||
|
{cwOn && (
|
||||||
|
<div className="ml-2.5 mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1 text-xs">
|
||||||
|
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||||
|
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||||
|
audio device is wrong/silent rather than a decode problem. */}
|
||||||
|
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
|
||||||
|
<div className="h-full bg-emerald-500 transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
||||||
|
</span>
|
||||||
|
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={cwPitch}
|
||||||
|
onChange={(e) => setCwPitch(e.target.value)}
|
||||||
|
placeholder="auto"
|
||||||
|
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
|
||||||
|
className="shrink-0 w-14 h-5 rounded border border-emerald-300/70 bg-white/60 px-1 text-[10px] font-mono text-center outline-none"
|
||||||
|
/>
|
||||||
|
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
||||||
|
text (see cwScrollRef effect) so the latest stays in view. */}
|
||||||
|
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-5">
|
||||||
|
{cwText.trim() === '' ? (
|
||||||
|
<span className="text-muted-foreground italic">listening…</span>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex whitespace-nowrap">
|
||||||
|
{cwText.trim().split(/\s+/).map((tok, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="mr-1 shrink-0 rounded px-1 hover:bg-emerald-200/70"
|
||||||
|
title="Use as callsign"
|
||||||
|
onClick={() => onCallsignInput(tok, { force: true })}
|
||||||
|
>
|
||||||
|
{tok}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="Clear" onClick={() => setCwText('')}>
|
||||||
|
<Eraser className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{compact ? null : <>
|
||||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||||
@@ -3202,8 +3456,9 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
rows={qsos as any}
|
rows={qsosWithAwards as any}
|
||||||
total={total}
|
total={total}
|
||||||
|
awardCols={awardCols}
|
||||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty}
|
onUpdateFromCty={bulkUpdateFromCty}
|
||||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||||
@@ -3211,9 +3466,10 @@ export default function App() {
|
|||||||
onSendTo={bulkSendTo}
|
onSendTo={bulkSendTo}
|
||||||
onSendRecording={bulkSendRecording}
|
onSendRecording={bulkSendRecording}
|
||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onRowSelected={(id) => setSelectedId(id)}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
/>
|
/>
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -3386,7 +3642,7 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -3471,7 +3727,7 @@ export default function App() {
|
|||||||
spotStatus={spotStatus}
|
spotStatus={spotStatus}
|
||||||
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||||
onSpotClick={handleSpotClick}
|
onSpotClick={handleSpotClick}
|
||||||
onClose={() => setShowBandMap(false)}
|
onClose={() => setBandMapShown(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3538,6 +3794,13 @@ export default function App() {
|
|||||||
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
|
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<BulkEditModal
|
||||||
|
open={bulkEditOpen}
|
||||||
|
ids={bulkEditIds}
|
||||||
|
onClose={() => setBulkEditOpen(false)}
|
||||||
|
onApplied={(n) => { afterBulkUpdate(n, 'in bulk'); }}
|
||||||
|
/>
|
||||||
|
|
||||||
<SendSpotModal
|
<SendSpotModal
|
||||||
open={showSpotModal}
|
open={showSpotModal}
|
||||||
onClose={() => setShowSpotModal(false)}
|
onClose={() => setShowSpotModal(false)}
|
||||||
@@ -3592,16 +3855,21 @@ export default function App() {
|
|||||||
onOpenDesigner={() => setQslDesignerOpen(true)}
|
onOpenDesigner={() => setQslDesignerOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{deletingQSO && (
|
{deletingIds.length > 0 && (() => {
|
||||||
|
const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null;
|
||||||
|
return (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete QSO?"
|
title={deletingIds.length > 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'}
|
||||||
message={`This will permanently delete the QSO with ${deletingQSO.callsign} on ${fmtDateUTC(deletingQSO.qso_date)} (${deletingQSO.band} ${deletingQSO.mode}). This cannot be undone.`}
|
message={single
|
||||||
|
? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.`
|
||||||
|
: `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`}
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
danger
|
danger
|
||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => setDeletingQSO(null)}
|
onCancel={() => setDeletingIds([])}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
{showDeleteAll && (
|
{showDeleteAll && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete ALL QSOs?"
|
title="Delete ALL QSOs?"
|
||||||
@@ -3708,6 +3976,19 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer pt-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={importApplyStation}
|
||||||
|
onCheckedChange={(c) => setImportApplyStation(!!c)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Fill my station fields from my profile
|
||||||
|
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||||
|
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { BulkUpdateField } from '../../wailsjs/go/main/App';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
type FieldKind = 'status' | 'text';
|
||||||
|
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||||
|
|
||||||
|
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||||
|
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
|
||||||
|
// the backend BulkUpdateField whitelist.
|
||||||
|
const FIELDS: FieldDef[] = [
|
||||||
|
// QSL / upload status
|
||||||
|
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
|
||||||
|
// My station / operator
|
||||||
|
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
|
||||||
|
// Misc
|
||||||
|
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_VALUES: { v: string; label: string }[] = [
|
||||||
|
{ v: 'Y', label: 'Y — Yes / uploaded' },
|
||||||
|
{ v: 'N', label: 'N — No' },
|
||||||
|
{ v: 'R', label: 'R — Requested' },
|
||||||
|
{ v: 'I', label: 'I — Ignore' },
|
||||||
|
{ v: '_', label: '(blank — clear)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
ids: number[];
|
||||||
|
onClose: () => void;
|
||||||
|
onApplied: (n: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// BulkEditModal sets one QSL/upload status field to a chosen value across the
|
||||||
|
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
||||||
|
// so they become eligible for upload.
|
||||||
|
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||||
|
const [field, setField] = useState('hrdlog_upload');
|
||||||
|
const [statusValue, setStatusValue] = useState('R');
|
||||||
|
const [textValue, setTextValue] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
|
||||||
|
const isStatus = def.kind === 'status';
|
||||||
|
const effectiveValue = isStatus
|
||||||
|
? (statusValue === '_' ? '' : statusValue)
|
||||||
|
: textValue.trim();
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const n = await BulkUpdateField(ids as any, field, effectiveValue);
|
||||||
|
onApplied(Number(n) || 0);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk edit field</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
|
||||||
|
This overwrites the current value — there is no undo.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="px-5 py-2 space-y-3">
|
||||||
|
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Field</Label>
|
||||||
|
<Select value={field} onValueChange={setField}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
|
||||||
|
{FIELDS.filter((f) => f.group === g).map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Label className="text-sm">Value</Label>
|
||||||
|
{isStatus ? (
|
||||||
|
<Select value={statusValue} onValueChange={setStatusValue}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={textValue}
|
||||||
|
placeholder="leave empty to clear the field"
|
||||||
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Will set <span className="font-semibold">{def.label}</span> ={' '}
|
||||||
|
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-rose-700">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
Apply to {ids.length}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
|
||||||
|
export type ChatPresence = { operator: string; station: string; ago_secs: number };
|
||||||
|
|
||||||
|
function hhmm(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPanel — presentational multi-op chat panel, docked in the aside row next
|
||||||
|
// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx.
|
||||||
|
export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||||
|
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
|
||||||
|
onSend: (text: string) => void; onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const me = (myCall || '').toUpperCase();
|
||||||
|
useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]);
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return;
|
||||||
|
setText('');
|
||||||
|
onSend(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
|
||||||
|
<MessageSquare className="size-4 text-sky-600" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Users className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-[11px] text-muted-foreground" title={online.map((o) => o.operator).join(', ')}>
|
||||||
|
{online.length}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
|
||||||
|
{msgs.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground italic text-center py-6">No messages yet.</div>
|
||||||
|
) : msgs.map((m) => {
|
||||||
|
const mine = m.operator.toUpperCase() === me;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
||||||
|
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}>
|
||||||
|
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
|
||||||
|
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground/70 px-1">{hhmm(m.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 p-2 border-t border-border/60 shrink-0">
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||||
|
placeholder="Message…"
|
||||||
|
maxLength={1000}
|
||||||
|
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={send} disabled={!text.trim()}
|
||||||
|
className="inline-flex items-center justify-center size-8 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
|
||||||
|
<Send className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'iota', label: 'IOTA', type: 'text' },
|
{ value: 'iota', label: 'IOTA', type: 'text' },
|
||||||
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
||||||
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
||||||
|
{ value: 'wwff_ref', label: 'WWFF ref', type: 'text' },
|
||||||
{ value: 'rig', label: 'Rig', type: 'text' },
|
{ value: 'rig', label: 'Rig', type: 'text' },
|
||||||
{ value: 'ant', label: 'Antenna', type: 'text' },
|
{ value: 'ant', label: 'Antenna', type: 'text' },
|
||||||
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
||||||
@@ -64,6 +65,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
||||||
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
||||||
|
{ value: 'hrdlog_qso_upload_status', label: 'HRDLog upload status', type: 'text' },
|
||||||
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
||||||
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
||||||
@@ -74,6 +76,17 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
||||||
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
||||||
{ value: 'my_country', label: 'My country', type: 'text' },
|
{ value: 'my_country', label: 'My country', type: 'text' },
|
||||||
|
{ value: 'my_state', label: 'My state', type: 'text' },
|
||||||
|
{ value: 'my_cnty', label: 'My county', type: 'text' },
|
||||||
|
{ value: 'my_iota', label: 'My IOTA', type: 'text' },
|
||||||
|
{ value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' },
|
||||||
|
{ value: 'my_pota_ref', label: 'My POTA ref', type: 'text' },
|
||||||
|
{ value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' },
|
||||||
|
{ value: 'my_street', label: 'My street', type: 'text' },
|
||||||
|
{ value: 'my_city', label: 'My city', type: 'text' },
|
||||||
|
{ value: 'my_postal_code', label: 'My postal code', type: 'text' },
|
||||||
|
{ value: 'my_rig', label: 'My rig', type: 'text' },
|
||||||
|
{ value: 'my_antenna', label: 'My antenna', type: 'text' },
|
||||||
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
||||||
{ value: 'comment', label: 'Comment', type: 'text' },
|
{ value: 'comment', label: 'Comment', type: 'text' },
|
||||||
{ value: 'notes', label: 'Notes', type: 'text' },
|
{ value: 'notes', label: 'Notes', type: 'text' },
|
||||||
@@ -211,6 +224,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
{conditions.map((c, i) => {
|
{conditions.map((c, i) => {
|
||||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||||
|
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
||||||
@@ -231,9 +245,10 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
|
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||||
className="h-8 flex-1 text-xs"
|
className="h-8 flex-1 text-xs"
|
||||||
disabled={!needsValue}
|
disabled={!needsValue}
|
||||||
placeholder={needsValue ? 'value' : '—'}
|
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
|
||||||
value={c.value}
|
value={c.value}
|
||||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Radio, Zap, Mic2, Settings2, Power, AudioLines, Antenna, Flame, Gauge } from 'lucide-react';
|
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ type FlexState = {
|
|||||||
atu_status?: string; atu_memories: boolean;
|
atu_status?: string; atu_memories: boolean;
|
||||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
|
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
|
mode?: string;
|
||||||
|
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||||
|
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||||
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||||
meters?: Meter[];
|
meters?: Meter[];
|
||||||
};
|
};
|
||||||
@@ -30,6 +35,8 @@ const ZERO: FlexState = {
|
|||||||
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||||
rx_avail: false, agc_threshold: 0, audio_level: 0,
|
rx_avail: false, agc_threshold: 0, audio_level: 0,
|
||||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||||
|
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||||
|
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||||
amp_available: false, amp_operate: false,
|
amp_available: false, amp_operate: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +181,15 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
|||||||
export function FlexPanel() {
|
export function FlexPanel() {
|
||||||
const [st, setSt] = useState<FlexState>(ZERO);
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
const hold = useRef<Record<string, number>>({});
|
const hold = useRef<Record<string, number>>({});
|
||||||
|
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||||
|
// read steadily instead of jumping every poll.
|
||||||
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
|
const peakHold = (key: string, val: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const p = peak.current[key];
|
||||||
|
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
|
return p.v;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -204,8 +220,11 @@ export function FlexPanel() {
|
|||||||
|
|
||||||
const off = !st.available;
|
const off = !st.available;
|
||||||
const rxOff = off || !st.rx_avail;
|
const rxOff = off || !st.rx_avail;
|
||||||
|
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
const 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 AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||||
|
const CW_BW = [100, 200, 300, 400, 500];
|
||||||
|
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
@@ -262,6 +281,7 @@ export function FlexPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isCW ? (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
|
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
|
||||||
@@ -288,6 +308,29 @@ export function FlexPanel() {
|
|||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
||||||
|
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
|
||||||
|
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
|
||||||
|
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
|
||||||
|
</div>
|
||||||
|
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
|
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
|
||||||
|
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* RECEIVE */}
|
{/* RECEIVE */}
|
||||||
@@ -318,28 +361,29 @@ export function FlexPanel() {
|
|||||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
{isCW && (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||||||
|
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
||||||
|
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{CW_BW.map((bw) => (
|
||||||
|
<button key={bw} type="button" disabled={rxOff}
|
||||||
|
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||||
|
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{bw}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ATU */}
|
|
||||||
<Card icon={Settings2} title="Antenna Tuner">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<button type="button" disabled={off} onClick={() => FlexATUStart().catch(() => {})}
|
|
||||||
className="px-3 py-1.5 rounded-md text-xs font-bold border border-emerald-400 text-emerald-700 hover:bg-emerald-50 disabled:opacity-30">
|
|
||||||
<Antenna className="size-3.5 inline mr-1 -mt-0.5" /> Tune ATU
|
|
||||||
</button>
|
|
||||||
<button type="button" disabled={off} onClick={() => FlexATUBypass().catch(() => {})}
|
|
||||||
className="px-3 py-1.5 rounded-md text-xs font-bold border border-border text-muted-foreground hover:bg-muted disabled:opacity-30">
|
|
||||||
Bypass
|
|
||||||
</button>
|
|
||||||
<Chip on={st.atu_memories} disabled={off} label="MEM"
|
|
||||||
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
|
|
||||||
<div className="flex-1" />
|
|
||||||
{st.atu_status && (
|
|
||||||
<span className="text-xs font-mono text-muted-foreground">{st.atu_status.replace(/_/g, ' ')}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||||
{st.amp_available && (
|
{st.amp_available && (
|
||||||
@@ -378,9 +422,6 @@ export function FlexPanel() {
|
|||||||
const sig = radio('LEVEL') || radio('SIGNAL');
|
const sig = radio('LEVEL') || radio('SIGNAL');
|
||||||
const fwd = radio('FWDPWR');
|
const fwd = radio('FWDPWR');
|
||||||
const swr = radio('SWR');
|
const swr = radio('SWR');
|
||||||
const alc = radio('ALC');
|
|
||||||
const temp = radio('PATEMP');
|
|
||||||
const volts = radio('13.8B') || meters.find((m) => /volts/i.test(m.unit || '') && !(m.src || '').toUpperCase().includes('AMP'));
|
|
||||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
||||||
@@ -396,16 +437,15 @@ export function FlexPanel() {
|
|||||||
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
|
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
|
||||||
};
|
};
|
||||||
const cur = [
|
const cur = [
|
||||||
sig && (() => { const s = sUnit(sig.value); return (
|
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={`${sig.value.toFixed(1)} dBm`}
|
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||||
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
|
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
|
||||||
); })(),
|
); })(),
|
||||||
fwd && <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||||
value={isDbm(fwd) ? dbmToW(fwd.value) : fwd.value} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />,
|
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||||
swr && <MeterBar key="w" label="SWR" value={swr.value} unit="" lo={1} hi={3} accent="#d97706" />,
|
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||||
alc && <MeterBar key="a" label="ALC" value={alc.value} unit={alc.unit} lo={alc.lo} hi={alc.hi || 100} accent="#7c3aed" />,
|
); })(),
|
||||||
temp && <MeterBar key="t" label="PA TEMP" value={temp.value} unit={temp.unit} lo={temp.lo || 0} hi={temp.hi || 80} accent="#ea580c" />,
|
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||||
volts && <MeterBar key="v" label="VOLTS" value={volts.value} unit={volts.unit} lo={volts.lo || 0} hi={volts.hi || 15} accent="#2563eb" />,
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -416,9 +456,9 @@ export function FlexPanel() {
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
{amp.map((m) => {
|
{amp.map((m) => {
|
||||||
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={dbmToW(m.value)} unit="W" lo={0} hi={1500} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
||||||
}
|
}
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={m.value} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
||||||
|
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community';
|
||||||
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
|
// Warm theme matching the other grids (Recent QSOs / Cluster).
|
||||||
|
const qslTheme = themeQuartz.withParams({
|
||||||
|
fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419',
|
||||||
|
headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600,
|
||||||
|
oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8',
|
||||||
|
borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 },
|
||||||
|
cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12,
|
||||||
|
});
|
||||||
|
|
||||||
type UploadRow = {
|
type UploadRow = {
|
||||||
id: number; qso_date: string; callsign: string;
|
id: number; qso_date: string; callsign: string;
|
||||||
band: string; mode: string; country: string; status: string;
|
band: string; mode: string; country: string; status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
||||||
|
{ field: 'qso_date', headerName: 'Date UTC', valueFormatter: (p) => fmtDate(p.value), minWidth: 150 },
|
||||||
|
{ field: 'callsign', headerName: 'Callsign', cellClass: 'font-mono font-bold', width: 130 },
|
||||||
|
{ field: 'band', headerName: 'Band', width: 90 },
|
||||||
|
{ field: 'mode', headerName: 'Mode', width: 100 },
|
||||||
|
{ field: 'country', headerName: 'Country', flex: 1, minWidth: 140 },
|
||||||
|
{ field: 'status', headerName: 'Sent', width: 90, valueFormatter: (p) => p.value || '—' },
|
||||||
|
];
|
||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
||||||
@@ -23,6 +45,8 @@ type Confirmation = {
|
|||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
{ v: 'qrz', label: 'QRZ.com' },
|
{ v: 'qrz', label: 'QRZ.com' },
|
||||||
{ v: 'clublog', label: 'Club Log' },
|
{ v: 'clublog', label: 'Club Log' },
|
||||||
|
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||||
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
{ v: 'pota', label: 'POTA hunter log' },
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
@@ -77,6 +101,14 @@ export function fmtQslDate(s?: string): string {
|
|||||||
// and download confirmations, while the rest of the app stays usable.
|
// and download confirmations, while the rest of the app stays usable.
|
||||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
const [service, setService] = useState('lotw');
|
const [service, setService] = useState('lotw');
|
||||||
|
// The callsign this profile signs/uploads/downloads as for the selected
|
||||||
|
// service (Force station callsign, else the profile call). Shown so the user
|
||||||
|
// knows WHICH of their calls a download/upload targets in a mixed-call log.
|
||||||
|
const [uploadCall, setUploadCall] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||||
|
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||||
|
}, [service]);
|
||||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
const [potaErr, setPotaErr] = useState('');
|
||||||
@@ -141,7 +173,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
}
|
}
|
||||||
const [sent, setSent] = useState('R');
|
const [sent, setSent] = useState('R');
|
||||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly.
|
||||||
|
const gridRef = useRef<AgGridReact<UploadRow>>(null);
|
||||||
|
const [selectedCount, setSelectedCount] = useState(0);
|
||||||
|
const selectAllNext = useRef(false); // selectAll once after the next data load
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [addNotFound, setAddNotFound] = useState(false);
|
const [addNotFound, setAddNotFound] = useState(false);
|
||||||
@@ -172,10 +207,20 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
return () => { offLog(); offDone(); offConf(); };
|
return () => { offLog(); offDone(); offConf(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedCount = selected.size;
|
|
||||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
|
||||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
|
// Grid selection → just track the count; ids are read from the grid at upload.
|
||||||
|
function onUploadSelChanged() {
|
||||||
|
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0);
|
||||||
|
}
|
||||||
|
// After "Select required" loads new rows, select them all (the old default).
|
||||||
|
function onUploadRowsLoaded() {
|
||||||
|
if (selectAllNext.current) {
|
||||||
|
selectAllNext.current = false;
|
||||||
|
gridRef.current?.api?.selectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
switch (confFilter) {
|
switch (confFilter) {
|
||||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
||||||
@@ -192,32 +237,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
try {
|
try {
|
||||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||||
const list = (r ?? []) as UploadRow[];
|
const list = (r ?? []) as UploadRow[];
|
||||||
|
selectAllNext.current = true; // pre-select everything once the grid renders
|
||||||
setRows(list);
|
setRows(list);
|
||||||
setSelected(new Set(list.map((x) => x.id)));
|
setSelectedCount(list.length);
|
||||||
setViewMode('upload');
|
setViewMode('upload');
|
||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
setError(String(e?.message ?? e));
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setSelected(new Set());
|
setSelectedCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
}, [service, sent]);
|
}, [service, sent]);
|
||||||
|
|
||||||
function toggle(id: number) {
|
|
||||||
setSelected((s) => {
|
|
||||||
const n = new Set(s);
|
|
||||||
if (n.has(id)) n.delete(id); else n.add(id);
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toggleAll() {
|
|
||||||
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upload() {
|
async function upload() {
|
||||||
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
try { await UploadQSOsManual(service, ids); }
|
try { await UploadQSOsManual(service, ids); }
|
||||||
@@ -251,6 +286,17 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{uploadCall && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||||
|
<span
|
||||||
|
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
||||||
|
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
||||||
|
>
|
||||||
|
{uploadCall}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{service === 'pota' ? (
|
{service === 'pota' ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||||
@@ -463,33 +509,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs border-collapse">
|
<div className="h-full w-full">
|
||||||
<thead className="sticky top-0 bg-card">
|
<AgGridReact<UploadRow>
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
ref={gridRef}
|
||||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
theme={qslTheme}
|
||||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
rowData={rows}
|
||||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
columnDefs={UPLOAD_COLS}
|
||||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
|
defaultColDef={{ sortable: true, resizable: true, filter: true }}
|
||||||
</tr>
|
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
|
||||||
</thead>
|
onSelectionChanged={onUploadSelChanged}
|
||||||
<tbody>
|
onRowDataUpdated={onUploadRowsLoaded}
|
||||||
{rows.map((r) => (
|
animateRows={false}
|
||||||
<tr key={r.id}
|
suppressCellFocus
|
||||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
onClick={() => toggle(r.id)}>
|
/>
|
||||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
</div>
|
||||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
|
||||||
</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
|
||||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
|
||||||
<td className="py-1 px-2">{r.band}</td>
|
|
||||||
<td className="py-1 px-2">{r.mode}</td>
|
|
||||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react';
|
||||||
|
|
||||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ type Props = {
|
|||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
};
|
};
|
||||||
@@ -19,13 +20,15 @@ type Props = {
|
|||||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
{ service: 'qrz', label: 'Send to QRZ.com' },
|
||||||
{ service: 'clublog', label: 'Send to Club Log' },
|
{ service: 'clublog', label: 'Send to Club Log' },
|
||||||
|
{ service: 'hrdlog', label: 'Send to HRDLog.net' },
|
||||||
|
{ service: 'eqsl', label: 'Send to eQSL.cc' },
|
||||||
{ service: 'lotw', label: 'Send to LoTW' },
|
{ service: 'lotw', label: 'Send to LoTW' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -105,6 +108,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onBulkEdit && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<PencilLine className="size-4 text-indigo-600" />
|
||||||
|
<span>Bulk edit field… ({n})</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{(onExportSelected || onExportFiltered) && (
|
{(onExportSelected || onExportFiltered) && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
@@ -46,15 +46,19 @@ type Props = {
|
|||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (id: number | null) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
@@ -218,7 +222,7 @@ export const GROUP_ORDER = [
|
|||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
|
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -244,10 +248,21 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
// Compute initial column defs: all columns defined, but those not marked
|
// Compute initial column defs: all columns defined, but those not marked
|
||||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||||
// overrides this so a previously toggled column wins.
|
// overrides this so a previously toggled column wins.
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
return { ...rest, hide: !defaultVisible };
|
||||||
}), []);
|
});
|
||||||
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
|
colId: `award_${a.code}`,
|
||||||
|
headerName: a.code,
|
||||||
|
headerTooltip: `${a.name} — reference this QSO counts for`,
|
||||||
|
width: 110,
|
||||||
|
cellClass: 'text-[11px]',
|
||||||
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
|
}));
|
||||||
|
return [...base, ...awards];
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -273,12 +288,24 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The award columns load asynchronously; when they arrive (or change) the
|
||||||
|
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
||||||
|
// default — wiping the user's saved visibility (award columns reappear,
|
||||||
|
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
||||||
|
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
|
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
@@ -365,6 +392,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
/>
|
/>
|
||||||
@@ -405,6 +433,26 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{awardCols && awardCols.length > 0 && (
|
||||||
|
<div className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
|
||||||
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{awardCols.map((a) => (
|
||||||
|
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
|
||||||
|
<span className="font-mono font-semibold">{a.code}</span>
|
||||||
|
<span className="text-muted-foreground truncate">{a.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
@@ -747,20 +747,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
||||||
type ExtServiceCfg = {
|
type ExtServiceCfg = {
|
||||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||||
|
code: string; qth_nickname: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
tqsl_path: string; station_location: string; key_password: string;
|
tqsl_path: string; station_location: string; key_password: string;
|
||||||
upload_flag: string; write_log: boolean;
|
upload_flags: string[]; write_log: boolean;
|
||||||
auto_upload: boolean; upload_mode: string;
|
auto_upload: boolean; upload_mode: string;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', email: '', username: '', password: '', callsign: '',
|
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||||
upload_flag: 'R', write_log: false,
|
upload_flags: ['N', 'R'], write_log: false,
|
||||||
auto_upload: false, upload_mode: 'immediate',
|
auto_upload: false, upload_mode: 'immediate',
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(),
|
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
|
||||||
});
|
});
|
||||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [qrzTesting, setQrzTesting] = useState(false);
|
const [qrzTesting, setQrzTesting] = useState(false);
|
||||||
@@ -768,10 +769,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [clublogTesting, setClublogTesting] = useState(false);
|
const [clublogTesting, setClublogTesting] = useState(false);
|
||||||
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [lotwTesting, setLotwTesting] = useState(false);
|
const [lotwTesting, setLotwTesting] = useState(false);
|
||||||
|
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||||
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [eqslTesting, setEqslTesting] = useState(false);
|
||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
// Active tab in the External Services panel — lifted here because
|
// Active tab in the External Services panel — lifted here because
|
||||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
|
||||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||||
const [potaToken, setPotaToken] = useState('');
|
const [potaToken, setPotaToken] = useState('');
|
||||||
const [potaBusy, setPotaBusy] = useState(false);
|
const [potaBusy, setPotaBusy] = useState(false);
|
||||||
@@ -2603,9 +2608,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
|
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
|
||||||
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
||||||
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
||||||
{ k: 'hrdlog', label: 'HRDLOG.NET' },
|
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
||||||
{ k: 'eqsl', label: 'EQSL' },
|
{ k: 'eqsl', label: 'EQSL', ready: true },
|
||||||
{ k: 'hamqth', label: 'HAMQTH' },
|
|
||||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||||
{ k: 'pota', label: 'POTA', ready: true },
|
{ k: 'pota', label: 'POTA', ready: true },
|
||||||
];
|
];
|
||||||
@@ -2658,6 +2662,42 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hrdlog = extSvc.hrdlog;
|
||||||
|
const setHrdlog = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, hrdlog: { ...s.hrdlog, ...patch } }));
|
||||||
|
|
||||||
|
async function testHrdlog() {
|
||||||
|
setHrdlogTesting(true);
|
||||||
|
setHrdlogTest(null);
|
||||||
|
try {
|
||||||
|
await SaveExternalServices(extSvc as any);
|
||||||
|
const msg = await TestHRDLogUpload();
|
||||||
|
setHrdlogTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setHrdlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setHrdlogTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eqsl = extSvc.eqsl;
|
||||||
|
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
|
||||||
|
|
||||||
|
async function testEqsl() {
|
||||||
|
setEqslTesting(true);
|
||||||
|
setEqslTest(null);
|
||||||
|
try {
|
||||||
|
await SaveExternalServices(extSvc as any);
|
||||||
|
const msg = await TestEQSLUpload();
|
||||||
|
setEqslTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setEqslTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setEqslTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lotw = extSvc.lotw;
|
const lotw = extSvc.lotw;
|
||||||
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
||||||
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
||||||
@@ -2833,6 +2873,131 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'hrdlog' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Station callsign</Label>
|
||||||
|
<Input
|
||||||
|
value={hrdlog.callsign}
|
||||||
|
onChange={(e) => setHrdlog({ callsign: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="defaults to the active profile's callsign"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Upload code</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={hrdlog.code}
|
||||||
|
onChange={(e) => setHrdlog({ code: e.target.value })}
|
||||||
|
placeholder="HRDLog account → My Account → Upload code"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||||
|
Find the upload code on HRDLog.net under <em>My Account</em>. It authorises uploads for this callsign.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={hrdlog.auto_upload}
|
||||||
|
onCheckedChange={(c) => setHrdlog({ auto_upload: !!c })}
|
||||||
|
/>
|
||||||
|
Automatic upload on new QSO
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Upload timing</Label>
|
||||||
|
<Select
|
||||||
|
value={hrdlog.upload_mode || 'immediate'}
|
||||||
|
onValueChange={(v) => setHrdlog({ upload_mode: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="immediate">Immediate</SelectItem>
|
||||||
|
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||||
|
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testHrdlog} disabled={hrdlogTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {hrdlogTesting ? 'Testing…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
{hrdlogTest && (
|
||||||
|
<span className={cn('text-xs', hrdlogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||||
|
{hrdlogTest.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : extSvcTab === 'eqsl' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Username (callsign)</Label>
|
||||||
|
<Input
|
||||||
|
value={eqsl.username}
|
||||||
|
onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="your eQSL.cc login (callsign)"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={eqsl.password}
|
||||||
|
onChange={(e) => setEqsl({ password: e.target.value })}
|
||||||
|
placeholder="eQSL.cc account password"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">QTH nickname</Label>
|
||||||
|
<Input
|
||||||
|
value={eqsl.qth_nickname}
|
||||||
|
onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
|
||||||
|
placeholder="optional — required only if your eQSL account has several QTHs"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||||
|
The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={eqsl.auto_upload}
|
||||||
|
onCheckedChange={(c) => setEqsl({ auto_upload: !!c })}
|
||||||
|
/>
|
||||||
|
Automatic upload on new QSO
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Upload timing</Label>
|
||||||
|
<Select
|
||||||
|
value={eqsl.upload_mode || 'immediate'}
|
||||||
|
onValueChange={(v) => setEqsl({ upload_mode: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="immediate">Immediate</SelectItem>
|
||||||
|
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||||
|
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testEqsl} disabled={eqslTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {eqslTesting ? 'Testing…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
{eqslTest && (
|
||||||
|
<span className={cn('text-xs', eqslTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||||
|
{eqslTest.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : extSvcTab === 'lotw' ? (
|
) : extSvcTab === 'lotw' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
@@ -2892,17 +3057,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
placeholder="only if your certificate key has a password"
|
placeholder="only if your certificate key has a password"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">Upload flag</Label>
|
<Label className="text-sm">Consider as unsent</Label>
|
||||||
<div>
|
<div>
|
||||||
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
|
<div className="flex items-center gap-4">
|
||||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
{(['N', 'R'] as const).map((f) => {
|
||||||
<SelectContent>
|
const flags = lotw.upload_flags ?? [];
|
||||||
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
|
const checked = flags.includes(f);
|
||||||
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
|
return (
|
||||||
</SelectContent>
|
<label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
</Select>
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
const next = c
|
||||||
|
? Array.from(new Set([...flags, f]))
|
||||||
|
: flags.filter((x) => x !== f);
|
||||||
|
setLotw({ upload_flags: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{f === 'N' ? 'No (N)' : 'Requested (R)'}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
Must match your default LoTW <em>sent</em> status in Confirmations, or new QSOs won't be picked up.
|
At app close, every QSO whose LoTW <em>sent</em> status is one of these is signed and
|
||||||
|
uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become
|
||||||
|
<em> Y</em> and won't be re-sent. Must include your default <em>sent</em> status from Confirmations.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function WinkeyerPanel({
|
|||||||
someone answers. The seconds box is the gap AFTER the message. */}
|
someone answers. The seconds box is the gap AFTER the message. */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||||
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop">
|
title="Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.">
|
||||||
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||||
Auto-call
|
Auto-call
|
||||||
@@ -193,7 +193,7 @@ export function WinkeyerPanel({
|
|||||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||||
</div>
|
</div>
|
||||||
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
|
{autoCall && <span className="text-[10px] text-amber-600/80">click a CQ macro to loop it</span>}
|
||||||
</div>
|
</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. */}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ type Props = {
|
|||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
// One column per defined award (cell = the reference this QSO counts for).
|
||||||
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
||||||
@@ -65,7 +67,7 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL }: Props) {
|
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -93,10 +95,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
const count = wb?.count ?? 0;
|
const count = wb?.count ?? 0;
|
||||||
const entries = wb?.entries ?? [];
|
const entries = wb?.entries ?? [];
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
|
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
||||||
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
return { ...rest, hide: !defaultVisible };
|
||||||
}), []);
|
});
|
||||||
|
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
|
||||||
|
colId: `award_${a.code}`,
|
||||||
|
headerName: a.code,
|
||||||
|
headerTooltip: `${a.name} — reference this QSO counts for`,
|
||||||
|
width: 110,
|
||||||
|
cellClass: 'text-[11px]',
|
||||||
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
|
}));
|
||||||
|
return [...base, ...awards];
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
@@ -283,6 +296,26 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{awardCols && awardCols.length > 0 && (
|
||||||
|
<div className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
|
||||||
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{awardCols.map((a) => (
|
||||||
|
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
|
||||||
|
<span className="font-mono font-semibold">{a.code}</span>
|
||||||
|
<span className="text-muted-foreground truncate">{a.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
|||||||
const PORTABLE_KEYS = [
|
const PORTABLE_KEYS = [
|
||||||
'hamlog.qsoLimit', // QSO list page size
|
'hamlog.qsoLimit', // QSO list page size
|
||||||
'bandmap.side', // band map docked left / right
|
'bandmap.side', // band map docked left / right
|
||||||
|
'bandmap.show', // band map open / closed
|
||||||
'opslog.autofocusWB', // auto-focus Worked-before
|
'opslog.autofocusWB', // auto-focus Worked-before
|
||||||
'hamlog.filterPresets', // Filter Builder saved presets
|
'hamlog.filterPresets', // Filter Builder saved presets
|
||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.11.2';
|
export const APP_VERSION = '0.12';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+47
-1
@@ -33,10 +33,18 @@ export function AwardFields():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function 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 BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
|
|
||||||
export function ClearLookupCache():Promise<void>;
|
export function ClearLookupCache():Promise<void>;
|
||||||
@@ -87,6 +95,8 @@ export function DeleteProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DeleteQSO(arg1:number):Promise<void>;
|
export function DeleteQSO(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DisconnectAllClusters():Promise<void>;
|
export function DisconnectAllClusters():Promise<void>;
|
||||||
@@ -131,10 +141,24 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWFilter(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMic(arg1:number):Promise<void>;
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMon(arg1:boolean):Promise<void>;
|
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||||
@@ -155,6 +179,8 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
@@ -189,6 +215,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
|||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
export function GetCATState():Promise<cat.RigState>;
|
||||||
|
|
||||||
|
export function GetCWDecoderPitch():Promise<number>;
|
||||||
|
|
||||||
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function GetClusterAutoConnect():Promise<boolean>;
|
export function GetClusterAutoConnect():Promise<boolean>;
|
||||||
@@ -227,6 +257,8 @@ export function GetLookupSettings():Promise<main.LookupSettings>;
|
|||||||
|
|
||||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
@@ -257,7 +289,7 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
|||||||
|
|
||||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
@@ -433,6 +465,8 @@ export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||||
|
|
||||||
|
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||||
|
|
||||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||||
@@ -445,6 +479,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
@@ -463,14 +499,22 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
|
export function TestEQSLUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestEmail(arg1:string):Promise<void>;
|
export function TestEmail(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function TestHRDLogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLoTWUpload():Promise<string>;
|
export function TestLoTWUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
@@ -499,6 +543,8 @@ export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
|||||||
|
|
||||||
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
export function UploadCallsign(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
export function WinkeyerBackspace():Promise<void>;
|
export function WinkeyerBackspace():Promise<void>;
|
||||||
|
|||||||
@@ -38,14 +38,30 @@ export function AwardMissingQSOs(arg1) {
|
|||||||
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AwardRefsForQSOs(arg1) {
|
||||||
|
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BulkUpdateField(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['BulkUpdateField'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1, arg2) {
|
export function BulkUpdateQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CWDecoderRunning() {
|
||||||
|
return window['go']['main']['App']['CWDecoderRunning']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatAvailable() {
|
||||||
|
return window['go']['main']['App']['ChatAvailable']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CheckForUpdate() {
|
export function CheckForUpdate() {
|
||||||
return window['go']['main']['App']['CheckForUpdate']();
|
return window['go']['main']['App']['CheckForUpdate']();
|
||||||
}
|
}
|
||||||
@@ -146,6 +162,10 @@ export function DeleteQSO(arg1) {
|
|||||||
return window['go']['main']['App']['DeleteQSO'](arg1);
|
return window['go']['main']['App']['DeleteQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteQSOs(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteQSOs'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteUDPIntegration(arg1) {
|
export function DeleteUDPIntegration(arg1) {
|
||||||
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
@@ -234,6 +254,14 @@ export function FlexSetANFLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetAPF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAPFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAPFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetATUMemories(arg1) {
|
export function FlexSetATUMemories(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||||
}
|
}
|
||||||
@@ -242,6 +270,26 @@ export function FlexSetAudioLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWBreakInDelay(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWBreakInDelay'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWFilter(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWFilter'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWPitch(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWPitch'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWSidetone(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWSidetone'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWSpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMic(arg1) {
|
export function FlexSetMic(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
@@ -282,6 +330,10 @@ export function FlexSetProcessorLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetSidetoneLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1) {
|
export function FlexSetTunePower(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||||
}
|
}
|
||||||
@@ -350,6 +402,14 @@ export function GetCATState() {
|
|||||||
return window['go']['main']['App']['GetCATState']();
|
return window['go']['main']['App']['GetCATState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetCWDecoderPitch() {
|
||||||
|
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetChatHistory(arg1) {
|
||||||
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetClublogCtyInfo() {
|
export function GetClublogCtyInfo() {
|
||||||
return window['go']['main']['App']['GetClublogCtyInfo']();
|
return window['go']['main']['App']['GetClublogCtyInfo']();
|
||||||
}
|
}
|
||||||
@@ -426,6 +486,10 @@ export function GetMySQLSettings() {
|
|||||||
return window['go']['main']['App']['GetMySQLSettings']();
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOnlineOperators() {
|
||||||
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPOTAToken() {
|
export function GetPOTAToken() {
|
||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
@@ -486,8 +550,8 @@ export function HasBuiltinReferences(arg1) {
|
|||||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportADIF(arg1, arg2, arg3) {
|
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1, arg2) {
|
export function ImportAwardReferencesText(arg1, arg2) {
|
||||||
@@ -838,6 +902,10 @@ export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendChatMessage(arg1) {
|
||||||
|
return window['go']['main']['App']['SendChatMessage'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendClusterCommand(arg1) {
|
export function SendClusterCommand(arg1) {
|
||||||
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||||
}
|
}
|
||||||
@@ -862,6 +930,10 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetCWDecoderPitch(arg1) {
|
||||||
|
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1) {
|
export function SetClublogCtyEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -898,6 +970,14 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartCWDecoder() {
|
||||||
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StopCWDecoder() {
|
||||||
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
@@ -910,10 +990,18 @@ export function TestClublogUpload() {
|
|||||||
return window['go']['main']['App']['TestClublogUpload']();
|
return window['go']['main']['App']['TestClublogUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestEQSLUpload() {
|
||||||
|
return window['go']['main']['App']['TestEQSLUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestEmail(arg1) {
|
export function TestEmail(arg1) {
|
||||||
return window['go']['main']['App']['TestEmail'](arg1);
|
return window['go']['main']['App']['TestEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestHRDLogUpload() {
|
||||||
|
return window['go']['main']['App']['TestHRDLogUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestLoTWUpload() {
|
export function TestLoTWUpload() {
|
||||||
return window['go']['main']['App']['TestLoTWUpload']();
|
return window['go']['main']['App']['TestLoTWUpload']();
|
||||||
}
|
}
|
||||||
@@ -970,6 +1058,10 @@ export function UpdateQSOsFromQRZ(arg1) {
|
|||||||
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
|
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UploadCallsign(arg1) {
|
||||||
|
return window['go']['main']['App']['UploadCallsign'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1, arg2) {
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,6 +458,16 @@ export namespace cat {
|
|||||||
nr_level: number;
|
nr_level: number;
|
||||||
anf: boolean;
|
anf: boolean;
|
||||||
anf_level: number;
|
anf_level: number;
|
||||||
|
mode?: string;
|
||||||
|
cw_speed: number;
|
||||||
|
cw_pitch: number;
|
||||||
|
cw_break_in_delay: number;
|
||||||
|
cw_sidetone: boolean;
|
||||||
|
cw_mon_level: number;
|
||||||
|
apf: boolean;
|
||||||
|
apf_level: number;
|
||||||
|
filter_lo: number;
|
||||||
|
filter_hi: number;
|
||||||
amp_available: boolean;
|
amp_available: boolean;
|
||||||
amp_model?: string;
|
amp_model?: string;
|
||||||
amp_operate: boolean;
|
amp_operate: boolean;
|
||||||
@@ -496,6 +506,16 @@ export namespace cat {
|
|||||||
this.nr_level = source["nr_level"];
|
this.nr_level = source["nr_level"];
|
||||||
this.anf = source["anf"];
|
this.anf = source["anf"];
|
||||||
this.anf_level = source["anf_level"];
|
this.anf_level = source["anf_level"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.cw_speed = source["cw_speed"];
|
||||||
|
this.cw_pitch = source["cw_pitch"];
|
||||||
|
this.cw_break_in_delay = source["cw_break_in_delay"];
|
||||||
|
this.cw_sidetone = source["cw_sidetone"];
|
||||||
|
this.cw_mon_level = source["cw_mon_level"];
|
||||||
|
this.apf = source["apf"];
|
||||||
|
this.apf_level = source["apf_level"];
|
||||||
|
this.filter_lo = source["filter_lo"];
|
||||||
|
this.filter_hi = source["filter_hi"];
|
||||||
this.amp_available = source["amp_available"];
|
this.amp_available = source["amp_available"];
|
||||||
this.amp_model = source["amp_model"];
|
this.amp_model = source["amp_model"];
|
||||||
this.amp_operate = source["amp_operate"];
|
this.amp_operate = source["amp_operate"];
|
||||||
@@ -669,11 +689,13 @@ export namespace extsvc {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
|
code: string;
|
||||||
|
qth_nickname: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
tqsl_path: string;
|
tqsl_path: string;
|
||||||
station_location: string;
|
station_location: string;
|
||||||
key_password: string;
|
key_password: string;
|
||||||
upload_flag: string;
|
upload_flags: string[];
|
||||||
write_log: boolean;
|
write_log: boolean;
|
||||||
auto_upload: boolean;
|
auto_upload: boolean;
|
||||||
upload_mode: string;
|
upload_mode: string;
|
||||||
@@ -689,11 +711,13 @@ export namespace extsvc {
|
|||||||
this.username = source["username"];
|
this.username = source["username"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
|
this.code = source["code"];
|
||||||
|
this.qth_nickname = source["qth_nickname"];
|
||||||
this.force_station_callsign = source["force_station_callsign"];
|
this.force_station_callsign = source["force_station_callsign"];
|
||||||
this.tqsl_path = source["tqsl_path"];
|
this.tqsl_path = source["tqsl_path"];
|
||||||
this.station_location = source["station_location"];
|
this.station_location = source["station_location"];
|
||||||
this.key_password = source["key_password"];
|
this.key_password = source["key_password"];
|
||||||
this.upload_flag = source["upload_flag"];
|
this.upload_flags = source["upload_flags"];
|
||||||
this.write_log = source["write_log"];
|
this.write_log = source["write_log"];
|
||||||
this.auto_upload = source["auto_upload"];
|
this.auto_upload = source["auto_upload"];
|
||||||
this.upload_mode = source["upload_mode"];
|
this.upload_mode = source["upload_mode"];
|
||||||
@@ -703,6 +727,8 @@ export namespace extsvc {
|
|||||||
qrz: ServiceConfig;
|
qrz: ServiceConfig;
|
||||||
clublog: ServiceConfig;
|
clublog: ServiceConfig;
|
||||||
lotw: ServiceConfig;
|
lotw: ServiceConfig;
|
||||||
|
hrdlog: ServiceConfig;
|
||||||
|
eqsl: ServiceConfig;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ExternalServices(source);
|
return new ExternalServices(source);
|
||||||
@@ -713,6 +739,8 @@ export namespace extsvc {
|
|||||||
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
|
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
|
||||||
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
||||||
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
||||||
|
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||||
|
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -1035,6 +1063,42 @@ export namespace main {
|
|||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ChatMessage {
|
||||||
|
id: number;
|
||||||
|
operator: string;
|
||||||
|
station: string;
|
||||||
|
message: string;
|
||||||
|
created_at: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChatMessage(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.station = source["station"];
|
||||||
|
this.message = source["message"];
|
||||||
|
this.created_at = source["created_at"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ChatPresence {
|
||||||
|
operator: string;
|
||||||
|
station: string;
|
||||||
|
ago_secs: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChatPresence(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.station = source["station"];
|
||||||
|
this.ago_secs = source["ago_secs"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ClublogCtyInfo {
|
export class ClublogCtyInfo {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
// SampleRate is the fixed capture rate (mono 16-bit PCM). Exported so consumers
|
||||||
|
// like the CW decoder can configure their DSP to match.
|
||||||
|
const SampleRate = sampleRate
|
||||||
|
|
||||||
|
// StreamCapture captures from deviceID and calls onSamples with mono 16-bit PCM
|
||||||
|
// frames (as int16) until stop closes. A thin wrapper over the internal capture
|
||||||
|
// loop for live consumers (the CW decoder) that want samples, not raw bytes.
|
||||||
|
func StreamCapture(deviceID string, stop <-chan struct{}, onSamples func([]int16)) error {
|
||||||
|
return captureStream(deviceID, stop, func(chunk []byte) {
|
||||||
|
if len(chunk) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSamples(bytesToInt16(chunk))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -257,6 +257,17 @@ type FlexTXState struct {
|
|||||||
NRLevel int `json:"nr_level"`
|
NRLevel int `json:"nr_level"`
|
||||||
ANF bool `json:"anf"`
|
ANF bool `json:"anf"`
|
||||||
ANFLevel int `json:"anf_level"`
|
ANFLevel int `json:"anf_level"`
|
||||||
|
// CW / mode-specific controls.
|
||||||
|
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||||
|
CWSpeed int `json:"cw_speed"`
|
||||||
|
CWPitch int `json:"cw_pitch"`
|
||||||
|
CWBreakInDelay int `json:"cw_break_in_delay"`
|
||||||
|
CWSidetone bool `json:"cw_sidetone"`
|
||||||
|
CWMonLevel int `json:"cw_mon_level"` // sidetone level
|
||||||
|
APF bool `json:"apf"`
|
||||||
|
APFLevel int `json:"apf_level"`
|
||||||
|
FilterLo int `json:"filter_lo"`
|
||||||
|
FilterHi int `json:"filter_hi"`
|
||||||
// External amplifier (PowerGenius XL).
|
// External amplifier (PowerGenius XL).
|
||||||
AmpAvailable bool `json:"amp_available"`
|
AmpAvailable bool `json:"amp_available"`
|
||||||
AmpModel string `json:"amp_model,omitempty"`
|
AmpModel string `json:"amp_model,omitempty"`
|
||||||
@@ -307,6 +318,15 @@ type FlexController interface {
|
|||||||
SetNRLevel(int) error
|
SetNRLevel(int) error
|
||||||
SetANF(bool) error
|
SetANF(bool) error
|
||||||
SetANFLevel(int) error
|
SetANFLevel(int) error
|
||||||
|
SetAPF(bool) error
|
||||||
|
SetAPFLevel(int) error
|
||||||
|
// CW keyer + mode-specific controls.
|
||||||
|
SetCWSpeed(int) error
|
||||||
|
SetCWPitch(int) error
|
||||||
|
SetCWBreakInDelay(int) error
|
||||||
|
SetCWSidetone(bool) error
|
||||||
|
SetSidetoneLevel(int) error
|
||||||
|
SetCWFilter(int) error
|
||||||
// External amplifier (PowerGenius XL) operate/standby.
|
// External amplifier (PowerGenius XL) operate/standby.
|
||||||
SetAmpOperate(bool) error
|
SetAmpOperate(bool) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Flex struct {
|
|||||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||||
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
||||||
lastStateSig string // last logged derived-state signature (log only on change)
|
lastStateSig string // last logged derived-state signature (log only on change)
|
||||||
|
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
||||||
|
|
||||||
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions
|
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions
|
||||||
// pushed over TCP; meterVal the latest scaled values keyed by meter id.
|
// pushed over TCP; meterVal the latest scaled values keyed by meter id.
|
||||||
@@ -78,6 +79,10 @@ type flexSlice struct {
|
|||||||
nrLevel int
|
nrLevel int
|
||||||
anf bool // auto notch filter
|
anf bool // auto notch filter
|
||||||
anfLevel int
|
anfLevel int
|
||||||
|
apf bool // CW audio peaking filter
|
||||||
|
apfLevel int
|
||||||
|
filterLo int // slice filter low cut (Hz)
|
||||||
|
filterHi int // slice filter high cut (Hz)
|
||||||
}
|
}
|
||||||
|
|
||||||
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
||||||
@@ -97,6 +102,12 @@ type flexTX struct {
|
|||||||
micLevel int
|
micLevel int
|
||||||
atuStatus string
|
atuStatus string
|
||||||
atuMemories bool
|
atuMemories bool
|
||||||
|
// CW keyer params (set via the top-level "cw" commands).
|
||||||
|
cwSpeed int // WPM
|
||||||
|
cwPitch int // Hz
|
||||||
|
cwBreakInDelay int // ms (QSK delay)
|
||||||
|
cwSidetone bool // sidetone (audible monitor) enable
|
||||||
|
cwMonLevel int // sidetone level (mon_gain_cw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the
|
// flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the
|
||||||
@@ -164,6 +175,7 @@ func (f *Flex) Connect() error {
|
|||||||
f.slices = map[int]*flexSlice{}
|
f.slices = map[int]*flexSlice{}
|
||||||
f.meterVal = map[int]float64{}
|
f.meterVal = map[int]float64{}
|
||||||
f.meterSub = map[int]bool{}
|
f.meterSub = map[int]bool{}
|
||||||
|
f.boundClientID = "" // re-bind to the GUI client on each (re)connect
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
debugLog.Printf("Flex: connected to %s:%d", host, port)
|
debugLog.Printf("Flex: connected to %s:%d", host, port)
|
||||||
|
|
||||||
@@ -176,6 +188,8 @@ func (f *Flex) Connect() error {
|
|||||||
f.send("sub atu all") // antenna-tuner status + memories
|
f.send("sub atu all") // antenna-tuner status + memories
|
||||||
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
||||||
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
||||||
|
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
||||||
|
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
||||||
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||||
if f.spotsEnabled {
|
if f.spotsEnabled {
|
||||||
// Subscribe so the radio pushes existing spots (we learn their indices),
|
// Subscribe so the radio pushes existing spots (we learn their indices),
|
||||||
@@ -364,12 +378,85 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.tx.mon = val == "1"
|
f.tx.mon = val == "1"
|
||||||
case "mon_gain_sb":
|
case "mon_gain_sb":
|
||||||
f.tx.monLevel = atoiDefault(val, f.tx.monLevel)
|
f.tx.monLevel = atoiDefault(val, f.tx.monLevel)
|
||||||
|
case "mon_gain_cw":
|
||||||
|
f.tx.cwMonLevel = atoiDefault(val, f.tx.cwMonLevel)
|
||||||
|
case "sidetone", "cw_sidetone":
|
||||||
|
f.tx.cwSidetone = val == "1"
|
||||||
|
// Once bound to the GUI client (see the client branch) the transmit
|
||||||
|
// object carries the GUI client's LIVE CW values, so read them here
|
||||||
|
// (and from cwx). Before binding these are the radio's static
|
||||||
|
// defaults — that was the "always 600 / 5" bug.
|
||||||
|
case "speed", "cwl_speed", "cw_speed", "wpm", "cw_wpm":
|
||||||
|
f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed)
|
||||||
|
case "pitch", "cwl_pitch", "cw_pitch":
|
||||||
|
f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch)
|
||||||
|
case "break_in_delay", "cwl_delay", "cw_break_in_delay", "delay":
|
||||||
|
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
||||||
case "mic_level", "miclevel":
|
case "mic_level", "miclevel":
|
||||||
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
// Client object — list of connected clients. GUI clients (SmartSDR /
|
||||||
|
// Maestro) carry a client_id; non-GUI clients don't. We bind to the GUI
|
||||||
|
// client so the radio routes GUI-tied data (CW pitch/speed, break-in
|
||||||
|
// delay, RF power) to us. Logged so the exact field names are confirmable.
|
||||||
|
if len(fields) >= 1 && fields[0] == "client" {
|
||||||
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
|
var clientID, program string
|
||||||
|
disconnected := false
|
||||||
|
for _, kv := range fields[1:] {
|
||||||
|
if kv == "disconnected" {
|
||||||
|
disconnected = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(kv)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "client_id":
|
||||||
|
clientID = val
|
||||||
|
case "program":
|
||||||
|
program = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
alreadyBound := f.boundClientID != ""
|
||||||
|
f.mu.Unlock()
|
||||||
|
lp := strings.ToLower(program)
|
||||||
|
isGUI := program == "" || strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")
|
||||||
|
if !disconnected && clientID != "" && !alreadyBound && isGUI {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.boundClientID = clientID
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.send("client bind client_id=" + clientID)
|
||||||
|
debugLog.Printf("Flex: bound to GUI client %s (program=%q)", clientID, program)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CWX object — the LIVE CW keyer values (speed/pitch/break-in delay).
|
||||||
|
// SmartSDR reads these here; the transmit object only carries a static
|
||||||
|
// default. Logged in full so we can confirm the exact field names.
|
||||||
|
if len(fields) >= 1 && fields[0] == "cwx" {
|
||||||
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
|
f.mu.Lock()
|
||||||
|
for _, kv := range fields[1:] {
|
||||||
|
key, val, ok := splitKV(kv)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "wpm", "speed", "cw_speed":
|
||||||
|
f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed)
|
||||||
|
case "pitch", "cw_pitch":
|
||||||
|
f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch)
|
||||||
|
case "delay", "break_in_delay", "cw_break_in_delay":
|
||||||
|
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
// ATU object — auto-tuner status + memories.
|
// ATU object — auto-tuner status + memories.
|
||||||
if len(fields) >= 1 && fields[0] == "atu" {
|
if len(fields) >= 1 && fields[0] == "atu" {
|
||||||
debugLog.Printf("Flex: status %s", payload)
|
debugLog.Printf("Flex: status %s", payload)
|
||||||
@@ -425,6 +512,16 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE")
|
f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE")
|
||||||
case "mode":
|
case "mode":
|
||||||
f.amp.operate = strings.EqualFold(val, "OPERATE")
|
f.amp.operate = strings.EqualFold(val, "OPERATE")
|
||||||
|
case "state":
|
||||||
|
// The PowerGenius XL reports its live state here (the status
|
||||||
|
// push has no operate= field). Anything but STANDBY/OFF means
|
||||||
|
// the amp is IN LINE (OPERATE) — IDLE = operate, not keyed.
|
||||||
|
switch strings.ToUpper(val) {
|
||||||
|
case "STANDBY", "OFF", "POWERED_OFF", "DISCONNECTED":
|
||||||
|
f.amp.operate = false
|
||||||
|
case "OPERATE", "IDLE", "TRANSMIT", "TX", "RECEIVE", "RX", "KEYED", "OPERATING":
|
||||||
|
f.amp.operate = true
|
||||||
|
}
|
||||||
case "fault":
|
case "fault":
|
||||||
f.amp.fault = val
|
f.amp.fault = val
|
||||||
}
|
}
|
||||||
@@ -582,11 +679,28 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.anf = val == "1"
|
s.anf = val == "1"
|
||||||
case "anf_level":
|
case "anf_level":
|
||||||
s.anfLevel = atoiDefault(val, s.anfLevel)
|
s.anfLevel = atoiDefault(val, s.anfLevel)
|
||||||
|
case "apf":
|
||||||
|
s.apf = val == "1"
|
||||||
|
case "apf_level":
|
||||||
|
s.apfLevel = atoiDefault(val, s.apfLevel)
|
||||||
|
case "filter_lo":
|
||||||
|
s.filterLo = atoiDefault(val, s.filterLo)
|
||||||
|
case "filter_hi":
|
||||||
|
s.filterHi = atoiDefault(val, s.filterHi)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defInt returns v, or def when v is zero (so sliders show sane defaults before
|
||||||
|
// the radio has pushed the real value).
|
||||||
|
func defInt(v, def int) int {
|
||||||
|
if v == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// ReadState returns the cached state derived from the radio's push messages —
|
// ReadState returns the cached state derived from the radio's push messages —
|
||||||
// no round-trip, so it's always current.
|
// no round-trip, so it's always current.
|
||||||
func (f *Flex) ReadState() (RigState, error) {
|
func (f *Flex) ReadState() (RigState, error) {
|
||||||
@@ -899,9 +1013,16 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
MicLevel: f.tx.micLevel,
|
MicLevel: f.tx.micLevel,
|
||||||
ATUStatus: f.tx.atuStatus,
|
ATUStatus: f.tx.atuStatus,
|
||||||
ATUMemories: f.tx.atuMemories,
|
ATUMemories: f.tx.atuMemories,
|
||||||
|
// CW keyer (defaults applied so the sliders show sane values pre-read).
|
||||||
|
CWSpeed: defInt(f.tx.cwSpeed, 25),
|
||||||
|
CWPitch: defInt(f.tx.cwPitch, 600),
|
||||||
|
CWBreakInDelay: defInt(f.tx.cwBreakInDelay, 30),
|
||||||
|
CWSidetone: f.tx.cwSidetone,
|
||||||
|
CWMonLevel: f.tx.cwMonLevel,
|
||||||
}
|
}
|
||||||
if _, rx := f.rxSliceLocked(); rx != nil {
|
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||||
st.RXAvail = true
|
st.RXAvail = true
|
||||||
|
st.Mode = strings.ToUpper(rx.mode)
|
||||||
st.AGCMode = rx.agcMode
|
st.AGCMode = rx.agcMode
|
||||||
st.AGCThreshold = rx.agcThreshold
|
st.AGCThreshold = rx.agcThreshold
|
||||||
st.AudioLevel = rx.audioLevel
|
st.AudioLevel = rx.audioLevel
|
||||||
@@ -911,6 +1032,10 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.NRLevel = rx.nrLevel
|
st.NRLevel = rx.nrLevel
|
||||||
st.ANF = rx.anf
|
st.ANF = rx.anf
|
||||||
st.ANFLevel = rx.anfLevel
|
st.ANFLevel = rx.anfLevel
|
||||||
|
st.APF = rx.apf
|
||||||
|
st.APFLevel = rx.apfLevel
|
||||||
|
st.FilterLo = rx.filterLo
|
||||||
|
st.FilterHi = rx.filterHi
|
||||||
}
|
}
|
||||||
if f.amp.handle != "" {
|
if f.amp.handle != "" {
|
||||||
st.AmpAvailable = true
|
st.AmpAvailable = true
|
||||||
@@ -960,6 +1085,10 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.anf = val == "1"
|
rx.anf = val == "1"
|
||||||
case "anf_level":
|
case "anf_level":
|
||||||
rx.anfLevel = toInt(val)
|
rx.anfLevel = toInt(val)
|
||||||
|
case "apf":
|
||||||
|
rx.apf = val == "1"
|
||||||
|
case "apf_level":
|
||||||
|
rx.apfLevel = toInt(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1000,6 +1129,104 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(
|
|||||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
// ── CW keyer controls (top-level "cw" commands) ──
|
||||||
|
|
||||||
|
func (f *Flex) SetCWSpeed(wpm int) error {
|
||||||
|
if wpm < 5 {
|
||||||
|
wpm = 5
|
||||||
|
} else if wpm > 100 {
|
||||||
|
wpm = 100
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.tx.cwSpeed = wpm
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.send(fmt.Sprintf("cw wpm %d", wpm))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetCWPitch(hz int) error {
|
||||||
|
if hz < 100 {
|
||||||
|
hz = 100
|
||||||
|
} else if hz > 6000 {
|
||||||
|
hz = 6000
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.tx.cwPitch = hz
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.send(fmt.Sprintf("cw pitch %d", hz))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetCWBreakInDelay(ms int) error {
|
||||||
|
if ms < 0 {
|
||||||
|
ms = 0
|
||||||
|
} else if ms > 2000 {
|
||||||
|
ms = 2000
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.tx.cwBreakInDelay = ms
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.send(fmt.Sprintf("cw break_in_delay %d", ms))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) SetCWSidetone(on bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.tx.cwSidetone = on
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.send("cw sidetone " + boolWord(on))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSidetoneLevel sets the CW sidetone (audible monitor) gain via mon_gain_cw.
|
||||||
|
func (f *Flex) SetSidetoneLevel(l int) error {
|
||||||
|
l = clampLevel(l)
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set mon_gain_cw=%d", l), "mon_gain_cw", func(t *flexTX) { t.cwMonLevel = l })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCWFilter changes the CW passband WIDTH to bw Hz while keeping the current
|
||||||
|
// filter CENTER fixed — so the frequency never shifts. The new low/high cuts are
|
||||||
|
// center ± bw/2, where center is the midpoint of the slice's current filter
|
||||||
|
// (falling back to the CW pitch only if the filter isn't known yet).
|
||||||
|
func (f *Flex) SetCWFilter(bw int) error {
|
||||||
|
if bw < 50 {
|
||||||
|
bw = 50
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
idx, rx := f.rxSliceLocked()
|
||||||
|
connected := f.conn != nil
|
||||||
|
center := 0
|
||||||
|
if rx != nil && (rx.filterLo != 0 || rx.filterHi != 0) {
|
||||||
|
center = (rx.filterLo + rx.filterHi) / 2
|
||||||
|
} else {
|
||||||
|
center = defInt(f.tx.cwPitch, 600)
|
||||||
|
}
|
||||||
|
lo := center - bw/2
|
||||||
|
hi := center + bw/2
|
||||||
|
if rx != nil {
|
||||||
|
rx.filterLo, rx.filterHi = lo, hi
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if rx == nil || idx < 0 {
|
||||||
|
return fmt.Errorf("flex: no receive slice")
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolWord renders a Flex on/off boolean as the word form some commands want.
|
||||||
|
func boolWord(on bool) string {
|
||||||
|
if on {
|
||||||
|
return "on"
|
||||||
|
}
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
|
||||||
// connected reports whether the TCP link is up (commands are no-ops otherwise).
|
// connected reports whether the TCP link is up (commands are no-ops otherwise).
|
||||||
func (f *Flex) connected() bool {
|
func (f *Flex) connected() bool {
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
||||||
|
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
||||||
|
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
|
||||||
|
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
|
||||||
|
// tone (level-independent, so weak or strong signals both key cleanly), an
|
||||||
|
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
|
||||||
|
// marks/spaces to Morse and then to characters.
|
||||||
|
//
|
||||||
|
// It is deliberately self-contained and dependency-free so it can be unit
|
||||||
|
// tested with synthetic signals. As with every audio CW decoder, weak signals
|
||||||
|
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
|
||||||
|
// out of the decode.
|
||||||
|
package cwdecode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
||||||
|
type Status struct {
|
||||||
|
WPM int `json:"wpm"`
|
||||||
|
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
||||||
|
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
||||||
|
Active bool `json:"active"` // a tone is currently keyed down
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
||||||
|
// characters at a time, including " " for word gaps) and periodic onStatus.
|
||||||
|
type Decoder struct {
|
||||||
|
fs int
|
||||||
|
hop int // samples between updates
|
||||||
|
win int // Goertzel window length
|
||||||
|
freqs []float64
|
||||||
|
coeffs []float64 // precomputed 2*cos(w) per freq
|
||||||
|
|
||||||
|
ring []float64 // last win samples
|
||||||
|
acc int // samples since last hop
|
||||||
|
mags []float64 // per-bin magnitude this hop
|
||||||
|
nbuf []float64 // scratch for the noise percentile
|
||||||
|
|
||||||
|
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
|
||||||
|
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
|
||||||
|
// another goroutine, so it's atomic.
|
||||||
|
targetHz atomic.Int32
|
||||||
|
|
||||||
|
// Pitch lock.
|
||||||
|
lockIdx int // index of the locked tone bin, -1 = unlocked
|
||||||
|
candIdx int // current argmax candidate while unlocked
|
||||||
|
candHops int // consecutive hops the candidate has been dominant
|
||||||
|
quietHops int // consecutive key-up hops while locked
|
||||||
|
noise float64 // broadband noise estimate (percentile of bins)
|
||||||
|
relockHops int // quiet hops before the lock is released
|
||||||
|
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
|
||||||
|
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
|
||||||
|
|
||||||
|
// Adaptive keying envelope, on the LOCKED bin's magnitude.
|
||||||
|
peak, floor float64
|
||||||
|
state bool // true = mark (key down)
|
||||||
|
stateHops int
|
||||||
|
dotHops float64 // adaptive dot length, in hops
|
||||||
|
elem []byte // current "." / "-" run for the in-progress character
|
||||||
|
charEmitted bool
|
||||||
|
wordEmitted bool
|
||||||
|
|
||||||
|
lastPitch float64
|
||||||
|
lastRMS float64
|
||||||
|
|
||||||
|
statusEvery int
|
||||||
|
sinceStatus int
|
||||||
|
|
||||||
|
onChar func(string)
|
||||||
|
onStatus func(Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var morse = map[string]byte{
|
||||||
|
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
|
||||||
|
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
|
||||||
|
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
|
||||||
|
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
|
||||||
|
"-.--": 'Y', "--..": 'Z',
|
||||||
|
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
|
||||||
|
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
|
||||||
|
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
||||||
|
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||||
|
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
||||||
|
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||||
|
if sampleRate <= 0 {
|
||||||
|
sampleRate = 16000
|
||||||
|
}
|
||||||
|
d := &Decoder{
|
||||||
|
fs: sampleRate,
|
||||||
|
hop: sampleRate / 250, // ~4 ms resolution
|
||||||
|
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
|
||||||
|
dotHops: 15, // ~20 WPM seed
|
||||||
|
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
|
||||||
|
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
|
||||||
|
lockIdx: -1,
|
||||||
|
candIdx: -1,
|
||||||
|
statusEvery: 25, // ~10 Hz
|
||||||
|
onChar: onChar,
|
||||||
|
onStatus: onStatus,
|
||||||
|
}
|
||||||
|
if d.hop < 1 {
|
||||||
|
d.hop = 1
|
||||||
|
}
|
||||||
|
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
||||||
|
// Candidate CW tones: 400–1000 Hz every 25 Hz. Deliberately NOT lower: strong
|
||||||
|
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
|
||||||
|
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
|
||||||
|
for f := 400.0; f <= 1000.0; f += 25 {
|
||||||
|
d.freqs = append(d.freqs, f)
|
||||||
|
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
||||||
|
}
|
||||||
|
d.mags = make([]float64, len(d.freqs))
|
||||||
|
d.nbuf = make([]float64, len(d.freqs))
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
|
||||||
|
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
|
||||||
|
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
||||||
|
|
||||||
|
// nearestBin returns the bin index closest to hz.
|
||||||
|
func (d *Decoder) nearestBin(hz float64) int {
|
||||||
|
best, bestD := 0, math.Inf(1)
|
||||||
|
for i, f := range d.freqs {
|
||||||
|
if dd := math.Abs(f - hz); dd < bestD {
|
||||||
|
bestD, best = dd, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
||||||
|
func (d *Decoder) Reset() {
|
||||||
|
d.ring = d.ring[:0]
|
||||||
|
d.acc = 0
|
||||||
|
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
||||||
|
d.peak, d.floor = 0, 0
|
||||||
|
d.state = false
|
||||||
|
d.stateHops = 0
|
||||||
|
d.dotHops = 15
|
||||||
|
d.elem = d.elem[:0]
|
||||||
|
d.charEmitted, d.wordEmitted = false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process feeds a block of mono samples through the decoder.
|
||||||
|
func (d *Decoder) Process(samples []int16) {
|
||||||
|
for _, s := range samples {
|
||||||
|
d.ring = append(d.ring, float64(s))
|
||||||
|
if len(d.ring) > d.win {
|
||||||
|
d.ring = d.ring[len(d.ring)-d.win:]
|
||||||
|
}
|
||||||
|
d.acc++
|
||||||
|
if d.acc >= d.hop && len(d.ring) >= d.win {
|
||||||
|
d.acc = 0
|
||||||
|
d.analyze()
|
||||||
|
d.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
|
||||||
|
// pitch lock (which tone the envelope detector then follows).
|
||||||
|
func (d *Decoder) analyze() {
|
||||||
|
n := float64(len(d.ring))
|
||||||
|
var sumSq float64
|
||||||
|
maxIdx, maxMag := 0, -1.0
|
||||||
|
for i, coeff := range d.coeffs {
|
||||||
|
var s1, s2 float64
|
||||||
|
for _, x := range d.ring {
|
||||||
|
s0 := x + coeff*s1 - s2
|
||||||
|
s2 = s1
|
||||||
|
s1 = s0
|
||||||
|
}
|
||||||
|
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
|
||||||
|
d.mags[i] = m
|
||||||
|
if m > maxMag {
|
||||||
|
maxMag = m
|
||||||
|
maxIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, x := range d.ring {
|
||||||
|
sumSq += x * x
|
||||||
|
}
|
||||||
|
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
||||||
|
|
||||||
|
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
|
||||||
|
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
|
||||||
|
if th := int(d.targetHz.Load()); th > 0 {
|
||||||
|
d.lockIdx = d.nearestBin(float64(th))
|
||||||
|
d.lastPitch = d.freqs[d.lockIdx]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
|
||||||
|
copy(d.nbuf, d.mags)
|
||||||
|
sort.Float64s(d.nbuf)
|
||||||
|
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
|
||||||
|
|
||||||
|
if d.lockIdx < 0 {
|
||||||
|
if maxIdx == d.candIdx {
|
||||||
|
d.candHops++
|
||||||
|
} else {
|
||||||
|
d.candIdx, d.candHops = maxIdx, 1
|
||||||
|
}
|
||||||
|
snr := maxMag / (d.noise + 1e-9)
|
||||||
|
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
||||||
|
// don't eat the first element of a strong signal), a marginal/weak tone
|
||||||
|
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
||||||
|
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
|
||||||
|
d.lockIdx = maxIdx
|
||||||
|
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||||
|
d.quietHops = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.lockIdx >= 0 {
|
||||||
|
d.lastPitch = d.freqs[d.lockIdx]
|
||||||
|
} else {
|
||||||
|
d.lastPitch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// step runs the adaptive envelope on the locked bin and the timing state
|
||||||
|
// machine, one hop. The envelope adapts to the signal level (not an absolute
|
||||||
|
// threshold), so weak and strong signals both key correctly.
|
||||||
|
func (d *Decoder) step() {
|
||||||
|
on := false
|
||||||
|
if d.lockIdx >= 0 {
|
||||||
|
m := d.mags[d.lockIdx]
|
||||||
|
// Peak: fast attack, slow release.
|
||||||
|
if m > d.peak {
|
||||||
|
d.peak += (m - d.peak) * 0.4
|
||||||
|
} else {
|
||||||
|
d.peak += (m - d.peak) * 0.02
|
||||||
|
}
|
||||||
|
// Floor: drops fast toward the signal, but only RISES between marks (when
|
||||||
|
// keyed up). Letting the floor rise during a long dash would shrink the
|
||||||
|
// span until the dash drops below the threshold and fragments into dots —
|
||||||
|
// the cause of the "all dots" garbage on a strong clean signal.
|
||||||
|
if m < d.floor {
|
||||||
|
d.floor += (m - d.floor) * 0.4
|
||||||
|
} else if !d.state {
|
||||||
|
d.floor += (m - d.floor) * 0.02
|
||||||
|
}
|
||||||
|
span := d.peak - d.floor
|
||||||
|
// The frozen floor already stops dashes fragmenting, so keep balanced
|
||||||
|
// thresholds: low enough that short inter-element GAPS are still seen
|
||||||
|
// (otherwise elements merge into >7-symbol runs that decode to nothing).
|
||||||
|
if span > d.floor*0.3+1e-9 {
|
||||||
|
onTh := d.floor + 0.55*span
|
||||||
|
offTh := d.floor + 0.35*span
|
||||||
|
if d.state {
|
||||||
|
on = m > offTh
|
||||||
|
} else {
|
||||||
|
on = m > onTh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Release the lock after a long quiet so we can retune to a new signal.
|
||||||
|
if on {
|
||||||
|
d.quietHops = 0
|
||||||
|
} else {
|
||||||
|
d.quietHops++
|
||||||
|
if d.quietHops > d.relockHops {
|
||||||
|
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if on == d.state {
|
||||||
|
d.stateHops++
|
||||||
|
if !d.state {
|
||||||
|
d.spaceProgress()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if d.state {
|
||||||
|
d.endMark(d.stateHops)
|
||||||
|
}
|
||||||
|
d.state = on
|
||||||
|
d.stateHops = 1
|
||||||
|
if on {
|
||||||
|
d.charEmitted, d.wordEmitted = false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.emitStatus(on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
||||||
|
// dot-length estimate. Runs shorter than a third of a dot are rejected as
|
||||||
|
// clicks/noise.
|
||||||
|
func (d *Decoder) endMark(hops int) {
|
||||||
|
h := float64(hops)
|
||||||
|
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
|
||||||
|
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
|
||||||
|
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
|
||||||
|
if h < d.dotHops*0.35 || h < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h > d.dotHops*2 {
|
||||||
|
d.elem = append(d.elem, '-')
|
||||||
|
d.adaptDot(h / 3)
|
||||||
|
} else {
|
||||||
|
d.elem = append(d.elem, '.')
|
||||||
|
d.adaptDot(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||||
|
// to ~5–100 WPM).
|
||||||
|
func (d *Decoder) adaptDot(obs float64) {
|
||||||
|
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
|
||||||
|
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||||
|
d.dotHops = 5
|
||||||
|
}
|
||||||
|
if d.dotHops > 55 {
|
||||||
|
d.dotHops = 55
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaceProgress flushes the current character once the gap exceeds a character
|
||||||
|
// gap, and a word space once it exceeds a word gap.
|
||||||
|
func (d *Decoder) spaceProgress() {
|
||||||
|
g := float64(d.stateHops)
|
||||||
|
if !d.charEmitted && g > d.dotHops*2 {
|
||||||
|
d.flushChar()
|
||||||
|
d.charEmitted = true
|
||||||
|
}
|
||||||
|
if !d.wordEmitted && g > d.dotHops*5 {
|
||||||
|
if d.onChar != nil {
|
||||||
|
d.onChar(" ")
|
||||||
|
}
|
||||||
|
d.wordEmitted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushChar looks up the accumulated element string and emits the character.
|
||||||
|
func (d *Decoder) flushChar() {
|
||||||
|
if len(d.elem) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c, ok := morse[string(d.elem)]; ok {
|
||||||
|
if d.onChar != nil {
|
||||||
|
d.onChar(string(c))
|
||||||
|
}
|
||||||
|
} else if d.onChar != nil && len(d.elem) <= 7 {
|
||||||
|
// Only flag a genuinely Morse-shaped but unknown char with "?". An
|
||||||
|
// over-long element run is noise — drop it silently rather than spam "?".
|
||||||
|
d.onChar("?")
|
||||||
|
}
|
||||||
|
d.elem = d.elem[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Decoder) emitStatus(on bool) {
|
||||||
|
d.sinceStatus++
|
||||||
|
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.sinceStatus = 0
|
||||||
|
hopMs := float64(d.hop) / float64(d.fs) * 1000
|
||||||
|
wpm := 0
|
||||||
|
if d.dotHops > 0 {
|
||||||
|
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
|
||||||
|
}
|
||||||
|
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package cwdecode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reverse Morse map for the synthesizer.
|
||||||
|
func charToMorse() map[byte]string {
|
||||||
|
m := map[byte]string{}
|
||||||
|
for code, ch := range morse {
|
||||||
|
m[ch] = code
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
||||||
|
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
||||||
|
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
||||||
|
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
||||||
|
c2m := charToMorse()
|
||||||
|
var out []int16
|
||||||
|
phase := 0.0
|
||||||
|
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||||
|
|
||||||
|
tone := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = append(out, int16(amp*math.Sin(phase)))
|
||||||
|
phase += dphi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
silence := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = append(out, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
silence(fs / 4) // 250 ms lead-in for AGC warmup
|
||||||
|
for i := 0; i < len(msg); i++ {
|
||||||
|
ch := msg[i]
|
||||||
|
if ch == ' ' {
|
||||||
|
silence(7 * dot)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := c2m[ch]
|
||||||
|
for j := 0; j < len(code); j++ {
|
||||||
|
if code[j] == '.' {
|
||||||
|
tone(dot)
|
||||||
|
} else {
|
||||||
|
tone(3 * dot)
|
||||||
|
}
|
||||||
|
silence(dot) // inter-element gap
|
||||||
|
}
|
||||||
|
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
|
||||||
|
}
|
||||||
|
silence(fs / 4)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCleanSignal(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
|
||||||
|
// Repeat so AGC warm-up only costs the first word.
|
||||||
|
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
|
||||||
|
// Feed in small chunks like the live capture would.
|
||||||
|
for i := 0; i < len(samples); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "PARIS") {
|
||||||
|
t.Fatalf("decoded %q, want it to contain PARIS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeWithQRM(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
|
||||||
|
// quieter, sending different text. The pitch lock should hold on the target.
|
||||||
|
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
||||||
|
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
|
||||||
|
mix := make([]int16, len(target))
|
||||||
|
for i := range target {
|
||||||
|
v := int(target[i])
|
||||||
|
if i < len(qrm) {
|
||||||
|
v += int(qrm[i])
|
||||||
|
}
|
||||||
|
if v > 32767 {
|
||||||
|
v = 32767
|
||||||
|
} else if v < -32768 {
|
||||||
|
v = -32768
|
||||||
|
}
|
||||||
|
mix[i] = int16(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
for i := 0; i < len(mix); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(mix) {
|
||||||
|
end = len(mix)
|
||||||
|
}
|
||||||
|
d.Process(mix[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "PARIS") {
|
||||||
|
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeFirstCharStrong(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
// Strong signal: the very first element (T = a dash) must not be eaten by
|
||||||
|
// lock acquisition. Output should begin with the first character.
|
||||||
|
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
|
||||||
|
for i := 0; i < len(samples); i += 200 {
|
||||||
|
end := i + 200
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(strings.TrimSpace(sb.String()))
|
||||||
|
if !strings.HasPrefix(got, "TEST") {
|
||||||
|
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeWithAmplitudeRipple(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
// A real signal's tone amplitude wobbles within a mark; if the floor chases
|
||||||
|
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
|
||||||
|
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
|
||||||
|
rp := 0.0
|
||||||
|
for i := range samples {
|
||||||
|
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
|
||||||
|
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
for i := 0; i < len(samples); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "TEST DE OM") {
|
||||||
|
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCQFixedPitch(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
d.SetTarget(700) // fixed pitch like the user's manual override
|
||||||
|
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
|
||||||
|
for i := 0; i < len(samples); i += 200 {
|
||||||
|
end := i + 200
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if n := strings.Count(got, "CQ"); n < 2 {
|
||||||
|
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeNumbersAndProsign(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
|
||||||
|
for i := 0; i < len(samples); i += 200 {
|
||||||
|
end := i + 200
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "599") {
|
||||||
|
t.Fatalf("decoded %q, want it to contain 599", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded
|
||||||
|
// POST (or URL params) with the account credentials and the ADIF content.
|
||||||
|
const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm"
|
||||||
|
|
||||||
|
// eqslResultRe extracts "Result: X out of Y records added" from the reply.
|
||||||
|
var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`)
|
||||||
|
|
||||||
|
// eqslPost performs the import POST and returns the raw response body. eQSL
|
||||||
|
// replies HTTP 200 with a plain-text/HTML body for both success and errors;
|
||||||
|
// callers classify it via the markers below.
|
||||||
|
func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("EQSL_USER", user)
|
||||||
|
form.Set("EQSL_PSWD", pswd)
|
||||||
|
form.Set("ADIFData", adif)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authErrEQSL returns a reason when the response signals bad credentials, else
|
||||||
|
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
||||||
|
func authErrEQSL(body string) string {
|
||||||
|
if strings.Contains(strings.ToLower(body), "no match on eqsl") {
|
||||||
|
return "invalid username or password"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF
|
||||||
|
// record when nick is set, so eQSL files the QSO under the right QTH profile
|
||||||
|
// (required when the account has more than one). ADIF field order is free, so
|
||||||
|
// prepending before the rest of the record is valid.
|
||||||
|
func eqslRecordWithNickname(record, nick string) string {
|
||||||
|
nick = strings.TrimSpace(nick)
|
||||||
|
if nick == "" {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("<APP_EQSL_QTH_NICKNAME:%d>%s%s", len(nick), nick, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick
|
||||||
|
// is the optional eQSL QTH nickname.
|
||||||
|
//
|
||||||
|
// eQSL replies with text: "Result: 1 out of 1 records added" on success,
|
||||||
|
// "Bad record: Duplicate" for an already-present QSO (treated as success so
|
||||||
|
// retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad
|
||||||
|
// credentials.
|
||||||
|
func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) {
|
||||||
|
user = strings.ToUpper(strings.TrimSpace(user))
|
||||||
|
if user == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pswd) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: password not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(adifRecord) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: empty adif record")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick))
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{OK: false, Message: body}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b := strings.ToLower(body)
|
||||||
|
if reason := authErrEQSL(body); reason != "" {
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
|
||||||
|
}
|
||||||
|
if strings.Contains(b, "duplicate") {
|
||||||
|
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||||
|
}
|
||||||
|
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
|
||||||
|
added, _ := strconv.Atoi(m[1])
|
||||||
|
if added >= 1 {
|
||||||
|
return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil
|
||||||
|
}
|
||||||
|
// "0 out of N" — eQSL accepted nothing; surface why if it said so.
|
||||||
|
reason := eqslReason(body)
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||||
|
}
|
||||||
|
reason := eqslReason(body)
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslReason trims an eQSL reply to a short human-readable reason: the first
|
||||||
|
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
|
||||||
|
// (capped), else a generic phrase.
|
||||||
|
func eqslReason(body string) string {
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
l := strings.TrimSpace(line)
|
||||||
|
ll := strings.ToLower(l)
|
||||||
|
if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := strings.TrimSpace(body)
|
||||||
|
if b == "" {
|
||||||
|
return "upload rejected"
|
||||||
|
}
|
||||||
|
if len(b) > 200 {
|
||||||
|
b = b[:200]
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEQSL validates the configured eQSL credentials with a REAL request: it
|
||||||
|
// posts an empty ADIF so nothing is inserted, then checks for the bad-login
|
||||||
|
// marker. Anything else means the credentials were accepted.
|
||||||
|
func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
|
||||||
|
if user == "" {
|
||||||
|
return "", fmt.Errorf("eqsl: username (callsign) not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Password) == "" {
|
||||||
|
return "", fmt.Errorf("eqsl: password not set")
|
||||||
|
}
|
||||||
|
body, err := eqslPost(ctx, client, user, cfg.Password, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reason := authErrEQSL(body); reason != "" {
|
||||||
|
return "", fmt.Errorf("eqsl: %s", reason)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Credentials accepted — %s", user), nil
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ const (
|
|||||||
ServiceQRZ Service = "qrz" // QRZ.com Logbook
|
ServiceQRZ Service = "qrz" // QRZ.com Logbook
|
||||||
ServiceClublog Service = "clublog" // Club Log real-time upload
|
ServiceClublog Service = "clublog" // Club Log real-time upload
|
||||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||||
|
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||||||
|
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||||
@@ -64,12 +66,14 @@ type ServiceConfig struct {
|
|||||||
Email string `json:"email"` // Club Log account email
|
Email string `json:"email"` // Club Log account email
|
||||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||||
Password string `json:"password"` // Club Log account / LoTW website password
|
Password string `json:"password"` // Club Log account / LoTW website password
|
||||||
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||||
|
Code string `json:"code"` // HRDLog: account upload code
|
||||||
|
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||||
UploadFlag string `json:"upload_flag"` // LoTW: sent status that means "ready to upload" — "N" or "R"
|
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||||
AutoUpload bool `json:"auto_upload"`
|
AutoUpload bool `json:"auto_upload"`
|
||||||
UploadMode UploadMode `json:"upload_mode"`
|
UploadMode UploadMode `json:"upload_mode"`
|
||||||
@@ -81,16 +85,25 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
|||||||
c.APIKey = strings.TrimSpace(c.APIKey)
|
c.APIKey = strings.TrimSpace(c.APIKey)
|
||||||
c.Email = strings.TrimSpace(c.Email)
|
c.Email = strings.TrimSpace(c.Email)
|
||||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||||
|
c.Code = strings.TrimSpace(c.Code)
|
||||||
|
c.Username = strings.TrimSpace(c.Username)
|
||||||
|
c.QTHNickname = strings.TrimSpace(c.QTHNickname)
|
||||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||||
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||||||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||||||
// Upload flag is the LoTW sent-status that marks a QSO ready to upload.
|
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
|
||||||
// Only "N" (no) and "R" (requested) are valid; default to "R".
|
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
|
||||||
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" {
|
// here (the caller injects N+R when nothing is configured).
|
||||||
c.UploadFlag = uf
|
var flags []string
|
||||||
} else {
|
seen := map[string]bool{}
|
||||||
c.UploadFlag = "R"
|
for _, f := range c.UploadFlags {
|
||||||
|
f = strings.ToUpper(strings.TrimSpace(f))
|
||||||
|
if (f == "N" || f == "R") && !seen[f] {
|
||||||
|
seen[f] = true
|
||||||
|
flags = append(flags, f)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
c.UploadFlags = flags
|
||||||
switch c.UploadMode {
|
switch c.UploadMode {
|
||||||
case ModeDelayed, ModeOnClose:
|
case ModeDelayed, ModeOnClose:
|
||||||
// keep
|
// keep
|
||||||
@@ -105,6 +118,8 @@ type ExternalServices struct {
|
|||||||
QRZ ServiceConfig `json:"qrz"`
|
QRZ ServiceConfig `json:"qrz"`
|
||||||
Clublog ServiceConfig `json:"clublog"`
|
Clublog ServiceConfig `json:"clublog"`
|
||||||
LoTW ServiceConfig `json:"lotw"`
|
LoTW ServiceConfig `json:"lotw"`
|
||||||
|
HRDLog ServiceConfig `json:"hrdlog"`
|
||||||
|
EQSL ServiceConfig `json:"eqsl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResult is the outcome of a single upload attempt.
|
// UploadResult is the outcome of a single upload attempt.
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hrdlogUploadURL is HRDLog.net's real-time upload endpoint. It accepts a
|
||||||
|
// form-encoded POST with the uploader's callsign, the account's secret upload
|
||||||
|
// Code (HRDLog account → "My Account" → upload code), an App identifier, and
|
||||||
|
// one ADIF record.
|
||||||
|
const hrdlogUploadURL = "https://robot.hrdlog.net/NewEntry.aspx"
|
||||||
|
|
||||||
|
// hrdlogApp is the App identifier sent to HRDLog so uploads are attributed to
|
||||||
|
// OpsLog in the user's HRDLog activity.
|
||||||
|
const hrdlogApp = "OpsLog"
|
||||||
|
|
||||||
|
// hrdlogPost performs the form POST to NewEntry.aspx and returns the raw
|
||||||
|
// response body. The endpoint replies HTTP 200 with a small XML document even
|
||||||
|
// for errors; callers classify it via the markers in classifyHRDLog.
|
||||||
|
func hrdlogPost(ctx context.Context, client *http.Client, callsign, code, adif string) (string, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("Callsign", callsign)
|
||||||
|
form.Set("Code", code)
|
||||||
|
form.Set("App", hrdlogApp)
|
||||||
|
form.Set("ADIFData", adif)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogUploadURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hrdlog: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hrdlog: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return msg, fmt.Errorf("hrdlog: http %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authErrHRDLog returns a non-empty, human-readable reason when the response
|
||||||
|
// signals a credential problem (wrong upload code or unregistered callsign),
|
||||||
|
// or "" otherwise. Markers mirror HRDLog's documented XML replies
|
||||||
|
// ("Invalid token</error>" / "Unknown user</error>").
|
||||||
|
func authErrHRDLog(body string) string {
|
||||||
|
b := strings.ToLower(body)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(b, "invalid token"):
|
||||||
|
return "invalid upload code"
|
||||||
|
case strings.Contains(b, "unknown user"):
|
||||||
|
return "callsign not registered at HRDLog"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadHRDLog pushes one ADIF record to HRDLog.net. callsign is the station
|
||||||
|
// callsign the log belongs to, code is the account's upload code.
|
||||||
|
//
|
||||||
|
// Form fields (application/x-www-form-urlencoded POST):
|
||||||
|
//
|
||||||
|
// Callsign=<station call>&Code=<upload code>&App=OpsLog&ADIFData=<one record>
|
||||||
|
//
|
||||||
|
// HRDLog replies with XML: "<insert>1" on success, "<insert>0" for a duplicate
|
||||||
|
// (already logged — treated as success so retries are idempotent), or an
|
||||||
|
// "<error>…</error>" payload otherwise.
|
||||||
|
func UploadHRDLog(ctx context.Context, client *http.Client, callsign, code, adifRecord string) (UploadResult, error) {
|
||||||
|
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if callsign == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("hrdlog: station callsign not set")
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("hrdlog: upload code not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(adifRecord) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("hrdlog: empty adif record")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := hrdlogPost(ctx, client, callsign, code, adifRecord)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{OK: false, Message: body}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b := strings.ToLower(body)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(b, "<insert>1"):
|
||||||
|
return UploadResult{OK: true, Message: "uploaded"}, nil
|
||||||
|
case strings.Contains(b, "<insert>0"):
|
||||||
|
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||||
|
}
|
||||||
|
reason := authErrHRDLog(body)
|
||||||
|
if reason == "" {
|
||||||
|
reason = body
|
||||||
|
if reason == "" {
|
||||||
|
reason = "upload rejected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("hrdlog: upload failed: %s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: HRDLog's NewEntry.aspx inserts ONLY the first record of a multi-record
|
||||||
|
// ADIFData, so there is no batch upload — callers must POST one record per
|
||||||
|
// request (see UploadHRDLog). The bulk uploader in app.go does exactly that.
|
||||||
|
|
||||||
|
// TestHRDLog validates the configured HRDLog credentials with a REAL request:
|
||||||
|
// it posts an empty ADIF so nothing is inserted, then checks for HRDLog's auth
|
||||||
|
// errors. A wrong upload code comes back as "Invalid token", a wrong callsign
|
||||||
|
// as "Unknown user"; anything else means the credentials were accepted (HRDLog
|
||||||
|
// simply had no QSO to add).
|
||||||
|
func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
callsign := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||||
|
code := strings.TrimSpace(cfg.Code)
|
||||||
|
if callsign == "" {
|
||||||
|
return "", fmt.Errorf("hrdlog: station callsign not set")
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
return "", fmt.Errorf("hrdlog: upload code not set")
|
||||||
|
}
|
||||||
|
body, err := hrdlogPost(ctx, client, callsign, code, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reason := authErrHRDLog(body); reason != "" {
|
||||||
|
return "", fmt.Errorf("hrdlog: %s", reason)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Credentials accepted — %s", callsign), nil
|
||||||
|
}
|
||||||
@@ -21,8 +21,11 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
|||||||
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
||||||
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
||||||
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
||||||
// returned — used for incremental "Last download" updates.
|
// returned — used for incremental "Last download" updates. When ownCall is
|
||||||
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
|
// non-empty, only confirmations for that station callsign are returned (an
|
||||||
|
// LoTW account holds every call you operate — F4BPO, F4BPO/P, TM2Q — so this
|
||||||
|
// scopes the pull to the active profile's call).
|
||||||
|
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string) (string, error) {
|
||||||
user := strings.TrimSpace(cfg.Username)
|
user := strings.TrimSpace(cfg.Username)
|
||||||
if user == "" || cfg.Password == "" {
|
if user == "" || cfg.Password == "" {
|
||||||
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
||||||
@@ -33,6 +36,9 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
|
|||||||
q.Set("qso_query", "1")
|
q.Set("qso_query", "1")
|
||||||
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
||||||
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
|
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
|
||||||
|
if c := strings.TrimSpace(ownCall); c != "" {
|
||||||
|
q.Set("qso_owncall", c) // restrict to this station callsign
|
||||||
|
}
|
||||||
if s := strings.TrimSpace(since); s != "" {
|
if s := strings.TrimSpace(since); s != "" {
|
||||||
q.Set("qso_qslsince", s)
|
q.Set("qso_qslsince", s)
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-34
@@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool {
|
|||||||
return baseCall(a) == baseCall(b)
|
return baseCall(a) == baseCall(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SameBaseCall is the exported form of sameBaseCall, so the host app can apply
|
||||||
|
// the same "same operator?" rule when filtering an on-close upload batch by the
|
||||||
|
// active logbook's callsign.
|
||||||
|
func SameBaseCall(a, b string) bool { return sameBaseCall(a, b) }
|
||||||
|
|
||||||
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||||
// keeps the upload-scheduling logic testable.
|
// keeps the upload-scheduling logic testable.
|
||||||
@@ -62,6 +67,13 @@ type Deps struct {
|
|||||||
// option would otherwise silently relabel it). "" → no station call known.
|
// option would otherwise silently relabel it). "" → no station call known.
|
||||||
StationCallOf func(id int64) string
|
StationCallOf func(id int64) string
|
||||||
|
|
||||||
|
// CloseUploadIDs returns the QSO ids to upload for a service when the app
|
||||||
|
// closes — scanning the WHOLE logbook, not just this session: LoTW returns
|
||||||
|
// rows whose lotw_sent matches the configured status set; QRZ/Club Log
|
||||||
|
// return anything not yet "Y". This is what makes an imported ADIF (old
|
||||||
|
// QSOs still marked unsent) upload on close. nil → nothing to do.
|
||||||
|
CloseUploadIDs func(svc Service) []int64
|
||||||
|
|
||||||
// Logf is an optional diagnostic logger.
|
// Logf is an optional diagnostic logger.
|
||||||
Logf func(format string, args ...any)
|
Logf func(format string, args ...any)
|
||||||
}
|
}
|
||||||
@@ -75,7 +87,6 @@ type Manager struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg ExternalServices
|
cfg ExternalServices
|
||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(deps Deps) *Manager {
|
func NewManager(deps Deps) *Manager {
|
||||||
@@ -87,7 +98,6 @@ func NewManager(deps Deps) *Manager {
|
|||||||
// Seeded from the clock; the delay only needs to be unpredictable
|
// Seeded from the clock; the delay only needs to be unpredictable
|
||||||
// enough to spread bursts, not cryptographically random.
|
// enough to spread bursts, not cryptographically random.
|
||||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
pending: map[Service][]int64{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +113,10 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
cfg.QRZ = cfg.QRZ.normalised()
|
cfg.QRZ = cfg.QRZ.normalised()
|
||||||
|
cfg.Clublog = cfg.Clublog.normalised()
|
||||||
|
cfg.LoTW = cfg.LoTW.normalised()
|
||||||
|
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||||
|
cfg.EQSL = cfg.EQSL.normalised()
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,17 +153,23 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
|
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
|
||||||
m.route(ServiceLoTW, id, lt)
|
m.route(ServiceLoTW, id, lt)
|
||||||
}
|
}
|
||||||
|
// HRDLog — needs the station callsign + the account upload code.
|
||||||
|
if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
|
||||||
|
m.route(ServiceHRDLog, id, h)
|
||||||
|
}
|
||||||
|
// eQSL — needs the account username (callsign) + password.
|
||||||
|
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
||||||
|
m.route(ServiceEQSL, id, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// route sends a logged QSO down the configured timing path: queue it for the
|
// route sends a logged QSO down the configured timing path: queue it for the
|
||||||
// app-close batch, or schedule an immediate / delayed upload.
|
// app-close batch, or schedule an immediate / delayed upload.
|
||||||
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||||
if cfg.UploadMode == ModeOnClose {
|
if cfg.UploadMode == ModeOnClose {
|
||||||
m.mu.Lock()
|
// Nothing to queue: on-close upload sweeps the whole logbook from the
|
||||||
m.pending[svc] = append(m.pending[svc], id)
|
// database at shutdown (see FlushOnClose), so this QSO is picked up by
|
||||||
n := len(m.pending[svc])
|
// its sent-status then — no in-memory tracking needed.
|
||||||
m.mu.Unlock()
|
|
||||||
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.scheduleUpload(svc, id, cfg)
|
m.scheduleUpload(svc, id, cfg)
|
||||||
@@ -166,47 +186,82 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
go m.upload(svc, id, cfg)
|
go m.upload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PendingCount returns how many QSOs are queued for on-close upload across
|
// onCloseServices returns the services configured for on-close auto-upload,
|
||||||
// all services. The shutdown sequence uses it to decide whether to show the
|
// with the minimum credentials to actually run.
|
||||||
// upload step.
|
func (m *Manager) onCloseServices() []Service {
|
||||||
func (m *Manager) PendingCount() int {
|
cfg := m.Config()
|
||||||
m.mu.Lock()
|
var out []Service
|
||||||
defer m.mu.Unlock()
|
if q := cfg.QRZ; q.AutoUpload && q.UploadMode == ModeOnClose && q.APIKey != "" {
|
||||||
|
out = append(out, ServiceQRZ)
|
||||||
|
}
|
||||||
|
if c := cfg.Clublog; c.AutoUpload && c.UploadMode == ModeOnClose && c.Email != "" && c.Password != "" {
|
||||||
|
out = append(out, ServiceClublog)
|
||||||
|
}
|
||||||
|
if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" {
|
||||||
|
out = append(out, ServiceLoTW)
|
||||||
|
}
|
||||||
|
if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
|
||||||
|
out = append(out, ServiceHRDLog)
|
||||||
|
}
|
||||||
|
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
||||||
|
out = append(out, ServiceEQSL)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseUploadCount returns how many QSOs across the whole logbook would be
|
||||||
|
// uploaded at app close (sum over every on-close service). The shutdown
|
||||||
|
// sequence uses it to decide whether to show the upload step and its label.
|
||||||
|
func (m *Manager) CloseUploadCount() int {
|
||||||
|
if m.deps.CloseUploadIDs == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
n := 0
|
n := 0
|
||||||
for _, ids := range m.pending {
|
for _, svc := range m.onCloseServices() {
|
||||||
n += len(ids)
|
n += len(m.deps.CloseUploadIDs(svc))
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
|
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
|
||||||
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
|
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
|
||||||
// single TQSL batch. Returns the number of QSOs uploaded successfully.
|
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL
|
||||||
|
// batch. Returns the number of QSOs uploaded successfully.
|
||||||
func (m *Manager) FlushOnClose() int {
|
func (m *Manager) FlushOnClose() int {
|
||||||
m.mu.Lock()
|
if m.deps.CloseUploadIDs == nil {
|
||||||
pending := m.pending
|
return 0
|
||||||
m.pending = map[Service][]int64{}
|
}
|
||||||
cfg := m.cfg
|
cfg := m.Config()
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
uploaded := 0
|
uploaded := 0
|
||||||
for svc, ids := range pending {
|
for _, svc := range m.onCloseServices() {
|
||||||
|
ids := m.deps.CloseUploadIDs(svc)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch svc {
|
switch svc {
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||||
default:
|
|
||||||
var sc ServiceConfig
|
|
||||||
switch svc {
|
|
||||||
case ServiceQRZ:
|
case ServiceQRZ:
|
||||||
sc = cfg.QRZ
|
|
||||||
case ServiceClublog:
|
|
||||||
sc = cfg.Clublog
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, sc) {
|
if m.upload(svc, id, cfg.QRZ) {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ServiceClublog:
|
||||||
|
for _, id := range ids {
|
||||||
|
if m.upload(svc, id, cfg.Clublog) {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ServiceHRDLog:
|
||||||
|
for _, id := range ids {
|
||||||
|
if m.upload(svc, id, cfg.HRDLog) {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ServiceEQSL:
|
||||||
|
for _, id := range ids {
|
||||||
|
if m.upload(svc, id, cfg.EQSL) {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,8 +331,10 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
switch svc {
|
switch svc {
|
||||||
case ServiceQRZ, ServiceLoTW:
|
case ServiceQRZ, ServiceLoTW:
|
||||||
owner = cfg.ForceStationCallsign
|
owner = cfg.ForceStationCallsign
|
||||||
case ServiceClublog:
|
case ServiceClublog, ServiceHRDLog:
|
||||||
owner = cfg.Callsign
|
owner = cfg.Callsign
|
||||||
|
case ServiceEQSL:
|
||||||
|
owner = cfg.Username
|
||||||
}
|
}
|
||||||
if owner != "" && m.deps.StationCallOf != nil {
|
if owner != "" && m.deps.StationCallOf != nil {
|
||||||
qcall := m.deps.StationCallOf(id)
|
qcall := m.deps.StationCallOf(id)
|
||||||
@@ -324,6 +381,24 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
res, err = UploadLoTW(ctx, cfg, "", record)
|
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||||
|
case ServiceHRDLog:
|
||||||
|
// HRDLog takes the station callsign as a separate param, so the ADIF
|
||||||
|
// keeps the QSO's own station call (no override), like Club Log.
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
||||||
|
case ServiceEQSL:
|
||||||
|
// eQSL keeps the QSO's own station call; the account is identified by
|
||||||
|
// the Username + Password, with an optional QTH nickname.
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-2
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -491,6 +492,7 @@ var uploadStatusCols = map[string]bool{
|
|||||||
"lotw_sent": true,
|
"lotw_sent": true,
|
||||||
"qrzcom_qso_upload_status": true,
|
"qrzcom_qso_upload_status": true,
|
||||||
"clublog_qso_upload_status": true,
|
"clublog_qso_upload_status": true,
|
||||||
|
"hrdlog_qso_upload_status": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListForUpload returns QSOs whose per-service sent-status column equals
|
// ListForUpload returns QSOs whose per-service sent-status column equals
|
||||||
@@ -519,6 +521,56 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadCandidate is a QSO eligible for an on-close upload: its id plus its
|
||||||
|
// STATION_CALLSIGN, so the caller can keep only the rows that belong to the
|
||||||
|
// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not
|
||||||
|
// all be signed under one cert).
|
||||||
|
type UploadCandidate struct {
|
||||||
|
ID int64
|
||||||
|
StationCallsign string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUploadCandidates returns QSOs eligible for an on-close upload to a
|
||||||
|
// service, scanning the whole logbook. For LoTW (column "lotw_sent"), statuses
|
||||||
|
// is the set of sent-status values to treat as "to send" (e.g. N, R); rows
|
||||||
|
// already "Y" are excluded. For QRZ/Club Log, statuses is ignored and anything
|
||||||
|
// whose upload status isn't yet "Y" qualifies.
|
||||||
|
func (r *Repo) ListUploadCandidates(ctx context.Context, column string, statuses []string) ([]UploadCandidate, error) {
|
||||||
|
if !uploadStatusCols[column] {
|
||||||
|
return nil, fmt.Errorf("invalid upload column %q", column)
|
||||||
|
}
|
||||||
|
var where string
|
||||||
|
var args []any
|
||||||
|
if column == "lotw_sent" {
|
||||||
|
if len(statuses) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(statuses))
|
||||||
|
for i, s := range statuses {
|
||||||
|
ph[i] = "?"
|
||||||
|
args = append(args, strings.ToUpper(strings.TrimSpace(s)))
|
||||||
|
}
|
||||||
|
where = "UPPER(COALESCE(lotw_sent,'')) IN (" + strings.Join(ph, ",") + ")"
|
||||||
|
} else {
|
||||||
|
where = "UPPER(COALESCE(" + column + ",'')) <> 'Y'"
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, COALESCE(station_callsign,'') FROM qso WHERE `+where+` ORDER BY qso_date`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list upload candidates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []UploadCandidate
|
||||||
|
for rows.Next() {
|
||||||
|
var c UploadCandidate
|
||||||
|
if err := rows.Scan(&c.ID, &c.StationCallsign); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
|
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
|
||||||
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
|
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
|
||||||
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
|
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
|
||||||
@@ -547,6 +599,19 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkHRDLogUploaded stamps HRDLOG_QSO_UPLOAD_STATUS=Y and the upload date
|
||||||
|
// after a successful HRDLog.net push. date is an ADIF YYYYMMDD string.
|
||||||
|
func (r *Repo) MarkHRDLogUploaded(ctx context.Context, id int64, date string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET hrdlog_qso_upload_status = 'Y', hrdlog_qso_upload_date = ?,
|
||||||
|
updated_at = ? WHERE id = ?`,
|
||||||
|
date, db.NowISO(), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark hrdlog uploaded %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a
|
// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a
|
||||||
// successful TQSL upload. date is an ADIF YYYYMMDD string.
|
// successful TQSL upload. date is an ADIF YYYYMMDD string.
|
||||||
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
|
||||||
@@ -560,6 +625,29 @@ func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkUploadedBatch sets <statusCol>='Y' and <dateCol>=date on EVERY id in one
|
||||||
|
// UPDATE — used by bulk upload (Club Log / HRDLog) so a 25k-QSO run isn't one
|
||||||
|
// round-trip per QSO on a remote MySQL. statusCol/dateCol come from a fixed
|
||||||
|
// whitelist (not user input), so the column interpolation is safe.
|
||||||
|
func (r *Repo) MarkUploadedBatch(ctx context.Context, statusCol, dateCol, date string, ids []int64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||||
|
args := make([]any, 0, len(ids)+2)
|
||||||
|
args = append(args, date, db.NowISO())
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
|
||||||
|
args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark uploaded batch (%d): %w", len(ids), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkEQSLSent stamps EQSL_QSL_SENT=Y and the sent date after a successful
|
// MarkEQSLSent stamps EQSL_QSL_SENT=Y and the sent date after a successful
|
||||||
// eQSL e-mail. date is an ADIF YYYYMMDD string.
|
// eQSL e-mail. date is an ADIF YYYYMMDD string.
|
||||||
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
||||||
@@ -573,6 +661,76 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bulkEditableCols whitelists the columns BulkSetField may write. Limited to
|
||||||
|
// TEXT fields where setting one value across many QSOs is meaningful: the
|
||||||
|
// per-service QSL/upload status fields, plus "my station"/operator fields that
|
||||||
|
// are naturally constant across a run (grid, antenna, rig, address, …). It
|
||||||
|
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the
|
||||||
|
// contacted station's details) and numeric columns (power, zones, lat/lon),
|
||||||
|
// which would be corrupted or meaningless if bulk-set to a single value.
|
||||||
|
var bulkEditableCols = map[string]bool{
|
||||||
|
// QSL / upload status
|
||||||
|
"lotw_sent": true,
|
||||||
|
"lotw_rcvd": true,
|
||||||
|
"eqsl_sent": true,
|
||||||
|
"eqsl_rcvd": true,
|
||||||
|
"qsl_sent": true,
|
||||||
|
"qsl_rcvd": true,
|
||||||
|
"qsl_via": true,
|
||||||
|
"qrzcom_qso_upload_status": true,
|
||||||
|
"clublog_qso_upload_status": true,
|
||||||
|
"hrdlog_qso_upload_status": true,
|
||||||
|
// My station / operator
|
||||||
|
"station_callsign": true,
|
||||||
|
"operator": true,
|
||||||
|
"my_grid": true,
|
||||||
|
"my_country": true,
|
||||||
|
"my_state": true,
|
||||||
|
"my_cnty": true,
|
||||||
|
"my_iota": true,
|
||||||
|
"my_sota_ref": true,
|
||||||
|
"my_pota_ref": true,
|
||||||
|
"my_wwff_ref": true,
|
||||||
|
"my_street": true,
|
||||||
|
"my_city": true,
|
||||||
|
"my_postal_code": true,
|
||||||
|
"my_rig": true,
|
||||||
|
"my_antenna": true,
|
||||||
|
"my_sig": true,
|
||||||
|
"my_sig_info": true,
|
||||||
|
// Misc text
|
||||||
|
"comment": true,
|
||||||
|
"notes": true,
|
||||||
|
"rig": true,
|
||||||
|
"ant": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkSetField sets one whitelisted column to value on every listed QSO in a
|
||||||
|
// single statement. value "" clears the field. Returns rows affected.
|
||||||
|
func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value string) (int64, error) {
|
||||||
|
if !bulkEditableCols[column] {
|
||||||
|
return 0, fmt.Errorf("field %q is not bulk-editable", column)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(ids))
|
||||||
|
args := make([]any, 0, len(ids)+2)
|
||||||
|
args = append(args, value, db.NowISO())
|
||||||
|
for i, id := range ids {
|
||||||
|
ph[i] = "?"
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
res, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||||
|
args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -651,6 +809,25 @@ func (r *Repo) DeleteAll(ctx context.Context) (int64, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteMany removes several QSOs in one statement. Returns the number deleted.
|
||||||
|
func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(ids))
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
ph[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+strings.Join(ph, ",")+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("delete qsos: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Delete removes a QSO by id.
|
// Delete removes a QSO by id.
|
||||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
|
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
|
||||||
@@ -746,16 +923,26 @@ var filterableColumns = map[string]bool{
|
|||||||
"name": true, "qth": true, "address": true, "email": true,
|
"name": true, "qth": true, "address": true, "email": true,
|
||||||
"grid": true, "country": true, "state": true, "cnty": true,
|
"grid": true, "country": true, "state": true, "cnty": true,
|
||||||
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
|
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
|
||||||
"iota": true, "sota_ref": true, "pota_ref": true, "rig": true, "ant": true,
|
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
|
||||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
||||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||||
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true,
|
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||||
"contest_id": true, "srx": true, "stx": true,
|
"contest_id": true, "srx": true, "stx": true,
|
||||||
"prop_mode": true, "sat_name": true,
|
"prop_mode": true, "sat_name": true,
|
||||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
||||||
|
"my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true,
|
||||||
|
"my_wwff_ref": true, "my_street": true, "my_city": true, "my_postal_code": true,
|
||||||
|
"my_rig": true, "my_antenna": true, "my_sig": true, "my_sig_info": true,
|
||||||
"tx_pwr": true, "comment": true, "notes": true,
|
"tx_pwr": true, "comment": true, "notes": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dateColumns are stored as full ISO timestamps; a filter on a bare YYYY-MM-DD
|
||||||
|
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||||
|
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||||
|
|
||||||
|
// bareDateRe matches a plain calendar date with no time component.
|
||||||
|
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||||
|
|
||||||
// filterableExtras whitelists virtual filter fields stored inside extras_json
|
// filterableExtras whitelists virtual filter fields stored inside extras_json
|
||||||
// (valid ADIF fields we don't promote to columns). The value is the uppercase
|
// (valid ADIF fields we don't promote to columns). The value is the uppercase
|
||||||
// ADIF/Extras key; the SQL expression uses json_extract.
|
// ADIF/Extras key; the SQL expression uses json_extract.
|
||||||
@@ -802,6 +989,14 @@ func conditionSQL(c Condition) (string, []any, error) {
|
|||||||
return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
|
return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
|
||||||
}
|
}
|
||||||
v := c.Value
|
v := c.Value
|
||||||
|
// Date columns hold full ISO timestamps ("2020-01-01T12:34:56.000Z"). When
|
||||||
|
// the user filters on a bare calendar date, compare only the date part so
|
||||||
|
// "= 2020-01-01" matches that whole day and "<= 2020-12-31" includes it
|
||||||
|
// (a raw string compare would otherwise drop times on the boundary day).
|
||||||
|
if dateColumns[strings.ToLower(strings.TrimSpace(c.Field))] && bareDateRe.MatchString(strings.TrimSpace(v)) {
|
||||||
|
col = "substr(" + col + ",1,10)"
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
switch c.Op {
|
switch c.Op {
|
||||||
case "eq":
|
case "eq":
|
||||||
return col + " = ?", []any{v}, nil
|
return col + " = ?", []any{v}, nil
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
@@ -11,9 +13,32 @@ import (
|
|||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
// profileArg extracts a profile name from the command line. Accepts
|
||||||
|
// "--profile NAME", "--profile=NAME", "-profile NAME", "-p NAME" so a desktop
|
||||||
|
// shortcut can launch OpsLog straight into a given profile (e.g. F4BPO / TM2Q).
|
||||||
|
func profileArg(args []string) string {
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
a := args[i]
|
||||||
|
switch {
|
||||||
|
case a == "--profile" || a == "-profile" || a == "-p":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
return strings.TrimSpace(args[i+1])
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(a, "--profile="):
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(a, "--profile="))
|
||||||
|
case strings.HasPrefix(a, "-profile="):
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(a, "-profile="))
|
||||||
|
case strings.HasPrefix(a, "-p="):
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(a, "-p="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
app.startupProfile = profileArg(os.Args[1:])
|
||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.11.2"
|
appVersion = "0.12"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user