Compare commits
25
Commits
00cab6b204
...
v0.11.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40e95e6a16 | ||
|
|
cc0f9ffc64 | ||
|
|
e1f1ab4922 | ||
|
|
b6d991b799 | ||
|
|
59f1775fcd | ||
|
|
b2a8b1946f | ||
|
|
8b1609f5ce | ||
|
|
bde1195b34 | ||
|
|
abdab22010 | ||
|
|
16dc864dbd | ||
|
|
01235624ee | ||
|
|
a7bbc53c35 | ||
|
|
3d15f20c7f | ||
|
|
e5c6bddb29 | ||
|
|
75ee5344a4 | ||
|
|
957182611d | ||
|
|
69d0780bac | ||
|
|
33af122964 | ||
|
|
22e3bb4a18 | ||
|
|
29fd832bcd | ||
|
|
67203cd4a8 | ||
|
|
08162fa126 | ||
|
|
81e505e040 | ||
|
|
0b3e22c97e | ||
|
|
d3ba7c71f4 |
+10
-1
@@ -13,7 +13,16 @@
|
|||||||
"Bash(git config *)",
|
"Bash(git config *)",
|
||||||
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
|
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
|
||||||
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
|
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
|
||||||
"Bash(which git-credential-manager *)"
|
"Bash(which git-credential-manager *)",
|
||||||
|
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)",
|
||||||
|
"Bash(cd \"c:/Perso/Seafile/Programmation/Golang/OpsLog/frontend\" && npx tsc --noEmit 2>&1 | grep -v \"npm notice\" | head && cd .. && /c/Users/legre/go/bin/wails build 2>&1 | tail -2 && ls -la --time-style=+%H:%M build/bin/OpsLog.exe)",
|
||||||
|
"Read(//c/Perso/Seafile/Programmation/Golang/**)",
|
||||||
|
"Bash(gofmt -w internal/qslcard/*.go)",
|
||||||
|
"Bash(awk '{print $3}')",
|
||||||
|
"Bash(xargs grep -n \"CREATE TABLE\\\\|key\\\\|value\")",
|
||||||
|
"Bash(ls -la .claude/)",
|
||||||
|
"Bash(cat .claude/settings.local.json)",
|
||||||
|
"Bash(cat .claude/settings.json)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go:*)",
|
||||||
|
"Bash(gofmt:*)",
|
||||||
|
"Bash(/c/Users/legre/go/bin/wails:*)",
|
||||||
|
"Bash(wails:*)",
|
||||||
|
"Bash(npm:*)",
|
||||||
|
"Bash(npx:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(rg:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(head:*)",
|
||||||
|
"Bash(tail:*)",
|
||||||
|
"Bash(awk:*)",
|
||||||
|
"Bash(sed:*)",
|
||||||
|
"Bash(sort:*)",
|
||||||
|
"Bash(uniq:*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(xargs:*)",
|
||||||
|
"Bash(for f in *)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git show:*)",
|
||||||
|
"Bash(curl -sI --max-time 10 https://raw.githubusercontent.com/google/fonts/main/ofl/archivoblack/ArchivoBlack-Regular.ttf)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
+13
-12
@@ -34,6 +34,10 @@ const (
|
|||||||
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
|
||||||
|
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
|
||||||
|
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
||||||
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
||||||
@@ -74,7 +78,7 @@ type QSLPresetInfo struct {
|
|||||||
// so picking through the native dialog is the reliable route to real paths).
|
// so picking through the native dialog is the reliable route to real paths).
|
||||||
func (a *App) QSLPickPhotos() ([]string, error) {
|
func (a *App) QSLPickPhotos() ([]string, error) {
|
||||||
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
|
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
Title: "Choose card photos (1–3)",
|
Title: "Choose card photos (1–5)",
|
||||||
Filters: []wruntime.FileFilter{
|
Filters: []wruntime.FileFilter{
|
||||||
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
|
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
|
||||||
},
|
},
|
||||||
@@ -87,8 +91,8 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
|||||||
if len(photoPaths) == 0 {
|
if len(photoPaths) == 0 {
|
||||||
return nil, fmt.Errorf("no photos selected")
|
return nil, fmt.Errorf("no photos selected")
|
||||||
}
|
}
|
||||||
if len(photoPaths) > 3 {
|
if len(photoPaths) > 5 {
|
||||||
return nil, fmt.Errorf("at most 3 photos (got %d)", len(photoPaths))
|
return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
|
||||||
}
|
}
|
||||||
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
|
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
|
||||||
for _, p := range photoPaths {
|
for _, p := range photoPaths {
|
||||||
@@ -425,17 +429,14 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Stamp the standard ADIF eqsl_sent flag plus an app-specific timestamp of
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
||||||
// the eQSL-card e-mail (APP_OPSLOG_QSL_SENT) — distinct from eqsl_sent, which
|
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
||||||
// an eQSL.cc upload may also set. q came straight from GetByID, so a full
|
// independent. q came straight from GetByID, so a full Update rewrites the
|
||||||
// Update rewrites the row unchanged apart from these fields.
|
// row unchanged apart from this field.
|
||||||
now := time.Now().UTC()
|
|
||||||
q.EQSLSent = "Y"
|
|
||||||
q.EQSLSentDate = now.Format("20060102")
|
|
||||||
if q.Extras == nil {
|
if q.Extras == nil {
|
||||||
q.Extras = map[string]string{}
|
q.Extras = map[string]string{}
|
||||||
}
|
}
|
||||||
q.Extras["APP_OPSLOG_QSL_SENT"] = now.Format(time.RFC3339)
|
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
||||||
@@ -502,7 +503,7 @@ func (a *App) maybeAutoSendEQSL(q qso.QSO) {
|
|||||||
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
|
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(q.Email) == "" || strings.EqualFold(q.EQSLSent, "Y") {
|
if strings.TrimSpace(q.Email) == "" || q.Extras[appQSLCardSentField] != "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := a.profiles.Active(a.ctx)
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
|||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyAutostartPrograms holds the per-profile list of external programs OpsLog
|
||||||
|
// launches on startup (JSON array of AutostartProgram).
|
||||||
|
const keyAutostartPrograms = "autostart.programs"
|
||||||
|
|
||||||
|
// AutostartProgram is one external application OpsLog can launch when it starts
|
||||||
|
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
|
||||||
|
type AutostartProgram struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Args string `json:"args"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutostartLaunchResult reports what happened for one program when launching.
|
||||||
|
type AutostartLaunchResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"` // launched | already_running | missing | disabled | error
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAutostartPrograms returns the active profile's autostart list.
|
||||||
|
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
|
||||||
|
out := []AutostartProgram{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||||
|
return []AutostartProgram{}, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAutostartPrograms persists the autostart list for the active profile.
|
||||||
|
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(progs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowseExecutable opens a native file picker for choosing a program to launch.
|
||||||
|
func (a *App) BrowseExecutable() (string, error) {
|
||||||
|
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose a program to launch on startup",
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchAutostartPrograms starts every enabled program that isn't already
|
||||||
|
// running, returning a per-program result. Used at startup (best effort) and by
|
||||||
|
// the "Launch now" button in settings.
|
||||||
|
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
|
||||||
|
progs, _ := a.GetAutostartPrograms()
|
||||||
|
running := runningProcessNames()
|
||||||
|
out := make([]AutostartLaunchResult, 0, len(progs))
|
||||||
|
for _, p := range progs {
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, launchProgram(p, running))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchAutostartProgram launches a single program by id on demand (the per-row
|
||||||
|
// "launch now" action), regardless of its enabled flag.
|
||||||
|
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
|
||||||
|
progs, err := a.GetAutostartPrograms()
|
||||||
|
if err != nil {
|
||||||
|
return AutostartLaunchResult{}, err
|
||||||
|
}
|
||||||
|
for _, p := range progs {
|
||||||
|
if p.ID == id {
|
||||||
|
return launchProgram(p, runningProcessNames()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// launchProgram starts one program unless its executable is already running.
|
||||||
|
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
|
||||||
|
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
|
||||||
|
path := strings.TrimSpace(p.Path)
|
||||||
|
if path == "" {
|
||||||
|
res.Status, res.Message = "error", "no path configured"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
res.Status, res.Message = "missing", "executable not found: "+path
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
// Skip if a process with the same executable name is already running, so we
|
||||||
|
// never spawn a second copy of WSJT-X / JTAlert / etc.
|
||||||
|
base := strings.ToLower(filepath.Base(path))
|
||||||
|
if running[base] {
|
||||||
|
res.Status, res.Message = "already_running", base+" already running"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
cmd := exec.Command(path, splitArgs(p.Args)...)
|
||||||
|
cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
res.Status, res.Message = "error", err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
// Don't wait on the child — it runs independently of OpsLog. Release the
|
||||||
|
// handle so we don't accumulate zombies.
|
||||||
|
go func() { _ = cmd.Wait() }()
|
||||||
|
res.Status, res.Message = "launched", "started "+base
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitArgs does a minimal shell-like split of an argument string, honouring
|
||||||
|
// double quotes so a path with spaces stays one argument.
|
||||||
|
func splitArgs(s string) []string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var args []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inQuote := false
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r == '"':
|
||||||
|
inQuote = !inQuote
|
||||||
|
case r == ' ' && !inQuote:
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// runningProcessNames returns the set of lowercase executable names currently
|
||||||
|
// running, via the Windows `tasklist`. Best effort — on failure the set is
|
||||||
|
// empty (we then just attempt to launch, which is acceptable).
|
||||||
|
func runningProcessNames() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||||
|
data, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("autostart: tasklist failed: %v", err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// CSV row: "image.exe","PID",... — take the first quoted field.
|
||||||
|
field := line
|
||||||
|
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
|
||||||
|
field = line[1 : i+1]
|
||||||
|
}
|
||||||
|
field = strings.Trim(field, "\"")
|
||||||
|
if field != "" {
|
||||||
|
out[strings.ToLower(field)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* OpsLog multi-operator LIVE STATUS renderer.
|
||||||
|
*
|
||||||
|
* Reads the shared `live_status` table that every OpsLog instance heartbeats
|
||||||
|
* (operator call + station call + freq/band/mode, refreshed every ~15s) and
|
||||||
|
* shows the operators active in the last 2 minutes.
|
||||||
|
*
|
||||||
|
* Put this file on YOUR web server (the one reachable from the internet), point
|
||||||
|
* it at the SAME MySQL database OpsLog uses for the shared logbook, and embed it
|
||||||
|
* on the QRZ.com bio of the station call:
|
||||||
|
*
|
||||||
|
* <img src="https://your-server/tm74-status.php?img=1"> (image, cached ~min by QRZ)
|
||||||
|
* or <a href="https://your-server/tm74-status.php">Live operators</a> (real-time page)
|
||||||
|
*
|
||||||
|
* QRZ strips <script>/<iframe>, so only an <img> auto-updates the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||||
|
$DB_NAME = 'opslog'; // database name
|
||||||
|
$DB_USER = 'opslog';
|
||||||
|
$DB_PASS = 'CHANGE_ME';
|
||||||
|
$STALE_SECONDS = 120; // an operator is "active" if seen within this window
|
||||||
|
|
||||||
|
// PHP 8.1+ makes mysqli THROW on errors by default; turn that off so a missing
|
||||||
|
// `live_status` table (not yet created by OpsLog) just yields an empty list
|
||||||
|
// instead of a fatal "table doesn't exist".
|
||||||
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
|
||||||
|
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||||
|
if ($mysqli->connect_errno) {
|
||||||
|
http_response_code(500);
|
||||||
|
exit('DB error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The table is created by OpsLog on first publish; tolerate it not existing yet.
|
||||||
|
$rows = [];
|
||||||
|
$sql = "SELECT operator, station, freq_hz, band, mode, updated_at
|
||||||
|
FROM live_status
|
||||||
|
WHERE updated_at >= UTC_TIMESTAMP() - INTERVAL ? SECOND
|
||||||
|
ORDER BY band, freq_hz";
|
||||||
|
if ($stmt = @$mysqli->prepare($sql)) {
|
||||||
|
$stmt->bind_param('i', $STALE_SECONDS);
|
||||||
|
@$stmt->execute();
|
||||||
|
if ($res = $stmt->get_result()) {
|
||||||
|
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$station = $rows ? $rows[0]['station'] : 'OpsLog';
|
||||||
|
$fmtFreq = function ($hz) { return $hz > 0 ? number_format($hz / 1e6, 3) . ' MHz' : '—'; };
|
||||||
|
|
||||||
|
// ── Image output (SVG) for the QRZ <img> embed: ?img=1 ──────────────────────
|
||||||
|
if (isset($_GET['img'])) {
|
||||||
|
header('Content-Type: image/svg+xml');
|
||||||
|
header('Cache-Control: no-cache, max-age=30');
|
||||||
|
$rowH = 26; $h = 44 + max(1, count($rows)) * $rowH; $w = 440;
|
||||||
|
echo "<svg xmlns='http://www.w3.org/2000/svg' width='$w' height='$h' font-family='Segoe UI,Arial'>";
|
||||||
|
echo "<rect width='$w' height='$h' rx='8' fill='#0f172a'/>";
|
||||||
|
echo "<text x='14' y='26' fill='#38bdf8' font-size='15' font-weight='bold'>" . htmlspecialchars($station) . " — live operators</text>";
|
||||||
|
$y = 44 + 18;
|
||||||
|
if (!$rows) {
|
||||||
|
echo "<text x='14' y='$y' fill='#94a3b8' font-size='13'>No operator active right now.</text>";
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$line = sprintf('%-10s %-4s %-9s %s', $r['operator'], $r['band'], $r['mode'], $fmtFreq($r['freq_hz']));
|
||||||
|
echo "<text x='14' y='$y' fill='#e2e8f0' font-size='13' font-family='monospace'>" . htmlspecialchars($line) . "</text>";
|
||||||
|
$y += $rowH;
|
||||||
|
}
|
||||||
|
echo "</svg>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML page (real-time when opened directly; auto-refreshes every 20s) ─────
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="20">
|
||||||
|
<title><?= htmlspecialchars($station) ?> — live operators</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Segoe UI, Arial, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:16px; }
|
||||||
|
h1 { color:#38bdf8; font-size:18px; margin:0 0 12px; }
|
||||||
|
table { border-collapse:collapse; width:100%; max-width:560px; }
|
||||||
|
th,td { text-align:left; padding:6px 12px; border-bottom:1px solid #1e293b; font-size:14px; }
|
||||||
|
th { color:#94a3b8; text-transform:uppercase; font-size:11px; letter-spacing:.05em; }
|
||||||
|
td.mono { font-family:monospace; }
|
||||||
|
.none { color:#94a3b8; }
|
||||||
|
</style></head><body>
|
||||||
|
<h1><?= htmlspecialchars($station) ?> — operators active now</h1>
|
||||||
|
<?php if (!$rows): ?>
|
||||||
|
<p class="none">No operator active right now.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table>
|
||||||
|
<tr><th>Operator</th><th>Band</th><th>Mode</th><th>Frequency</th></tr>
|
||||||
|
<?php foreach ($rows as $r): ?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?= htmlspecialchars($r['operator']) ?></strong></td>
|
||||||
|
<td><?= htmlspecialchars($r['band']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($r['mode']) ?></td>
|
||||||
|
<td class="mono"><?= $fmtFreq($r['freq_hz']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</body></html>
|
||||||
+669
-290
File diff suppressed because it is too large
Load Diff
@@ -31,11 +31,17 @@ export type AwardDef = {
|
|||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
||||||
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
total: number; builtin?: boolean;
|
total: number; builtin?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AwardOrRule = {
|
||||||
|
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
|
||||||
|
leading_str?: string; trailing_str?: string; prefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
code: string; name: string; dxcc: number; group: string; subgrp: string;
|
code: string; name: string; dxcc: number; group: string; subgrp: string;
|
||||||
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
|
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
|
||||||
@@ -162,7 +168,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setErr('');
|
setErr('');
|
||||||
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
||||||
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
||||||
.catch((e) => setErr(String(e?.message ?? e)));
|
.catch((e) => setErr(String(e?.message ?? e)));
|
||||||
loadMeta();
|
loadMeta();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -344,6 +350,46 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Additional OR searches: a QSO earns a reference if the
|
||||||
|
primary rule OR any of these match. */}
|
||||||
|
<div className="border-t pt-2.5 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> — also match the reference if any of these hit</p>
|
||||||
|
<Button size="sm" variant="outline" className="h-7"
|
||||||
|
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
|
||||||
|
<Plus className="size-3.5" /> Add OR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(cur.or_rules ?? []).map((r, ri) => {
|
||||||
|
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
|
||||||
|
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
|
||||||
|
return (
|
||||||
|
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground">OR — search in</span>
|
||||||
|
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
|
||||||
|
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
{['code', 'description', 'pattern'].map((m) => (
|
||||||
|
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
|
||||||
|
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||||||
|
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
|
||||||
|
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,13 @@ interface Props {
|
|||||||
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
|
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
|
||||||
// automatically — we surface that so the operator needn't pick it by hand.
|
// automatically — we surface that so the operator needn't pick it by hand.
|
||||||
fieldValues?: Record<string, string>;
|
fieldValues?: Record<string, string>;
|
||||||
|
// Height of the selector. Default is a fixed 210px (the QSO editor modal);
|
||||||
|
// the live entry panel passes "flex-1 min-h-0" so it fills the available
|
||||||
|
// space instead of overflowing and forcing a scrollbar.
|
||||||
|
heightClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) {
|
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
||||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||||
const [awardCode, setAwardCode] = useState('POTA');
|
const [awardCode, setAwardCode] = useState('POTA');
|
||||||
@@ -63,13 +67,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
|
|||||||
// for a French call but not for others.
|
// for a French call but not for others.
|
||||||
const awards = useMemo(() => {
|
const awards = useMemo(() => {
|
||||||
return defs.filter((d) => {
|
return defs.filter((d) => {
|
||||||
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
|
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||||
const m = metas[String(d.code).toUpperCase()];
|
|
||||||
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
|
|
||||||
if (!hasRefs) return false;
|
|
||||||
const scope = d.dxcc_filter ?? [];
|
const scope = d.dxcc_filter ?? [];
|
||||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||||
|
// Offer the award even when its reference list isn't loaded yet: a custom
|
||||||
|
// award (e.g. "Worked All Provinces of China") has no built-in list, so
|
||||||
|
// requiring one would make it impossible to assign references by hand. The
|
||||||
|
// operator can pick from a loaded list when present, or add an unlisted
|
||||||
|
// reference (the right-hand panel). Dynamic / list-backed awards behave as
|
||||||
|
// before — they just always pass here now.
|
||||||
return true;
|
return true;
|
||||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
@@ -169,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 h-[210px]">
|
<div className={`flex gap-2 ${heightClass}`}>
|
||||||
{/* Left panel */}
|
{/* Left panel */}
|
||||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react';
|
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App';
|
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
@@ -57,7 +58,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean };
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||||
|
|
||||||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
@@ -75,6 +76,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
const [showMissing, setShowMissing] = useState(false);
|
const [showMissing, setShowMissing] = useState(false);
|
||||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
// Bumped by Rescan to force the stats matrix to re-fetch (the selected award
|
||||||
|
// didn't change, but the backend snapshot did).
|
||||||
|
const [rescanTick, setRescanTick] = useState(0);
|
||||||
|
|
||||||
// Lazily fetch the statistics matrix when the Stats view is shown.
|
// Lazily fetch the statistics matrix when the Stats view is shown.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,7 +88,24 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
.then((s) => setStats(s as any))
|
.then((s) => setStats(s as any))
|
||||||
.catch(() => setStats(null))
|
.catch(() => setStats(null))
|
||||||
.finally(() => setStatsLoading(false));
|
.finally(() => setStatsLoading(false));
|
||||||
}, [view, selected]);
|
}, [view, selected, rescanTick]);
|
||||||
|
|
||||||
|
// Rescan: drop the backend snapshot (so confirmations from a fresh LoTW/QRZ
|
||||||
|
// download are picked up) and the cached results, then recompute everything.
|
||||||
|
async function rescan() {
|
||||||
|
if (!selected) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await RescanAwards();
|
||||||
|
setByCode({});
|
||||||
|
setRescanTick((t) => t + 1);
|
||||||
|
await compute(selected, true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||||
async function compute(code: string, force = false) {
|
async function compute(code: string, force = false) {
|
||||||
@@ -108,7 +129,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
try {
|
try {
|
||||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
||||||
const list: AwardListItem[] = defs
|
const list: AwardListItem[] = defs
|
||||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid }))
|
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
setAwardList(list);
|
setAwardList(list);
|
||||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||||
@@ -121,6 +142,53 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
|
|
||||||
const current = byCode[selected];
|
const current = byCode[selected];
|
||||||
|
|
||||||
|
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||||
|
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||||
|
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
|
||||||
|
// the bands the operator actually has contacts on (so DXCC, which has no band
|
||||||
|
// restriction, shows 160m–6m instead of empty VHF/UHF columns). Empty set =
|
||||||
|
// no basis yet → callers fall back to all bands.
|
||||||
|
const awardBands = useMemo(() => {
|
||||||
|
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
|
||||||
|
if (vb.length > 0) return new Set(vb);
|
||||||
|
const worked = (current?.bands ?? [])
|
||||||
|
.filter((b) => (b.worked ?? 0) > 0)
|
||||||
|
.map((b) => String(b.band).toLowerCase());
|
||||||
|
return new Set(worked);
|
||||||
|
}, [awardList, selected, current]);
|
||||||
|
|
||||||
|
const gridBands = useMemo(() => {
|
||||||
|
if (awardBands.size === 0) return GRID_BANDS;
|
||||||
|
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
|
||||||
|
return filtered.length ? filtered : GRID_BANDS;
|
||||||
|
}, [awardBands]);
|
||||||
|
|
||||||
|
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
|
||||||
|
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
|
||||||
|
// shows only when the award has no emission restriction or lists that emission.
|
||||||
|
const statsRows = useMemo(() => {
|
||||||
|
if (!stats) return [];
|
||||||
|
const em = new Set((awardList.find((a) => a.code === selected)?.emission ?? []).map((e) => e.toUpperCase()));
|
||||||
|
if (em.size === 0) return stats.rows;
|
||||||
|
return stats.rows.filter((r) => {
|
||||||
|
const m = String(r.label).toUpperCase().match(/\b(CW|DIGITAL|PHONE)$/);
|
||||||
|
return !m || em.has(m[1]);
|
||||||
|
});
|
||||||
|
}, [stats, awardList, selected]);
|
||||||
|
|
||||||
|
// Indices into stats.bands to display (and to slice each row's cells by), same
|
||||||
|
// band basis as the grid.
|
||||||
|
const statsBandIdx = useMemo(() => {
|
||||||
|
if (!stats) return [] as number[];
|
||||||
|
const all = stats.bands.map((_, i) => i);
|
||||||
|
if (awardBands.size === 0) return all;
|
||||||
|
const idx = stats.bands
|
||||||
|
.map((b, i) => ({ b: b.toLowerCase(), i }))
|
||||||
|
.filter((x) => awardBands.has(x.b))
|
||||||
|
.map((x) => x.i);
|
||||||
|
return idx.length ? idx : all;
|
||||||
|
}, [stats, awardBands]);
|
||||||
|
|
||||||
const filteredRefs = useMemo(() => {
|
const filteredRefs = useMemo(() => {
|
||||||
if (!current) return [];
|
if (!current) return [];
|
||||||
const q = refSearch.trim().toUpperCase();
|
const q = refSearch.trim().toUpperCase();
|
||||||
@@ -144,8 +212,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
|
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||||
title="Rescan all QSOs and recompute this award">
|
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
|
||||||
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
Rescan
|
Rescan
|
||||||
</Button>
|
</Button>
|
||||||
@@ -228,10 +296,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{/* Band breakdown */}
|
{/* Band breakdown */}
|
||||||
{(current.bands ?? []).length > 0 && (
|
{(current.bands ?? []).length > 0 && (
|
||||||
<div className="px-4 py-2 border-b border-border/60">
|
<div className="px-4 py-2 border-b border-border/60">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{(current.bands ?? []).map((b) => (
|
{(current.bands ?? []).map((b) => (
|
||||||
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
||||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
||||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||||
@@ -245,9 +313,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
<Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs">
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||||
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
|
||||||
<button key={k} onClick={() => setRefFilter(k)}
|
<button key={k} onClick={() => setRefFilter(k)}
|
||||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
@@ -255,10 +323,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
onClick={() => setShowMissing(true)}
|
||||||
className="flex items-center gap-1 text-[11px] text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
||||||
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="size-3" /> Missing refs
|
<AlertTriangle className="size-3" /> Missing refs
|
||||||
@@ -283,27 +351,27 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{statsLoading || !stats ? (
|
{statsLoading || !stats ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing…</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<tr className="bg-card">
|
||||||
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
|
||||||
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)}
|
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||||
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th>
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
|
||||||
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th>
|
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{stats.rows.map((row, i) => {
|
{statsRows.map((row, i) => {
|
||||||
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
|
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
|
||||||
return (
|
return (
|
||||||
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
|
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
|
||||||
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap',
|
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||||
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||||
{row.cells.map((c, j) => (
|
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
||||||
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||||
))}
|
); })}
|
||||||
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||||
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -313,30 +381,30 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</div>
|
</div>
|
||||||
) : view === 'grid' ? (
|
) : view === 'grid' ? (
|
||||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<tr className="bg-card">
|
||||||
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
|
||||||
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th>
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
|
||||||
{GRID_BANDS.map((b) => (
|
{gridBands.map((b) => (
|
||||||
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th>
|
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredRefs.map((r) => (
|
{filteredRefs.map((r) => (
|
||||||
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||||
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30">
|
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
||||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
||||||
</td>
|
</td>
|
||||||
{GRID_BANDS.map((b) => {
|
{gridBands.map((b) => {
|
||||||
const s = cellStatus(r, b);
|
const s = cellStatus(r, b);
|
||||||
return (
|
return (
|
||||||
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||||
{s === 'none' ? <span className="block w-9 h-5" /> : (
|
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||||
<button
|
<button
|
||||||
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||||
title={`${r.ref} · ${b} — click to view QSOs`}
|
title={`${r.ref} · ${b} — click to view QSOs`}
|
||||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||||
>{CELL_LABEL[s]}</button>
|
>{CELL_LABEL[s]}</button>
|
||||||
@@ -351,22 +419,22 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto px-4 pb-3">
|
<div className="flex-1 overflow-auto px-4 pb-3">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1 pr-2 font-medium w-24">Ref</th>
|
<th className="py-1.5 pr-2 font-medium w-24">Ref</th>
|
||||||
<th className="py-1 pr-2 font-medium">Name</th>
|
<th className="py-1.5 pr-2 font-medium">Name</th>
|
||||||
<th className="py-1 pr-2 font-medium w-40">Group</th>
|
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
|
||||||
<th className="py-1 pr-2 font-medium w-24">Status</th>
|
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
|
||||||
<th className="py-1 font-medium">Bands</th>
|
<th className="py-1.5 font-medium">Bands</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredRefs.map((r) => (
|
{filteredRefs.map((r) => (
|
||||||
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
|
||||||
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||||
<td className="py-1 pr-2">
|
<td className="py-1 pr-2">
|
||||||
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
{!r.worked ? <span className="text-muted-foreground/70">— missing</span>
|
||||||
: r.validated ? <span className="text-emerald-600">validated</span>
|
: r.validated ? <span className="text-emerald-600">validated</span>
|
||||||
@@ -401,23 +469,91 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
// MissingQSOModal lists contacts within an award's scope that carry NO
|
// MissingQSOModal lists contacts within an award's scope that carry NO
|
||||||
// reference — the silent gaps. Rows open the QSO editor so the operator can add
|
// reference — the silent gaps. Rows open the QSO editor so the operator can add
|
||||||
// the missing reference (e.g. a department for DDFM).
|
// the missing reference (e.g. a department for DDFM).
|
||||||
|
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||||
|
|
||||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sel, setSel] = useState<Set<number>>(new Set());
|
||||||
|
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
||||||
|
const [assignRef, setAssignRef] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setSel(new Set());
|
||||||
AwardMissingQSOs(code)
|
AwardMissingQSOs(code)
|
||||||
.then((r) => setQsos((r ?? []) as any))
|
.then((r) => setQsos((r ?? []) as any))
|
||||||
.catch(() => setQsos([]))
|
.catch(() => setQsos([]))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, [code]);
|
useEffect(() => { load(); }, [code]);
|
||||||
// Distinct stations vs total contacts (a station may appear on several QSOs).
|
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
|
||||||
|
useEffect(() => {
|
||||||
|
ListAwardReferences(code)
|
||||||
|
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
||||||
|
.catch(() => setRefs([]));
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
const val = (q: any): string => {
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'qso_date': return String(q.qso_date ?? '');
|
||||||
|
case 'callsign': return String(q.callsign ?? '').toUpperCase();
|
||||||
|
case 'band': return String(q.band ?? '');
|
||||||
|
case 'mode': return String(q.mode ?? '');
|
||||||
|
case 'country': return String(q.country ?? '').toUpperCase();
|
||||||
|
case 'qth': return qthOf(q).toUpperCase();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
|
||||||
|
}, [qsos, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
|
||||||
|
const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
|
||||||
|
const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
|
||||||
|
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||||
|
const setSort = (k: MissingSortKey) => {
|
||||||
|
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
else { setSortKey(k); setSortDir('asc'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
async function applyAssign() {
|
||||||
|
if (!assignRef || sel.size === 0) return;
|
||||||
|
setBusy(true); setMsg('');
|
||||||
|
try {
|
||||||
|
const assignedIds = Array.from(sel);
|
||||||
|
const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
|
||||||
|
// Optimistic: the assigned contacts now carry a reference, so drop them
|
||||||
|
// from the list locally instead of re-running the slow whole-log scan.
|
||||||
|
const done = new Set(assignedIds);
|
||||||
|
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
|
||||||
|
setSel(new Set());
|
||||||
|
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setMsg(String(e?.message ?? e));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
|
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
|
||||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||||
|
|
||||||
|
const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
|
||||||
|
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
|
||||||
|
<span className="inline-flex items-center gap-0.5">{label}
|
||||||
|
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
||||||
<div className="bg-card border border-border rounded-lg shadow-xl w-[760px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||||
<AlertTriangle className="size-4 text-amber-600" />
|
<AlertTriangle className="size-4 text-amber-600" />
|
||||||
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
<span className="font-semibold text-sm">{code} — contacts missing a reference</span>
|
||||||
@@ -432,8 +568,28 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
|
||||||
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
|
||||||
{onEditQSO && ' Click a row to open the QSO and add the reference.'}
|
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk-assign toolbar */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||||
|
<span className="text-xs text-muted-foreground">{sel.size} selected →</span>
|
||||||
|
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||||
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{refs.map((r) => (
|
||||||
|
<SelectItem key={r.code} value={r.code}>
|
||||||
|
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
|
||||||
|
</Button>
|
||||||
|
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning…</div>
|
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning…</div>
|
||||||
@@ -443,22 +599,35 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
|
||||||
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-2 font-medium">Country</th><th className="py-1 pr-3 font-medium">QTH / Note</th></tr>
|
<tr>
|
||||||
|
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||||
|
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
|
||||||
|
<SortTh k="callsign" label="Callsign" />
|
||||||
|
<SortTh k="band" label="Band" />
|
||||||
|
<SortTh k="mode" label="Mode" />
|
||||||
|
<SortTh k="country" label="Country" />
|
||||||
|
<SortTh k="qth" label="QTH / Note" />
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{qsos.map((q, i) => (
|
{sorted.map((q, i) => {
|
||||||
<tr key={q.id ?? i}
|
const id = q.id as number;
|
||||||
className={cn('border-b border-border/30', onEditQSO && 'cursor-pointer hover:bg-accent/40')}
|
return (
|
||||||
onClick={() => onEditQSO && q.id && onEditQSO(q.id as number)}>
|
<tr key={id ?? i}
|
||||||
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
|
className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
|
||||||
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
<td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
|
||||||
|
</td>
|
||||||
|
<td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
|
||||||
|
<td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
|
||||||
<td className="py-1 pr-2">{q.band}</td>
|
<td className="py-1 pr-2">{q.band}</td>
|
||||||
<td className="py-1 pr-2">{q.mode}</td>
|
<td className="py-1 pr-2">{q.mode}</td>
|
||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[120px]">{q.country}</td>
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
|
||||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[200px]">{q.qth || q.notes}</td>
|
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface Props {
|
|||||||
currentBand: string;
|
currentBand: string;
|
||||||
currentMode: string;
|
currentMode: string;
|
||||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
|
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||||
@@ -78,7 +79,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
|||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -100,6 +101,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
|
|||||||
return m;
|
return m;
|
||||||
}, [wb]);
|
}, [wb]);
|
||||||
|
|
||||||
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
|
// worked on — not just the operator's configured column list).
|
||||||
|
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
||||||
|
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
||||||
|
// lists every real (band, mode) the entity was worked on.
|
||||||
|
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||||
|
const curMode = (currentMode || '').toUpperCase().trim();
|
||||||
|
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||||
|
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
||||||
|
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
||||||
|
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||||
|
// exact band+mode is NOT yet:
|
||||||
|
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||||
|
// New Band = the band is new (the mode was worked on another band).
|
||||||
|
// New Mode = the mode is new (the band was worked in another mode).
|
||||||
|
// New Slot = both band and mode already worked — just not together.
|
||||||
|
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
|
||||||
|
const newBandMode = slotNew && !bandWorked && !modeWorked;
|
||||||
|
const newBand = slotNew && !bandWorked && modeWorked;
|
||||||
|
const newMode = slotNew && bandWorked && !modeWorked;
|
||||||
|
const newSlot = slotNew && bandWorked && modeWorked;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -135,6 +159,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
{(newBand || newMode || newBandMode || newSlot) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||||
|
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||||
|
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||||
|
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : busy ? (
|
) : busy ? (
|
||||||
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
||||||
@@ -181,7 +213,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands }: Prop
|
|||||||
</th>
|
</th>
|
||||||
{cols.map((b) => {
|
{cols.map((b) => {
|
||||||
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
const isCurrent = b.tag === currentBand && classCurrent;
|
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
|
|||||||
@@ -113,6 +113,19 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusBadge maps a resolved spot status to a short labelled badge for the
|
||||||
|
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||||
|
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||||
|
// there's nothing notable to show.
|
||||||
|
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
||||||
|
switch (s?.status) {
|
||||||
|
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||||
|
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||||
|
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||||
|
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const COL_CATALOG: ColEntry[] = [
|
const COL_CATALOG: ColEntry[] = [
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'Time', colId: 'time',
|
group: 'Spot', label: 'Time', colId: 'time',
|
||||||
@@ -136,6 +149,38 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Spot', label: 'Status', colId: 'status',
|
||||||
|
headerName: 'Status', width: 96, sortable: true,
|
||||||
|
defaultVisible: true,
|
||||||
|
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||||
|
// is obvious at the row level, not just a single coloured cell.
|
||||||
|
valueGetter: (p: any) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
if (s?.status === 'new') return 'NEW DXCC';
|
||||||
|
if (s?.status === 'new-band') return 'NEW BAND';
|
||||||
|
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||||
|
return s?.worked_call ? 'WKD CALL' : '';
|
||||||
|
},
|
||||||
|
cellRenderer: (p: any) => {
|
||||||
|
const b = statusBadge(statusFor(p));
|
||||||
|
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
||||||
|
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||||
|
}}>{b.text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tooltipValueGetter: (p: any) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
|
||||||
|
if (s?.status === 'new-band') return 'NEW BAND for this entity';
|
||||||
|
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
|
||||||
|
if (s?.worked_call) return 'Already worked this call';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: 'POTA', colId: 'pota',
|
group: 'Spot', label: 'POTA', colId: 'pota',
|
||||||
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -118,9 +119,43 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
|
|
||||||
|
// Live award detection: run the SAME engine used at log time over the current
|
||||||
|
// contact (callsign + looked-up address/state/zones) so award references the
|
||||||
|
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
||||||
|
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
||||||
|
// of only appearing after logging. Pickable matches are merged into award_refs
|
||||||
|
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||||
|
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const q: any = {
|
||||||
|
callsign, band, mode,
|
||||||
|
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||||
|
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||||
|
qso_date: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
|
||||||
|
setDetected(all as any);
|
||||||
|
const cur = details.award_refs ?? '';
|
||||||
|
const have = new Set(cur.split(';').filter(Boolean));
|
||||||
|
let next = cur;
|
||||||
|
for (const r of all) {
|
||||||
|
if (!r.pickable) continue;
|
||||||
|
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||||
|
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||||
|
}
|
||||||
|
if (next !== cur) onChange({ award_refs: next });
|
||||||
|
} catch { /* leave detection empty on failure */ }
|
||||||
|
}, 400);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
||||||
// Bearing/distance from operator's home grid to the remote station.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
@@ -179,10 +214,10 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{open === 'stats' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} />
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -205,8 +240,18 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
|
||||||
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
|
||||||
</Field>
|
</Field>
|
||||||
{/* DXCC #, Continent and Azimuth SP live in the main entry strip /
|
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||||
bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */}
|
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||||
|
bearing and distances move to the row below. */}
|
||||||
|
<Field label="DXCC #">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
className="font-mono bg-muted/40 cursor-default"
|
||||||
|
value={details.dxcc ?? ''}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Azimuth LP">
|
<Field label="Azimuth LP">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
@@ -247,13 +292,24 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'awards' && (
|
{open === 'awards' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
|
||||||
<AwardRefSelector
|
<AwardRefSelector
|
||||||
dxcc={details.dxcc}
|
dxcc={details.dxcc}
|
||||||
value={details.award_refs ?? ''}
|
value={details.award_refs ?? ''}
|
||||||
onChange={(v) => onChange({ award_refs: v })}
|
onChange={(v) => onChange({ award_refs: v })}
|
||||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||||
|
heightClass="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
|
{detected.length > 0 && (
|
||||||
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||||
|
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
||||||
|
{detected.map((r) => (
|
||||||
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
||||||
|
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
<Mic className="size-3.5 text-primary" />
|
<Mic className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
|
||||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
||||||
{status.playing && <span className="text-[10px] text-amber-600 font-medium">transmitting…</span>}
|
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||||
<Square className="size-3" /> Stop
|
<Square className="size-3" /> Stop
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Radio } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||||
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
|
|
||||||
|
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||||
|
|
||||||
|
// FirstRunModal collects the mandatory station identity on the very first launch
|
||||||
|
// (no callsign configured yet). It writes straight into the active profile, so
|
||||||
|
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||||
|
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||||
|
const [p, setP] = useState<Profile | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [refsState, setRefsState] = useState<'idle' | 'loading' | 'done'>('idle');
|
||||||
|
const [refsMsg, setRefsMsg] = useState('');
|
||||||
|
|
||||||
|
async function downloadRefs() {
|
||||||
|
setRefsState('loading');
|
||||||
|
try {
|
||||||
|
const summary = await DownloadAllReferenceLists();
|
||||||
|
setRefsMsg(summary);
|
||||||
|
setRefsState('done');
|
||||||
|
} catch (e: any) {
|
||||||
|
setRefsMsg(String(e?.message ?? e));
|
||||||
|
setRefsState('idle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
GetActiveProfile().then((x) => setP(x as Profile)).catch(() => setP({} as Profile));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = (patch: Partial<Profile>) => setP((s: Profile | null) => ({ ...(s as Profile), ...patch }));
|
||||||
|
|
||||||
|
const callsign = (p?.callsign ?? '').trim().toUpperCase();
|
||||||
|
const grid = (p?.my_grid ?? '').trim().toUpperCase();
|
||||||
|
const operator = (p?.operator ?? '').trim().toUpperCase();
|
||||||
|
const canSave = callsign.length >= 3 && grid.length >= 4;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!p || !canSave) return;
|
||||||
|
setSaving(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
await SaveProfile({
|
||||||
|
...p,
|
||||||
|
callsign,
|
||||||
|
my_grid: grid,
|
||||||
|
operator: operator || callsign,
|
||||||
|
owner_callsign: (p.owner_callsign ?? '').trim().toUpperCase() || callsign,
|
||||||
|
op_name: (p.op_name ?? '').trim(),
|
||||||
|
} as any);
|
||||||
|
onDone();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Radio className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||||
|
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
|
||||||
|
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
|
||||||
|
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Operator</Label>
|
||||||
|
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Owner</Label>
|
||||||
|
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">Name</Label>
|
||||||
|
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional: grab the award reference lists now (also in Tools later). */}
|
||||||
|
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-medium">Award reference lists</div>
|
||||||
|
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
|
||||||
|
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
|
||||||
|
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, Zap, Mic2, Settings2, Power, AudioLines, Antenna, Flame, Gauge } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
|
FlexMox, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexAmpOperate,
|
||||||
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
||||||
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type FlexState = {
|
||||||
|
available: boolean; model?: string;
|
||||||
|
rf_power: number; tune_power: number; tune: boolean; transmitting: boolean;
|
||||||
|
vox_enable: boolean; vox_level: number; vox_delay: number;
|
||||||
|
proc_enable: boolean; proc_level: number;
|
||||||
|
mon: boolean; mon_level: number; mic_level: number;
|
||||||
|
atu_status?: string; atu_memories: boolean;
|
||||||
|
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
|
||||||
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
|
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||||
|
meters?: Meter[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
||||||
|
|
||||||
|
const ZERO: FlexState = {
|
||||||
|
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
||||||
|
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
||||||
|
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||||
|
rx_avail: false, agc_threshold: 0, audio_level: 0,
|
||||||
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||||
|
amp_available: false, amp_operate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max = 100 }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; max?: number;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(0, Math.min(max, value));
|
||||||
|
const pct = max > 0 ? (v / max) * 100 : 0;
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
|
||||||
|
// is ignored), so attach a non-passive native listener; read live values via
|
||||||
|
// refs to avoid stale closures.
|
||||||
|
const valRef = useRef(value); valRef.current = value;
|
||||||
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||||
|
const disRef = useRef(disabled); disRef.current = disabled;
|
||||||
|
const stepRef = useRef(step); stepRef.current = step;
|
||||||
|
const maxRef = useRef(max); maxRef.current = max;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (disRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||||
|
const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d));
|
||||||
|
if (nv !== valRef.current) cbRef.current(nv);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="range" min={0} max={max} value={v} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
|
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
|
||||||
|
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segmented — radio-style multi-choice (AGC, Processor preset).
|
||||||
|
function Segmented({ value, options, onChange, disabled }: {
|
||||||
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.v} type="button" disabled={disabled} onClick={() => onChange(o.v)}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||||
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
|
||||||
|
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||||
|
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||||
|
}) {
|
||||||
|
const onCls = {
|
||||||
|
emerald: 'bg-emerald-600 border-emerald-600 text-white',
|
||||||
|
violet: 'bg-violet-600 border-violet-600 text-white',
|
||||||
|
cyan: 'bg-cyan-600 border-cyan-600 text-white',
|
||||||
|
amber: 'bg-amber-500 border-amber-500 text-white',
|
||||||
|
}[accent];
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} disabled={disabled}
|
||||||
|
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
|
||||||
|
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||||
|
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent={accent} />
|
||||||
|
<Slider value={value} disabled={disabled || !on} accent={sliderAccent} onChange={onLevel} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
|
const METER_SEGMENTS = 26;
|
||||||
|
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor }: {
|
||||||
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
|
segColor?: (frac: number) => string;
|
||||||
|
}) {
|
||||||
|
const span = hi - lo;
|
||||||
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-1 mb-1.5">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
|
||||||
|
{display !== undefined ? display : (
|
||||||
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[2px] h-2.5 items-stretch">
|
||||||
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
|
const on = i < lit;
|
||||||
|
const frac = i / METER_SEGMENTS;
|
||||||
|
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 rounded-[1.5px] transition-colors duration-100"
|
||||||
|
style={on ? { background: col, boxShadow: `0 0 3px ${col}66` } : { background: '#cfc6ad', opacity: 0.45 }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexPanel() {
|
||||||
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
|
const hold = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = (await GetFlexState()) as any as FlexState;
|
||||||
|
if (!alive) return;
|
||||||
|
setSt((prev) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const merged: any = { ...s };
|
||||||
|
for (const k in hold.current) {
|
||||||
|
if (hold.current[k] > now) merged[k] = (prev as any)[k];
|
||||||
|
}
|
||||||
|
return merged as FlexState;
|
||||||
|
});
|
||||||
|
} catch { /* not connected */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 400);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||||
|
hold.current[key] = Date.now() + 900;
|
||||||
|
setSt((p) => ({ ...p, [key]: val }));
|
||||||
|
send().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const off = !st.available;
|
||||||
|
const rxOff = off || !st.rx_avail;
|
||||||
|
const 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' }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
|
{/* Header strip */}
|
||||||
|
<div className="flex items-center gap-3 rounded-xl px-4 py-3 text-white shadow-sm"
|
||||||
|
style={{ background: 'linear-gradient(135deg,#1e293b,#0f172a)' }}>
|
||||||
|
<Radio className="size-6 text-sky-400" />
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
|
||||||
|
<span className="text-[11px] text-slate-400">SmartSDR remote control</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||||||
|
!st.available ? 'bg-slate-700 text-slate-300'
|
||||||
|
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
|
||||||
|
<span className="size-2 rounded-full bg-current" />
|
||||||
|
{!st.available ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{off && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground py-6">
|
||||||
|
Waiting for the FlexRadio… (set CAT to FlexRadio and connect)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TX + RX columns */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{/* TRANSMIT */}
|
||||||
|
<Card icon={Zap} title="Transmit" accent="#dc2626">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">RF Power</span>
|
||||||
|
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||||||
|
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">Tune Pwr</span>
|
||||||
|
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
|
||||||
|
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-0.5">
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
|
||||||
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}>
|
||||||
|
TUNE
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
|
||||||
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}>
|
||||||
|
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
|
||||||
|
onClick={() => change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} />
|
||||||
|
<Segmented value={String(st.proc_level)} options={PROC} disabled={off || !st.proc_enable}
|
||||||
|
onChange={(v) => change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} />
|
||||||
|
<span className="flex-1" />
|
||||||
|
</div>
|
||||||
|
<LevelRow label="VOX" on={st.vox_enable} disabled={off} value={st.vox_level} sliderAccent="#16a34a"
|
||||||
|
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
|
||||||
|
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">VOX Dly</span>
|
||||||
|
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
|
||||||
|
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
|
||||||
|
</div>
|
||||||
|
<LevelRow label="MON" on={st.mon} disabled={off} value={st.mon_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
|
onToggle={() => change('mon', !st.mon, () => FlexSetMon(!st.mon))}
|
||||||
|
onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">MIC</span>
|
||||||
|
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RECEIVE */}
|
||||||
|
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
|
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span>
|
||||||
|
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||||
|
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||||
|
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||||||
|
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
||||||
|
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
|
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||||
|
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||||
|
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
|
||||||
|
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||||
|
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||||
|
{st.amp_available && (
|
||||||
|
<Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}>
|
||||||
|
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||||||
|
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live meters (UDP VITA-49 stream) */}
|
||||||
|
<Card icon={Gauge} title="Meters">
|
||||||
|
{(() => {
|
||||||
|
const meters = st.meters || [];
|
||||||
|
if (off || meters.length === 0) {
|
||||||
|
return <p className="text-[11px] text-muted-foreground text-center py-2">No meters yet — waiting for the radio's UDP stream…</p>;
|
||||||
|
}
|
||||||
|
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
// Radio meters (exclude the amplifier's, which we show separately).
|
||||||
|
const radio = (name: string) => meters.find((m) =>
|
||||||
|
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
const sig = radio('LEVEL') || radio('SIGNAL');
|
||||||
|
const fwd = radio('FWDPWR');
|
||||||
|
const swr = radio('SWR');
|
||||||
|
const 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')
|
||||||
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
|
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
||||||
|
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
|
||||||
|
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
|
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
||||||
|
const sUnit = (dbm: number) => {
|
||||||
|
const s = (dbm + 127) / 6; // S0 = -127 dBm
|
||||||
|
if (s >= 9) {
|
||||||
|
const over = Math.round(dbm + 73); // dB over S9
|
||||||
|
return { display: over > 0 ? `S9+${over}` : 'S9', bar: s };
|
||||||
|
}
|
||||||
|
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
|
||||||
|
};
|
||||||
|
const cur = [
|
||||||
|
sig && (() => { const s = sUnit(sig.value); 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`}
|
||||||
|
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"
|
||||||
|
value={isDbm(fwd) ? dbmToW(fwd.value) : fwd.value} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />,
|
||||||
|
swr && <MeterBar key="w" label="SWR" value={swr.value} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||||
|
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" />,
|
||||||
|
volts && <MeterBar key="v" label="VOLTS" value={volts.value} unit={volts.unit} lo={volts.lo || 0} hi={volts.hi || 15} accent="#2563eb" />,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||||
|
{amp.length > 0 && (
|
||||||
|
<div className="border-t border-border/60 pt-2 mt-1">
|
||||||
|
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">AMPLIFIER</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{amp.map((m) => {
|
||||||
|
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
||||||
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={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={m.value} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+182
-104
@@ -14,22 +14,15 @@ function saveMapView(m: L.Map) {
|
|||||||
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// MainMap — Log4OM-style dual map for the Main tab:
|
// The Main tab is built from two independent map panes that the operator can
|
||||||
// • Left: a world map with the great-circle path drawn from the operator to
|
// place on either side (Settings → Main view):
|
||||||
// the contacted station, plus distance + short/long-path azimuth.
|
// • WorldMap ("map1"): a world map with the great-circle path from the
|
||||||
// • Right: a street map zoomed onto the contacted station's grid locator.
|
// operator to the contacted station, distance, short/long-path azimuth and
|
||||||
|
// the antenna beam lobe.
|
||||||
|
// • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid.
|
||||||
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
|
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
|
||||||
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
|
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
|
||||||
|
|
||||||
interface Props {
|
|
||||||
fromGrid: string; // operator grid (active profile)
|
|
||||||
toGrid: string; // contacted-station grid
|
|
||||||
fromLabel?: string; // operator callsign
|
|
||||||
toLabel?: string; // DX callsign
|
|
||||||
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
|
|
||||||
beamWidth?: number; // beamwidth (deg), default 30
|
|
||||||
}
|
|
||||||
|
|
||||||
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
||||||
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
||||||
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
||||||
@@ -53,6 +46,24 @@ const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|||||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||||
const OSM_ATTR = '© OpenStreetMap contributors';
|
const OSM_ATTR = '© OpenStreetMap contributors';
|
||||||
|
|
||||||
|
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
||||||
|
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||||
|
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
||||||
|
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||||
|
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
||||||
|
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||||
|
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||||
|
attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||||
|
street: { label: 'Street', url: OSM, attr: OSM_ATTR },
|
||||||
|
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||||
|
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||||
|
};
|
||||||
|
function loadBasemap(): BasemapKey {
|
||||||
|
const v = localStorage.getItem('opslog.mapBasemap');
|
||||||
|
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
function dot(color: string): L.DivIcon {
|
function dot(color: string): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -62,14 +73,24 @@ function dot(color: string): L.DivIcon {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) {
|
interface WorldProps {
|
||||||
|
fromGrid: string; // operator grid (active profile)
|
||||||
|
toGrid: string; // contacted-station grid
|
||||||
|
fromLabel?: string; // operator callsign
|
||||||
|
toLabel?: string; // DX callsign
|
||||||
|
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
||||||
|
beamWidth?: number; // beamwidth (deg), default 30
|
||||||
|
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||||
|
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
||||||
const worldRef = useRef<HTMLDivElement>(null);
|
const worldRef = useRef<HTMLDivElement>(null);
|
||||||
const locatorRef = useRef<HTMLDivElement>(null);
|
|
||||||
const worldMap = useRef<L.Map | null>(null);
|
const worldMap = useRef<L.Map | null>(null);
|
||||||
const locatorMap = useRef<L.Map | null>(null);
|
|
||||||
// Layers we add/remove as the QSO changes (kept separate from the basemap).
|
|
||||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||||
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
const baseLayer = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsLayer = useRef<L.TileLayer | null>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
|
||||||
|
|
||||||
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
|
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
|
||||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||||
@@ -83,95 +104,110 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
if (worldRef.current && !worldMap.current) {
|
if (worldRef.current && !worldMap.current) {
|
||||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
||||||
.setView([20, 0], 1);
|
.setView([20, 0], 1);
|
||||||
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
|
const bm = BASEMAPS[basemap];
|
||||||
|
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
// Restore the saved free-pan view when not auto-zooming.
|
|
||||||
const sv = loadMapView();
|
const sv = loadMapView();
|
||||||
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
||||||
// Remember the view as the user pans/zooms (only meaningful when free).
|
|
||||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||||
}
|
}
|
||||||
if (locatorRef.current && !locatorMap.current) {
|
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||||
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
|
||||||
.setView([20, 0], 2);
|
|
||||||
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
|
||||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
|
||||||
locatorMap.current = m;
|
|
||||||
}
|
|
||||||
// The Main tab may have just become visible — fix tile sizing.
|
|
||||||
const t = window.setTimeout(() => {
|
|
||||||
worldMap.current?.invalidateSize();
|
|
||||||
locatorMap.current?.invalidateSize();
|
|
||||||
}, 80);
|
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids change.
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
|
// picks a different one. Vector overlays (path/beam) live in Leaflet's
|
||||||
|
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wm = worldMap.current, lm = locatorMap.current;
|
const m = worldMap.current;
|
||||||
const wo = worldOverlay.current, lo = locatorOverlay.current;
|
if (!m) return;
|
||||||
if (!wm || !lm || !wo || !lo) return;
|
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
|
||||||
|
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
|
||||||
|
const bm = BASEMAPS[basemap];
|
||||||
|
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
|
useEffect(() => {
|
||||||
|
const wm = worldMap.current, wo = worldOverlay.current;
|
||||||
|
if (!wm || !wo) return;
|
||||||
wo.clearLayers();
|
wo.clearLayers();
|
||||||
lo.clearLayers();
|
|
||||||
|
|
||||||
const from = gridToLatLon(fromGrid);
|
const from = gridToLatLon(fromGrid);
|
||||||
const to = gridToLatLon(toGrid);
|
const to = gridToLatLon(toGrid);
|
||||||
|
|
||||||
|
if (from && to) {
|
||||||
|
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||||
|
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(wo);
|
||||||
|
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||||
|
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(wo);
|
||||||
|
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||||
|
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
|
||||||
|
// line, which makes a smooth arc look angular/bumpy).
|
||||||
|
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
||||||
|
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||||
|
|
||||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||||
if (from && beamAzimuths && beamAzimuths.length) {
|
if (beamAzimuths && beamAzimuths.length) {
|
||||||
const half = (beamWidth ?? 30) / 2;
|
const half = (beamWidth ?? 30) / 2;
|
||||||
const D = 5500; // lobe length (km) — short enough to rarely reach a pole
|
const D = 5500; // lobe length (km)
|
||||||
const radial = (b: number): [number, number][] =>
|
// Great-circle radial out to distance D, stopping just short of the pole
|
||||||
Array.from({ length: 14 }, (_, i) => {
|
// so a poleward line doesn't snap across the top of the Mercator map.
|
||||||
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
|
const radial = (b: number): [number, number][] => {
|
||||||
return [d.lat, d.lon] as [number, number];
|
const out: [number, number][] = [];
|
||||||
});
|
const N = 64;
|
||||||
const edge = { color: '#dc2626', weight: 1.5, opacity: 0.6 };
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
|
||||||
|
out.push([d.lat, d.lon]);
|
||||||
|
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
for (const az of beamAzimuths) {
|
for (const az of beamAzimuths) {
|
||||||
const arc: [number, number][] = [];
|
// Draw the lobe as a FAN of translucent great-circle radials, not a
|
||||||
for (let b = az - half; b <= az + half + 0.001; b += 2) {
|
// filled polygon: a polygon breaks badly near the poles on Mercator
|
||||||
const d = destinationPoint(from.lat, from.lon, b, D);
|
// (its edges run off toward ±90° and the fill smears across the map),
|
||||||
arc.push([d.lat, d.lon]);
|
// while each radial LINE stays clean. The overlapping lines read as a
|
||||||
|
// lobe — solid near the antenna, fanning out toward the front. Works
|
||||||
|
// for any azimuth, north/south included.
|
||||||
|
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
||||||
|
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||||
|
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo);
|
||||||
}
|
}
|
||||||
const ring = unwrapLon([
|
|
||||||
[from.lat, from.lon],
|
|
||||||
...radial(az - half),
|
|
||||||
...arc,
|
|
||||||
...radial(az + half).reverse(),
|
|
||||||
]);
|
|
||||||
// A geodesic lobe that reaches near a pole can't be filled on a
|
|
||||||
// Mercator map without the polygon snapping across the whole world —
|
|
||||||
// draw just the two edges in that case; otherwise the translucent lobe.
|
|
||||||
if (ring.some(([la]) => Math.abs(la) > 82)) {
|
|
||||||
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az - half)]) as L.LatLngExpression[], edge).addTo(wo);
|
|
||||||
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az + half)]) as L.LatLngExpression[], edge).addTo(wo);
|
|
||||||
} else {
|
|
||||||
L.polygon(ring as L.LatLngExpression[], {
|
|
||||||
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
|
|
||||||
}).addTo(wo);
|
|
||||||
}
|
|
||||||
// Boresight (dashed centre line) — always; great-circle polyline is safe.
|
|
||||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||||
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
|
// Dark casing under the boresight so the bright dashed line stays
|
||||||
|
// readable on any basemap (esp. dark satellite imagery). Same dashArray
|
||||||
|
// as the red line so the casing tracks each dash — otherwise the wide
|
||||||
|
// casing peeks through the gaps and the line looks bumpy.
|
||||||
|
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
|
||||||
|
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
|
||||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Left: world + great-circle arc ──
|
// Mechanical boom (rotor) direction — thin grey dashed line. Drawn when the
|
||||||
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
|
// Ultrabeam radiates elsewhere (reverse/bi) so the boom heading stays visible
|
||||||
if (to) {
|
// next to the red radiating lobe(s).
|
||||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
if (boomAzimuth != null) {
|
||||||
.bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo);
|
const bpts: [number, number][] = [[from.lat, from.lon]];
|
||||||
|
const N = 64, D = 5500;
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const d = destinationPoint(from.lat, from.lon, boomAzimuth, (D * i) / N);
|
||||||
|
bpts.push([d.lat, d.lon]);
|
||||||
|
if (Math.abs(d.lat) > 86) break;
|
||||||
}
|
}
|
||||||
if (from && to) {
|
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
||||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
|
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
|
}
|
||||||
// Only re-frame the map when auto-zoom is on; otherwise keep the user's
|
|
||||||
// chosen (remembered) view so the beam heading stays visible.
|
|
||||||
if (autoZoom) {
|
if (autoZoom) {
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // include the arc
|
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
}
|
}
|
||||||
} else if (autoZoom && to) {
|
} else if (autoZoom && to) {
|
||||||
@@ -179,31 +215,31 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
} else if (autoZoom && from) {
|
} else if (autoZoom && from) {
|
||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
|
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||||
// ── Right: street map on the DX locator ──
|
|
||||||
if (to) {
|
|
||||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
|
||||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
|
||||||
.addTo(lo);
|
|
||||||
const b = gridSquareBounds(toGrid);
|
|
||||||
if (b) {
|
|
||||||
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
|
|
||||||
}
|
|
||||||
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
|
|
||||||
} else if (from) {
|
|
||||||
lm.setView([from.lat, from.lon], 5);
|
|
||||||
}
|
|
||||||
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
|
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
|
||||||
|
|
||||||
const path = pathBetween(fromGrid, toGrid);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 gap-2 p-2">
|
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||||
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
|
||||||
<div className="relative isolate rounded-lg overflow-hidden border border-border">
|
|
||||||
<div ref={worldRef} className="absolute inset-0" />
|
<div ref={worldRef} className="absolute inset-0" />
|
||||||
|
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
||||||
|
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||||
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
|
||||||
|
title={`Basemap: ${BASEMAPS[k].label}`}
|
||||||
|
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
||||||
|
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{BASEMAPS[k].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
|
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
|
||||||
(remembered across restarts), so the beam heading stays visible. */}
|
(remembered across restarts), so the beam heading stays visible. */}
|
||||||
<button
|
<button
|
||||||
@@ -213,8 +249,7 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
setAutoZoom(v);
|
setAutoZoom(v);
|
||||||
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||||
const m = worldMap.current;
|
const m = worldMap.current;
|
||||||
if (!v && m) saveMapView(m); // entering free mode → remember current view
|
if (!v && m) saveMapView(m);
|
||||||
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
|
|
||||||
}}
|
}}
|
||||||
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
||||||
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||||
@@ -232,7 +267,52 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative isolate rounded-lg overflow-hidden border border-border">
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocatorProps {
|
||||||
|
toGrid: string; // contacted-station grid
|
||||||
|
toLabel?: string; // DX callsign
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocatorMap — street map zoomed onto the DX grid, the "map2" pane.
|
||||||
|
export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
||||||
|
const locatorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const locatorMap = useRef<L.Map | null>(null);
|
||||||
|
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (locatorRef.current && !locatorMap.current) {
|
||||||
|
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
||||||
|
.setView([20, 0], 2);
|
||||||
|
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
||||||
|
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||||
|
locatorMap.current = m;
|
||||||
|
}
|
||||||
|
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lm = locatorMap.current, lo = locatorOverlay.current;
|
||||||
|
if (!lm || !lo) return;
|
||||||
|
lo.clearLayers();
|
||||||
|
const to = gridToLatLon(toGrid);
|
||||||
|
if (to) {
|
||||||
|
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||||
|
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(lo);
|
||||||
|
const b = gridSquareBounds(toGrid);
|
||||||
|
if (b) {
|
||||||
|
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
|
||||||
|
}
|
||||||
|
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
|
||||||
|
}
|
||||||
|
setTimeout(() => { lm.invalidateSize(); }, 0);
|
||||||
|
}, [toGrid, toLabel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||||
<div ref={locatorRef} className="absolute inset-0" />
|
<div ref={locatorRef} className="absolute inset-0" />
|
||||||
{!gridToLatLon(toGrid) && (
|
{!gridToLatLon(toGrid) && (
|
||||||
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
|
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
|
||||||
@@ -240,7 +320,5 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { 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';
|
||||||
|
|
||||||
@@ -77,6 +77,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('');
|
||||||
@@ -251,6 +259,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}>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Mail className="size-4 text-amber-600" />
|
<Mail className="size-4 text-amber-600" />
|
||||||
<span>Send eQSL by e-mail</span>
|
<span>Send OpsLog QSL by e-mail</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onSendRecording && (
|
{onSendRecording && (
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ function toLocalISO(d: any): string {
|
|||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
const date = new Date(d);
|
const date = new Date(d);
|
||||||
if (isNaN(date.getTime())) return '';
|
if (isNaN(date.getTime())) return '';
|
||||||
|
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
|
||||||
|
// doesn't apply to a time struct), so a QSO with no end time arrives as a
|
||||||
|
// year-1 date. Treat anything that old as unset — otherwise the datetime
|
||||||
|
// field shows a garbage value and fights the user's typing.
|
||||||
|
if (date.getUTCFullYear() <= 1) return '';
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
||||||
}
|
}
|
||||||
@@ -159,8 +164,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
|
||||||
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
|
||||||
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
||||||
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
|
||||||
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
|
const [dateOff, setDateOff] = useState(dateOffISO);
|
||||||
|
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
|
||||||
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
||||||
const [localErr, setLocalErr] = useState('');
|
const [localErr, setLocalErr] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -428,7 +434,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
|
<Checkbox checked={endEnabled} onCheckedChange={(c) => {
|
||||||
|
const on = !!c;
|
||||||
|
setEndEnabled(on);
|
||||||
|
// Prefill an empty end with the start time so the user
|
||||||
|
// only tweaks the minutes instead of typing a full date.
|
||||||
|
if (on && !dateOff) setDateOff(dateOn);
|
||||||
|
}} /> QSO End (UTC)
|
||||||
</Label>
|
</Label>
|
||||||
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ 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;
|
||||||
@@ -101,8 +101,8 @@ export const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||||
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
{ group: 'QSO', label: 'Freq (TX)', colId: 'freq_hz', headerName: 'Freq', field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
{ group: 'QSO', label: 'Freq (RX)', colId: 'freq_rx_hz', headerName: 'Freq RX', field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||||
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'QSO', label: 'TX Power', colId: 'tx_pwr', headerName: 'TX Power', field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
@@ -150,6 +150,8 @@ export const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
{ group: 'eQSL', label: 'eQSL rcvd', colId: 'eqsl_rcvd', headerName: 'eQSL rcvd', field: 'eqsl_rcvd' as any, width: 80 },
|
||||||
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||||
|
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||||
|
|
||||||
// ── Uploads (online logbooks) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||||
@@ -275,8 +277,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
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) ──
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const GRATICULE = geoGraticule10();
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bearing?: number | null; // short-path azimuth to DX (deg)
|
bearing?: number | null; // short-path azimuth to DX (deg)
|
||||||
headings: number[]; // antenna heading(s) — rotor + Ultrabeam pattern
|
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
|
||||||
|
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
|
||||||
|
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
|
||||||
centerLat?: number | null; // operator latitude (projection centre)
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
centerLon?: number | null; // operator longitude
|
centerLon?: number | null; // operator longitude
|
||||||
rotorEnabled?: boolean;
|
rotorEnabled?: boolean;
|
||||||
@@ -36,7 +38,7 @@ function pt(az: number, radius: number): [number, number] {
|
|||||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
||||||
const cardinals = useMemo(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
@@ -76,6 +78,18 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
|
|||||||
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
||||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{pattern && (
|
||||||
|
<span
|
||||||
|
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||||
|
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
|
||||||
|
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
|
||||||
|
: 'bg-emerald-200 text-emerald-900')}
|
||||||
|
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||||
|
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||||
|
: 'Ultrabeam normal'}>
|
||||||
|
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
||||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||||
</span>
|
</span>
|
||||||
@@ -123,7 +137,21 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
|
|||||||
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
||||||
); })()}
|
); })()}
|
||||||
|
|
||||||
{/* antenna heading needle(s) — green; two when bidirectional */}
|
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
|
||||||
|
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
|
||||||
|
operator sees where the antenna physically points vs where it boom-sits */}
|
||||||
|
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
|
||||||
|
const [x, y] = pt(boomHeading, MAP_R - 2);
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
|
||||||
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
|
||||||
|
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* radiating heading needle(s) — green; two when bidirectional */}
|
||||||
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud, Loader2,
|
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings,
|
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
@@ -25,12 +25,17 @@ import {
|
|||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||||
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetDataDir,
|
GetDataDir,
|
||||||
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
|
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
|
GetUIPref, SetUIPref,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -124,6 +129,7 @@ const emptyProfile = (): Profile => ({
|
|||||||
tx_pwr: undefined,
|
tx_pwr: undefined,
|
||||||
is_active: false,
|
is_active: false,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
|
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
|
||||||
created_at: '' as any,
|
created_at: '' as any,
|
||||||
updated_at: '' as any,
|
updated_at: '' as any,
|
||||||
});
|
});
|
||||||
@@ -132,6 +138,8 @@ interface Props {
|
|||||||
initialSection?: string;
|
initialSection?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
|
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
||||||
|
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
||||||
@@ -156,6 +164,7 @@ type SectionId =
|
|||||||
| 'cluster'
|
| 'cluster'
|
||||||
| 'backup'
|
| 'backup'
|
||||||
| 'database'
|
| 'database'
|
||||||
|
| 'autostart'
|
||||||
| 'awards'
|
| 'awards'
|
||||||
| 'cat'
|
| 'cat'
|
||||||
| 'rotator'
|
| 'rotator'
|
||||||
@@ -189,16 +198,16 @@ const TREE: TreeNode[] = [
|
|||||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||||
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
|
||||||
{ kind: 'item', label: 'Database', id: 'database' },
|
{ kind: 'item', label: 'Database', id: 'database' },
|
||||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
{ kind: 'item', label: 'Autostart', id: 'autostart' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
{ kind: 'item', label: 'CAT interface', id: 'cat' },
|
||||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
{ kind: 'item', label: 'Rotator', id: 'rotator' },
|
||||||
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
|
||||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna' },
|
{ kind: 'item', label: 'Antenna', id: 'antenna' },
|
||||||
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
{ kind: 'item', label: 'Audio devices', id: 'audio' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -216,11 +225,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
cluster: 'DX Cluster',
|
cluster: 'DX Cluster',
|
||||||
backup: 'Database backup',
|
backup: 'Database backup',
|
||||||
database: 'Database',
|
database: 'Database',
|
||||||
|
autostart: 'Autostart',
|
||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
rotator: 'Rotator',
|
||||||
winkeyer: 'CW Keyer (WinKeyer)',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'Antenna',
|
antenna: 'Antenna',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
@@ -316,6 +326,243 @@ function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutostartPanelComponent manages the per-profile list of external programs to
|
||||||
|
// launch when OpsLog starts. It's a self-contained component (its own state) so
|
||||||
|
// it can use hooks — rendered via the `() => <AutostartPanelComponent/>` wrapper
|
||||||
|
// in PANELS. Changes persist immediately (config is local SQLite, cheap writes).
|
||||||
|
type AutostartProg = { id: string; name: string; path: string; args: string; enabled: boolean };
|
||||||
|
|
||||||
|
function AutostartPanelComponent() {
|
||||||
|
const [progs, setProgs] = useState<AutostartProg[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [launchMsg, setLaunchMsg] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try { setProgs(((await GetAutostartPrograms()) ?? []) as any); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setLoaded(true); }
|
||||||
|
}
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('profile:changed', () => load());
|
||||||
|
return () => { if (typeof off === 'function') off(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function commit(next: AutostartProg[]) {
|
||||||
|
setProgs(next);
|
||||||
|
try { await SaveAutostartPrograms(next as any); setErr(''); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
const patch = (id: string, p: Partial<AutostartProg>) =>
|
||||||
|
commit(progs.map((x) => (x.id === id ? { ...x, ...p } : x)));
|
||||||
|
const remove = (id: string) => commit(progs.filter((x) => x.id !== id));
|
||||||
|
|
||||||
|
async function addProgram() {
|
||||||
|
try {
|
||||||
|
const path = await BrowseExecutable();
|
||||||
|
if (!path) return;
|
||||||
|
const base = path.split(/[\\/]/).pop() || path;
|
||||||
|
const name = base.replace(/\.(exe|bat|cmd)$/i, '');
|
||||||
|
const id = (crypto as any)?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
commit([...progs, { id, name, path, args: '', enabled: true }]);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function rebrowse(id: string) {
|
||||||
|
try { const path = await BrowseExecutable(); if (path) patch(id, { path }); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function launchNow(id: string) {
|
||||||
|
try {
|
||||||
|
const r: any = await LaunchAutostartProgram(id);
|
||||||
|
const txt = r?.status === 'launched' ? '✓ launched'
|
||||||
|
: r?.status === 'already_running' ? 'already running — not started again'
|
||||||
|
: r?.status === 'missing' ? '✗ executable not found'
|
||||||
|
: (r?.message || r?.status || 'done');
|
||||||
|
setLaunchMsg((m) => ({ ...m, [id]: txt }));
|
||||||
|
} catch (e: any) { setLaunchMsg((m) => ({ ...m, [id]: String(e?.message ?? e) })); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Autostart"
|
||||||
|
hint="Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile."
|
||||||
|
/>
|
||||||
|
<div className="space-y-2 max-w-3xl">
|
||||||
|
{loaded && progs.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No programs yet — add one below.</p>
|
||||||
|
)}
|
||||||
|
{progs.map((p) => (
|
||||||
|
<div key={p.id} className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={p.enabled} onCheckedChange={(c) => patch(p.id, { enabled: !!c })} title="Launch at startup" />
|
||||||
|
<Input className="h-8 flex-1 font-medium" value={p.name} placeholder="Name"
|
||||||
|
onChange={(e) => patch(p.id, { name: e.target.value })} />
|
||||||
|
<Button size="sm" variant="outline" onClick={() => launchNow(p.id)} title="Launch now">
|
||||||
|
<Play className="size-3.5" /> Launch
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => remove(p.id)} title="Remove">
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[78px_1fr] items-center gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Program</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="h-8 flex-1 font-mono text-xs" value={p.path} readOnly title={p.path} />
|
||||||
|
<Button size="sm" variant="outline" onClick={() => rebrowse(p.id)}>
|
||||||
|
<FolderOpen className="size-3.5" /> Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Arguments</Label>
|
||||||
|
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
|
||||||
|
onChange={(e) => patch(p.id, { args: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" onClick={addProgram}>
|
||||||
|
<Plus className="size-4" /> Add program…
|
||||||
|
</Button>
|
||||||
|
{err && <div className="text-xs text-destructive">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat
|
||||||
|
// (a random install ID + version + OS, sent once a day). Real component so it
|
||||||
|
// can own its state; embedded inside GeneralPanel.
|
||||||
|
function TelemetryToggle() {
|
||||||
|
const [on, setOn] = useState(true);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||||
|
Send anonymous usage statistics
|
||||||
|
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day — no callsign or QSO data)</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveStatusToggle publishes this operator's current activity (call + band +
|
||||||
|
// freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op
|
||||||
|
// events — a small web script on your server renders it for the QRZ page. Only
|
||||||
|
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||||
|
function LiveStatusToggle() {
|
||||||
|
const [on, setOn] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetLiveStatusEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||||
|
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||||
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
|
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||||
|
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||||
|
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'map1', label: 'Map — great-circle + beam' },
|
||||||
|
{ value: 'map2', label: 'Map — locator (street)' },
|
||||||
|
{ value: 'cluster', label: 'Cluster spots' },
|
||||||
|
{ value: 'worked', label: 'Worked before' },
|
||||||
|
];
|
||||||
|
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
|
||||||
|
const [left, setLeft] = useState('map1');
|
||||||
|
const [right, setRight] = useState('map2');
|
||||||
|
// FlexRadio is only offered when the CAT backend is a Flex.
|
||||||
|
const options = flexAvailable
|
||||||
|
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
|
||||||
|
: MAIN_PANE_OPTIONS;
|
||||||
|
useEffect(() => {
|
||||||
|
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||||
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
|
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||||
|
}, []);
|
||||||
|
const pick = (side: 'left' | 'right', v: string) => {
|
||||||
|
if (side === 'left') setLeft(v); else setRight(v);
|
||||||
|
// Persist (per-profile) AND tell the parent the new value directly, so the
|
||||||
|
// Main view updates from the chosen value — never a stale DB re-read.
|
||||||
|
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {});
|
||||||
|
onChanged?.(side, v);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||||
|
<label className="flex flex-col gap-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">Left pane</span>
|
||||||
|
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">Right pane</span>
|
||||||
|
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
|
||||||
|
// (fills the IP/port). Self-contained so it can own its state (rendered inside
|
||||||
|
// the hook-less CATPanel).
|
||||||
|
function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [found, setFound] = useState<Array<{ ip: string; port: number; model?: string; nickname?: string }>>([]);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
async function scan() {
|
||||||
|
setBusy(true); setMsg('');
|
||||||
|
try {
|
||||||
|
const r = ((await DiscoverFlexRadios()) ?? []) as any[];
|
||||||
|
setFound(r as any);
|
||||||
|
if (r.length === 0) setMsg('No radio found — check it\'s on the same network, or enter the IP manually.');
|
||||||
|
} catch (e: any) {
|
||||||
|
setMsg(String(e?.message ?? e));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-2 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={scan} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Wifi className="size-3.5" />} Detect radios
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">listens for FlexRadio broadcast on the LAN</span>
|
||||||
|
</div>
|
||||||
|
{found.map((r) => (
|
||||||
|
<button key={r.ip} type="button" onClick={() => onPick(r.ip, r.port || 4992)}
|
||||||
|
className="w-full text-left text-xs rounded border border-border px-2 py-1 hover:bg-accent/50">
|
||||||
|
<span className="font-mono font-semibold">{r.ip}</span>
|
||||||
|
{r.model ? <span className="text-muted-foreground"> · {r.model}</span> : ''}
|
||||||
|
{r.nickname ? <span className="text-muted-foreground"> ({r.nickname})</span> : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{msg && <div className="text-[11px] text-muted-foreground">{msg}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||||
const label = SECTION_LABELS[id] ?? id;
|
const label = SECTION_LABELS[id] ?? id;
|
||||||
const IconCmp = Icon ?? Construction;
|
const IconCmp = Icon ?? Construction;
|
||||||
@@ -328,7 +575,7 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
|
||||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -363,7 +610,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
@@ -426,6 +673,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
||||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||||
|
const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0');
|
||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
@@ -455,11 +703,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
// E-mail / SMTP (send QSO recordings).
|
// E-mail / SMTP (send QSO recordings).
|
||||||
type EmailCfg = {
|
type EmailCfg = {
|
||||||
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
||||||
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
from: string; reply_to: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
||||||
};
|
};
|
||||||
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
||||||
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
||||||
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||||
});
|
});
|
||||||
const [emailMsg, setEmailMsg] = useState('');
|
const [emailMsg, setEmailMsg] = useState('');
|
||||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||||
@@ -501,14 +749,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
api_key: string; email: string; username: string; password: string; callsign: 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 };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', email: '', username: '', password: '', callsign: '',
|
api_key: '', email: '', username: '', password: '', callsign: '',
|
||||||
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>({
|
||||||
@@ -539,6 +787,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
|
|
||||||
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
||||||
const [dbMsg, setDbMsg] = useState('');
|
const [dbMsg, setDbMsg] = useState('');
|
||||||
|
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string };
|
||||||
|
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
|
||||||
|
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
|
||||||
|
const [mysqlMsg, setMysqlMsg] = useState('');
|
||||||
|
const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply"
|
||||||
|
const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
|
||||||
const [dataDir, setDataDir] = useState('');
|
const [dataDir, setDataDir] = useState('');
|
||||||
|
|
||||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||||
@@ -628,6 +882,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||||||
|
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||||||
|
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
||||||
try { setDataDir(await GetDataDir()); } catch {}
|
try { setDataDir(await GetDataDir()); } catch {}
|
||||||
try {
|
try {
|
||||||
const locs: any = await ListTQSLStationLocations();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
@@ -648,6 +904,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Every setting is per-profile, so when the active profile changes WHILE this
|
||||||
|
// dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
|
||||||
|
// — otherwise they keep showing the previous profile's values until reopen.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('profile:changed', () => {
|
||||||
|
(async () => {
|
||||||
|
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||||||
|
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
||||||
|
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
||||||
|
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||||
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
|
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
||||||
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
|
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||||
|
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||||
|
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
|
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||||
|
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||||||
|
try {
|
||||||
|
const ls: any = await GetListsSettings();
|
||||||
|
setLists(ls);
|
||||||
|
setRstText({
|
||||||
|
phone: (ls.rst_phone ?? []).join(' '),
|
||||||
|
cw: (ls.rst_cw ?? []).join(' '),
|
||||||
|
digital: (ls.rst_digital ?? []).join(' '),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
return () => { off(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-fill the active profile's MY_* DXCC metadata from the station
|
// Auto-fill the active profile's MY_* DXCC metadata from the station
|
||||||
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
|
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
|
||||||
// are derived values, so they always recompute when the callsign or grid
|
// are derived values, so they always recompute when the callsign or grid
|
||||||
@@ -1462,10 +1752,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="CAT interface (OmniRig)"
|
title="CAT interface"
|
||||||
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — OpsLog just talks to it."
|
hint="Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug)."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-lg">
|
<div className="space-y-4 max-w-3xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable CAT
|
Enable CAT
|
||||||
@@ -1477,11 +1767,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
|
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
|
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
||||||
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
|
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{catCfg.backend === 'omnirig' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>OmniRig rig slot</Label>
|
<Label>OmniRig rig slot</Label>
|
||||||
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
|
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
|
||||||
@@ -1492,6 +1783,30 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{catCfg.backend === 'flex' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>FlexRadio IP</Label>
|
||||||
|
<Input placeholder="192.168.1.50" value={catCfg.flex_host ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Port</Label>
|
||||||
|
<Input type="number" value={catCfg.flex_port || 4992}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||||||
|
</div>
|
||||||
|
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||||
|
Show cluster spots on the panadapter <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{catCfg.backend === 'omnirig' && (
|
||||||
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Poll interval (ms)</Label>
|
<Label>Poll interval (ms)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1508,6 +1823,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
|
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Default digital mode (when rig reports DIG)</Label>
|
<Label>Default digital mode (when rig reports DIG)</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -1523,6 +1840,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{catCfg.backend === 'omnirig' && (
|
||||||
|
<>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={catModeBeforeFreq}
|
checked={catModeBeforeFreq}
|
||||||
@@ -1537,6 +1856,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
||||||
{' '}is the specific mode OpsLog will surface (and log).
|
{' '}is the specific mode OpsLog will surface (and log).
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{catCfg.backend === 'flex' && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in
|
||||||
|
real time from the radio (no polling, no second-click mode bug). Use <strong>Detect
|
||||||
|
radios</strong> or enter the IP. <strong>Default digital mode</strong> is what OpsLog
|
||||||
|
logs when the slice is in a digital mode (DIGU/DIGL).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1647,7 +1976,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator (PstRotator)"
|
title="Rotator"
|
||||||
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
|
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
@@ -1720,7 +2049,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="CW Keyer (WinKeyer)"
|
title="CW Keyer"
|
||||||
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
@@ -1836,7 +2165,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<div className="border-t border-border/60 pt-3">
|
<div className="border-t border-border/60 pt-3">
|
||||||
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
||||||
<p className="text-[11px] text-muted-foreground mb-2">
|
<p className="text-[11px] text-muted-foreground mb-2">
|
||||||
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call.
|
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. <span className="font-mono"><LOGQSO></span> = log the QSO when the macro is sent (e.g. <span className="font-mono">73 TU <LOGQSO></span>).
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||||||
{Array.from({ length: 12 }).map((_, i) => {
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
@@ -2177,7 +2506,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Database backup"
|
title="Database backup"
|
||||||
hint="OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
|
hint={mysqlCfg.enabled
|
||||||
|
? "On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each."
|
||||||
|
: "OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -2561,17 +2892,32 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
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>
|
||||||
@@ -2689,8 +3035,68 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Database"
|
title="Database"
|
||||||
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
|
|
||||||
/>
|
/>
|
||||||
|
{/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can
|
||||||
|
target its own database; choosing here and Save switches the live
|
||||||
|
logbook immediately (no restart). */}
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
||||||
|
<Label className="text-sm">Backend</Label>
|
||||||
|
<Select
|
||||||
|
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||||||
|
onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sqlite">SQLite — local file (solo)</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL — shared server (multi-operator)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">
|
||||||
|
This is the logbook for the <strong>active profile</strong>. Different profiles can point at different databases — switching profile switches the logbook.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Save (always visible) applies the active profile's DB target live. */}
|
||||||
|
<div className="max-w-2xl mb-4 flex items-center gap-3">
|
||||||
|
<Button size="sm" className="h-8"
|
||||||
|
onClick={() => {
|
||||||
|
SaveMySQLSettings(mysqlCfg as any)
|
||||||
|
.then(() => setRestartMsg(mysqlCfg.enabled
|
||||||
|
? 'Logbook switched to MySQL ✓'
|
||||||
|
: 'Logbook switched to local SQLite ✓'))
|
||||||
|
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||||
|
}}>
|
||||||
|
Save & switch logbook
|
||||||
|
</Button>
|
||||||
|
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
|
||||||
|
{backendStatus && (
|
||||||
|
<div className="max-w-2xl mb-4">
|
||||||
|
{backendStatus.fallback ? (
|
||||||
|
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 rounded-md px-3 py-2">
|
||||||
|
MySQL is enabled but the connection failed at startup — OpsLog is running on the local <strong>SQLite</strong> database.
|
||||||
|
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
|
||||||
|
</div>
|
||||||
|
) : backendStatus.active === 'mysql' ? (
|
||||||
|
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
|
||||||
|
<strong>Logbook:</strong> shared MySQL <span className="text-emerald-700">connected ✓</span>
|
||||||
|
<span className="mx-1.5 text-muted-foreground">·</span>
|
||||||
|
<strong>Config:</strong> local SQLite
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
Only QSOs go to MySQL; your settings, profiles, rigs and cluster stay local (and fast). Existing local QSOs aren't copied — import them into the shared log if you want your history there.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
|
||||||
|
Active backend: <strong className="uppercase">{backendStatus.active}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!mysqlCfg.enabled && (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Current database</Label>
|
<Label>Current database</Label>
|
||||||
@@ -2710,13 +3116,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
|
||||||
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
|
|
||||||
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
|
|
||||||
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
|
|
||||||
Any database change takes effect on the next launch.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dbMsg && (
|
{dbMsg && (
|
||||||
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
|
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
|
||||||
<span>{dbMsg}</span>
|
<span>{dbMsg}</span>
|
||||||
@@ -2724,14 +3123,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shared MySQL database (multi-operator) */}
|
||||||
|
{mysqlCfg.enabled && (
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
<div className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
Several OpsLog instances pointed at one MySQL database see each other's QSOs live (refreshed every 2 s). <strong>Test & create</strong> the database, then <strong>Save & switch logbook</strong> above to start logging there.
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
|
<Label className="text-sm">Host</Label>
|
||||||
|
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
||||||
|
<Label className="text-sm">Port</Label>
|
||||||
|
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
|
||||||
|
<Label className="text-sm">Database</Label>
|
||||||
|
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
|
||||||
|
<Label className="text-sm">User</Label>
|
||||||
|
<Input className="h-8" value={mysqlCfg.user} onChange={(e) => setMysqlField({ user: e.target.value })} />
|
||||||
|
<Label className="text-sm">Password</Label>
|
||||||
|
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" className="h-8"
|
||||||
|
onClick={() => { setMysqlMsg('Testing…'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected — database ready ✓')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}>
|
||||||
|
Test & create database
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Data location */}
|
{/* Data location */}
|
||||||
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
|
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">Data location</div>
|
<div className="text-sm font-medium">Data location</div>
|
||||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
|
||||||
OpsLog is fully portable — all data lives next to the executable so you can run it from a USB stick or reinstall Windows without losing anything.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Current data directory</Label>
|
<Label>Current data directory</Label>
|
||||||
@@ -2939,7 +3364,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
|
<SectionHeader title="General" hint="App behaviour (saved instantly)." />
|
||||||
<div className="space-y-3 max-w-lg">
|
<div className="space-y-3 max-w-3xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
||||||
Auto-focus "Worked before" for known stations
|
Auto-focus "Worked before" for known stations
|
||||||
@@ -2956,6 +3381,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||||
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
|
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||||
|
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
||||||
|
</label>
|
||||||
|
<TelemetryToggle />
|
||||||
|
<LiveStatusToggle />
|
||||||
|
|
||||||
|
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
||||||
@@ -3076,6 +3509,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||||
<Label className="text-sm">From address</Label>
|
<Label className="text-sm">From address</Label>
|
||||||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||||
|
<Label className="text-sm">Reply-To address</Label>
|
||||||
|
<div>
|
||||||
|
<Input className="h-8" placeholder="(optional — where replies go)" value={emailCfg.reply_to} onChange={(e) => setEmailField({ reply_to: e.target.value })} />
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1">Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="outline" size="sm" className="h-8"
|
<Button variant="outline" size="sm" className="h-8"
|
||||||
@@ -3086,7 +3524,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 mt-2 border-t border-border space-y-2">
|
<div className="pt-2 mt-2 border-t border-border space-y-2">
|
||||||
<Label className="text-sm font-semibold">eQSL card e-mail</Label>
|
<Label className="text-sm font-semibold">OpsLog QSL card e-mail</Label>
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
||||||
</div>
|
</div>
|
||||||
@@ -3096,7 +3534,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
onChange={(e) => setEqslField({ body: e.target.value })} />
|
onChange={(e) => setEqslField({ body: e.target.value })} />
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
|
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
|
||||||
Auto-send eQSL when a QSO is logged
|
Auto-send OpsLog QSL when a QSO is logged
|
||||||
</label>
|
</label>
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
Sends automatically only when the contact has an e-mail address and a default QSL template exists.
|
Sends automatically only when the contact has an e-mail address and a default QSL template exists.
|
||||||
@@ -3123,6 +3561,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
database: DatabasePanel,
|
database: DatabasePanel,
|
||||||
|
autostart: () => <AutostartPanelComponent />,
|
||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
rotator: RotatorPanel,
|
rotator: RotatorPanel,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ interface Props {
|
|||||||
onToggleSendOnType: (on: boolean) => void;
|
onToggleSendOnType: (on: boolean) => void;
|
||||||
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
|
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
|
||||||
onBackspace: () => void; // remove last not-yet-keyed char
|
onBackspace: () => void; // remove last not-yet-keyed char
|
||||||
|
autoCall: boolean; // repeat the clicked macro on a timer
|
||||||
|
autoCallSecs: number; // gap (s) after the message before repeating
|
||||||
|
onToggleAutoCall: (on: boolean) => void;
|
||||||
|
onSetAutoCallSecs: (n: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
|
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
|
||||||
@@ -48,6 +52,7 @@ export function WinkeyerPanel({
|
|||||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||||
onSend, onSendMacro, onStop, onClose,
|
onSend, onSendMacro, onStop, onClose,
|
||||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||||
|
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [cwText, setCwText] = useState('');
|
const [cwText, setCwText] = useState('');
|
||||||
const [speed, setSpeed] = useState(wpm);
|
const [speed, setSpeed] = useState(wpm);
|
||||||
@@ -172,6 +177,25 @@ export function WinkeyerPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
|
||||||
|
someone answers. The seconds box is the gap AFTER the message. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||||
|
title="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">
|
||||||
|
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||||
|
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||||
|
Auto-call
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] text-muted-foreground">gap</span>
|
||||||
|
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
|
||||||
|
<input type="number" min={0} max={120}
|
||||||
|
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
|
||||||
|
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||||
|
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||||
|
</div>
|
||||||
|
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{macros.map((m, i) => (
|
{macros.map((m, i) => (
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
|||||||
const busy = useRef(false);
|
const busy = useRef(false);
|
||||||
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
|
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
|
||||||
const svgEl = useRef<SVGSVGElement | null>(null);
|
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||||
|
const sentForRef = useRef<number | null>(null); // qsoId we've already fired SendEQSL for
|
||||||
|
|
||||||
|
// Keep the callbacks in refs so they never change the effects' identity — a
|
||||||
|
// toast/grid re-render from onSent must NOT re-run the send effect (that
|
||||||
|
// re-sent the same eQSL many times in a row).
|
||||||
|
const onSentRef = useRef(onSent);
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; });
|
||||||
|
|
||||||
// Pull the next job, fetch its render model + assets, then mount it (the
|
// Pull the next job, fetch its render model + assets, then mount it (the
|
||||||
// effect below rasterizes once the DOM has it).
|
// effect below rasterizes once the DOM has it).
|
||||||
@@ -41,14 +49,16 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
|||||||
const assets = await loadCardAssets(model.template, job.templateId);
|
const assets = await loadCardAssets(model.template, job.templateId);
|
||||||
setCurrent({ job, model, assets });
|
setCurrent({ job, model, assets });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError?.(`Auto eQSL: ${e}`);
|
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||||
busy.current = false;
|
busy.current = false;
|
||||||
void pump();
|
void pump();
|
||||||
}
|
}
|
||||||
}, [onError]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
|
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
|
||||||
|
// Dedupe: ignore a repeat event for a QSO we're already handling/handled.
|
||||||
|
if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return;
|
||||||
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
|
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
|
||||||
void pump();
|
void pump();
|
||||||
});
|
});
|
||||||
@@ -56,20 +66,23 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
|||||||
}, [pump]);
|
}, [pump]);
|
||||||
|
|
||||||
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
|
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
|
||||||
|
// Sends exactly once per job (guarded by sentForRef), independent of renders.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
|
if (sentForRef.current === current.job.qsoId) return; // already sent this one
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
||||||
if (cancelled || !svgEl.current) return;
|
if (cancelled || !svgEl.current) return;
|
||||||
|
sentForRef.current = current.job.qsoId;
|
||||||
const card = current.model.template.card;
|
const card = current.model.template.card;
|
||||||
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
||||||
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
|
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
|
||||||
onSent?.(current.job.callsign);
|
onSentRef.current?.(current.job.callsign);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError?.(`Auto eQSL: ${e}`);
|
onErrorRef.current?.(`Auto eQSL: ${e}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCurrent(null);
|
setCurrent(null);
|
||||||
@@ -79,7 +92,7 @@ export function AutoEQSL({ onSent, onError }: Props) {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [current, pump, onSent, onError]);
|
}, [current, pump]);
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
// Off-screen at full card resolution so the rasterized output matches the
|
// Off-screen at full card resolution so the rasterized output matches the
|
||||||
|
|||||||
@@ -72,10 +72,11 @@ function buildFxParams(e: CardElement): TextFxParams | null {
|
|||||||
size: e.size,
|
size: e.size,
|
||||||
space: e.size * (kind === 'western' ? 0.08 : 0.04),
|
space: e.size * (kind === 'western' ? 0.08 : 0.04),
|
||||||
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
|
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
|
||||||
// Dark inter-letter edge — fixed near-black (the navy outline adaptStyle
|
// Dark inter-letter edge + rim — from the chosen colour palette, else the
|
||||||
// sets is for the old SVG stack, not this look).
|
// default near-black edge (the navy outline adaptStyle sets is for the old
|
||||||
cDark: kind === 'western' ? '#1c130a' : '#262630',
|
// SVG stack, not this look).
|
||||||
cOuter: silver ? '#e8edf2' : '#ced3db',
|
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
|
||||||
|
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
|
||||||
// Per-call overrides from the editor (undefined → renderer default).
|
// Per-call overrides from the editor (undefined → renderer default).
|
||||||
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
|
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
|
||||||
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
|
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
async function choosePhotos() {
|
async function choosePhotos() {
|
||||||
try {
|
try {
|
||||||
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
||||||
if (paths.length) setPhotoPaths(paths.slice(0, 3));
|
if (paths.length) setPhotoPaths(paths.slice(0, 5));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">New design</h3>
|
<h3 className="text-sm font-semibold">New design</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Pick 1–3 photos — OpsLog analyzes them and proposes three card designs
|
Pick 1–5 photos — OpsLog analyzes them and proposes three card designs
|
||||||
with your callsign, name, zones and country placed automatically.
|
with your callsign, name, zones and country placed automatically.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
|||||||
<DialogContent className="max-w-[820px]">
|
<DialogContent className="max-w-[820px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Mail className="size-5 text-rose-600" /> Send eQSL by e-mail
|
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||||
<CheckCircle2 className="size-4" /> eQSL sent.
|
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ interface Props {
|
|||||||
onChange: (preset: string, params: StyleParams) => void;
|
onChange: (preset: string, params: StyleParams) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick colour palettes per FX family (mirrors the reference generators):
|
||||||
|
// each sets the 3-stop gradient plus the dark edge and (glossy) silver rim.
|
||||||
|
type Palette = { name: string; top: string; mid: string; bot: string; dark: string; outer?: string };
|
||||||
|
const GLOSSY_PALETTES: Palette[] = [
|
||||||
|
{ name: 'Gold', top: '#ffe22d', mid: '#ffd600', bot: '#ffcc00', dark: '#262630', outer: '#ced3db' },
|
||||||
|
{ name: 'Silver', top: '#fbfdff', mid: '#c9d4de', bot: '#8496a8', dark: '#262630', outer: '#e8edf2' },
|
||||||
|
{ name: 'Red', top: '#ff7a66', mid: '#ee3322', bot: '#d42410', dark: '#260b08', outer: '#ead9d6' },
|
||||||
|
{ name: 'Blue', top: '#5fb8ff', mid: '#1f8fe8', bot: '#107ad0', dark: '#0d1726', outer: '#d6e2ee' },
|
||||||
|
{ name: 'Green', top: '#a5e84f', mid: '#6ec424', bot: '#5ab012', dark: '#122108', outer: '#dbe8d2' },
|
||||||
|
{ name: 'Pink', top: '#ff9ed0', mid: '#f5559f', bot: '#e83b8c', dark: '#260818', outer: '#ecd8e3' },
|
||||||
|
];
|
||||||
|
const WESTERN_PALETTES: Palette[] = [
|
||||||
|
{ name: 'Gold', top: '#f7c036', mid: '#f39612', bot: '#d06200', dark: '#20140a' },
|
||||||
|
{ name: 'Red', top: '#f0856e', mid: '#d8402a', bot: '#8c1606', dark: '#1f0805' },
|
||||||
|
{ name: 'Blue', top: '#7fc0ee', mid: '#2a7fc0', bot: '#0c4378', dark: '#081320' },
|
||||||
|
{ name: 'Green', top: '#bfd96a', mid: '#7fa82e', bot: '#3f6210', dark: '#101a06' },
|
||||||
|
{ name: 'Cream', top: '#f7ecd0', mid: '#e8d3a0', bot: '#bf9b58', dark: '#2a1f10' },
|
||||||
|
];
|
||||||
|
|
||||||
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -76,6 +95,25 @@ export function StylePresetPicker({ presets, preset, params, onChange }: Props)
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{isFx && (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Palette</Label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(fxWestern ? WESTERN_PALETTES : GLOSSY_PALETTES).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.name} type="button" title={p.name}
|
||||||
|
className="h-6 w-6 rounded border border-border"
|
||||||
|
style={{ background: `linear-gradient(${p.top}, ${p.mid}, ${p.bot})` }}
|
||||||
|
onClick={() => onChange(preset, {
|
||||||
|
...params,
|
||||||
|
gradient: [p.top, p.mid, p.bot],
|
||||||
|
fx: { ...fx, dark: p.dark, ...(p.outer ? { outer: p.outer } : {}) },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{has('color') && (
|
{has('color') && (
|
||||||
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
|
<ColorRow label="Color" value={params.color ?? '#ffffff'} onChange={(v) => set({ color: v })} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export interface FxParams {
|
|||||||
grunge?: number;
|
grunge?: number;
|
||||||
bevel?: number;
|
bevel?: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
dark?: string;
|
||||||
|
outer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StyleParams {
|
export interface StyleParams {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
|
|||||||
// only an exact (case-insensitive) match — otherwise it reverts, so the field
|
// only an exact (case-insensitive) match — otherwise it reverts, so the field
|
||||||
// can't hold a typo'd value that isn't in the list.
|
// can't hold a typo'd value that isn't in the list.
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
value, onChange, options, placeholder, className, allowFreeText = false,
|
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
@@ -14,6 +14,10 @@ export function Combobox({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
allowFreeText?: boolean;
|
allowFreeText?: boolean;
|
||||||
|
// Commit each keystroke to the parent immediately (not just on blur). Use for
|
||||||
|
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
||||||
|
// leaving the field uses the value just typed.
|
||||||
|
commitOnType?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -41,9 +45,13 @@ export function Combobox({
|
|||||||
// Defer so a click on an option registers first.
|
// Defer so a click on an option registers first.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase());
|
const trimmed = query.trim();
|
||||||
if (exact) { onChange(exact); setQuery(exact); }
|
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
|
||||||
else if (allowFreeText) { onChange(query.trim()); }
|
// Only fire onChange when the value actually changed — committing an
|
||||||
|
// unchanged value on a plain tab-through would wrongly flag the field as
|
||||||
|
// "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default).
|
||||||
|
if (exact) { if (exact !== value) onChange(exact); setQuery(exact); }
|
||||||
|
else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); }
|
||||||
else { setQuery(value); } // revert typo
|
else { setQuery(value); } // revert typo
|
||||||
}, 120);
|
}, 120);
|
||||||
}
|
}
|
||||||
@@ -56,7 +64,7 @@ export function Combobox({
|
|||||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||||
// open the list (so tabbing in doesn't pop the dropdown).
|
// open the list (so tabbing in doesn't pop the dropdown).
|
||||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -33,12 +33,28 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyAwardRefs writes picked references onto a QSO payload using each award's
|
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||||
// scanned field. fieldOf maps an award CODE (uppercase) to its field name.
|
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||||
|
// The award engine honours these regardless of how the award matches, so a
|
||||||
|
// reference can be assigned even for awards that scan a free-text field by
|
||||||
|
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
|
||||||
|
// the field wouldn't match.
|
||||||
|
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
||||||
|
|
||||||
|
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
|
||||||
|
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
|
||||||
|
// awards backed by a standard ADIF column also get that column written so the
|
||||||
|
// data lives in its conventional place and exports correctly. Awards that match
|
||||||
|
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||||
|
// on the override — we don't pollute the text field with a code.
|
||||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
||||||
const byCode = parseAwardRefs(awardRefs);
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||||
|
const overrides: string[] = [];
|
||||||
for (const [code, ref] of Object.entries(byCode)) {
|
for (const [code, ref] of Object.entries(byCode)) {
|
||||||
|
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
overrides.push(`${code}@${r}`);
|
||||||
|
}
|
||||||
const field = fieldOf[code] || code.toLowerCase();
|
const field = fieldOf[code] || code.toLowerCase();
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'iota': payload.iota = ref; break;
|
case 'iota': payload.iota = ref; break;
|
||||||
@@ -53,21 +69,24 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
|
|||||||
extras['SIG'] = 'WWFF';
|
extras['SIG'] = 'WWFF';
|
||||||
extras['SIG_INFO'] = ref;
|
extras['SIG_INFO'] = ref;
|
||||||
break;
|
break;
|
||||||
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM
|
// QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
|
||||||
// scans the note for "D06"). Picking such a reference appends its code(s)
|
// "D06" in the note): append the code so the in-field matcher finds it too.
|
||||||
// to that field so the matcher finds it.
|
|
||||||
case 'note': case 'notes':
|
case 'note': case 'notes':
|
||||||
payload.notes = appendTokens(payload.notes, ref);
|
payload.notes = appendTokens(payload.notes, ref);
|
||||||
break;
|
break;
|
||||||
case 'comment':
|
case 'comment':
|
||||||
payload.comment = appendTokens(payload.comment, ref);
|
payload.comment = appendTokens(payload.comment, ref);
|
||||||
break;
|
break;
|
||||||
|
// address / qth / name / custom: the override above is authoritative; do
|
||||||
|
// not write a code into a free-text field the award matches by name.
|
||||||
default:
|
default:
|
||||||
extras[field.toUpperCase()] = ref;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
|
||||||
|
else delete extras[MANUAL_REFS_KEY];
|
||||||
if (Object.keys(extras).length > 0) payload.extras = extras;
|
if (Object.keys(extras).length > 0) payload.extras = extras;
|
||||||
|
else if (payload.extras) delete payload.extras;
|
||||||
}
|
}
|
||||||
|
|
||||||
// awardRefValue reads a single award's stored reference from a QSO, inverse of
|
// awardRefValue reads a single award's stored reference from a QSO, inverse of
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
|
export const APP_VERSION = '0.11.3';
|
||||||
|
|
||||||
|
// Author / credits, shown in Help -> About.
|
||||||
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
Vendored
+99
-1
@@ -3,10 +3,10 @@
|
|||||||
import {adif} from '../models';
|
import {adif} from '../models';
|
||||||
import {qso} from '../models';
|
import {qso} from '../models';
|
||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {cat} from '../models';
|
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
@@ -25,14 +25,20 @@ export function AddQSO(arg1:qso.QSO):Promise<number>;
|
|||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
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 BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
|
|
||||||
export function ClearLookupCache():Promise<void>;
|
export function ClearLookupCache():Promise<void>;
|
||||||
|
|
||||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||||
@@ -81,12 +87,18 @@ export function DeleteProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DeleteQSO(arg1:number):Promise<void>;
|
export function DeleteQSO(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DisconnectAllClusters():Promise<void>;
|
export function DisconnectAllClusters():Promise<void>;
|
||||||
|
|
||||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||||
|
|
||||||
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
@@ -105,10 +117,62 @@ export function FilterFields():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||||
|
|
||||||
|
export function FlexATUBypass():Promise<void>;
|
||||||
|
|
||||||
|
export function FlexATUStart():Promise<void>;
|
||||||
|
|
||||||
|
export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetPower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||||
|
|
||||||
|
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||||
|
|
||||||
export function GetAward(arg1:string):Promise<award.Result>;
|
export function GetAward(arg1:string):Promise<award.Result>;
|
||||||
|
|
||||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||||
@@ -135,6 +199,10 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
|||||||
|
|
||||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||||
|
|
||||||
|
export function GetDBConnectionInfo():Promise<main.DBConnectionInfo>;
|
||||||
|
|
||||||
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
||||||
|
|
||||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||||
@@ -147,12 +215,20 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
|
|||||||
|
|
||||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||||
|
|
||||||
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
@@ -169,6 +245,8 @@ export function GetStartupStatus():Promise<main.StartupStatus>;
|
|||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||||
@@ -187,6 +265,10 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
|
|||||||
|
|
||||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
|
|
||||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||||
|
|
||||||
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||||
@@ -287,6 +369,10 @@ export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
|||||||
|
|
||||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
|
|
||||||
|
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RescanAwards():Promise<void>;
|
||||||
|
|
||||||
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
@@ -305,6 +391,8 @@ export function SaveADIFFile():Promise<string>;
|
|||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
|
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
|
||||||
@@ -323,6 +411,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
||||||
|
|
||||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||||
@@ -365,8 +455,12 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
@@ -383,6 +477,8 @@ export function TestLoTWUpload():Promise<string>;
|
|||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function TestMySQLConnection(arg1:main.MySQLSettings):Promise<void>;
|
||||||
|
|
||||||
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
@@ -405,6 +501,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>;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export function ApplyAwardPreset(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -34,10 +38,18 @@ export function AwardMissingQSOs(arg1) {
|
|||||||
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BrowseExecutable() {
|
||||||
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
|
}
|
||||||
|
|
||||||
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 CheckForUpdate() {
|
||||||
|
return window['go']['main']['App']['CheckForUpdate']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ClearLookupCache() {
|
export function ClearLookupCache() {
|
||||||
return window['go']['main']['App']['ClearLookupCache']();
|
return window['go']['main']['App']['ClearLookupCache']();
|
||||||
}
|
}
|
||||||
@@ -134,6 +146,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);
|
||||||
}
|
}
|
||||||
@@ -146,6 +162,14 @@ export function DisconnectClusterServer(arg1) {
|
|||||||
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DiscoverFlexRadios() {
|
||||||
|
return window['go']['main']['App']['DiscoverFlexRadios']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadAllReferenceLists() {
|
||||||
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadClublogCty() {
|
export function DownloadClublogCty() {
|
||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
@@ -182,6 +206,106 @@ export function FindQSOsForUpload(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexATUBypass() {
|
||||||
|
return window['go']['main']['App']['FlexATUBypass']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexATUStart() {
|
||||||
|
return window['go']['main']['App']['FlexATUStart']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexAmpOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexAmpOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexMox(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexMox'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAGCMode(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAGCThreshold(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAGCThreshold'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetANF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetANF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetANFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetATUMemories(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAudioLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetMic(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetMon(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMon'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetMonLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNB(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNBLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNBLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNR(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetProcessor(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetProcessor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetProcessorLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetTunePower(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetVox(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetVox'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetVoxDelay(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetVoxDelay'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetVoxLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexTune(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetActiveProfile() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -190,6 +314,10 @@ export function GetAudioSettings() {
|
|||||||
return window['go']['main']['App']['GetAudioSettings']();
|
return window['go']['main']['App']['GetAudioSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAutostartPrograms() {
|
||||||
|
return window['go']['main']['App']['GetAutostartPrograms']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAward(arg1) {
|
export function GetAward(arg1) {
|
||||||
return window['go']['main']['App']['GetAward'](arg1);
|
return window['go']['main']['App']['GetAward'](arg1);
|
||||||
}
|
}
|
||||||
@@ -242,6 +370,14 @@ export function GetCtyDatInfo() {
|
|||||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDBBackendStatus() {
|
||||||
|
return window['go']['main']['App']['GetDBBackendStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetDBConnectionInfo() {
|
||||||
|
return window['go']['main']['App']['GetDBConnectionInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetDVKMessages() {
|
export function GetDVKMessages() {
|
||||||
return window['go']['main']['App']['GetDVKMessages']();
|
return window['go']['main']['App']['GetDVKMessages']();
|
||||||
}
|
}
|
||||||
@@ -266,18 +402,34 @@ export function GetExternalServices() {
|
|||||||
return window['go']['main']['App']['GetExternalServices']();
|
return window['go']['main']['App']['GetExternalServices']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFlexState() {
|
||||||
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetListsSettings() {
|
export function GetListsSettings() {
|
||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLiveStatusEnabled() {
|
||||||
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogFilePath() {
|
export function GetLogFilePath() {
|
||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLogbookRevision() {
|
||||||
|
return window['go']['main']['App']['GetLogbookRevision']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLookupSettings() {
|
export function GetLookupSettings() {
|
||||||
return window['go']['main']['App']['GetLookupSettings']();
|
return window['go']['main']['App']['GetLookupSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetMySQLSettings() {
|
||||||
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPOTAToken() {
|
export function GetPOTAToken() {
|
||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
@@ -310,6 +462,10 @@ export function GetStationSettings() {
|
|||||||
return window['go']['main']['App']['GetStationSettings']();
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetTelemetryEnabled() {
|
||||||
|
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetUIPref(arg1) {
|
export function GetUIPref(arg1) {
|
||||||
return window['go']['main']['App']['GetUIPref'](arg1);
|
return window['go']['main']['App']['GetUIPref'](arg1);
|
||||||
}
|
}
|
||||||
@@ -346,6 +502,14 @@ export function ImportAwards() {
|
|||||||
return window['go']['main']['App']['ImportAwards']();
|
return window['go']['main']['App']['ImportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LaunchAutostartProgram(arg1) {
|
||||||
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LaunchAutostartPrograms() {
|
||||||
|
return window['go']['main']['App']['LaunchAutostartPrograms']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListAudioInputDevices() {
|
export function ListAudioInputDevices() {
|
||||||
return window['go']['main']['App']['ListAudioInputDevices']();
|
return window['go']['main']['App']['ListAudioInputDevices']();
|
||||||
}
|
}
|
||||||
@@ -546,6 +710,14 @@ export function ReplaceAwardReferences(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReportLiveActivity(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['ReportLiveActivity'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RescanAwards() {
|
||||||
|
return window['go']['main']['App']['RescanAwards']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ResetAwardDefs() {
|
export function ResetAwardDefs() {
|
||||||
return window['go']['main']['App']['ResetAwardDefs']();
|
return window['go']['main']['App']['ResetAwardDefs']();
|
||||||
}
|
}
|
||||||
@@ -582,6 +754,10 @@ export function SaveAudioSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAutostartPrograms(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAwardDefs(arg1) {
|
export function SaveAwardDefs(arg1) {
|
||||||
return window['go']['main']['App']['SaveAwardDefs'](arg1);
|
return window['go']['main']['App']['SaveAwardDefs'](arg1);
|
||||||
}
|
}
|
||||||
@@ -618,6 +794,10 @@ export function SaveLookupSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveMySQLSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveOperatingAntenna(arg1) {
|
export function SaveOperatingAntenna(arg1) {
|
||||||
return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
|
return window['go']['main']['App']['SaveOperatingAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -702,10 +882,18 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetLiveStatusEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetTelemetryEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetUIPref(arg1, arg2) {
|
export function SetUIPref(arg1, arg2) {
|
||||||
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -738,6 +926,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestMySQLConnection(arg1) {
|
||||||
|
return window['go']['main']['App']['TestMySQLConnection'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestPTT(arg1) {
|
export function TestPTT(arg1) {
|
||||||
return window['go']['main']['App']['TestPTT'](arg1);
|
return window['go']['main']['App']['TestPTT'](arg1);
|
||||||
}
|
}
|
||||||
@@ -782,6 +974,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,30 @@ export namespace award {
|
|||||||
this.confirmed = source["confirmed"];
|
this.confirmed = source["confirmed"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class OrRule {
|
||||||
|
field: string;
|
||||||
|
match_by?: string;
|
||||||
|
exact_match?: boolean;
|
||||||
|
pattern?: string;
|
||||||
|
leading_str?: string;
|
||||||
|
trailing_str?: string;
|
||||||
|
prefix?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OrRule(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.field = source["field"];
|
||||||
|
this.match_by = source["match_by"];
|
||||||
|
this.exact_match = source["exact_match"];
|
||||||
|
this.pattern = source["pattern"];
|
||||||
|
this.leading_str = source["leading_str"];
|
||||||
|
this.trailing_str = source["trailing_str"];
|
||||||
|
this.prefix = source["prefix"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Def {
|
export class Def {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -126,6 +150,7 @@ export namespace award {
|
|||||||
multi?: boolean;
|
multi?: boolean;
|
||||||
dynamic?: boolean;
|
dynamic?: boolean;
|
||||||
add_prefixes?: string[];
|
add_prefixes?: string[];
|
||||||
|
or_rules?: OrRule[];
|
||||||
dxcc_filter: number[];
|
dxcc_filter: number[];
|
||||||
valid_bands?: string[];
|
valid_bands?: string[];
|
||||||
valid_modes?: string[];
|
valid_modes?: string[];
|
||||||
@@ -164,6 +189,7 @@ export namespace award {
|
|||||||
this.multi = source["multi"];
|
this.multi = source["multi"];
|
||||||
this.dynamic = source["dynamic"];
|
this.dynamic = source["dynamic"];
|
||||||
this.add_prefixes = source["add_prefixes"];
|
this.add_prefixes = source["add_prefixes"];
|
||||||
|
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
||||||
this.dxcc_filter = source["dxcc_filter"];
|
this.dxcc_filter = source["dxcc_filter"];
|
||||||
this.valid_bands = source["valid_bands"];
|
this.valid_bands = source["valid_bands"];
|
||||||
this.valid_modes = source["valid_modes"];
|
this.valid_modes = source["valid_modes"];
|
||||||
@@ -175,7 +201,26 @@ export namespace award {
|
|||||||
this.total = source["total"];
|
this.total = source["total"];
|
||||||
this.builtin = source["builtin"];
|
this.builtin = source["builtin"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
}
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Ref {
|
export class Ref {
|
||||||
ref: string;
|
ref: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -340,6 +385,142 @@ export namespace awardref {
|
|||||||
|
|
||||||
export namespace cat {
|
export namespace cat {
|
||||||
|
|
||||||
|
export class FlexMeter {
|
||||||
|
id: number;
|
||||||
|
src?: string;
|
||||||
|
name?: string;
|
||||||
|
unit?: string;
|
||||||
|
value: number;
|
||||||
|
lo: number;
|
||||||
|
hi: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlexMeter(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.src = source["src"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.unit = source["unit"];
|
||||||
|
this.value = source["value"];
|
||||||
|
this.lo = source["lo"];
|
||||||
|
this.hi = source["hi"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class FlexRadio {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
model: string;
|
||||||
|
nickname: string;
|
||||||
|
serial: string;
|
||||||
|
callsign: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlexRadio(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.ip = source["ip"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.nickname = source["nickname"];
|
||||||
|
this.serial = source["serial"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class FlexTXState {
|
||||||
|
available: boolean;
|
||||||
|
model?: string;
|
||||||
|
rf_power: number;
|
||||||
|
tune_power: number;
|
||||||
|
tune: boolean;
|
||||||
|
transmitting: boolean;
|
||||||
|
vox_enable: boolean;
|
||||||
|
vox_level: number;
|
||||||
|
vox_delay: number;
|
||||||
|
proc_enable: boolean;
|
||||||
|
proc_level: number;
|
||||||
|
mon: boolean;
|
||||||
|
mon_level: number;
|
||||||
|
mic_level: number;
|
||||||
|
atu_status?: string;
|
||||||
|
atu_memories: boolean;
|
||||||
|
rx_avail: boolean;
|
||||||
|
agc_mode?: string;
|
||||||
|
agc_threshold: number;
|
||||||
|
audio_level: number;
|
||||||
|
nb: boolean;
|
||||||
|
nb_level: number;
|
||||||
|
nr: boolean;
|
||||||
|
nr_level: number;
|
||||||
|
anf: boolean;
|
||||||
|
anf_level: number;
|
||||||
|
amp_available: boolean;
|
||||||
|
amp_model?: string;
|
||||||
|
amp_operate: boolean;
|
||||||
|
amp_fault?: string;
|
||||||
|
meters?: FlexMeter[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlexTXState(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.available = source["available"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.rf_power = source["rf_power"];
|
||||||
|
this.tune_power = source["tune_power"];
|
||||||
|
this.tune = source["tune"];
|
||||||
|
this.transmitting = source["transmitting"];
|
||||||
|
this.vox_enable = source["vox_enable"];
|
||||||
|
this.vox_level = source["vox_level"];
|
||||||
|
this.vox_delay = source["vox_delay"];
|
||||||
|
this.proc_enable = source["proc_enable"];
|
||||||
|
this.proc_level = source["proc_level"];
|
||||||
|
this.mon = source["mon"];
|
||||||
|
this.mon_level = source["mon_level"];
|
||||||
|
this.mic_level = source["mic_level"];
|
||||||
|
this.atu_status = source["atu_status"];
|
||||||
|
this.atu_memories = source["atu_memories"];
|
||||||
|
this.rx_avail = source["rx_avail"];
|
||||||
|
this.agc_mode = source["agc_mode"];
|
||||||
|
this.agc_threshold = source["agc_threshold"];
|
||||||
|
this.audio_level = source["audio_level"];
|
||||||
|
this.nb = source["nb"];
|
||||||
|
this.nb_level = source["nb_level"];
|
||||||
|
this.nr = source["nr"];
|
||||||
|
this.nr_level = source["nr_level"];
|
||||||
|
this.anf = source["anf"];
|
||||||
|
this.anf_level = source["anf_level"];
|
||||||
|
this.amp_available = source["amp_available"];
|
||||||
|
this.amp_model = source["amp_model"];
|
||||||
|
this.amp_operate = source["amp_operate"];
|
||||||
|
this.amp_fault = source["amp_fault"];
|
||||||
|
this.meters = this.convertValues(source["meters"], FlexMeter);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RigState {
|
export class RigState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -492,7 +673,7 @@ export namespace extsvc {
|
|||||||
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;
|
||||||
@@ -512,7 +693,7 @@ export namespace extsvc {
|
|||||||
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"];
|
||||||
@@ -682,6 +863,44 @@ export namespace main {
|
|||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AutostartLaunchResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutostartLaunchResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.status = source["status"];
|
||||||
|
this.message = source["message"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AutostartProgram {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
args: string;
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutostartProgram(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.path = source["path"];
|
||||||
|
this.args = source["args"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AwardImportResult {
|
export class AwardImportResult {
|
||||||
awards: number;
|
awards: number;
|
||||||
references: number;
|
references: number;
|
||||||
@@ -792,6 +1011,9 @@ export namespace main {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
backend: string;
|
backend: string;
|
||||||
omnirig_rig: number;
|
omnirig_rig: number;
|
||||||
|
flex_host: string;
|
||||||
|
flex_port: number;
|
||||||
|
flex_spots: boolean;
|
||||||
poll_ms: number;
|
poll_ms: number;
|
||||||
delay_ms: number;
|
delay_ms: number;
|
||||||
digital_default: string;
|
digital_default: string;
|
||||||
@@ -805,6 +1027,9 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
this.backend = source["backend"];
|
this.backend = source["backend"];
|
||||||
this.omnirig_rig = source["omnirig_rig"];
|
this.omnirig_rig = source["omnirig_rig"];
|
||||||
|
this.flex_host = source["flex_host"];
|
||||||
|
this.flex_port = source["flex_port"];
|
||||||
|
this.flex_spots = source["flex_spots"];
|
||||||
this.poll_ms = source["poll_ms"];
|
this.poll_ms = source["poll_ms"];
|
||||||
this.delay_ms = source["delay_ms"];
|
this.delay_ms = source["delay_ms"];
|
||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
@@ -846,6 +1071,36 @@ export namespace main {
|
|||||||
this.file_mod_time = source["file_mod_time"];
|
this.file_mod_time = source["file_mod_time"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class DBBackendStatus {
|
||||||
|
active: string;
|
||||||
|
fallback: boolean;
|
||||||
|
error: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DBBackendStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.active = source["active"];
|
||||||
|
this.fallback = source["fallback"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class DBConnectionInfo {
|
||||||
|
backend: string;
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DBConnectionInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.label = source["label"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class DVKMessage {
|
export class DVKMessage {
|
||||||
slot: number;
|
slot: number;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -903,6 +1158,7 @@ export namespace main {
|
|||||||
smtp_user: string;
|
smtp_user: string;
|
||||||
smtp_password: string;
|
smtp_password: string;
|
||||||
from: string;
|
from: string;
|
||||||
|
reply_to: string;
|
||||||
encryption: string;
|
encryption: string;
|
||||||
auth: boolean;
|
auth: boolean;
|
||||||
auto_send: boolean;
|
auto_send: boolean;
|
||||||
@@ -921,6 +1177,7 @@ export namespace main {
|
|||||||
this.smtp_user = source["smtp_user"];
|
this.smtp_user = source["smtp_user"];
|
||||||
this.smtp_password = source["smtp_password"];
|
this.smtp_password = source["smtp_password"];
|
||||||
this.from = source["from"];
|
this.from = source["from"];
|
||||||
|
this.reply_to = source["reply_to"];
|
||||||
this.encryption = source["encryption"];
|
this.encryption = source["encryption"];
|
||||||
this.auth = source["auth"];
|
this.auth = source["auth"];
|
||||||
this.auto_send = source["auto_send"];
|
this.auto_send = source["auto_send"];
|
||||||
@@ -1009,6 +1266,28 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MySQLSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MySQLSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.user = source["user"];
|
||||||
|
this.password = source["password"];
|
||||||
|
this.database = source["database"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class POTAUnmatched {
|
export class POTAUnmatched {
|
||||||
activator: string;
|
activator: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -1328,7 +1607,6 @@ export namespace main {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
err: string;
|
err: string;
|
||||||
db_path: string;
|
db_path: string;
|
||||||
migrated_from_app_data: boolean;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new StartupStatus(source);
|
return new StartupStatus(source);
|
||||||
@@ -1339,7 +1617,6 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.err = source["err"];
|
this.err = source["err"];
|
||||||
this.db_path = source["db_path"];
|
this.db_path = source["db_path"];
|
||||||
this.migrated_from_app_data = source["migrated_from_app_data"];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StationInfoComputed {
|
export class StationInfoComputed {
|
||||||
@@ -1428,6 +1705,24 @@ export namespace main {
|
|||||||
this.moving = source["moving"];
|
this.moving = source["moving"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class UpdateInfo {
|
||||||
|
current: string;
|
||||||
|
latest: string;
|
||||||
|
available: boolean;
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new UpdateInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.current = source["current"];
|
||||||
|
this.latest = source["latest"];
|
||||||
|
this.available = source["available"];
|
||||||
|
this.url = source["url"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class WKMacro {
|
export class WKMacro {
|
||||||
label: string;
|
label: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -1631,6 +1926,28 @@ export namespace operating {
|
|||||||
|
|
||||||
export namespace profile {
|
export namespace profile {
|
||||||
|
|
||||||
|
export class ProfileDB {
|
||||||
|
backend: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ProfileDB(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.user = source["user"];
|
||||||
|
this.password = source["password"];
|
||||||
|
this.database = source["database"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Profile {
|
export class Profile {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1657,6 +1974,7 @@ export namespace profile {
|
|||||||
tx_pwr?: number;
|
tx_pwr?: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
db: ProfileDB;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -1693,6 +2011,7 @@ export namespace profile {
|
|||||||
this.tx_pwr = source["tx_pwr"];
|
this.tx_pwr = source["tx_pwr"];
|
||||||
this.is_active = source["is_active"];
|
this.is_active = source["is_active"];
|
||||||
this.sort_order = source["sort_order"];
|
this.sort_order = source["sort_order"];
|
||||||
|
this.db = this.convertValues(source["db"], ProfileDB);
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
}
|
}
|
||||||
@@ -1752,6 +2071,8 @@ export namespace qslcard {
|
|||||||
grunge?: number;
|
grunge?: number;
|
||||||
bevel?: number;
|
bevel?: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
dark?: string;
|
||||||
|
outer?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new FxParams(source);
|
return new FxParams(source);
|
||||||
@@ -1772,6 +2093,8 @@ export namespace qslcard {
|
|||||||
this.grunge = source["grunge"];
|
this.grunge = source["grunge"];
|
||||||
this.bevel = source["bevel"];
|
this.bevel = source["bevel"];
|
||||||
this.seed = source["seed"];
|
this.seed = source["seed"];
|
||||||
|
this.dark = source["dark"];
|
||||||
|
this.outer = source["outer"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Halo {
|
export class Halo {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/braheezy/shine-mp3 v0.1.0
|
github.com/braheezy/shine-mp3 v0.1.0
|
||||||
github.com/go-ole/go-ole v1.3.0
|
github.com/go-ole/go-ole v1.3.0
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
github.com/moutend/go-wca v0.3.0
|
github.com/moutend/go-wca v0.3.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
github.com/wneessen/go-mail v0.7.3
|
github.com/wneessen/go-mail v0.7.3
|
||||||
@@ -16,6 +17,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
||||||
@@ -9,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
|
|||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
|||||||
@@ -120,6 +120,25 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||||
|
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
||||||
|
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||||
|
func BatchRecordsADIF(records []string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
now := time.Now().UTC().Format("20060102 150405")
|
||||||
|
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
|
||||||
|
len(adifVersion), adifVersion, now)
|
||||||
|
for _, r := range records {
|
||||||
|
r = strings.TrimRight(r, "\r\n")
|
||||||
|
if r == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(r)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
|
|||||||
@@ -5,11 +5,26 @@ package audio
|
|||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
|
||||||
|
// Defaults to a no-op so the package is usable without wiring.
|
||||||
|
var LogSink = func(string, ...any) {}
|
||||||
|
|
||||||
|
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
|
||||||
|
// event with a stack trace instead of a silent process-killing crash. (It can't
|
||||||
|
// catch a hard Windows access violation from the WASAPI layer — those are fatal
|
||||||
|
// — but it catches any Go-level panic in capture/mix.)
|
||||||
|
func recoverGoroutine(what string) {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
|
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
|
||||||
// recording can begin a few seconds BEFORE the operator entered the callsign.
|
// recording can begin a few seconds BEFORE the operator entered the callsign.
|
||||||
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
|
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
|
||||||
@@ -108,6 +123,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
|
defer recoverGoroutine("recorder capture (radio)")
|
||||||
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
_ = captureStream(fromDev, stop, func(chunk []byte) {
|
||||||
s := bytesToInt16(chunk)
|
s := bytesToInt16(chunk)
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
@@ -119,6 +135,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
|
defer recoverGoroutine("recorder capture (mic)")
|
||||||
_ = captureStream(micDev, stop, func(chunk []byte) {
|
_ = captureStream(micDev, stop, func(chunk []byte) {
|
||||||
s := bytesToInt16(chunk)
|
s := bytesToInt16(chunk)
|
||||||
r.srcMu.Lock()
|
r.srcMu.Lock()
|
||||||
@@ -132,6 +149,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
|||||||
r.wg.Add(1)
|
r.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer r.wg.Done()
|
defer r.wg.Done()
|
||||||
|
defer recoverGoroutine("recorder mixer")
|
||||||
t := time.NewTicker(40 * time.Millisecond)
|
t := time.NewTicker(40 * time.Millisecond)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -221,27 +239,43 @@ func (r *Recorder) RestartQSO() {
|
|||||||
r.active = true
|
r.active = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveQSO writes the accumulated recording to path as a WAV and stops
|
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
|
||||||
// accumulating. Returns an error if no recording was active.
|
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
|
||||||
func (r *Recorder) SaveQSO(path string) error {
|
// new take immediately. Pair with WritePCM to encode/write off the hot path so
|
||||||
|
// a long recording doesn't delay logging.
|
||||||
|
func (r *Recorder) TakeQSO() ([]byte, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
if !r.active {
|
if !r.active {
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
return fmt.Errorf("no active recording")
|
return nil, fmt.Errorf("no active recording")
|
||||||
}
|
}
|
||||||
samples := r.acc
|
samples := r.acc
|
||||||
r.acc, r.active = nil, false
|
r.acc, r.active = nil, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if len(samples) == 0 {
|
if len(samples) == 0 {
|
||||||
return fmt.Errorf("recording was empty")
|
return nil, fmt.Errorf("recording was empty")
|
||||||
}
|
}
|
||||||
data := int16sToBytes(samples)
|
return int16sToBytes(samples), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePCM encodes raw 16 kHz mono PCM to path (WAV, or MP3 when path ends in
|
||||||
|
// .mp3). Slow for MP3 — call off the logging path.
|
||||||
|
func WritePCM(path string, data []byte) error {
|
||||||
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
|
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
|
||||||
return writeMP3(path, data)
|
return writeMP3(path, data)
|
||||||
}
|
}
|
||||||
return writeWAV(path, data)
|
return writeWAV(path, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveQSO snapshots and writes the recording in one call (synchronous).
|
||||||
|
func (r *Recorder) SaveQSO(path string) error {
|
||||||
|
data, err := r.TakeQSO()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return WritePCM(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
// DiscardQSO drops the active accumulation without saving (callsign cleared).
|
||||||
func (r *Recorder) DiscardQSO() {
|
func (r *Recorder) DiscardQSO() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|||||||
+120
-7
@@ -68,6 +68,12 @@ type Def struct {
|
|||||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
||||||
|
|
||||||
|
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
|
||||||
|
// earns a reference if the primary match OR any of these match. Lets a
|
||||||
|
// French department (DDFM) be found from "D74" in the note AND from a postal
|
||||||
|
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
|
||||||
|
OrRules []OrRule `json:"or_rules,omitempty"`
|
||||||
|
|
||||||
// --- Scope ---
|
// --- Scope ---
|
||||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||||
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
||||||
@@ -84,6 +90,20 @@ type Def struct {
|
|||||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrRule is one additional search OR'd with the award's primary matching rule.
|
||||||
|
// Same knobs as the primary (field + how to match), plus Prefix which is
|
||||||
|
// prepended to each reference it finds so a captured value can be normalised to
|
||||||
|
// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74").
|
||||||
|
type OrRule struct {
|
||||||
|
Field string `json:"field"` // QSO field to scan
|
||||||
|
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||||
|
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||||
|
Pattern string `json:"pattern,omitempty"` // Go regexp; group 1 = reference
|
||||||
|
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||||
|
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||||
|
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
||||||
|
}
|
||||||
|
|
||||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||||
func Defaults() []Def {
|
func Defaults() []Def {
|
||||||
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
||||||
@@ -414,7 +434,73 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl := NewRefList(metas)
|
rl := NewRefList(metas)
|
||||||
return candidates(&d, re, q, rl, len(metas) > 0)
|
found := candidates(&d, re, q, rl, len(metas) > 0)
|
||||||
|
// Merge operator-assigned references (manual override). These let the
|
||||||
|
// operator tag a QSO for an award whose field/description matching can't
|
||||||
|
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
|
||||||
|
// province NAME, so a contact whose address doesn't spell it out needs the
|
||||||
|
// province picked by hand. For a predefined award the override is still
|
||||||
|
// validated against its reference list.
|
||||||
|
if man := manualRefs(q, d.Code); len(man) > 0 {
|
||||||
|
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
||||||
|
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
|
||||||
|
// comma list). Honoured by MatchQSO regardless of how the award matches.
|
||||||
|
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"
|
||||||
|
|
||||||
|
// manualRefs returns the reference codes the operator assigned to award `code`
|
||||||
|
// on this QSO (from the ManualRefsKey extra).
|
||||||
|
func manualRefs(q *qso.QSO, code string) []string {
|
||||||
|
if q == nil || q.Extras == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(q.Extras[ManualRefsKey])
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
var out []string
|
||||||
|
for _, entry := range strings.Split(raw, ";") {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
at := strings.IndexByte(entry, '@')
|
||||||
|
if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
|
||||||
|
if r = strings.TrimSpace(r); r != "" {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
|
||||||
|
// When the award is predefined, only references present and valid in its list
|
||||||
|
// are kept (so a typo can't invent a reference).
|
||||||
|
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, c := range found {
|
||||||
|
seen[normalizeRef(c)] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, c := range manual {
|
||||||
|
c = normalizeRef(c)
|
||||||
|
if _, dup := seen[c]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if predefined {
|
||||||
|
if m, ok := rl.byCode[c]; !ok || !m.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[c] = struct{}{}
|
||||||
|
found = append(found, c)
|
||||||
|
}
|
||||||
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||||
@@ -445,13 +531,15 @@ func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf Nam
|
|||||||
|
|
||||||
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
|
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
|
||||||
// a predefined list when one applies.
|
// a predefined list when one applies.
|
||||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
// searchOne runs one matching rule (the primary or an OR rule) over a QSO and
|
||||||
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
|
// returns the reference codes it finds, each prefixed with `prefix` (so a
|
||||||
|
// captured "74" becomes "D74"). predefined enables list-aware matching.
|
||||||
|
func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string {
|
||||||
|
raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
predefined := hasList && !d.Dynamic
|
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
|
||||||
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
|
|
||||||
|
|
||||||
var found []string
|
var found []string
|
||||||
switch {
|
switch {
|
||||||
@@ -464,7 +552,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// the field equals the name; otherwise the name is a substring of it.
|
// the field equals the name; otherwise the name is a substring of it.
|
||||||
up := strings.ToUpper(raw)
|
up := strings.ToUpper(raw)
|
||||||
for _, nc := range rl.names {
|
for _, nc := range rl.names {
|
||||||
if d.ExactMatch {
|
if exact {
|
||||||
if up == nc.name {
|
if up == nc.name {
|
||||||
found = append(found, nc.code)
|
found = append(found, nc.code)
|
||||||
}
|
}
|
||||||
@@ -472,7 +560,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
found = append(found, nc.code)
|
found = append(found, nc.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case predefined && !d.ExactMatch:
|
case predefined && !exact:
|
||||||
// "Search reference inside the field": look up each token of the field in
|
// "Search reference inside the field": look up each token of the field in
|
||||||
// the list — O(tokens), not O(all references) — plus test the few
|
// the list — O(tokens), not O(all references) — plus test the few
|
||||||
// references that declare a regex.
|
// references that declare a regex.
|
||||||
@@ -492,6 +580,31 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// counts each reference separately.
|
// counts each reference separately.
|
||||||
found = splitRefs(raw)
|
found = splitRefs(raw)
|
||||||
}
|
}
|
||||||
|
if prefix != "" {
|
||||||
|
for i := range found {
|
||||||
|
found[i] = prefix + found[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||||
|
predefined := hasList && !d.Dynamic
|
||||||
|
|
||||||
|
// Primary search, then each OR rule — a QSO earns a reference if any matches.
|
||||||
|
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
||||||
|
for i := range d.OrRules {
|
||||||
|
r := &d.OrRules[i]
|
||||||
|
var rre *regexp.Regexp
|
||||||
|
if p := strings.TrimSpace(r.Pattern); p != "" {
|
||||||
|
c, err := regexp.Compile(p)
|
||||||
|
if err != nil {
|
||||||
|
continue // skip a rule with a bad regex rather than failing the award
|
||||||
|
}
|
||||||
|
rre = c
|
||||||
|
}
|
||||||
|
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
|
||||||
|
}
|
||||||
|
|
||||||
if !predefined {
|
if !predefined {
|
||||||
return dedupe(found)
|
return dedupe(found)
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (in
|
|||||||
return 0, fmt.Errorf("clear refs: %w", err)
|
return 0, fmt.Errorf("clear refs: %w", err)
|
||||||
}
|
}
|
||||||
stmt, err := tx.PrepareContext(ctx,
|
stmt, err := tx.PrepareContext(ctx,
|
||||||
`INSERT OR REPLACE INTO award_references
|
`REPLACE INTO award_references
|
||||||
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,7 +257,7 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
|
|||||||
return fmt.Errorf("empty award or reference code")
|
return fmt.Errorf("empty award or reference code")
|
||||||
}
|
}
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`INSERT OR REPLACE INTO award_references
|
`REPLACE INTO award_references
|
||||||
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
|
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
|
||||||
|
|||||||
+82
-10
@@ -99,6 +99,54 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
|||||||
return dstPath, nil
|
return dstPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the
|
||||||
|
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
|
||||||
|
// ADIF export is a portable, re-importable backup of the actual contacts.
|
||||||
|
// writeADIF must write the full ADIF to the path it's handed.
|
||||||
|
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) {
|
||||||
|
if rotation <= 0 {
|
||||||
|
rotation = 5
|
||||||
|
}
|
||||||
|
if folder == "" {
|
||||||
|
return "", fmt.Errorf("backup folder not set")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create backup folder: %w", err)
|
||||||
|
}
|
||||||
|
base := "opslog-log-" + time.Now().Format("2006-01-02")
|
||||||
|
tmp := filepath.Join(folder, base+".adi.tmp")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if err := writeADIF(tmp); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", fmt.Errorf("export adif: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dstPath string
|
||||||
|
if doZip {
|
||||||
|
dstPath = filepath.Join(folder, base+".adi.zip")
|
||||||
|
if err := copyZipped(tmp, dstPath, base+".adi"); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
} else {
|
||||||
|
dstPath = filepath.Join(folder, base+".adi")
|
||||||
|
_ = os.Remove(dstPath)
|
||||||
|
if err := os.Rename(tmp, dstPath); err != nil {
|
||||||
|
if cerr := copyFile(tmp, dstPath); cerr != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", cerr
|
||||||
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil {
|
||||||
|
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
|
||||||
|
}
|
||||||
|
return dstPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// copyFile performs a plain file copy. We don't use os.Rename because
|
// copyFile performs a plain file copy. We don't use os.Rename because
|
||||||
// the source is the live database; we want a fresh standalone file.
|
// the source is the live database; we want a fresh standalone file.
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
@@ -155,10 +203,18 @@ func copyZipped(src, dst, innerName string) error {
|
|||||||
return out.Close()
|
return out.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotate keeps the most recent `keep` backups in folder and deletes the
|
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
|
||||||
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
|
// opslog-*.db.zip) and deletes the rest.
|
||||||
// are touched — never user files that happen to live in the same folder.
|
|
||||||
func rotate(folder string, keep int) error {
|
func rotate(folder string, keep int) error {
|
||||||
|
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotateMatch keeps the most recent `keep` files in folder whose name has the
|
||||||
|
// given prefix and one of the given suffixes, deleting older ones. Only matching
|
||||||
|
// files are touched — never unrelated user files in the same folder. The suffix
|
||||||
|
// filter keeps the .db family and the .adi family from rotating each other even
|
||||||
|
// though both share the "opslog-" prefix.
|
||||||
|
func rotateMatch(folder string, keep int, prefix string, suffixes ...string) error {
|
||||||
entries, err := os.ReadDir(folder)
|
entries, err := os.ReadDir(folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -173,10 +229,17 @@ func rotate(folder string, keep int) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := e.Name()
|
name := e.Name()
|
||||||
if !strings.HasPrefix(name, "opslog-") {
|
if !strings.HasPrefix(name, prefix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
|
ok := false
|
||||||
|
for _, sfx := range suffixes {
|
||||||
|
if strings.HasSuffix(name, sfx) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, err := e.Info()
|
info, err := e.Info()
|
||||||
@@ -198,16 +261,25 @@ func rotate(folder string, keep int) error {
|
|||||||
return firstErr
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasBackupToday returns true if a backup for today's date already exists
|
// HasBackupToday returns true if a SQLite backup for today's date already
|
||||||
// in folder. Used by the startup auto-backup to skip when the user has
|
// exists in folder. Used by the auto-backup to skip when the user has already
|
||||||
// already restarted the app once today.
|
// restarted the app once today.
|
||||||
func HasBackupToday(folder string) bool {
|
func HasBackupToday(folder string) bool {
|
||||||
|
return hasBackupToday(folder, "opslog-", ".db", ".db.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode).
|
||||||
|
func HasADIFBackupToday(folder string) bool {
|
||||||
|
return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasBackupToday(folder, prefix string, exts ...string) bool {
|
||||||
if folder == "" {
|
if folder == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
stamp := time.Now().Format("2006-01-02")
|
stamp := time.Now().Format("2006-01-02")
|
||||||
for _, ext := range []string{".db", ".db.zip"} {
|
for _, ext := range exts {
|
||||||
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
|
if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+200
-24
@@ -57,6 +57,7 @@ type RigState struct {
|
|||||||
// Manager owns the active backend and runs the polling loop.
|
// Manager owns the active backend and runs the polling loop.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
|
||||||
state RigState
|
state RigState
|
||||||
emit func(RigState)
|
emit func(RigState)
|
||||||
backend Backend
|
backend Backend
|
||||||
@@ -115,7 +116,13 @@ func (m *Manager) State() RigState {
|
|||||||
// state.Error rather than returned, so the UI can keep retrying via the
|
// state.Error rather than returned, so the UI can keep retrying via the
|
||||||
// poll loop on next reconnect attempt.
|
// poll loop on next reconnect attempt.
|
||||||
func (m *Manager) Start(b Backend) {
|
func (m *Manager) Start(b Backend) {
|
||||||
m.Stop()
|
// Serialize the whole stop-old-then-start-new sequence. Two concurrent
|
||||||
|
// Start (or Start+Stop) calls could otherwise interleave and leave the
|
||||||
|
// previous poll goroutine alive — two pollers then fight, e.g. flipping
|
||||||
|
// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
|
||||||
|
m.startMu.Lock()
|
||||||
|
defer m.startMu.Unlock()
|
||||||
|
m.stopLocked()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.stopCh = make(chan struct{})
|
m.stopCh = make(chan struct{})
|
||||||
m.doneCh = make(chan struct{})
|
m.doneCh = make(chan struct{})
|
||||||
@@ -134,6 +141,18 @@ func (m *Manager) Start(b Backend) {
|
|||||||
|
|
||||||
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
|
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
|
||||||
func (m *Manager) Stop() {
|
func (m *Manager) Stop() {
|
||||||
|
m.startMu.Lock()
|
||||||
|
defer m.startMu.Unlock()
|
||||||
|
m.stopLocked()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state = RigState{Enabled: false}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.emitState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopLocked tears down any running poller and blocks until it exits. The
|
||||||
|
// caller must hold startMu so it can't race a concurrent Start.
|
||||||
|
func (m *Manager) stopLocked() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
stop := m.stopCh
|
stop := m.stopCh
|
||||||
done := m.doneCh
|
done := m.doneCh
|
||||||
@@ -148,10 +167,6 @@ func (m *Manager) Stop() {
|
|||||||
if done != nil {
|
if done != nil {
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
|
||||||
m.state = RigState{Enabled: false}
|
|
||||||
m.mu.Unlock()
|
|
||||||
m.emitState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
||||||
@@ -169,6 +184,157 @@ func (m *Manager) SetPTT(on bool) error {
|
|||||||
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SpotInfo is one cluster spot to render on a backend that supports a spot
|
||||||
|
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
|
||||||
|
// the backend picks a default when it's empty. (Status-based colouring can be
|
||||||
|
// driven later by setting Color per spot.)
|
||||||
|
type SpotInfo struct {
|
||||||
|
FreqHz int64
|
||||||
|
Callsign string
|
||||||
|
Mode string
|
||||||
|
Color string
|
||||||
|
Comment string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||||
|
// (FlexRadio panadapter). Backends that don't implement it are simply skipped.
|
||||||
|
type Spotter interface {
|
||||||
|
SendSpot(SpotInfo) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on
|
||||||
|
// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a
|
||||||
|
// missed spot on the panadapter is harmless.
|
||||||
|
func (m *Manager) SendSpot(s SpotInfo) {
|
||||||
|
m.mu.RLock()
|
||||||
|
cmds := m.cmdCh
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if cmds == nil || b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := b.(Spotter); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case cmds <- func() {
|
||||||
|
if sp, ok := b.(Spotter); ok {
|
||||||
|
_ = sp.SendSpot(s)
|
||||||
|
}
|
||||||
|
}:
|
||||||
|
default: // queue busy → drop this spot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
||||||
|
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
||||||
|
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
||||||
|
type FlexTXState struct {
|
||||||
|
Available bool `json:"available"` // backend is Flex and handshaked
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
RFPower int `json:"rf_power"`
|
||||||
|
TunePower int `json:"tune_power"`
|
||||||
|
Tune bool `json:"tune"` // tune carrier active
|
||||||
|
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
|
||||||
|
VoxEnable bool `json:"vox_enable"`
|
||||||
|
VoxLevel int `json:"vox_level"`
|
||||||
|
VoxDelay int `json:"vox_delay"`
|
||||||
|
ProcEnable bool `json:"proc_enable"`
|
||||||
|
ProcLevel int `json:"proc_level"`
|
||||||
|
Mon bool `json:"mon"`
|
||||||
|
MonLevel int `json:"mon_level"`
|
||||||
|
MicLevel int `json:"mic_level"`
|
||||||
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
|
ATUMemories bool `json:"atu_memories"`
|
||||||
|
// Active RX slice DSP controls.
|
||||||
|
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||||
|
AGCMode string `json:"agc_mode,omitempty"`
|
||||||
|
AGCThreshold int `json:"agc_threshold"`
|
||||||
|
AudioLevel int `json:"audio_level"`
|
||||||
|
NB bool `json:"nb"`
|
||||||
|
NBLevel int `json:"nb_level"`
|
||||||
|
NR bool `json:"nr"`
|
||||||
|
NRLevel int `json:"nr_level"`
|
||||||
|
ANF bool `json:"anf"`
|
||||||
|
ANFLevel int `json:"anf_level"`
|
||||||
|
// External amplifier (PowerGenius XL).
|
||||||
|
AmpAvailable bool `json:"amp_available"`
|
||||||
|
AmpModel string `json:"amp_model,omitempty"`
|
||||||
|
AmpOperate bool `json:"amp_operate"`
|
||||||
|
AmpFault string `json:"amp_fault,omitempty"`
|
||||||
|
// Live meters streamed over UDP (S-meter, PWR, SWR, temp, voltage…).
|
||||||
|
Meters []FlexMeter `json:"meters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexMeter is one live meter value (already scaled to real units).
|
||||||
|
type FlexMeter struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Lo float64 `json:"lo"`
|
||||||
|
Hi float64 `json:"hi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexController is an OPTIONAL backend capability (the FlexRadio backend): the
|
||||||
|
// SmartSDR-style transmit controls. Backends that don't implement it are skipped
|
||||||
|
// by the FlexRadio tab. FlexState() is mutex-guarded in the backend so it's safe
|
||||||
|
// to read off the CAT goroutine; the setters are dispatched onto it via FlexDo.
|
||||||
|
type FlexController interface {
|
||||||
|
FlexState() FlexTXState
|
||||||
|
SetRFPower(int) error
|
||||||
|
SetTunePower(int) error
|
||||||
|
SetTune(bool) error
|
||||||
|
SetVOX(bool) error
|
||||||
|
SetVOXLevel(int) error
|
||||||
|
SetVOXDelay(int) error
|
||||||
|
SetProcessor(bool) error
|
||||||
|
SetProcessorLevel(int) error
|
||||||
|
SetMon(bool) error
|
||||||
|
SetMonLevel(int) error
|
||||||
|
SetMic(int) error
|
||||||
|
ATUStart() error
|
||||||
|
ATUBypass() error
|
||||||
|
SetATUMemories(bool) error
|
||||||
|
// RX slice DSP controls (target the active receive slice).
|
||||||
|
SetAGCMode(string) error
|
||||||
|
SetAGCThreshold(int) error
|
||||||
|
SetAudioLevel(int) error
|
||||||
|
SetNB(bool) error
|
||||||
|
SetNBLevel(int) error
|
||||||
|
SetNR(bool) error
|
||||||
|
SetNRLevel(int) error
|
||||||
|
SetANF(bool) error
|
||||||
|
SetANFLevel(int) error
|
||||||
|
// External amplifier (PowerGenius XL) operate/standby.
|
||||||
|
SetAmpOperate(bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexState returns the current FlexRadio transmit state, or (zero, false) when
|
||||||
|
// the active backend isn't a Flex. Safe to call from any goroutine.
|
||||||
|
func (m *Manager) FlexState() (FlexTXState, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fc, ok := b.(FlexController); ok {
|
||||||
|
return fc.FlexState(), true
|
||||||
|
}
|
||||||
|
return FlexTXState{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexDo dispatches a FlexRadio control onto the CAT goroutine. Errors if the
|
||||||
|
// active backend isn't a Flex.
|
||||||
|
func (m *Manager) FlexDo(fn func(FlexController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
fc, ok := b.(FlexController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not a FlexRadio")
|
||||||
|
}
|
||||||
|
return fn(fc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
func (m *Manager) exec(fn func(Backend) error) error {
|
func (m *Manager) exec(fn func(Backend) error) error {
|
||||||
@@ -195,23 +361,27 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
defer runtime.UnlockOSThread()
|
defer runtime.UnlockOSThread()
|
||||||
defer close(done)
|
defer close(done)
|
||||||
|
|
||||||
if err := b.Connect(); err != nil {
|
|
||||||
m.update(RigState{
|
|
||||||
Enabled: true, Backend: b.Name(), Connected: false,
|
|
||||||
Error: err.Error(), UpdatedAt: time.Now(),
|
|
||||||
})
|
|
||||||
// Stay idle until Stop is called — let the user fix config and re-Start.
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case fn := <-cmds:
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer b.Disconnect()
|
defer b.Disconnect()
|
||||||
|
|
||||||
|
// Connection is (re)established lazily and retried with a backoff, so a rig
|
||||||
|
// that's off at startup — or a FlexRadio that reboots/drops its TCP link —
|
||||||
|
// reconnects on its own instead of staying dead until the user toggles CAT.
|
||||||
|
const reconnectEvery = 5 * time.Second
|
||||||
|
connected := false
|
||||||
|
var lastAttempt time.Time
|
||||||
|
tryConnect := func() {
|
||||||
|
if connected || time.Since(lastAttempt) < reconnectEvery {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastAttempt = time.Now()
|
||||||
|
if err := b.Connect(); err != nil {
|
||||||
|
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connected = true
|
||||||
|
}
|
||||||
|
tryConnect()
|
||||||
|
|
||||||
ticker := time.NewTicker(pollEvery)
|
ticker := time.NewTicker(pollEvery)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -223,12 +393,18 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
fn()
|
fn()
|
||||||
m.applyCommandDelay()
|
m.applyCommandDelay()
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
if !connected {
|
||||||
|
tryConnect()
|
||||||
|
continue
|
||||||
|
}
|
||||||
ns, err := b.ReadState()
|
ns, err := b.ReadState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.update(RigState{
|
// Lost the rig — drop the backend so the next attempt reconnects
|
||||||
Enabled: true, Backend: b.Name(), Connected: false,
|
// cleanly, then back off before retrying.
|
||||||
Error: err.Error(), UpdatedAt: time.Now(),
|
connected = false
|
||||||
})
|
lastAttempt = time.Now()
|
||||||
|
b.Disconnect()
|
||||||
|
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ns.Enabled = true
|
ns.Enabled = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FlexRadio is one radio found by discovery.
|
||||||
|
type FlexRadio struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
Callsign string `json:"callsign"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexRadios on the LAN broadcast a discovery datagram to UDP :4992 about once a
|
||||||
|
// second. DiscoverFlex listens for that broadcast for the given duration and
|
||||||
|
// returns the unique radios seen. Best effort: if the port can't be bound
|
||||||
|
// (SmartSDR running, firewall…), it returns what it has (often nothing) and the
|
||||||
|
// user falls back to entering the IP by hand.
|
||||||
|
func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 2 * time.Second
|
||||||
|
}
|
||||||
|
// Bind :4992 with SO_REUSEADDR so we coexist with SmartSDR, which also
|
||||||
|
// listens for the same broadcast.
|
||||||
|
lc := net.ListenConfig{
|
||||||
|
Control: func(_, _ string, c syscall.RawConn) error {
|
||||||
|
var serr error
|
||||||
|
_ = c.Control(func(fd uintptr) {
|
||||||
|
serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
|
||||||
|
})
|
||||||
|
return serr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
pc, err := lc.ListenPacket(ctx, "udp4", ":4992")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
_ = pc.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
found := map[string]FlexRadio{}
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
for {
|
||||||
|
n, _, err := pc.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
break // deadline reached or socket closed
|
||||||
|
}
|
||||||
|
if r, ok := parseFlexDiscovery(buf[:n]); ok && r.IP != "" {
|
||||||
|
if _, dup := found[r.IP]; !dup {
|
||||||
|
found[r.IP] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]FlexRadio, 0, len(found))
|
||||||
|
for _, r := range found {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reFlexModel = regexp.MustCompile(`model=(\S+)`)
|
||||||
|
reFlexIP = regexp.MustCompile(`ip=(\S+)`)
|
||||||
|
reFlexPort = regexp.MustCompile(`port=(\d+)`)
|
||||||
|
reFlexSerial = regexp.MustCompile(`serial=(\S+)`)
|
||||||
|
reFlexNickname = regexp.MustCompile(`nickname=(\S+)`)
|
||||||
|
reFlexCallsign = regexp.MustCompile(`callsign=(\S+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseFlexDiscovery extracts radio fields from a VITA-49 discovery datagram.
|
||||||
|
// The payload carries a space-separated key=value ASCII blob after a binary
|
||||||
|
// header, so we scan the whole packet text for the keys we need.
|
||||||
|
func parseFlexDiscovery(pkt []byte) (FlexRadio, bool) {
|
||||||
|
s := string(pkt)
|
||||||
|
m := reFlexIP.FindStringSubmatch(s)
|
||||||
|
if m == nil {
|
||||||
|
return FlexRadio{}, false
|
||||||
|
}
|
||||||
|
r := FlexRadio{IP: m[1], Port: 4992}
|
||||||
|
if mm := reFlexPort.FindStringSubmatch(s); mm != nil {
|
||||||
|
if p, err := strconv.Atoi(mm[1]); err == nil && p > 0 {
|
||||||
|
r.Port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mm := reFlexModel.FindStringSubmatch(s); mm != nil {
|
||||||
|
r.Model = mm[1]
|
||||||
|
}
|
||||||
|
if mm := reFlexSerial.FindStringSubmatch(s); mm != nil {
|
||||||
|
r.Serial = mm[1]
|
||||||
|
}
|
||||||
|
if mm := reFlexNickname.FindStringSubmatch(s); mm != nil {
|
||||||
|
r.Nickname = mm[1]
|
||||||
|
}
|
||||||
|
if mm := reFlexCallsign.FindStringSubmatch(s); mm != nil {
|
||||||
|
r.Callsign = mm[1]
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
+94
-37
@@ -3,11 +3,21 @@ package cat
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-ole/go-ole"
|
"github.com/go-ole/go-ole"
|
||||||
"github.com/go-ole/go-ole/oleutil"
|
"github.com/go-ole/go-ole/oleutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
|
||||||
|
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
|
||||||
|
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
|
||||||
|
// split ON = 0x8000, split OFF = 0x10000.
|
||||||
|
const (
|
||||||
|
pmSplitOn = 0x8000 // PM_SPLITON
|
||||||
|
pmSplitOff = 0x10000 // PM_SPLITOFF
|
||||||
|
)
|
||||||
|
|
||||||
// OmniRig talks to the user's installed OmniRig server over COM.
|
// OmniRig talks to the user's installed OmniRig server over COM.
|
||||||
//
|
//
|
||||||
// All methods MUST be called from the same OS thread (the one Manager.run
|
// All methods MUST be called from the same OS thread (the one Manager.run
|
||||||
@@ -21,6 +31,15 @@ type OmniRig struct {
|
|||||||
|
|
||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
|
lastSig string // last logged Split/VFO signature — only log on change
|
||||||
|
|
||||||
|
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||||
|
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||||
|
// async Freq property, which still reports the OLD band for a poll or two
|
||||||
|
// after a QSY — that lag is why a clicked spot needed a second click to fix
|
||||||
|
// the sideband (freq moved, but mode read the old band → wrong sideband).
|
||||||
|
lastSetFreq int64
|
||||||
|
lastSetFreqAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||||
@@ -107,13 +126,19 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
||||||
s.Mode = omniRigMode(modeVar.Val)
|
s.Mode = omniRigMode(modeVar.Val)
|
||||||
}
|
}
|
||||||
|
rawVfo := int64(0)
|
||||||
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||||
|
rawVfo = vfoVar.Val
|
||||||
s.Vfo = omniRigVfo(vfoVar.Val)
|
s.Vfo = omniRigVfo(vfoVar.Val)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read both VFO frequencies separately so we can expose split TX/RX.
|
// Read the active/displayed frequency (generic Freq) AND both VFOs. The
|
||||||
// Fall back to generic Freq if the rig only exposes the merged property.
|
// generic Freq is what the rig is operating on — the reliable source for the
|
||||||
freqA, freqB := int64(0), int64(0)
|
// main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
|
||||||
|
freqMain, freqA, freqB := int64(0), int64(0), int64(0)
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
|
freqMain = v.Val
|
||||||
|
}
|
||||||
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||||
freqA = v.Val
|
freqA = v.Val
|
||||||
}
|
}
|
||||||
@@ -121,38 +146,58 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
freqB = v.Val
|
freqB = v.Val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split detection: trust the explicit Split property when it's set,
|
// Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
|
||||||
// BUT only call it a real split if both VFO frequencies are non-zero
|
// compared to PM_SPLITON, not "!= 0".
|
||||||
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
|
splitRaw := int64(0)
|
||||||
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
|
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
|
||||||
// slice model doesn't map to VFO A/B — that would yield a useless
|
splitRaw = v.Val
|
||||||
// permanent SPLIT badge.
|
|
||||||
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
|
|
||||||
s.Split = true
|
|
||||||
}
|
|
||||||
if s.Split && (freqB == 0 || freqA == freqB) {
|
|
||||||
s.Split = false
|
|
||||||
s.RxFreqHz = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
|
||||||
|
// normal operation stays quiet but toggling split on the radio is captured —
|
||||||
|
// needed to pin down this rig's PM_SPLITON value.
|
||||||
|
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
|
||||||
|
o.lastSig = sig
|
||||||
|
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
|
||||||
|
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
|
||||||
|
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
|
||||||
|
return v.Val
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
|
||||||
|
// distinct and non-zero, AND they're in the same band. The same-band test
|
||||||
|
// kills the common false positive where VFO B just holds a leftover from
|
||||||
|
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
|
||||||
|
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
|
||||||
|
genuineSplit := splitRaw == pmSplitOn &&
|
||||||
|
freqA != 0 && freqB != 0 && freqA != freqB &&
|
||||||
|
BandFromHz(freqA) == BandFromHz(freqB)
|
||||||
|
|
||||||
|
if genuineSplit {
|
||||||
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
||||||
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
|
// ADIF: FreqHz = TX, RxFreqHz = RX.
|
||||||
|
s.Split = true
|
||||||
switch s.Vfo {
|
switch s.Vfo {
|
||||||
case "AB":
|
|
||||||
s.FreqHz = freqB // TX
|
|
||||||
s.RxFreqHz = freqA // RX
|
|
||||||
case "BA":
|
case "BA":
|
||||||
s.FreqHz = freqA // TX
|
s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B
|
||||||
s.RxFreqHz = freqB // RX
|
default: // "AB" and the usual "TX on the other VFO" case
|
||||||
case "B", "BB":
|
s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
||||||
|
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
||||||
|
s.Split = false
|
||||||
|
s.RxFreqHz = 0
|
||||||
|
s.FreqHz = freqMain
|
||||||
|
if s.FreqHz == 0 {
|
||||||
|
if s.Vfo == "B" || s.Vfo == "BB" {
|
||||||
s.FreqHz = freqB
|
s.FreqHz = freqB
|
||||||
default: // "A", "AA", "" — single VFO on A or unknown
|
} else {
|
||||||
s.FreqHz = freqA
|
s.FreqHz = freqA
|
||||||
}
|
}
|
||||||
if s.FreqHz == 0 {
|
|
||||||
// Last resort — some rigs only update generic Freq.
|
|
||||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
|
||||||
s.FreqHz = v.Val
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -169,6 +214,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
return fmt.Errorf("frequency out of OmniRig int32 range")
|
return fmt.Errorf("frequency out of OmniRig int32 range")
|
||||||
}
|
}
|
||||||
hz32 := int32(hz)
|
hz32 := int32(hz)
|
||||||
|
// Remember the commanded frequency so a mode change moments later (a clicked
|
||||||
|
// spot sets freq then mode) picks the sideband from the TARGET band, not the
|
||||||
|
// not-yet-updated OmniRig Freq property.
|
||||||
|
o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()
|
||||||
|
|
||||||
// Log the rig's writable-params, status and VFO state up front so a
|
// Log the rig's writable-params, status and VFO state up front so a
|
||||||
// friend's session shows exactly what OmniRig reports for their rig.
|
// friend's session shows exactly what OmniRig reports for their rig.
|
||||||
@@ -274,9 +323,15 @@ func (o *OmniRig) SetMode(mode string) error {
|
|||||||
case "CW":
|
case "CW":
|
||||||
bit, bitName = pmCWU, "PM_CW_U"
|
bit, bitName = pmCWU, "PM_CW_U"
|
||||||
case "SSB":
|
case "SSB":
|
||||||
// Read current freq to decide USB vs LSB.
|
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
|
||||||
|
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
|
||||||
|
// property still reports the OLD band for a poll or two after a QSY, so
|
||||||
|
// reading it here picked the wrong sideband and the user had to click a
|
||||||
|
// second time. Fall back to the live read for a standalone mode change.
|
||||||
var freq int64
|
var freq int64
|
||||||
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
|
||||||
|
freq = o.lastSetFreq
|
||||||
|
} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
freq = freqVar.Val
|
freq = freqVar.Val
|
||||||
}
|
}
|
||||||
if freq > 0 && freq < 10_000_000 {
|
if freq > 0 && freq < 10_000_000 {
|
||||||
@@ -396,20 +451,22 @@ func omniRigMode(m int64) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
|
||||||
|
// documented PM_VFO* constants.
|
||||||
func omniRigVfo(v int64) string {
|
func omniRigVfo(v int64) string {
|
||||||
switch {
|
switch {
|
||||||
case v&1024 != 0:
|
case v&0x40 != 0: // PM_VFOAA
|
||||||
return "A"
|
|
||||||
case v&2048 != 0:
|
|
||||||
return "B"
|
|
||||||
case v&64 != 0:
|
|
||||||
return "AA"
|
return "AA"
|
||||||
case v&128 != 0:
|
case v&0x80 != 0: // PM_VFOAB
|
||||||
return "AB"
|
return "AB"
|
||||||
case v&256 != 0:
|
case v&0x100 != 0: // PM_VFOBA
|
||||||
return "BA"
|
return "BA"
|
||||||
case v&512 != 0:
|
case v&0x200 != 0: // PM_VFOBB
|
||||||
return "BB"
|
return "BB"
|
||||||
|
case v&0x400 != 0: // PM_VFOA
|
||||||
|
return "A"
|
||||||
|
case v&0x800 != 0: // PM_VFOB
|
||||||
|
return "B"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-8
@@ -7,13 +7,143 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MySQLConfig targets a shared MySQL database for multi-operator logging
|
||||||
|
// (multiple OpsLog instances on one logbook, à la Log4OM).
|
||||||
|
type MySQLConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c MySQLConfig) dsn() string {
|
||||||
|
port := c.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 3306
|
||||||
|
}
|
||||||
|
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
|
||||||
|
// Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
|
||||||
|
// or wrong-port server fails fast with a real error instead of hanging
|
||||||
|
// startup (which would surface only as "db not initialized"); read/write
|
||||||
|
// timeouts cap a stuck statement (generous, so normal migrations/imports
|
||||||
|
// never trip them).
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
|
||||||
|
c.User, c.Password, c.Host, port, c.Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validDBIdent guards a database name we splice into DDL (CREATE DATABASE can't
|
||||||
|
// use a placeholder). Only plain identifiers allowed.
|
||||||
|
func validDBIdent(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r != '_' && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingMySQL verifies a shared-database connection and creates the logbook
|
||||||
|
// database if it doesn't exist yet. It connects at server level first (no
|
||||||
|
// database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
|
||||||
|
// database is actually usable. A restricted user (common on shared hosting)
|
||||||
|
// may lack the CREATE DATABASE privilege but still have full rights on a
|
||||||
|
// pre-created database — so a denied CREATE is not fatal as long as the
|
||||||
|
// database already exists and we can connect to it. Backs the "Test
|
||||||
|
// connection" button.
|
||||||
|
func PingMySQL(c MySQLConfig) error {
|
||||||
|
if strings.TrimSpace(c.Host) == "" {
|
||||||
|
return fmt.Errorf("host is required")
|
||||||
|
}
|
||||||
|
server := c
|
||||||
|
server.Database = "" // connect to the server, not a specific DB
|
||||||
|
conn, err := sql.Open("mysql", server.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open mysql: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
conn.SetConnMaxLifetime(15 * time.Second)
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(c.Database)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !validDBIdent(name) {
|
||||||
|
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||||
|
}
|
||||||
|
createErr := error(nil)
|
||||||
|
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
|
||||||
|
if !isAccessDenied(err) {
|
||||||
|
return fmt.Errorf("create database %q: %w", name, err)
|
||||||
|
}
|
||||||
|
createErr = err // remember it in case the DB also turns out to be unreachable
|
||||||
|
}
|
||||||
|
// Confirm the database is usable (it may have been pre-created by an admin
|
||||||
|
// even though this user can't CREATE one).
|
||||||
|
dbConn, err := sql.Open("mysql", c.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open mysql db: %w", err)
|
||||||
|
}
|
||||||
|
defer dbConn.Close()
|
||||||
|
dbConn.SetConnMaxLifetime(15 * time.Second)
|
||||||
|
if err := dbConn.Ping(); err != nil {
|
||||||
|
if createErr != nil {
|
||||||
|
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("connect to database %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
|
||||||
|
// dialect; run through the dialect translator for MySQL.
|
||||||
|
const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)`
|
||||||
|
|
||||||
|
// Dialect names the backend opened at startup. Repos consult it for the few
|
||||||
|
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
|
||||||
|
// Timestamps are generated in Go (NowISO) for both, so most queries are shared.
|
||||||
|
var Dialect = "sqlite"
|
||||||
|
|
||||||
|
// IsMySQL reports whether the active LOGBOOK backend is the shared MySQL
|
||||||
|
// server. Config tables always live in local SQLite, so this only governs
|
||||||
|
// qso-table SQL (see columnExpr).
|
||||||
|
func IsMySQL() bool { return Dialect == "mysql" }
|
||||||
|
|
||||||
|
// SetDialect pins the logbook dialect ("mysql" | "sqlite"). Called once at
|
||||||
|
// startup after the logbook backend is chosen, so it doesn't depend on the
|
||||||
|
// order in which the local and logbook connections were opened.
|
||||||
|
func SetDialect(d string) {
|
||||||
|
if d == "mysql" {
|
||||||
|
Dialect = "mysql"
|
||||||
|
} else {
|
||||||
|
Dialect = "sqlite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowISO returns the current UTC time as the ISO-8601 string OpsLog stores in
|
||||||
|
// *_at / date columns — millisecond precision, matching the old SQLite
|
||||||
|
// strftime('%Y-%m-%dT%H:%M:%fZ','now') default. Bind this as a parameter so the
|
||||||
|
// same INSERT/UPDATE works on both backends.
|
||||||
|
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
|
||||||
|
|
||||||
|
|
||||||
// Open opens (and creates if needed) the SQLite database at the given path,
|
// Open opens (and creates if needed) the SQLite database at the given path,
|
||||||
// enables performance PRAGMAs, and applies embedded migrations.
|
// enables performance PRAGMAs, and applies embedded migrations.
|
||||||
func Open(path string) (*sql.DB, error) {
|
func Open(path string) (*sql.DB, error) {
|
||||||
@@ -30,7 +160,8 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
}
|
}
|
||||||
if err := migrate(conn); err != nil {
|
Dialect = "sqlite"
|
||||||
|
if err := migrate(conn, nil); err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -39,12 +170,18 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
|
|
||||||
// migrate applies all embedded *.sql migrations in alphabetical order,
|
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||||
// skipping those already applied. Intentionally minimal in-house system
|
// skipping those already applied. Intentionally minimal in-house system
|
||||||
// (no external dependency).
|
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||||
func migrate(conn *sql.DB) error {
|
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
|
||||||
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
func migrate(conn *sql.DB, translate func(string) string) error {
|
||||||
name TEXT PRIMARY KEY,
|
// A non-nil translator means this is the MySQL connection (use the
|
||||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
// per-statement, FK-aware path); nil means a SQLite connection. This is
|
||||||
)`); err != nil {
|
// determined by the caller's argument, NOT the global Dialect, so the
|
||||||
|
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
|
||||||
|
mysqlPath := translate != nil
|
||||||
|
if translate == nil {
|
||||||
|
translate = func(s string) string { return s }
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
|
||||||
return fmt.Errorf("create schema_migrations: %w", err)
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +211,30 @@ func migrate(conn *sql.DB) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read migration %s: %w", name, err)
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
sqlText := translate(string(content))
|
||||||
|
|
||||||
|
// MySQL implicitly commits each DDL statement, so a wrapping transaction
|
||||||
|
// gives no atomicity — a mid-file failure would leave columns/tables
|
||||||
|
// behind, unrecorded, and every restart would re-run and choke on
|
||||||
|
// "duplicate column". Instead apply statement-by-statement and tolerate
|
||||||
|
// "already exists" errors, making migrations idempotent (and self-healing
|
||||||
|
// for a previously half-applied database).
|
||||||
|
if mysqlPath {
|
||||||
|
if err := applyMySQLMigration(conn, sqlText); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite supports DDL inside a transaction, so keep it atomic there.
|
||||||
tx, err := conn.Begin()
|
tx, err := conn.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin tx for %s: %w", name, err)
|
return fmt.Errorf("begin tx for %s: %w", name, err)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(string(content)); err != nil {
|
if _, err := tx.Exec(sqlText); err != nil {
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Per-profile logbook database. Each profile can target its own logbook:
|
||||||
|
-- the local SQLite file, or a specific shared MySQL database. Switching the
|
||||||
|
-- active profile switches the logbook accordingly. Stored as a small JSON
|
||||||
|
-- document {backend, host, port, user, password, database}; empty = inherit
|
||||||
|
-- the default (local SQLite).
|
||||||
|
ALTER TABLE station_profiles ADD COLUMN db_config TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysqldriver "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── SQLite → MySQL schema translation ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// OpsLog's migrations are authored in SQLite dialect (the default backend).
|
||||||
|
// For the shared-MySQL backend we translate that DDL on the fly rather than
|
||||||
|
// maintaining a second set of migration files, so the two backends can never
|
||||||
|
// drift apart. The translation is deliberately narrow — it only rewrites the
|
||||||
|
// SQLite-isms that actually appear in our migrations:
|
||||||
|
//
|
||||||
|
// - INTEGER PRIMARY KEY [AUTOINCREMENT] → BIGINT AUTO_INCREMENT PRIMARY KEY
|
||||||
|
// - bare INTEGER → BIGINT (so FK child/parent types match)
|
||||||
|
// - DEFAULT (strftime(... 'now')) → DEFAULT '' (timestamps come from Go)
|
||||||
|
// - TEXT → VARCHAR(255), except a few free-text
|
||||||
|
// columns that stay TEXT/LONGTEXT
|
||||||
|
// - reserved column `key` → backticked
|
||||||
|
// - CREATE INDEX IF NOT EXISTS → CREATE INDEX (MySQL has no IF NOT EXISTS)
|
||||||
|
//
|
||||||
|
// REAL is left as-is (MySQL accepts it as an alias for DOUBLE). CREATE/DROP
|
||||||
|
// TABLE IF [NOT] EXISTS, RENAME TO, INSERT…SELECT, CHECK(...) and foreign keys
|
||||||
|
// are all valid MySQL as written.
|
||||||
|
|
||||||
|
var (
|
||||||
|
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
|
||||||
|
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
|
||||||
|
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
|
||||||
|
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
|
||||||
|
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
|
||||||
|
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
|
||||||
|
// uses backticks. Our schema has no double-quoted string literals, so it's
|
||||||
|
// safe to convert every "ident" to `ident`.
|
||||||
|
reDoubleQuoteIdent = regexp.MustCompile(`"([^"\n]*)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
|
||||||
|
// The qso table has ~150 columns, so making them all VARCHAR(255) (1020 bytes
|
||||||
|
// each in utf8mb4) overflows the row. We therefore emit TEXT (stored off-page,
|
||||||
|
// ~20 bytes in-row) for the bulk of columns, and VARCHAR only where a column
|
||||||
|
// actually needs in-row storage: it's indexed, a primary key, or carries a
|
||||||
|
// DEFAULT (TEXT can't hold a literal default in MySQL).
|
||||||
|
|
||||||
|
// varcharColumns must be VARCHAR because they're indexed or part of a primary
|
||||||
|
// key declared on a separate line (so the DEFAULT/PRIMARY-KEY line heuristic
|
||||||
|
// below can't catch them). MySQL can't index a TEXT column without a prefix
|
||||||
|
// length. Keyed by column name (a name may repeat across tables — harmless).
|
||||||
|
var varcharColumns = map[string]bool{
|
||||||
|
// qso indexes (0001, 0003, 0019)
|
||||||
|
"callsign": true, "qso_date": true, "band": true, "mode": true,
|
||||||
|
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
|
||||||
|
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
|
||||||
|
// integrations_udp index (0011)
|
||||||
|
"direction": true,
|
||||||
|
// award_references composite primary key (0017)
|
||||||
|
"award_code": true, "ref_code": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// longTextColumns override the default TEXT with a bigger type where the
|
||||||
|
// content can be large (a full QSL-template document).
|
||||||
|
var longTextColumns = map[string]string{
|
||||||
|
"json": "LONGTEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mysqlDDL rewrites a SQLite DDL/DML statement (or multi-statement migration
|
||||||
|
// file) into the MySQL dialect. See the package note above for the rules.
|
||||||
|
func mysqlDDL(stmt string) string {
|
||||||
|
s := stmt
|
||||||
|
// SQLite double-quoted identifiers → MySQL backticks (renamed tables in the
|
||||||
|
// baseline dump, etc.).
|
||||||
|
s = reDoubleQuoteIdent.ReplaceAllString(s, "`$1`")
|
||||||
|
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
|
||||||
|
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
|
||||||
|
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
|
||||||
|
// "INTEGER PRIMARY KEY" (SQLite rowid alias) must become AUTO_INCREMENT so
|
||||||
|
// inserts that omit the id still get one.
|
||||||
|
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGINT AUTO_INCREMENT PRIMARY KEY")
|
||||||
|
s = strings.ReplaceAll(s, "INTEGER PRIMARY KEY", "BIGINT AUTO_INCREMENT PRIMARY KEY")
|
||||||
|
// Timestamp defaults: Go supplies ISO strings, so drop the SQLite function
|
||||||
|
// default (TEXT/VARCHAR can't carry it in MySQL anyway).
|
||||||
|
s = reStrftimeDefault.ReplaceAllString(s, "''")
|
||||||
|
// Remaining INTEGER columns → BIGINT so foreign-key parent/child types match.
|
||||||
|
s = reBareInteger.ReplaceAllString(s, "BIGINT")
|
||||||
|
// TEXT columns → TEXT (off-page) by default, VARCHAR(255) only when indexed,
|
||||||
|
// a primary key, or default-bearing — keeping the row under MySQL's limit.
|
||||||
|
s = translateTextColumns(s)
|
||||||
|
// `key` is a MySQL reserved word — backtick the settings column declaration.
|
||||||
|
s = reKeyColumn.ReplaceAllString(s, "$1`key`")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateTextColumns rewrites each "<col> TEXT" column declaration to the
|
||||||
|
// right MySQL type, deciding per line so it can see whether the line carries a
|
||||||
|
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
|
||||||
|
func translateTextColumns(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
m := reColText.FindStringSubmatchIndex(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
col := line[m[2]:m[3]]
|
||||||
|
var typ string
|
||||||
|
switch {
|
||||||
|
case longTextColumns[col] != "":
|
||||||
|
typ = longTextColumns[col]
|
||||||
|
case varcharColumns[col],
|
||||||
|
strings.Contains(line, "DEFAULT"),
|
||||||
|
strings.Contains(line, "PRIMARY KEY"):
|
||||||
|
typ = "VARCHAR(255)"
|
||||||
|
default:
|
||||||
|
typ = "TEXT"
|
||||||
|
}
|
||||||
|
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
||||||
|
// then applies the (translated) embedded migrations. multiStatements is enabled
|
||||||
|
// so multi-statement migration files run in a single Exec.
|
||||||
|
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||||
|
if strings.TrimSpace(c.Host) == "" {
|
||||||
|
return nil, fmt.Errorf("host is required")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(c.Database)
|
||||||
|
if !validDBIdent(name) {
|
||||||
|
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||||
|
}
|
||||||
|
// Ensure the database exists (connect server-level first).
|
||||||
|
if err := PingMySQL(c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := sql.Open("mysql", c.dsn())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
|
}
|
||||||
|
// The UI fires bursts of concurrent queries (the Preferences dialog alone
|
||||||
|
// loads ~8 settings in parallel, plus the grid and live cluster status). A
|
||||||
|
// low cap turns such a burst into a deadlock-like stall against a remote
|
||||||
|
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
|
||||||
|
conn.SetMaxOpenConns(50)
|
||||||
|
conn.SetMaxIdleConns(10)
|
||||||
|
conn.SetConnMaxIdleTime(90 * time.Second)
|
||||||
|
// No max lifetime: a slow server's first migration can run for minutes on a
|
||||||
|
// single connection, and reaping it mid-migration drops the selected database
|
||||||
|
// (surfacing as "Unknown database"). Idle connections are still recycled
|
||||||
|
// after 90s, and the driver retries stale pooled connections.
|
||||||
|
conn.SetConnMaxLifetime(0)
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||||
|
}
|
||||||
|
// Set the dialect before migrating so the runner takes the MySQL path
|
||||||
|
// (per-statement, idempotent) rather than the SQLite transaction path.
|
||||||
|
Dialect = "mysql"
|
||||||
|
fresh, err := isFreshMySQL(conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
Dialect = "sqlite"
|
||||||
|
return nil, fmt.Errorf("inspect database: %w", err)
|
||||||
|
}
|
||||||
|
if fresh {
|
||||||
|
// Empty database: build the whole final schema in one pass (one CREATE
|
||||||
|
// per table, no ALTERs) — far faster than replaying 21 migrations,
|
||||||
|
// especially on a server with slow DDL.
|
||||||
|
err = applyMySQLBaseline(conn)
|
||||||
|
} else {
|
||||||
|
// Existing database: apply only the migrations it's missing.
|
||||||
|
err = migrate(conn, mysqlDDL)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
|
||||||
|
// table), so the baseline fast-path applies. A partially-migrated database is
|
||||||
|
// NOT fresh and goes through the incremental migrator.
|
||||||
|
func isFreshMySQL(conn *sql.DB) (bool, error) {
|
||||||
|
var name string
|
||||||
|
err := conn.QueryRow("SHOW TABLES LIKE 'qso'").Scan(&name)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMySQLBaseline creates the entire current schema on a fresh MySQL database
|
||||||
|
// in a single pass, then records every migration as already applied. The schema
|
||||||
|
// is derived from the migrations themselves (replayed on a throwaway in-memory
|
||||||
|
// SQLite, whose sqlite_master holds each table's FINAL CREATE statement with all
|
||||||
|
// ALTER-added columns folded in) and translated to MySQL — so there's no second
|
||||||
|
// schema to maintain and the two backends can't drift. Future migrations apply
|
||||||
|
// incrementally on top.
|
||||||
|
func applyMySQLBaseline(conn *sql.DB) error {
|
||||||
|
mem, err := sql.Open("sqlite", "file:opslog_baseline?mode=memory&cache=shared")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open baseline sqlite: %w", err)
|
||||||
|
}
|
||||||
|
defer mem.Close()
|
||||||
|
if err := migrate(mem, nil); err != nil {
|
||||||
|
return fmt.Errorf("build baseline schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var migNames []string
|
||||||
|
mrows, err := mem.Query("SELECT name FROM schema_migrations ORDER BY name")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read baseline migrations: %w", err)
|
||||||
|
}
|
||||||
|
for mrows.Next() {
|
||||||
|
var n string
|
||||||
|
if err := mrows.Scan(&n); err != nil {
|
||||||
|
mrows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
migNames = append(migNames, n)
|
||||||
|
}
|
||||||
|
mrows.Close()
|
||||||
|
|
||||||
|
var tables, indexes []string
|
||||||
|
srows, err := mem.Query("SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read baseline schema: %w", err)
|
||||||
|
}
|
||||||
|
for srows.Next() {
|
||||||
|
var typ, s string
|
||||||
|
if err := srows.Scan(&typ, &s); err != nil {
|
||||||
|
srows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case "table":
|
||||||
|
tables = append(tables, s)
|
||||||
|
case "index":
|
||||||
|
indexes = append(indexes, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
srows.Close()
|
||||||
|
|
||||||
|
// Apply on one connection with FK checks off so table order doesn't matter.
|
||||||
|
ctx := context.Background()
|
||||||
|
c, err := conn.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("acquire conn: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
_ = c.Close()
|
||||||
|
}()
|
||||||
|
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
|
||||||
|
return fmt.Errorf("disable fk checks: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := c.ExecContext(ctx, mysqlDDL(schemaMigrationsDDL)); err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
for _, tb := range tables {
|
||||||
|
if _, err := c.ExecContext(ctx, mysqlDDL(tb)); err != nil {
|
||||||
|
if isIgnorableDDLError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("baseline table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ix := range indexes {
|
||||||
|
if _, err := c.ExecContext(ctx, mysqlDDL(ix)); err != nil {
|
||||||
|
if isIgnorableDDLError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("baseline index: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range migNames {
|
||||||
|
if _, err := conn.Exec("INSERT INTO schema_migrations(name) VALUES(?)", n); err != nil {
|
||||||
|
if isIgnorableDDLError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("record migration %s: %w", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMySQLMigration executes a translated migration one statement at a time.
|
||||||
|
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
|
||||||
|
// so running statements individually (and tolerating "already exists" errors)
|
||||||
|
// keeps migrations idempotent and lets a half-applied database self-heal.
|
||||||
|
//
|
||||||
|
// It runs on a single dedicated connection with FOREIGN_KEY_CHECKS=0, because a
|
||||||
|
// few migrations recreate tables by DROP/RENAME in an order that trips MySQL's
|
||||||
|
// foreign-key enforcement (SQLite is laxer). The flag is reset to 1 before the
|
||||||
|
// connection returns to the pool, so runtime queries keep FK enforcement.
|
||||||
|
func applyMySQLMigration(conn *sql.DB, sqlText string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
c, err := conn.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("acquire conn: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
_ = c.Close()
|
||||||
|
}()
|
||||||
|
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
|
||||||
|
return fmt.Errorf("disable fk checks: %w", err)
|
||||||
|
}
|
||||||
|
for _, stmt := range coalesceAddColumns(splitStatements(sqlText)) {
|
||||||
|
if _, err := c.ExecContext(ctx, stmt); err != nil {
|
||||||
|
if isIgnorableDDLError(err) {
|
||||||
|
// A coalesced multi-column ALTER fails wholesale if even one
|
||||||
|
// column already exists (partial prior apply). Re-run it column
|
||||||
|
// by column so the missing ones still get added.
|
||||||
|
if cols := decomposeAddColumns(stmt); len(cols) > 1 {
|
||||||
|
for _, one := range cols {
|
||||||
|
if _, e := c.ExecContext(ctx, one); e != nil && !isIgnorableDDLError(e) {
|
||||||
|
return fmt.Errorf("statement %q: %w", firstLine(one), e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var reAddColumn = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+COLUMN\s+(.+)$`)
|
||||||
|
|
||||||
|
// coalesceAddColumns merges consecutive "ALTER TABLE t ADD COLUMN …" statements
|
||||||
|
// on the same table into a single multi-column ALTER. SQLite needs one ALTER per
|
||||||
|
// column, but each is a full table operation in MySQL — and on a slow server
|
||||||
|
// that's seconds apiece, turning a ~50-column migration into minutes (long
|
||||||
|
// enough to trip connection lifetimes and stall startup). One combined ALTER is
|
||||||
|
// a single table rebuild instead of fifty.
|
||||||
|
func coalesceAddColumns(stmts []string) []string {
|
||||||
|
out := make([]string, 0, len(stmts))
|
||||||
|
for i := 0; i < len(stmts); {
|
||||||
|
m := reAddColumn.FindStringSubmatch(stmts[i])
|
||||||
|
if m == nil {
|
||||||
|
out = append(out, stmts[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
table := m[1]
|
||||||
|
cols := []string{strings.TrimSpace(m[2])}
|
||||||
|
j := i + 1
|
||||||
|
for j < len(stmts) {
|
||||||
|
m2 := reAddColumn.FindStringSubmatch(stmts[j])
|
||||||
|
if m2 == nil || m2[1] != table {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cols = append(cols, strings.TrimSpace(m2[2]))
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.Join(cols, ", ADD COLUMN "))
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// decomposeAddColumns splits a (possibly coalesced) ADD COLUMN ALTER back into
|
||||||
|
// one ALTER per column, for the idempotent fallback path.
|
||||||
|
func decomposeAddColumns(stmt string) []string {
|
||||||
|
m := reAddColumn.FindStringSubmatch(stmt)
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
table := m[1]
|
||||||
|
var out []string
|
||||||
|
for _, col := range strings.Split(m[2], ", ADD COLUMN ") {
|
||||||
|
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.TrimSpace(col))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitStatements breaks a migration file into individual SQL statements,
|
||||||
|
// dropping comments (full-line and inline "-- …") and blank fragments. Our
|
||||||
|
// migrations never embed a ';' or '--' inside a string literal, so this is safe.
|
||||||
|
func splitStatements(sqlText string) []string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, line := range strings.Split(sqlText, "\n") {
|
||||||
|
if i := strings.Index(line, "--"); i >= 0 {
|
||||||
|
line = line[:i] // strip inline / full-line comment
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(b.String(), ";") {
|
||||||
|
if strings.TrimSpace(part) != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAccessDenied reports whether a MySQL error is a privilege denial — e.g. a
|
||||||
|
// restricted user trying to CREATE DATABASE. Such users can still operate on a
|
||||||
|
// pre-existing database they've been granted, so the caller treats this as
|
||||||
|
// non-fatal and verifies database access separately.
|
||||||
|
func isAccessDenied(err error) bool {
|
||||||
|
var me *mysqldriver.MySQLError
|
||||||
|
if !errors.As(err, &me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch me.Number {
|
||||||
|
case 1044, // ER_DBACCESS_DENIED_ERROR — access denied to database
|
||||||
|
1045, // ER_ACCESS_DENIED_ERROR — access denied for user
|
||||||
|
1142: // ER_TABLEACCESS_DENIED_ERROR — command denied to user
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIgnorableDDLError reports whether a MySQL error means the object the
|
||||||
|
// statement creates/drops is already in the intended state — safe to skip when
|
||||||
|
// re-applying a forward-only migration.
|
||||||
|
func isIgnorableDDLError(err error) bool {
|
||||||
|
var me *mysqldriver.MySQLError
|
||||||
|
if !errors.As(err, &me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch me.Number {
|
||||||
|
case 1050, // ER_TABLE_EXISTS_ERROR — CREATE TABLE on an existing table
|
||||||
|
1051, // ER_BAD_TABLE_ERROR — DROP TABLE on a missing table
|
||||||
|
1060, // ER_DUP_FIELDNAME — ADD COLUMN already present
|
||||||
|
1061, // ER_DUP_KEYNAME — CREATE INDEX already present
|
||||||
|
1091: // ER_CANT_DROP_FIELD_OR_KEY — DROP of something that doesn't exist
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMySQLDDL_Translations(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"autoinc pk", "id INTEGER PRIMARY KEY AUTOINCREMENT,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
|
||||||
|
{"plain pk", "id INTEGER PRIMARY KEY,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
|
||||||
|
{"bare integer", "freq_hz INTEGER,", "freq_hz BIGINT,"},
|
||||||
|
{"strftime default", "created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),",
|
||||||
|
"created_at VARCHAR(255) NOT NULL DEFAULT '',"},
|
||||||
|
{"strftime tight", "updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),",
|
||||||
|
"updated_at VARCHAR(255) NOT NULL DEFAULT '',"},
|
||||||
|
// The column-name/type whitespace collapses to a single space — harmless.
|
||||||
|
{"text default N", "qsl_sent TEXT DEFAULT 'N',", "qsl_sent VARCHAR(255) DEFAULT 'N',"},
|
||||||
|
{"indexed col → varchar", "callsign TEXT NOT NULL,", "callsign VARCHAR(255) NOT NULL,"},
|
||||||
|
{"plain non-indexed col → text", "name TEXT,", "name TEXT,"},
|
||||||
|
{"plain text stays text", "comment TEXT,", "comment TEXT,"},
|
||||||
|
{"json longtext", " json TEXT NOT NULL,", " json LONGTEXT NOT NULL,"},
|
||||||
|
{"create index", "CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);",
|
||||||
|
"CREATE INDEX idx_qso_dxcc ON qso(dxcc);"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := mysqlDDL(c.in); got != c.want {
|
||||||
|
t.Errorf("%s:\n in %q\n got %q\n want %q", c.name, c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLDDL_KeyColumnBackticked(t *testing.T) {
|
||||||
|
in := "CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);"
|
||||||
|
got := mysqlDDL(in)
|
||||||
|
if !strings.Contains(got, "`key` VARCHAR(255) PRIMARY KEY") {
|
||||||
|
t.Errorf("key column not backticked/translated:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitStatements(t *testing.T) {
|
||||||
|
in := "-- a comment\n" +
|
||||||
|
"ALTER TABLE qso ADD COLUMN a TEXT;\n" +
|
||||||
|
"ALTER TABLE qso ADD COLUMN b TEXT; -- inline note\n" +
|
||||||
|
"\n" +
|
||||||
|
"CREATE INDEX idx ON qso(a);\n"
|
||||||
|
got := splitStatements(in)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("want 3 statements, got %d: %#v", len(got), got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[0], "ADD COLUMN a") ||
|
||||||
|
!strings.Contains(got[1], "ADD COLUMN b") ||
|
||||||
|
!strings.Contains(got[2], "CREATE INDEX") {
|
||||||
|
t.Errorf("unexpected split: %#v", got)
|
||||||
|
}
|
||||||
|
// No fragment should be a comment-only or blank statement.
|
||||||
|
for _, s := range got {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
t.Errorf("empty statement in result: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every embedded migration must split into at least one runnable statement
|
||||||
|
// and never produce an empty fragment.
|
||||||
|
func TestSplitStatements_AllMigrations(t *testing.T) {
|
||||||
|
entries, _ := migrationsFS.ReadDir("migrations")
|
||||||
|
for _, e := range entries {
|
||||||
|
if !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, _ := migrationsFS.ReadFile("migrations/" + e.Name())
|
||||||
|
stmts := splitStatements(mysqlDDL(string(raw)))
|
||||||
|
if len(stmts) == 0 {
|
||||||
|
t.Errorf("%s: produced no statements", e.Name())
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
t.Errorf("%s: empty statement fragment", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMySQLDDL_QSORowSizeUnderLimit guards against the InnoDB 65535-byte row
|
||||||
|
// limit: every VARCHAR(255) in utf8mb4 costs 1020 bytes in-row, and the qso
|
||||||
|
// table (built from 0001 + the qso-only ALTERs in 0003 and 0019) must stay well
|
||||||
|
// clear of it. TEXT columns are off-page and don't count here.
|
||||||
|
func TestMySQLDDL_QSORowSizeUnderLimit(t *testing.T) {
|
||||||
|
qsoMigrations := []string{"0001_init.sql", "0003_adif_extra.sql", "0019_adif_317_fields.sql"}
|
||||||
|
varchars := 0
|
||||||
|
for _, name := range qsoMigrations {
|
||||||
|
raw, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
varchars += strings.Count(mysqlDDL(string(raw)), "VARCHAR(255)")
|
||||||
|
}
|
||||||
|
const bytesPerVarchar = 255 * 4 // utf8mb4
|
||||||
|
rowBytes := varchars * bytesPerVarchar
|
||||||
|
t.Logf("qso VARCHAR(255) columns: %d (~%d bytes in-row)", varchars, rowBytes)
|
||||||
|
if rowBytes > 60000 { // leave headroom under the 65535 hard limit
|
||||||
|
t.Errorf("qso row too large: %d VARCHAR(255) cols = ~%d bytes (limit 65535)", varchars, rowBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMySQLDDL_NoLeftoverSQLiteisms translates every embedded migration and
|
||||||
|
// fails if any SQLite-only construct survives — a fast guard against a new
|
||||||
|
// migration sneaking in a dialect-ism the translator doesn't cover.
|
||||||
|
func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reInteger := regexp.MustCompile(`\bINTEGER\b`)
|
||||||
|
for _, e := range entries {
|
||||||
|
if !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := migrationsFS.ReadFile("migrations/" + e.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := mysqlDDL(string(raw))
|
||||||
|
if strings.Contains(out, "AUTOINCREMENT") {
|
||||||
|
t.Errorf("%s: AUTOINCREMENT survived", e.Name())
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "strftime") {
|
||||||
|
t.Errorf("%s: strftime survived", e.Name())
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "IF NOT EXISTS idx") {
|
||||||
|
t.Errorf("%s: CREATE INDEX IF NOT EXISTS survived", e.Name())
|
||||||
|
}
|
||||||
|
// Strip comment lines before checking for bare INTEGER (comments are
|
||||||
|
// prose and may legitimately mention the word).
|
||||||
|
var code strings.Builder
|
||||||
|
for _, ln := range strings.Split(out, "\n") {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(ln), "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code.WriteString(ln)
|
||||||
|
code.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if reInteger.MatchString(code.String()) {
|
||||||
|
t.Errorf("%s: bare INTEGER survived in code", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-14
@@ -278,36 +278,38 @@ func stripAnnotation(s string, open, close rune, cb func(string)) string {
|
|||||||
|
|
||||||
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
|
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
|
||||||
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
|
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
|
||||||
// operator's home DXCC. NOTE: "MM" and "AM" are NOT here — a TRAILING /MM or
|
// operator's home DXCC. A TRAILING /MM or /AM (maritime/aeronautical mobile) is
|
||||||
// /AM (maritime/aeronautical mobile) means "no DXCC entity", while a LEADING
|
// handled in normalizeCallsign (stripped, home entity kept) so a LEADING "MM"
|
||||||
// "MM" is the Scotland operating prefix; both are handled in normalizeCallsign.
|
// (the Scotland prefix) isn't mistaken for it.
|
||||||
var suffixModifiers = map[string]bool{
|
var suffixModifiers = map[string]bool{
|
||||||
"P": true, "M": true, "QRP": true, "A": true,
|
"P": true, "M": true, "QRP": true, "A": true,
|
||||||
"PM": true, "LH": true,
|
"PM": true, "LH": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeCallsign uppercases, trims, and resolves the "active" call when
|
// normalizeCallsign uppercases, trims, and resolves the "active" call when the
|
||||||
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). Returns "" for
|
// operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). A trailing /MM or /AM
|
||||||
// maritime/aeronautical mobile (.../MM, .../AM), which count for no DXCC.
|
// (maritime/aeronautical mobile) is stripped so the home entity still resolves
|
||||||
|
// (YB1SCY/AM → YB1SCY → Indonesia) — strict DXCC says no entity, but the log
|
||||||
|
// should still show the operator's country.
|
||||||
func normalizeCallsign(s string) string {
|
func normalizeCallsign(s string) string {
|
||||||
s = strings.ToUpper(strings.TrimSpace(s))
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
if !strings.ContainsRune(s, '/') {
|
if !strings.ContainsRune(s, '/') {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
parts := strings.Split(s, "/")
|
parts := strings.Split(s, "/")
|
||||||
// A trailing /MM or /AM is maritime/aeronautical mobile → no DXCC entity.
|
|
||||||
// (A leading "MM" is the Scotland prefix and must NOT trigger this.)
|
|
||||||
for i, p := range parts {
|
|
||||||
if i > 0 && (p == "MM" || p == "AM") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keep := parts[:0]
|
keep := parts[:0]
|
||||||
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
|
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
|
||||||
for _, p := range parts {
|
for i, p := range parts {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile is stripped and
|
||||||
|
// the operator's home entity is kept, so the contact still resolves to a
|
||||||
|
// country in the log (e.g. YB1SCY/AM → Indonesia). A LEADING "MM" is the
|
||||||
|
// Scotland operating prefix (MM/F4NIE) and must NOT be stripped.
|
||||||
|
if i > 0 && (p == "MM" || p == "AM") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if suffixModifiers[p] {
|
if suffixModifiers[p] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ func TestNormalize(t *testing.T) {
|
|||||||
"f4bpo": "F4BPO",
|
"f4bpo": "F4BPO",
|
||||||
" F4BPO ": "F4BPO",
|
" F4BPO ": "F4BPO",
|
||||||
"F4BPO/P": "F4BPO",
|
"F4BPO/P": "F4BPO",
|
||||||
"F4BPO/MM": "", // maritime mobile → no DXCC entity
|
"F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
|
||||||
"F4BPO/AM": "", // aeronautical mobile → no DXCC entity
|
"F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
|
||||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Config struct {
|
|||||||
User string
|
User string
|
||||||
Password string
|
Password string
|
||||||
From string
|
From string
|
||||||
|
ReplyTo string // optional Reply-To: where replies should go (e.g. a personal inbox)
|
||||||
Encryption string // "ssl" | "starttls" | "none"
|
Encryption string // "ssl" | "starttls" | "none"
|
||||||
Auth bool // SMTP requires authorization (send username/password)
|
Auth bool // SMTP requires authorization (send username/password)
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,11 @@ func Send(cfg Config, to, subject, body, attachPath string) error {
|
|||||||
if err := m.To(to); err != nil {
|
if err := m.To(to); err != nil {
|
||||||
return fmt.Errorf("bad recipient %q: %w", to, err)
|
return fmt.Errorf("bad recipient %q: %w", to, err)
|
||||||
}
|
}
|
||||||
|
if cfg.ReplyTo != "" {
|
||||||
|
if err := m.ReplyTo(cfg.ReplyTo); err != nil {
|
||||||
|
return fmt.Errorf("bad reply-to %q: %w", cfg.ReplyTo, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
m.Subject(subject)
|
m.Subject(subject)
|
||||||
m.SetBodyString(mail.TypeTextPlain, body)
|
m.SetBodyString(mail.TypeTextPlain, body)
|
||||||
if attachPath != "" {
|
if attachPath != "" {
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
package extsvc
|
package extsvc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
|
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
|
||||||
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
|
// when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
|
||||||
const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
||||||
|
|
||||||
|
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
|
||||||
|
// file in one multipart request and dedupes server-side, so a manual upload of
|
||||||
|
// N QSOs is one HTTP request instead of N realtime.php calls.
|
||||||
|
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
||||||
|
|
||||||
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
||||||
// requires an api parameter that identifies the client software (not the
|
// requires an api parameter that identifies the client software (not the
|
||||||
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
||||||
@@ -67,6 +74,73 @@ func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig,
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
|
||||||
|
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
|
||||||
|
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
|
||||||
|
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
|
||||||
|
//
|
||||||
|
// Multipart form fields: email, password, callsign, api, clientident, and the
|
||||||
|
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
|
||||||
|
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc string) (UploadResult, error) {
|
||||||
|
email := strings.TrimSpace(cfg.Email)
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||||
|
switch {
|
||||||
|
case email == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: account email not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: password not set")
|
||||||
|
case call == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
|
||||||
|
case strings.TrimSpace(adifDoc) == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
|
||||||
|
}
|
||||||
|
api := strings.TrimSpace(cfg.APIKey)
|
||||||
|
if api == "" {
|
||||||
|
api = clublogAppAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("email", email)
|
||||||
|
_ = mw.WriteField("password", cfg.Password)
|
||||||
|
_ = mw.WriteField("callsign", call)
|
||||||
|
_ = mw.WriteField("api", api)
|
||||||
|
_ = mw.WriteField("clientident", "OpsLog")
|
||||||
|
fw, err := mw.CreateFormFile("file", "opslog.adi")
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(fw, adifDoc); err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("clublog: 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 {
|
||||||
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
// TestClublog validates the configured credentials by attempting a no-op
|
// TestClublog validates the configured credentials by attempting a no-op
|
||||||
// style check. Club Log has no dedicated status endpoint, so we report the
|
// style check. Club Log has no dedicated status endpoint, so we report the
|
||||||
// fields look complete; a real failure surfaces on the first upload.
|
// fields look complete; a real failure surfaces on the first upload.
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ type ServiceConfig struct {
|
|||||||
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"`
|
||||||
@@ -84,13 +84,19 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
|||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-33
@@ -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,8 @@ 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()
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,11 +157,9 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
// 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 +176,64 @@ 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)
|
||||||
|
}
|
||||||
|
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++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Direction is "inbound" (we listen) or "outbound" (we emit).
|
// Direction is "inbound" (we listen) or "outbound" (we emit).
|
||||||
@@ -112,10 +114,10 @@ func (r *Repo) Save(ctx context.Context, c *Config) error {
|
|||||||
direction = ?, name = ?, port = ?, service_type = ?,
|
direction = ?, name = ?, port = ?, service_type = ?,
|
||||||
multicast = ?, multicast_group = ?, destination_ip = ?,
|
multicast = ?, multicast_group = ?, destination_ip = ?,
|
||||||
enabled = ?, sort_order = ?,
|
enabled = ?, sort_order = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
c.Direction, c.Name, c.Port, c.ServiceType,
|
c.Direction, c.Name, c.Port, c.ServiceType,
|
||||||
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.ID)
|
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, db.NowISO(), c.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update udp: %w", err)
|
return fmt.Errorf("update udp: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-14
@@ -12,6 +12,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotFound is returned by providers when a callsign is unknown.
|
// ErrNotFound is returned by providers when a callsign is unknown.
|
||||||
@@ -357,24 +359,29 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
return r, true
|
return r, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put upserts a lookup result.
|
// Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
|
||||||
|
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
|
||||||
func (c *Cache) Put(ctx context.Context, r Result) error {
|
func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||||
_, err := c.db.ExecContext(ctx, `
|
updateCols := []string{
|
||||||
|
"name", "qth", "address", "state", "cnty",
|
||||||
|
"country", "grid", "lat", "lon",
|
||||||
|
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url",
|
||||||
|
"source", "fetched_at",
|
||||||
|
}
|
||||||
|
// The lookup cache always lives in the local SQLite database, so SQLite
|
||||||
|
// upsert syntax is used unconditionally.
|
||||||
|
sets := make([]string, len(updateCols))
|
||||||
|
for i, c := range updateCols {
|
||||||
|
sets[i] = c + " = excluded." + c
|
||||||
|
}
|
||||||
|
q := `
|
||||||
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
||||||
country, grid, lat, lon,
|
country, grid, lat, lon,
|
||||||
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||||
source, fetched_at)
|
source, fetched_at)
|
||||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,
|
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?)
|
||||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
||||||
ON CONFLICT(callsign) DO UPDATE SET
|
_, err := c.db.ExecContext(ctx, q,
|
||||||
name = excluded.name, qth = excluded.qth, address = excluded.address,
|
|
||||||
state = excluded.state, cnty = excluded.cnty,
|
|
||||||
country = excluded.country, grid = excluded.grid,
|
|
||||||
lat = excluded.lat, lon = excluded.lon,
|
|
||||||
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
|
|
||||||
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
|
|
||||||
image_url = excluded.image_url,
|
|
||||||
source = excluded.source, fetched_at = excluded.fetched_at`,
|
|
||||||
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
||||||
nullable(r.State), nullable(r.County),
|
nullable(r.State), nullable(r.County),
|
||||||
nullable(r.Country), nullable(r.Grid),
|
nullable(r.Country), nullable(r.Grid),
|
||||||
@@ -382,7 +389,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
||||||
nullable(r.ImageURL),
|
nullable(r.ImageURL),
|
||||||
r.Source,
|
r.Source, db.NowISO(),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Station is a radio / TRX line. The display Name is also what gets
|
// Station is a radio / TRX line. The display Name is also what gets
|
||||||
@@ -56,6 +58,59 @@ type Repo struct{ db *sql.DB }
|
|||||||
|
|
||||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||||
|
|
||||||
|
// CopyProfile duplicates the entire stations→antennas→bands tree from one
|
||||||
|
// profile to another (used when a profile is duplicated). New ids are assigned;
|
||||||
|
// the band defaults and ordering are preserved.
|
||||||
|
func (r *Repo) CopyProfile(ctx context.Context, srcProfileID, dstProfileID int64) error {
|
||||||
|
tree, err := r.ListTree(ctx, srcProfileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
now := db.NowISO()
|
||||||
|
for _, st := range tree {
|
||||||
|
var pwr any
|
||||||
|
if st.TXPower != nil {
|
||||||
|
pwr = *st.TXPower
|
||||||
|
}
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?)`, dstProfileID, st.Name, pwr, st.SortOrder, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy station: %w", err)
|
||||||
|
}
|
||||||
|
newStationID, _ := res.LastInsertId()
|
||||||
|
for _, ant := range st.Antennas {
|
||||||
|
ares, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO operating_antennas(station_id, name, sort_order, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?)`, newStationID, ant.Name, ant.SortOrder, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy antenna: %w", err)
|
||||||
|
}
|
||||||
|
newAntID, _ := ares.LastInsertId()
|
||||||
|
for _, b := range ant.Bands {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default) VALUES(?,?,?)`,
|
||||||
|
newAntID, b.Band, boolToInt(b.IsDefault)); err != nil {
|
||||||
|
return fmt.Errorf("copy band: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// ListTree returns every station for the profile with its nested antennas
|
// ListTree returns every station for the profile with its nested antennas
|
||||||
// and bands. One round-trip per level — three queries total regardless of
|
// and bands. One round-trip per level — three queries total regardless of
|
||||||
// tree size, so the Settings panel stays snappy on big setups.
|
// tree size, so the Settings panel stays snappy on big setups.
|
||||||
@@ -190,8 +245,8 @@ func (r *Repo) SaveStation(ctx context.Context, s *Station) error {
|
|||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE operating_stations
|
`UPDATE operating_stations
|
||||||
SET name = ?, tx_pwr = ?, sort_order = ?,
|
SET name = ?, tx_pwr = ?, sort_order = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
updated_at = ?
|
||||||
WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID)
|
WHERE id = ?`, s.Name, pwr, s.SortOrder, db.NowISO(), s.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update station: %w", err)
|
return fmt.Errorf("update station: %w", err)
|
||||||
}
|
}
|
||||||
@@ -230,8 +285,8 @@ func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error {
|
|||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`UPDATE operating_antennas
|
`UPDATE operating_antennas
|
||||||
SET name = ?, sort_order = ?,
|
SET name = ?, sort_order = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
updated_at = ?
|
||||||
WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil {
|
WHERE id = ?`, a.Name, a.SortOrder, db.NowISO(), a.ID); err != nil {
|
||||||
return fmt.Errorf("update antenna: %w", err)
|
return fmt.Errorf("update antenna: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,23 @@ package profile
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ProfileDB is a profile's logbook database target. Backend "" or "sqlite" =
|
||||||
|
// the local SQLite file; "mysql" = the shared MySQL server described by the
|
||||||
|
// rest. Switching the active profile switches the live logbook to this.
|
||||||
|
type ProfileDB struct {
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
}
|
||||||
|
|
||||||
// Profile is one operating configuration. A user typically keeps a few:
|
// Profile is one operating configuration. A user typically keeps a few:
|
||||||
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
|
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
@@ -43,6 +56,7 @@ type Profile struct {
|
|||||||
TxPower *float64 `json:"tx_pwr,omitempty"`
|
TxPower *float64 `json:"tx_pwr,omitempty"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
|
DB ProfileDB `json:"db"` // per-profile logbook target (empty backend = local SQLite)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -56,7 +70,7 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
|||||||
const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
||||||
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||||
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
|
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
|
||||||
is_active, sort_order, created_at, updated_at`
|
is_active, sort_order, db_config, created_at, updated_at`
|
||||||
|
|
||||||
// List returns every profile, active first then by sort_order/id.
|
// List returns every profile, active first then by sort_order/id.
|
||||||
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
|
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
|
||||||
@@ -136,6 +150,20 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDB updates only a profile's logbook database target, leaving the rest of
|
||||||
|
// the profile untouched (the general Save deliberately doesn't write db_config).
|
||||||
|
func (r *Repo) SetDB(ctx context.Context, id int64, cfg ProfileDB) error {
|
||||||
|
b, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
_, err = r.db.ExecContext(ctx,
|
||||||
|
`UPDATE station_profiles SET db_config = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
string(b), now, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// SetActive atomically switches the active profile. Clears the flag on all
|
// SetActive atomically switches the active profile. Clears the flag on all
|
||||||
// rows first to keep the "only one active" invariant from the schema doc.
|
// rows first to keep the "only one active" invariant from the schema doc.
|
||||||
func (r *Repo) SetActive(ctx context.Context, id int64) error {
|
func (r *Repo) SetActive(ctx context.Context, id int64) error {
|
||||||
@@ -202,6 +230,7 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Profile{}, err
|
return Profile{}, err
|
||||||
}
|
}
|
||||||
|
dbCfg := src.DB // Save deliberately doesn't write db_config — copy it after.
|
||||||
src.ID = 0
|
src.ID = 0
|
||||||
src.Name = newName
|
src.Name = newName
|
||||||
src.IsActive = false
|
src.IsActive = false
|
||||||
@@ -209,6 +238,12 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
|
|||||||
if err := r.Save(ctx, &src); err != nil {
|
if err := r.Save(ctx, &src); err != nil {
|
||||||
return Profile{}, err
|
return Profile{}, err
|
||||||
}
|
}
|
||||||
|
if dbCfg.Backend != "" {
|
||||||
|
if err := r.SetDB(ctx, src.ID, dbCfg); err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
src.DB = dbCfg
|
||||||
|
}
|
||||||
return src, nil
|
return src, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,15 +262,19 @@ func scan(row scannable) (Profile, error) {
|
|||||||
myDXCC, myCQZ, myITUZ sql.NullInt64
|
myDXCC, myCQZ, myITUZ sql.NullInt64
|
||||||
myLat, myLon, txPwr sql.NullFloat64
|
myLat, myLon, txPwr sql.NullFloat64
|
||||||
isActive, sortOrder int
|
isActive, sortOrder int
|
||||||
|
dbConfig sql.NullString
|
||||||
createdAt, updatedAt string
|
createdAt, updatedAt string
|
||||||
)
|
)
|
||||||
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
|
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
|
||||||
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
|
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
|
||||||
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
|
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
|
||||||
&isActive, &sortOrder, &createdAt, &updatedAt)
|
&isActive, &sortOrder, &dbConfig, &createdAt, &updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, err
|
return p, err
|
||||||
}
|
}
|
||||||
|
if s := dbConfig.String; s != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &p.DB)
|
||||||
|
}
|
||||||
p.Callsign = callsign.String
|
p.Callsign = callsign.String
|
||||||
p.Operator = operator.String
|
p.Operator = operator.String
|
||||||
p.OpName = opName.String
|
p.OpName = opName.String
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T
|
|||||||
cool: sorted[0].Warmth < 0.02,
|
cool: sorted[0].Warmth < 0.02,
|
||||||
}
|
}
|
||||||
if len(sorted) > 1 {
|
if len(sorted) > 1 {
|
||||||
plan.inserts = sorted[1:min(len(sorted), 3)] // hero + up to 2 inserts
|
plan.inserts = sorted[1:min(len(sorted), 5)] // hero + up to 4 inserts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only side-column inserts (or none): a bottom strip collides with the QSO
|
// Only side-column inserts (or none): a bottom strip collides with the QSO
|
||||||
@@ -671,9 +671,19 @@ func placeInserts(inserts []PhotoAnalysis, hero PhotoAnalysis, archetype string)
|
|||||||
return els, pxRect{x: margin, y: cardH - 380, w: cardW - 2*margin, h: 380 - margin}
|
return els, pxRect{x: margin, y: cardH - 380, w: cardW - 2*margin, h: 380 - margin}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Side column: pick the calmer half of the hero photo.
|
// Side column: pick the calmer half of the hero photo. The insert width
|
||||||
n := min(len(inserts), 3)
|
// shrinks as needed so up to 4 framed photos stack down the side.
|
||||||
w, gap, margin := 400.0, 20.0, 70.0
|
n := min(len(inserts), 4)
|
||||||
|
gap, margin := 20.0, 70.0
|
||||||
|
availH := float64(cardH) - 2*margin - float64(n-1)*gap
|
||||||
|
var arSum float64
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
arSum += float64(inserts[i].H) / float64(inserts[i].W)
|
||||||
|
}
|
||||||
|
w := 400.0
|
||||||
|
if arSum > 0 {
|
||||||
|
w = clamp(availH/arSum, 240, 400) // fit all n vertically, within sane bounds
|
||||||
|
}
|
||||||
left := halfDetail(hero, true) < halfDetail(hero, false)
|
left := halfDetail(hero, true) < halfDetail(hero, false)
|
||||||
x := cardW - w - margin
|
x := cardW - w - margin
|
||||||
if left {
|
if left {
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ type FxParams struct {
|
|||||||
Grunge *float64 `json:"grunge,omitempty"`
|
Grunge *float64 `json:"grunge,omitempty"`
|
||||||
Bevel *float64 `json:"bevel,omitempty"`
|
Bevel *float64 `json:"bevel,omitempty"`
|
||||||
Seed *float64 `json:"seed,omitempty"`
|
Seed *float64 `json:"seed,omitempty"`
|
||||||
|
Dark string `json:"dark,omitempty"` // dark inter-letter edge (per colour palette)
|
||||||
|
Outer string `json:"outer,omitempty"` // silver/rim colour (glossy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setKeys lists the JSON names of the params that are actually set, for
|
// setKeys lists the JSON names of the params that are actually set, for
|
||||||
|
|||||||
+281
-36
@@ -10,6 +10,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MergeNonZero copies every non-zero field of src onto dst. Zero-value src
|
// MergeNonZero copies every non-zero field of src onto dst. Zero-value src
|
||||||
@@ -255,6 +257,19 @@ var columnCount = countColumns(columnList)
|
|||||||
// insertPlaceholders returns "?,?,?,..." matching columnCount.
|
// insertPlaceholders returns "?,?,?,..." matching columnCount.
|
||||||
var insertPlaceholders = buildInsertPlaceholders()
|
var insertPlaceholders = buildInsertPlaceholders()
|
||||||
|
|
||||||
|
// insertCols/insertVals append created_at + updated_at so they're set from Go
|
||||||
|
// (NowISO) on every backend — MySQL has no strftime default, and binding the
|
||||||
|
// timestamp keeps a single backend-agnostic INSERT. insertArgs pairs with them.
|
||||||
|
const insertCols = columnList + `, created_at, updated_at`
|
||||||
|
|
||||||
|
var insertVals = insertPlaceholders + ",?,?"
|
||||||
|
|
||||||
|
// insertArgs returns the column values plus the two timestamps for an INSERT.
|
||||||
|
func (q *QSO) insertArgs() []any {
|
||||||
|
now := db.NowISO()
|
||||||
|
return append(q.args(), now, now)
|
||||||
|
}
|
||||||
|
|
||||||
func countColumns(s string) int {
|
func countColumns(s string) int {
|
||||||
n := 1
|
n := 1
|
||||||
for i := 0; i < len(s); i++ {
|
for i := 0; i < len(s); i++ {
|
||||||
@@ -349,34 +364,30 @@ func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
|
|||||||
q.QSODate = time.Now().UTC()
|
q.QSODate = time.Now().UTC()
|
||||||
}
|
}
|
||||||
res, err := r.db.ExecContext(ctx,
|
res, err := r.db.ExecContext(ctx,
|
||||||
`INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`,
|
`INSERT INTO qso (`+insertCols+`) VALUES (`+insertVals+`)`,
|
||||||
q.args()...)
|
q.insertArgs()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("insert qso: %w", err)
|
return 0, fmt.Errorf("insert qso: %w", err)
|
||||||
}
|
}
|
||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddBatch inserts many QSOs inside a single transaction using a prepared
|
// batchInsertRows is how many QSOs go into one multi-row INSERT on MySQL. Each
|
||||||
// statement. Empty-callsign records are skipped. Returns rows inserted.
|
// row carries ~135 columns, so 200 rows ≈ 27k bound parameters — well under
|
||||||
|
// MySQL's 65535-placeholder limit and a modest packet — while cutting network
|
||||||
|
// round-trips ~200× versus one INSERT per row (critical for a remote server).
|
||||||
|
const batchInsertRows = 200
|
||||||
|
|
||||||
|
// AddBatch inserts many QSOs inside a single transaction. Empty-callsign records
|
||||||
|
// are skipped. On MySQL it uses chunked multi-row INSERTs so a 27k-record import
|
||||||
|
// over a remote link takes seconds, not many minutes; on local SQLite a prepared
|
||||||
|
// statement per row is already fast.
|
||||||
func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
|
func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
|
||||||
if len(qsos) == 0 {
|
if len(qsos) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
// Normalise and drop empty-callsign records up front.
|
||||||
if err != nil {
|
rows := make([]QSO, 0, len(qsos))
|
||||||
return 0, fmt.Errorf("begin tx: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
stmt, err := tx.PrepareContext(ctx,
|
|
||||||
`INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("prepare batch insert: %w", err)
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
var inserted int64
|
|
||||||
for _, q := range qsos {
|
for _, q := range qsos {
|
||||||
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||||
if q.Callsign == "" {
|
if q.Callsign == "" {
|
||||||
@@ -385,17 +396,77 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
|
|||||||
if q.QSODate.IsZero() {
|
if q.QSODate.IsZero() {
|
||||||
q.QSODate = time.Now().UTC()
|
q.QSODate = time.Now().UTC()
|
||||||
}
|
}
|
||||||
if _, err := stmt.ExecContext(ctx, q.args()...); err != nil {
|
rows = append(rows, q)
|
||||||
return inserted, fmt.Errorf("insert qso %q: %w", q.Callsign, err)
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var inserted int64
|
||||||
|
if db.IsMySQL() {
|
||||||
|
rowPlaceholder := "(" + insertVals + ")"
|
||||||
|
for start := 0; start < len(rows); start += batchInsertRows {
|
||||||
|
end := start + batchInsertRows
|
||||||
|
if end > len(rows) {
|
||||||
|
end = len(rows)
|
||||||
|
}
|
||||||
|
chunk := rows[start:end]
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("INSERT INTO qso (")
|
||||||
|
sb.WriteString(insertCols)
|
||||||
|
sb.WriteString(") VALUES ")
|
||||||
|
args := make([]any, 0, len(chunk)*(columnCount+2))
|
||||||
|
for j := range chunk {
|
||||||
|
if j > 0 {
|
||||||
|
sb.WriteByte(',')
|
||||||
|
}
|
||||||
|
sb.WriteString(rowPlaceholder)
|
||||||
|
args = append(args, chunk[j].insertArgs()...)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil {
|
||||||
|
return inserted, fmt.Errorf("batch insert: %w", err)
|
||||||
|
}
|
||||||
|
inserted += int64(len(chunk))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stmt, err := tx.PrepareContext(ctx,
|
||||||
|
`INSERT INTO qso (`+insertCols+`) VALUES (`+insertVals+`)`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("prepare batch insert: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
for i := range rows {
|
||||||
|
if _, err := stmt.ExecContext(ctx, rows[i].insertArgs()...); err != nil {
|
||||||
|
return inserted, fmt.Errorf("insert qso %q: %w", rows[i].Callsign, err)
|
||||||
}
|
}
|
||||||
inserted++
|
inserted++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return 0, fmt.Errorf("commit batch: %w", err)
|
return 0, fmt.Errorf("commit batch: %w", err)
|
||||||
}
|
}
|
||||||
return inserted, nil
|
return inserted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revision returns a cheap fingerprint of the logbook — row count and the
|
||||||
|
// highest id — so a client can poll for changes made by OTHER instances on a
|
||||||
|
// shared MySQL logbook and refresh when it differs. Inserts bump the max id;
|
||||||
|
// deletes change the count.
|
||||||
|
func (r *Repo) Revision(ctx context.Context) (string, error) {
|
||||||
|
var count, maxID int64
|
||||||
|
if err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*), COALESCE(MAX(id), 0) FROM qso`).Scan(&count, &maxID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%d", count, maxID), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetByID fetches a single QSO by primary key.
|
// GetByID fetches a single QSO by primary key.
|
||||||
func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
|
func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
|
||||||
row := r.db.QueryRowContext(ctx,
|
row := r.db.QueryRowContext(ctx,
|
||||||
@@ -448,6 +519,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
|
||||||
@@ -455,8 +576,8 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
|
|||||||
func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?,
|
`UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
updated_at = ? WHERE id = ?`,
|
||||||
date, id)
|
date, db.NowISO(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mark qrz uploaded %d: %w", id, err)
|
return fmt.Errorf("mark qrz uploaded %d: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -468,8 +589,8 @@ func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error
|
|||||||
func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?,
|
`UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
updated_at = ? WHERE id = ?`,
|
||||||
date, id)
|
date, db.NowISO(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mark clublog uploaded %d: %w", id, err)
|
return fmt.Errorf("mark clublog uploaded %d: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -481,8 +602,8 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e
|
|||||||
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?,
|
`UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
updated_at = ? WHERE id = ?`,
|
||||||
date, id)
|
date, db.NowISO(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mark lotw uploaded %d: %w", id, err)
|
return fmt.Errorf("mark lotw uploaded %d: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -494,8 +615,8 @@ func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) erro
|
|||||||
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET eqsl_sent = 'Y', eqsl_sent_date = ?,
|
`UPDATE qso SET eqsl_sent = 'Y', eqsl_sent_date = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
updated_at = ? WHERE id = ?`,
|
||||||
date, id)
|
date, db.NowISO(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mark eqsl sent %d: %w", id, err)
|
return fmt.Errorf("mark eqsl sent %d: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -515,9 +636,9 @@ func (r *Repo) Update(ctx context.Context, q QSO) error {
|
|||||||
q.QSODate = time.Now().UTC()
|
q.QSODate = time.Now().UTC()
|
||||||
}
|
}
|
||||||
setClause := buildUpdateSetClause()
|
setClause := buildUpdateSetClause()
|
||||||
args := append(q.args(), q.ID)
|
args := append(q.args(), db.NowISO(), q.ID)
|
||||||
res, err := r.db.ExecContext(ctx,
|
res, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET `+setClause+`, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
`UPDATE qso SET `+setClause+`, updated_at = ? WHERE id = ?`,
|
||||||
args...)
|
args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update qso %d: %w", q.ID, err)
|
return fmt.Errorf("update qso %d: %w", q.ID, err)
|
||||||
@@ -580,6 +701,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)
|
||||||
@@ -714,6 +854,11 @@ func columnExpr(field string) (string, bool) {
|
|||||||
return f, true
|
return f, true
|
||||||
}
|
}
|
||||||
if key, ok := filterableExtras[f]; ok {
|
if key, ok := filterableExtras[f]; ok {
|
||||||
|
if db.IsMySQL() {
|
||||||
|
// JSON_EXTRACT errors on an invalid/empty document, so guard with
|
||||||
|
// NULLIF; JSON_UNQUOTE strips the quotes MySQL adds around strings.
|
||||||
|
return "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + key + "'))", true
|
||||||
|
}
|
||||||
return "json_extract(extras_json, '$." + key + "')", true
|
return "json_extract(extras_json, '$." + key + "')", true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
@@ -1245,6 +1390,106 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
|
|||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// awardCols is the column set the award engine can read — every field reachable
|
||||||
|
// from refField/inScope/confirmed in internal/award plus enrichQSOForAwards:
|
||||||
|
// the reference fields (dxcc, zones, continent, country, state, grid/vucc, iota,
|
||||||
|
// sota, pota, wwff via extras_json, and the free-text fields name/qth/address/
|
||||||
|
// comment/notes a custom award may key on), band/mode/qso_date for scoping,
|
||||||
|
// freq_hz for band recovery, and the qsl/lotw/eqsl rcvd confirmation flags.
|
||||||
|
// This drops ~125 unused columns so award computation over a remote MySQL
|
||||||
|
// backend ships a fraction of each row instead of the whole record.
|
||||||
|
//
|
||||||
|
// IMPORTANT: when a new award references a QSO field not listed here, add the
|
||||||
|
// column to this list AND populate it in scanAwardQSO below, or that award will
|
||||||
|
// silently see an empty value during stats/computation.
|
||||||
|
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
|
||||||
|
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
|
||||||
|
`name, qth, address, comment, notes, ` +
|
||||||
|
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
|
||||||
|
|
||||||
|
// IterateForAwards streams a lightweight projection of every QSO — only the
|
||||||
|
// fields award computation reads (see awardCols). All other QSO fields are left
|
||||||
|
// zero. Use IterateAll when the full record is needed. Ordered by date/id like
|
||||||
|
// IterateAll for deterministic results.
|
||||||
|
func (r *Repo) IterateForAwards(ctx context.Context, fn func(QSO) error) error {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT `+awardCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query qso (awards): %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
q, err := scanAwardQSO(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fn(q); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanAwardQSO reads one row produced by awardCols into a QSO, populating only
|
||||||
|
// the award-relevant fields. Column order MUST match awardCols.
|
||||||
|
func scanAwardQSO(s scanner) (QSO, error) {
|
||||||
|
var q QSO
|
||||||
|
var (
|
||||||
|
qsoDateStr string
|
||||||
|
freqHz sql.NullInt64
|
||||||
|
grid, vucc, country, state sql.NullString
|
||||||
|
cont, iotaRef, sota, pota sql.NullString
|
||||||
|
dxcc, cqz, ituz sql.NullInt64
|
||||||
|
name, qth, address sql.NullString
|
||||||
|
comment, notes sql.NullString
|
||||||
|
qslRcvd, lotwRcvd, eqslRcvd sql.NullString
|
||||||
|
extrasJSON sql.NullString
|
||||||
|
)
|
||||||
|
if err := s.Scan(
|
||||||
|
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
|
||||||
|
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
|
||||||
|
&name, &qth, &address, &comment, ¬es,
|
||||||
|
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
|
||||||
|
); err != nil {
|
||||||
|
return QSO{}, fmt.Errorf("scan qso (awards): %w", err)
|
||||||
|
}
|
||||||
|
q.QSODate = parseTimeLoose(qsoDateStr)
|
||||||
|
if freqHz.Valid {
|
||||||
|
v := freqHz.Int64
|
||||||
|
q.FreqHz = &v
|
||||||
|
}
|
||||||
|
q.Grid = grid.String
|
||||||
|
q.VUCCGrids = vucc.String
|
||||||
|
q.Country = country.String
|
||||||
|
q.State = state.String
|
||||||
|
q.Continent = cont.String
|
||||||
|
if cqz.Valid {
|
||||||
|
v := int(cqz.Int64)
|
||||||
|
q.CQZ = &v
|
||||||
|
}
|
||||||
|
if ituz.Valid {
|
||||||
|
v := int(ituz.Int64)
|
||||||
|
q.ITUZ = &v
|
||||||
|
}
|
||||||
|
if dxcc.Valid {
|
||||||
|
v := int(dxcc.Int64)
|
||||||
|
q.DXCC = &v
|
||||||
|
}
|
||||||
|
q.IOTA = iotaRef.String
|
||||||
|
q.SOTARef = sota.String
|
||||||
|
q.POTARef = pota.String
|
||||||
|
q.Name = name.String
|
||||||
|
q.QTH = qth.String
|
||||||
|
q.Address = address.String
|
||||||
|
q.Comment = comment.String
|
||||||
|
q.Notes = notes.String
|
||||||
|
q.QSLRcvd = qslRcvd.String
|
||||||
|
q.LOTWRcvd = lotwRcvd.String
|
||||||
|
q.EQSLRcvd = eqslRcvd.String
|
||||||
|
q.Extras = decodeExtras(extrasJSON.String)
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
// EntitySlot bundles every (band, mode) tuple ever worked for a given
|
// EntitySlot bundles every (band, mode) tuple ever worked for a given
|
||||||
// DXCC entity name. Used by the cluster spot colouring code to decide
|
// DXCC entity name. Used by the cluster spot colouring code to decide
|
||||||
// NEW / NEW SLOT / WORKED in constant time after one batched query.
|
// NEW / NEW SLOT / WORKED in constant time after one batched query.
|
||||||
@@ -1348,7 +1593,7 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
// far cheaper than N exists-queries during the import loop.
|
// far cheaper than N exists-queries during the import loop.
|
||||||
func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) {
|
func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
|
SELECT callsign, substr(qso_date, 1, 16), band, mode
|
||||||
FROM qso`)
|
FROM qso`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1376,7 +1621,7 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
|
|||||||
// confirmations back to local QSOs.
|
// confirmations back to local QSOs.
|
||||||
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
|
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
|
SELECT id, callsign, substr(qso_date, 1, 16), band, mode
|
||||||
FROM qso`)
|
FROM qso`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1463,8 +1708,8 @@ func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets
|
|||||||
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
|
`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
updated_at = ? WHERE id = ?`,
|
||||||
date, id)
|
date, db.NowISO(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
|
return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -1476,8 +1721,8 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
|||||||
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
|
`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
updated_at = ? WHERE id = ?`,
|
||||||
date, id)
|
date, db.NowISO(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
|
return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+182
-40
@@ -11,8 +11,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/secret"
|
"hamlog/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,10 +29,111 @@ type Store struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
cipher Cipher // non-nil when secrets are unlocked
|
cipher Cipher // non-nil when secrets are unlocked
|
||||||
sensitive func(key string) bool // which keys are encrypted at rest
|
sensitive func(key string) bool // which keys are encrypted at rest
|
||||||
|
|
||||||
|
// prefix scopes every key to the active profile (e.g. "p3."), so each
|
||||||
|
// station profile keeps its own complete set of settings. Empty = unscoped
|
||||||
|
// (used briefly at startup before the active profile is known).
|
||||||
|
prefix string
|
||||||
|
|
||||||
|
// cache holds every setting's RAW (as-stored) value, loaded once. Reads are
|
||||||
|
// served from memory so the Preferences dialog (dozens of keys) doesn't pay
|
||||||
|
// a network round-trip per key against a remote MySQL. Decryption still
|
||||||
|
// happens on read, so a later Unlock takes effect without reloading.
|
||||||
|
cache map[string]string
|
||||||
|
cached bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||||
|
|
||||||
|
// CopyProfile duplicates every setting from one profile to another (used when
|
||||||
|
// a profile is duplicated), preserving raw/encrypted values verbatim.
|
||||||
|
func (s *Store) CopyProfile(ctx context.Context, srcID, dstID int64) error {
|
||||||
|
if err := s.ensureCache(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srcPrefix := fmt.Sprintf("p%d.", srcID)
|
||||||
|
dstPrefix := fmt.Sprintf("p%d.", dstID)
|
||||||
|
s.mu.RLock()
|
||||||
|
pairs := make(map[string]string)
|
||||||
|
for k, v := range s.cache {
|
||||||
|
if strings.HasPrefix(k, srcPrefix) {
|
||||||
|
pairs[dstPrefix+strings.TrimPrefix(k, srcPrefix)] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
|
||||||
|
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
|
||||||
|
for fullKey, val := range pairs {
|
||||||
|
if _, err := s.db.ExecContext(ctx, q, fullKey, val, db.NowISO()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.cachePut(fullKey, val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProfile scopes all subsequent reads/writes to the given profile id, so
|
||||||
|
// each profile has its own settings. Called at startup and whenever the active
|
||||||
|
// profile changes.
|
||||||
|
func (s *Store) SetProfile(id int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.prefix = fmt.Sprintf("p%d.", id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileKey returns the storage key for the active profile.
|
||||||
|
func (s *Store) profileKey(key string) string {
|
||||||
|
s.mu.RLock()
|
||||||
|
p := s.prefix
|
||||||
|
s.mu.RUnlock()
|
||||||
|
return p + key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureCache lazily loads all settings into memory on first read. A concurrent
|
||||||
|
// double-load is harmless (the result is identical), so it's done without a
|
||||||
|
// long-held lock.
|
||||||
|
func (s *Store) ensureCache(ctx context.Context) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
ok := s.cached
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, "SELECT `key`, value FROM settings")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
m := make(map[string]string, 256)
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.cached {
|
||||||
|
s.cache = m
|
||||||
|
s.cached = true
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachePut updates the in-memory copy after a write so reads stay coherent.
|
||||||
|
func (s *Store) cachePut(key, raw string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.cache == nil {
|
||||||
|
s.cache = map[string]string{}
|
||||||
|
}
|
||||||
|
s.cache[key] = raw
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// SetSensitivePredicate registers which keys hold secrets. Set once at startup.
|
// SetSensitivePredicate registers which keys hold secrets. Set once at startup.
|
||||||
func (s *Store) SetSensitivePredicate(fn func(key string) bool) {
|
func (s *Store) SetSensitivePredicate(fn func(key string) bool) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -101,39 +204,38 @@ func (s *Store) encodeWrite(key, val string) string {
|
|||||||
|
|
||||||
// Get returns the value for key, or "" if not set.
|
// Get returns the value for key, or "" if not set.
|
||||||
func (s *Store) Get(ctx context.Context, key string) (string, error) {
|
func (s *Store) Get(ctx context.Context, key string) (string, error) {
|
||||||
var v string
|
raw, err := s.GetRaw(ctx, key)
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return s.decodeRead(key, v), nil
|
return s.decodeRead(key, raw), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRaw returns the stored value WITHOUT decryption — used by the passphrase
|
// GetRaw returns the stored value WITHOUT decryption — used by the passphrase
|
||||||
// migration which must read/re-write the raw ciphertext or plaintext.
|
// migration which must read/re-write the raw ciphertext or plaintext.
|
||||||
func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
|
func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
|
||||||
var v string
|
if err := s.ensureCache(ctx); err != nil {
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
return "", err
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
}
|
||||||
return v, err
|
full := s.profileKey(key)
|
||||||
|
s.mu.RLock()
|
||||||
|
v := s.cache[full] // "" when absent
|
||||||
|
s.mu.RUnlock()
|
||||||
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRaw stores a value verbatim (no encryption) — used by the migration.
|
// SetRaw stores a value verbatim (no encryption) — used by the migration.
|
||||||
|
// The settings table always lives in the local SQLite database (config is
|
||||||
|
// per-operator, never on the shared MySQL logbook), so SQLite syntax is used
|
||||||
|
// unconditionally. The backticks around `key` are accepted by SQLite too.
|
||||||
func (s *Store) SetRaw(ctx context.Context, key, value string) error {
|
func (s *Store) SetRaw(ctx context.Context, key, value string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `
|
full := s.profileKey(key)
|
||||||
INSERT INTO settings(key, value) VALUES(?, ?)
|
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
|
||||||
ON CONFLICT(key) DO UPDATE SET
|
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
|
||||||
value = excluded.value,
|
if _, err := s.db.ExecContext(ctx, q, full, value, db.NowISO()); err != nil {
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
|
|
||||||
key, value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set %s: %w", key, err)
|
return fmt.Errorf("set %s: %w", key, err)
|
||||||
}
|
}
|
||||||
|
s.cachePut(full, value)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,33 +244,73 @@ func (s *Store) Set(ctx context.Context, key, value string) error {
|
|||||||
return s.SetRaw(ctx, key, s.encodeWrite(key, value))
|
return s.SetRaw(ctx, key, s.encodeWrite(key, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// All returns every stored setting (sensitive values decrypted when unlocked).
|
// GetGlobal reads a value stored WITHOUT the profile prefix — for settings that
|
||||||
func (s *Store) All(ctx context.Context) (map[string]string, error) {
|
// are shared across every profile (e.g. award definitions, which are the
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
|
// operator's own work and shouldn't be re-created per profile). Sensitive
|
||||||
if err != nil {
|
// decryption still applies.
|
||||||
return nil, err
|
func (s *Store) GetGlobal(ctx context.Context, key string) (string, error) {
|
||||||
|
if err := s.ensureCache(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
s.mu.RLock()
|
||||||
out := map[string]string{}
|
v := s.cache[key]
|
||||||
for rows.Next() {
|
s.mu.RUnlock()
|
||||||
var k, v string
|
return s.decodeRead(key, v), nil
|
||||||
if err := rows.Scan(&k, &v); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out[k] = s.decodeRead(k, v)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMany fetches several keys in a single round-trip.
|
// SetGlobal upserts a value WITHOUT the profile prefix (shared across profiles).
|
||||||
func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) {
|
func (s *Store) SetGlobal(ctx context.Context, key, value string) error {
|
||||||
out := make(map[string]string, len(keys))
|
value = s.encodeWrite(key, value)
|
||||||
for _, k := range keys {
|
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
|
||||||
v, err := s.Get(ctx, k)
|
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
|
||||||
if err != nil {
|
if _, err := s.db.ExecContext(ctx, q, key, value, db.NowISO()); err != nil {
|
||||||
|
return fmt.Errorf("set global %s: %w", key, err)
|
||||||
|
}
|
||||||
|
s.cachePut(key, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns every stored setting (sensitive values decrypted when unlocked).
|
||||||
|
func (s *Store) All(ctx context.Context) (map[string]string, error) {
|
||||||
|
if err := s.ensureCache(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out[k] = v
|
prefix := s.profileKey("")
|
||||||
|
s.mu.RLock()
|
||||||
|
raw := make(map[string]string, len(s.cache))
|
||||||
|
for k, v := range s.cache {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
raw[strings.TrimPrefix(k, prefix)] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
out := make(map[string]string, len(raw))
|
||||||
|
for k, v := range raw {
|
||||||
|
out[k] = s.decodeRead(k, v)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMany fetches several keys, all served from the in-memory cache (one DB
|
||||||
|
// round-trip total, on first access). Every requested key is present in the
|
||||||
|
// result (absent settings map to "").
|
||||||
|
func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) {
|
||||||
|
out := make(map[string]string, len(keys))
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := s.ensureCache(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prefix := s.profileKey("")
|
||||||
|
s.mu.RLock()
|
||||||
|
raw := make([]string, len(keys))
|
||||||
|
for i, k := range keys {
|
||||||
|
raw[i] = s.cache[prefix+k]
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
for i, k := range keys {
|
||||||
|
out[k] = s.decodeRead(k, raw[i])
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package ultrabeam
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -12,6 +13,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Connection tuning. Remote operation (the antenna controller reached over the
|
||||||
|
// internet, not the LAN) sees real latency and jitter, so the read timeout is
|
||||||
|
// generous and a few transient timeouts are tolerated before the link is torn
|
||||||
|
// down — otherwise a single slow reply dropped the whole connection and the
|
||||||
|
// client churned reconnect/disconnect.
|
||||||
|
const (
|
||||||
|
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
||||||
|
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
||||||
|
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
|
||||||
|
)
|
||||||
|
|
||||||
// Protocol constants
|
// Protocol constants
|
||||||
const (
|
const (
|
||||||
STX byte = 0xF5 // 245 decimal
|
STX byte = 0xF5 // 245 decimal
|
||||||
@@ -110,6 +122,7 @@ func (c *Client) pollLoop() {
|
|||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
pollCount := 0
|
pollCount := 0
|
||||||
|
pollFails := 0 // consecutive failed status polls (transient timeouts tolerated)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -120,7 +133,8 @@ func (c *Client) pollLoop() {
|
|||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
if c.conn == nil {
|
if c.conn == nil {
|
||||||
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
||||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
|
||||||
|
conn, err := dialer.Dial("tcp", net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Ultrabeam: Connection failed: %v", err)
|
log.Printf("Ultrabeam: Connection failed: %v", err)
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
@@ -133,6 +147,7 @@ func (c *Client) pollLoop() {
|
|||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.reader = bufio.NewReader(c.conn)
|
c.reader = bufio.NewReader(c.conn)
|
||||||
|
pollFails = 0
|
||||||
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
||||||
}
|
}
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
@@ -140,8 +155,17 @@ func (c *Client) pollLoop() {
|
|||||||
// Query status
|
// Query status
|
||||||
status, err := c.queryStatus()
|
status, err := c.queryStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Ultrabeam: Failed to query status: %v", err)
|
// A single slow/lost reply over a remote link is normal — keep
|
||||||
// Close connection and retry
|
// the connection (and the last status) for a few tries before
|
||||||
|
// tearing it down, so we don't churn reconnect/disconnect.
|
||||||
|
var ne net.Error
|
||||||
|
transient := errors.As(err, &ne) && ne.Timeout()
|
||||||
|
pollFails++
|
||||||
|
if transient && pollFails < ubMaxPollTimeout {
|
||||||
|
log.Printf("Ultrabeam: status timeout (%d/%d), keeping link: %v", pollFails, ubMaxPollTimeout, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Ultrabeam: Failed to query status, reconnecting: %v", err)
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
@@ -156,6 +180,7 @@ func (c *Client) pollLoop() {
|
|||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
pollFails = 0
|
||||||
|
|
||||||
// Mark as connected
|
// Mark as connected
|
||||||
status.Connected = true
|
status.Connected = true
|
||||||
@@ -318,8 +343,8 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to write: %w", err)
|
return nil, fmt.Errorf("failed to write: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read reply with timeout
|
// Read reply with timeout (generous — tolerates remote-link latency).
|
||||||
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s
|
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
|
||||||
|
|
||||||
// Read until we get a complete packet
|
// Read until we get a complete packet
|
||||||
var buffer []byte
|
var buffer []byte
|
||||||
|
|||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Live operator status — for multi-operator events on a SHARED MySQL logbook
|
||||||
|
// (e.g. a special-event call like TM74FR with several ops on different bands).
|
||||||
|
// Each OpsLog instance heartbeats its current activity (operator call + station
|
||||||
|
// call + freq/band/mode from CAT) into a `live_status` table every ~15s. A tiny
|
||||||
|
// web script on the operator's own server reads that table and renders a live
|
||||||
|
// page/image that the QRZ.com bio can embed (`<img src=…>`). OpsLog only WRITES
|
||||||
|
// to the DB — it is not a web server. Rows older than a couple of minutes are
|
||||||
|
// "stale" (operator went offline); the web side ignores them.
|
||||||
|
|
||||||
|
const keyLiveStatusEnabled = "livestatus.enabled"
|
||||||
|
|
||||||
|
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||||
|
func (a *App) GetLiveStatusEnabled() bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled)
|
||||||
|
return strings.TrimSpace(v) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLiveStatusEnabled turns live-status publishing on or off (off also removes
|
||||||
|
// this operator's row immediately).
|
||||||
|
func (a *App) SetLiveStatusEnabled(on bool) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
val := "0"
|
||||||
|
if on {
|
||||||
|
val = "1"
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
applog.Printf("livestatus: enabled (logbook backend=%q, mysql conn=%v)", a.dbBackend, a.logDb != nil)
|
||||||
|
go a.publishLiveStatus() // show up right away
|
||||||
|
} else {
|
||||||
|
a.clearLiveStatus()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||||
|
// startup; cheap no-op when disabled or not on MySQL.
|
||||||
|
func (a *App) liveStatusLoop() {
|
||||||
|
defer func() { _ = recover() }() // never crash the app from here
|
||||||
|
applog.Printf("livestatus: loop started")
|
||||||
|
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
||||||
|
t := time.NewTicker(15 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
a.publishLiveStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusActive reports whether publishing should run (MySQL logbook + on).
|
||||||
|
func (a *App) liveStatusActive() bool {
|
||||||
|
return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusOperator returns this instance's operator id (the operator callsign,
|
||||||
|
// falling back to the station callsign for a single-op setup). The callsign and
|
||||||
|
// operator live on the ACTIVE PROFILE (station_profiles table), NOT in the
|
||||||
|
// settings KV — read them there.
|
||||||
|
func (a *App) liveStatusOperator() (op, station string) {
|
||||||
|
if a.profiles == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
station = strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||||
|
op = strings.ToUpper(strings.TrimSpace(p.Operator))
|
||||||
|
if op == "" {
|
||||||
|
op = station
|
||||||
|
}
|
||||||
|
return op, station
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
|
||||||
|
// mode, used as a fallback for live status when the CAT isn't connected.
|
||||||
|
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
a.liveFreqHz = freqHz
|
||||||
|
a.liveBand = strings.ToUpper(strings.TrimSpace(band))
|
||||||
|
a.liveMode = strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishLiveStatus upserts this operator's current activity. Best effort, with
|
||||||
|
// explicit logging so a silent no-op is diagnosable.
|
||||||
|
func (a *App) publishLiveStatus() {
|
||||||
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
|
return // not a MySQL logbook — nothing to do (silent, runs every 15s)
|
||||||
|
}
|
||||||
|
if !a.GetLiveStatusEnabled() {
|
||||||
|
return // disabled (silent)
|
||||||
|
}
|
||||||
|
op, station := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var freqHz int64
|
||||||
|
var band, mode string
|
||||||
|
if a.cat != nil {
|
||||||
|
st := a.cat.State()
|
||||||
|
if st.Connected {
|
||||||
|
freqHz, band, mode = st.FreqHz, st.Band, st.Mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to whatever the entry strip last reported (so band/mode/freq are
|
||||||
|
// published even when the CAT isn't connected).
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
if freqHz == 0 {
|
||||||
|
freqHz = a.liveFreqHz
|
||||||
|
}
|
||||||
|
if band == "" {
|
||||||
|
band = a.liveBand
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = a.liveMode
|
||||||
|
}
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
|
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
||||||
|
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||||
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||||
|
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station, freqHz, band, mode)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureLiveStatusTable() error {
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
|
"station VARCHAR(32), "+
|
||||||
|
"freq_hz BIGINT, "+
|
||||||
|
"band VARCHAR(16), "+
|
||||||
|
"mode VARCHAR(16), "+
|
||||||
|
"updated_at DATETIME)")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||||
|
func (a *App) clearLiveStatus() {
|
||||||
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op, _ := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op)
|
||||||
|
}
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Anonymous usage telemetry - a once-a-day "app_opened" heartbeat to PostHog so
|
||||||
|
// the OpsLog author can see how many people actively use it. Privacy by design:
|
||||||
|
// only a random install ID + app version + OS are sent (no callsign, no QSO
|
||||||
|
// data, no IP beyond what any HTTP request reveals). Users can disable it in
|
||||||
|
// Preferences -> General. See [[user-analytics-posthog]] notes in MEMORY.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
|
appVersion = "0.11.3"
|
||||||
|
|
||||||
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
posthogHost = "https://eu.i.posthog.com"
|
||||||
|
|
||||||
|
// posthogAPIKey is the PostHog PROJECT API key ("phc_..."). It's a public,
|
||||||
|
// write-only ingestion key (safe to ship in the binary, like the ClubLog app
|
||||||
|
// key). Until it's filled in, telemetry is a no-op.
|
||||||
|
posthogAPIKey = "phc_vumvN7XTERNhmRzMZHNgY5DncZfFibTbomiE9epZvUJ4"
|
||||||
|
|
||||||
|
keyTelemetryEnabled = "telemetry.enabled" // "0" disables; default on
|
||||||
|
keyTelemetryInstallID = "telemetry.install_id" // random, stable per install
|
||||||
|
keyTelemetryLastSent = "telemetry.last_sent" // YYYY-MM-DD of last heartbeat
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTelemetryEnabled reports whether anonymous usage stats are on (default on).
|
||||||
|
func (a *App) GetTelemetryEnabled() bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
v, _ := a.settings.GetGlobal(a.ctx, keyTelemetryEnabled)
|
||||||
|
return strings.TrimSpace(v) != "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTelemetryEnabled turns anonymous usage stats on or off.
|
||||||
|
func (a *App) SetTelemetryEnabled(on bool) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
val := "1"
|
||||||
|
if !on {
|
||||||
|
val = "0"
|
||||||
|
}
|
||||||
|
return a.settings.SetGlobal(a.ctx, keyTelemetryEnabled, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// telemetryInstallID returns this install's stable anonymous ID, generating and
|
||||||
|
// persisting one on first use.
|
||||||
|
func (a *App) telemetryInstallID() string {
|
||||||
|
id, _ := a.settings.GetGlobal(a.ctx, keyTelemetryInstallID)
|
||||||
|
if id = strings.TrimSpace(id); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
id = randomUUID()
|
||||||
|
_ = a.settings.SetGlobal(a.ctx, keyTelemetryInstallID, id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTelemetryHeartbeat sends at most one "app_opened" event per calendar day
|
||||||
|
// (UTC). Best effort: any failure is logged and ignored. Runs in a goroutine at
|
||||||
|
// startup.
|
||||||
|
func (a *App) sendTelemetryHeartbeat() {
|
||||||
|
if a.settings == nil || !a.GetTelemetryEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(posthogAPIKey, "REPLACE") || strings.TrimSpace(posthogAPIKey) == "" {
|
||||||
|
return // not configured yet
|
||||||
|
}
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today {
|
||||||
|
return // already counted today
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"api_key": posthogAPIKey,
|
||||||
|
"event": "app_opened",
|
||||||
|
"distinct_id": a.telemetryInstallID(),
|
||||||
|
"properties": map[string]any{
|
||||||
|
"version": appVersion,
|
||||||
|
"os": runtime.GOOS,
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"$lib": "opslog",
|
||||||
|
},
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Post(posthogHost+"/capture/", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("telemetry: heartbeat failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
_ = a.settings.SetGlobal(a.ctx, keyTelemetryLastSent, today)
|
||||||
|
applog.Printf("telemetry: heartbeat ok (%s)", today)
|
||||||
|
} else {
|
||||||
|
applog.Printf("telemetry: heartbeat HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomUUID returns a random RFC-4122 v4 UUID string.
|
||||||
|
func randomUUID() string {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
// Extremely unlikely; fall back to a time-based id so we still get one.
|
||||||
|
return fmt.Sprintf("ts-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateCheckURL is the GitHub Releases "latest" endpoint for the public OpsLog
|
||||||
|
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
||||||
|
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
||||||
|
|
||||||
|
// UpdateInfo is the result of the startup version check.
|
||||||
|
type UpdateInfo struct {
|
||||||
|
Current string `json:"current"` // this build's version (appVersion)
|
||||||
|
Latest string `json:"latest"` // newest published release, "" if unknown
|
||||||
|
Available bool `json:"available"` // Latest > Current
|
||||||
|
URL string `json:"url"` // release page to open
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckForUpdate asks GitHub for the latest release and compares it to this
|
||||||
|
// build. Best effort — on any failure it reports "no update" so the app never
|
||||||
|
// nags about a check it couldn't complete.
|
||||||
|
func (a *App) CheckForUpdate() UpdateInfo {
|
||||||
|
out := UpdateInfo{Current: appVersion}
|
||||||
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, updateCheckURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("update: check failed: %v", err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return out // no release yet (404) or rate-limited — treat as up to date
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.Latest = strings.TrimPrefix(strings.TrimSpace(r.TagName), "v")
|
||||||
|
out.URL = r.HTMLURL
|
||||||
|
out.Available = versionLess(appVersion, out.Latest)
|
||||||
|
if out.Available {
|
||||||
|
applog.Printf("update: newer version available — current=%s latest=%s", appVersion, out.Latest)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionLess reports whether version a is older than b. Compares dot-separated
|
||||||
|
// numeric parts ("0.9" < "0.10" < "1.0"); non-numeric junk in a part counts as 0.
|
||||||
|
func versionLess(a, b string) bool {
|
||||||
|
pa := strings.Split(a, ".")
|
||||||
|
pb := strings.Split(b, ".")
|
||||||
|
n := len(pa)
|
||||||
|
if len(pb) > n {
|
||||||
|
n = len(pb)
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
ai, bi := 0, 0
|
||||||
|
if i < len(pa) {
|
||||||
|
ai = leadingInt(pa[i])
|
||||||
|
}
|
||||||
|
if i < len(pb) {
|
||||||
|
bi = leadingInt(pb[i])
|
||||||
|
}
|
||||||
|
if ai != bi {
|
||||||
|
return ai < bi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// leadingInt parses the leading digits of s (e.g. "2beta" → 2), 0 if none.
|
||||||
|
func leadingInt(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
end := 0
|
||||||
|
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if end == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(s[:end])
|
||||||
|
return n
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user