Files
OpsLog/app.go
T

11614 lines
384 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"hamlog/internal/adif"
"hamlog/internal/alerts"
"hamlog/internal/antgenius"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/award"
"hamlog/internal/awardref"
"hamlog/internal/backup"
"hamlog/internal/cabrillo"
"hamlog/internal/cat"
"hamlog/internal/clublog"
"hamlog/internal/cluster"
"hamlog/internal/contest"
"hamlog/internal/cwdecode"
"hamlog/internal/db"
"hamlog/internal/dxcc"
"hamlog/internal/email"
"hamlog/internal/extsvc"
"hamlog/internal/integrations/udp"
"hamlog/internal/lookup"
"hamlog/internal/lotwusers"
"hamlog/internal/netctl"
"hamlog/internal/offlineq"
"hamlog/internal/operating"
"hamlog/internal/pota"
"hamlog/internal/powergenius"
"hamlog/internal/profile"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/rotator/pst"
"hamlog/internal/settings"
"hamlog/internal/solar"
"hamlog/internal/steppir"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"go.bug.st/serial"
)
// Setting keys.
const (
keyQRZUser = "lookup.qrz.user"
keyQRZPassword = "lookup.qrz.password"
keyHQUser = "lookup.hamqth.user"
keyHQPassword = "lookup.hamqth.password"
keyCacheTTL = "lookup.cache.ttl_days"
// Provider routing. Each value is a provider name (qrz | hamqth)
// or empty to disable that slot. Primary is consulted first;
// Failsafe is the fallback when Primary returns not-found or errs.
keyLookupPrimary = "lookup.primary"
keyLookupFailsafe = "lookup.failsafe"
keyLookupImages = "lookup.download_images" // 1 = expose QRZ ImageURL to UI
keyStationCallsign = "station.callsign"
keyStationOperator = "station.operator"
keyStationMyGrid = "station.my_grid"
keyStationCountry = "station.my_country"
keyStationSOTA = "station.my_sota_ref"
keyStationPOTA = "station.my_pota_ref"
keyListsBands = "lists.bands"
keyListsModes = "lists.modes"
keyListsRSTPhone = "lists.rst_phone"
keyListsRSTCW = "lists.rst_cw"
keyListsRSTDigital = "lists.rst_digital"
keyCATEnabled = "cat.enabled"
keyCATBackend = "cat.backend" // "omnirig" | "flex"
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
keyCATPollMs = "cat.poll_ms"
keyCATDelayMs = "cat.delay_ms" // pause between commands
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
// global (not per-profile) like CAT/rotator. Device fields store the
// WASAPI endpoint id; the UI resolves it to a friendly name.
keyAudioFromRadio = "audio.from_radio" // capture: rig RX audio in
keyAudioToRadio = "audio.to_radio" // render: DVK plays into rig
keyAudioRecDevice = "audio.rec_device" // capture: your mic (record DVK msgs)
keyAudioListenDevice = "audio.listen_device" // render: local preview speakers
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp
keyAwardRefsSeeded = "awards.refs.seeded" // built-in reference-list seed version
keyAwardDefsFixed = "awards.defs.fixed" // built-in award def correction version
keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
// E-mail / SMTP — send QSO recordings to the correspondent.
keyEmailEnabled = "email.enabled"
keyEmailHost = "email.smtp_host"
keyEmailPort = "email.smtp_port"
keyEmailUser = "email.smtp_user"
keyEmailPassword = "email.smtp_password"
keyEmailFrom = "email.from"
keyEmailReplyTo = "email.reply_to" // optional Reply-To: replies go here, not the From sender
keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none"
keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password)
keyEmailAutoSend = "email.auto_send" // "1" → auto-send recording on log when an e-mail is known
keyEmailSubject = "email.subject"
keyEmailBody = "email.body"
// clublogAppAPIKey is OpsLog's ClubLog API key, also used for the country
// file download. Visible in the binary but must not be exposed publicly.
clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
keyRotatorEnabled = "rotator.enabled"
keyRotatorHost = "rotator.host"
keyRotatorPort = "rotator.port"
keyRotatorHasElevation = "rotator.has_elevation"
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
// "ultrabeam." prefix for backward compatibility with configs saved before the
// SteppIR support; keyMotorType / keyMotorTransport default to the old Ultrabeam
// TCP behaviour when absent, so an existing install keeps working untouched.
keyUltrabeamEnabled = "ultrabeam.enabled"
keyUltrabeamHost = "ultrabeam.host"
keyUltrabeamPort = "ultrabeam.port"
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
// port is fixed at 9007, so only the IP is configurable.
keyAntGeniusEnabled = "antgenius.enabled"
keyAntGeniusHost = "antgenius.host"
keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius.
keyPGXLEnabled = "pgxl.enabled"
keyPGXLHost = "pgxl.host"
keyPGXLPort = "pgxl.port"
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
keyWKEnabled = "winkeyer.enabled"
keyWKPort = "winkeyer.port"
keyWKBaud = "winkeyer.baud"
keyWKWPM = "winkeyer.wpm"
keyWKWeight = "winkeyer.weight"
keyWKLeadIn = "winkeyer.lead_in_ms"
keyWKTail = "winkeyer.tail_ms"
keyWKRatio = "winkeyer.ratio"
keyWKFarnsworth = "winkeyer.farnsworth"
keyWKSidetone = "winkeyer.sidetone_hz"
keyWKMode = "winkeyer.mode"
keyWKSwap = "winkeyer.swap"
keyWKAutoSpace = "winkeyer.autospace"
keyWKUsePTT = "winkeyer.use_ptt"
keyWKSerialEcho = "winkeyer.serial_echo"
keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
keyWKEngine = "winkeyer.engine" // "winkeyer" | "icom" | "tci"
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
keyBackupEnabled = "backup.enabled"
keyBackupFolder = "backup.folder"
keyBackupRotation = "backup.rotation"
keyBackupZip = "backup.zip"
keyBackupLast = "backup.last_at"
keyQSLDefaultQSLSent = "qsl.qsl_sent"
keyQSLDefaultQSLRcvd = "qsl.qsl_rcvd"
keyQSLDefaultLOTWSent = "qsl.lotw_sent"
keyQSLDefaultLOTWRcvd = "qsl.lotw_rcvd"
keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
keyQSLDefaultClublogStatus = "qsl.clublog_status"
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
// External services (logbook upload). QRZ.com first; Clublog / LoTW
// will add their own keys under the same extsvc.* prefix.
keyExtQRZAPIKey = "extsvc.qrz.api_key"
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
keyExtClublogEmail = "extsvc.clublog.email"
keyExtClublogPassword = "extsvc.clublog.password"
keyExtClublogCallsign = "extsvc.clublog.callsign"
keyExtClublogAPIKey = "extsvc.clublog.api_key"
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
keyExtHRDLogCallsign = "extsvc.hrdlog.callsign"
keyExtHRDLogCode = "extsvc.hrdlog.code" // HRDLog account upload code
keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
keyExtEQSLUsername = "extsvc.eqsl.username"
keyExtEQSLPassword = "extsvc.eqsl.password"
keyExtEQSLQTHNick = "extsvc.eqsl.qth_nickname"
keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download)
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull
keyExtEQSLLastDownload = "extsvc.eqsl.last_download" // YYYY-MM-DD of last eQSL inbox pull
)
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
// status fields. Applied to every QSO when the corresponding field is
// empty — both manual entry and UDP auto-log. Values are ADIF status
// codes: "Y" yes, "N" no, "R" requested, "Q" queued, "I" ignore, ""
// (empty) leaves the field untouched.
type QSLDefaults struct {
QSLSent string `json:"qsl_sent"`
QSLRcvd string `json:"qsl_rcvd"`
LOTWSent string `json:"lotw_sent"`
LOTWRcvd string `json:"lotw_rcvd"`
EQSLSent string `json:"eqsl_sent"`
EQSLRcvd string `json:"eqsl_rcvd"`
ClublogStatus string `json:"clublog_status"`
HRDLogStatus string `json:"hrdlog_status"`
QRZComStatus string `json:"qrzcom_status"`
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
}
// CATSettings is the user-tweakable rig-control configuration. Stored as
// individual key/value pairs to keep the settings table flat.
type CATSettings struct {
Enabled bool `json:"enabled"`
Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
}
// ModePreset is a mode entry with default RST values to auto-populate
// the entry form when the user picks this mode.
type ModePreset struct {
Name string `json:"name"`
DefaultRSTSent string `json:"default_rst_sent,omitempty"`
DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"`
}
// ListsSettings holds the user-customisable dropdown lists used by the
// entry form. Default values match common HF/VHF practice.
type ListsSettings struct {
Bands []string `json:"bands"`
Modes []ModePreset `json:"modes"`
RSTPhone []string `json:"rst_phone"` // RS reports for phone modes
RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK
RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT…
}
var defaultBands = []string{
"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m",
"12m", "10m", "6m", "2m", "70cm", "23cm",
}
var defaultModes = []ModePreset{
{Name: "SSB", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
{Name: "CW", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
{Name: "FT8", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
{Name: "FT4", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
{Name: "RTTY", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
{Name: "PSK31", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
{Name: "AM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
{Name: "FM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
{Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
}
// Default RST report lists, editable in Settings → Modes. Phone carries the
// over-S9 reports (59+10…59+60) plus the full RS grid; CW the full RST grid;
// digital the dB reports +30…-30.
var defaultRSTPhone = buildPhoneRST()
var defaultRSTCW = buildCWRST()
var defaultRSTDigital = buildDigitalRST()
func buildPhoneRST() []string {
out := []string{"59+60", "59+50", "59+40", "59+30", "59+20", "59+10"}
for r := 5; r >= 1; r-- {
for s := 9; s >= 1; s-- {
out = append(out, fmt.Sprintf("%d%d", r, s))
}
}
return out
}
func buildCWRST() []string {
var out []string
for r := 5; r >= 1; r-- {
for s := 9; s >= 1; s-- {
for t := 9; t >= 1; t-- {
out = append(out, fmt.Sprintf("%d%d%d", r, s, t))
}
}
}
return out
}
func buildDigitalRST() []string {
var out []string
for db := 30; db >= -30; db-- {
sign := "+"
if db < 0 {
sign = "-"
}
n := db
if n < 0 {
n = -n
}
out = append(out, fmt.Sprintf("%s%02d", sign, n))
}
return out
}
// StationSettings holds the active operator profile. Used to stamp every
// new QSO so we don't ask the user to retype it for each contact.
// Multi-profile support (portable / SOTA …) will layer on top of this.
type StationSettings struct {
Callsign string `json:"callsign"`
Operator string `json:"operator"`
MyGrid string `json:"my_grid"`
MyCountry string `json:"my_country"`
MySOTARef string `json:"my_sota_ref"`
MyPOTARef string `json:"my_pota_ref"`
}
// LookupSettings is the JSON shape exchanged with the frontend.
// Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to
// route lookups: primary first, failsafe on not-found / error.
type LookupSettings struct {
QRZUser string `json:"qrz_user"`
QRZPassword string `json:"qrz_password"`
HamQTHUser string `json:"hamqth_user"`
HamQTHPassword string `json:"hamqth_password"`
Primary string `json:"primary"`
Failsafe string `json:"failsafe"`
DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI
CacheTTLDays int `json:"cache_ttl_days"`
}
// App is the application context bound to the Wails runtime.
type App struct {
ctx context.Context
db *sql.DB
qso *qso.Repo
settings *settings.Store
profiles *profile.Repo
lookup *lookup.Manager
cache *lookup.Cache
cat *cat.Manager
dxcc *dxcc.Manager
cluster *cluster.Manager
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
// spot (DXCC/POTA), emitting it to the UI, running alert rules — which can hit
// a remote MySQL via isWorkedBandMode — and mirroring it to the Flex all used
// to run inline in the read loop, so a single slow step stopped draining the
// TCP socket and the whole feed fell behind the node (visible as the grid
// lagging telnet). The read loop now just enqueues here; one worker does the work.
clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
pota *pota.Cache
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
// NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO).
netStore *netctl.Store
netMu sync.Mutex
netOpenID string // id of the currently open net ("" = none)
netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order
netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …)
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
offlineMode bool // last write failed because the DB was unreachable
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
catFlexDecodeSecs int // decode spot display duration (seconds)
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
liveBand string
liveMode string
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
// shuttingDown gates beforeClose re-entry: the first user attempt to
// close fires shutdown tasks (backup, future LoTW upload, ...) while
// blocking the window close; the subsequent programmatic Quit() call
// must be allowed through.
shuttingDown bool
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
// Cached operator location used to compute distance/bearing for
// cluster spots. Refreshed on profile activation; zero means
// "unknown" and we skip the per-spot computation.
opLat float64
opLon float64
opSet bool
opCall string // active profile callsign, cached for outbound UDP emitters
// Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only
// send when the frequency or mode actually changes.
udpLastFreqHz int64
udpLastMode string
}
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
// centre lat/lon in degrees. Returns ok=false on malformed input.
func gridToLatLon(grid string) (lat, lon float64, ok bool) {
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
return 0, 0, false
}
A := g[0] - 'A'
B := g[1] - 'A'
C := g[2] - '0'
D := g[3] - '0'
if A > 17 || B > 17 || C > 9 || D > 9 {
return 0, 0, false
}
lon = -180 + float64(A)*20 + float64(C)*2
lat = -90 + float64(B)*10 + float64(D)*1
if len(g) >= 6 {
E := g[4] - 'A'
F := g[5] - 'A'
if E <= 23 && F <= 23 {
lon += float64(E)*(5.0/60.0) + 2.5/60.0
lat += float64(F)*(2.5/60.0) + 1.25/60.0
return lat, lon, true
}
}
// 4-char locator: aim at the centre of the square.
lon += 1
lat += 0.5
return lat, lon, true
}
// haversineKm returns the great-circle distance between two lat/lon pairs
// in kilometres. Standard Haversine, mean Earth radius 6371 km.
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
// initialBearingDeg returns the initial great-circle bearing (azimuth) in
// degrees [0, 360) from (lat1, lon1) towards (lat2, lon2). This is the
// "short path" heading.
func initialBearingDeg(lat1, lon1, lat2, lon2 float64) float64 {
rad := math.Pi / 180.0
dLon := (lon2 - lon1) * rad
y := math.Sin(dLon) * math.Cos(lat2*rad)
x := math.Cos(lat1*rad)*math.Sin(lat2*rad) -
math.Sin(lat1*rad)*math.Cos(lat2*rad)*math.Cos(dLon)
deg := math.Atan2(y, x) / rad
if deg < 0 {
deg += 360
}
return deg
}
// refreshOperatorGrid reloads the active profile and caches its grid as
// lat/lon. Called at startup and after profile activation so the cluster
// onSpot callback can compute distance/bearing without hitting the DB
// per spot.
func (a *App) refreshOperatorGrid() {
a.opSet = false
if a.profiles == nil || a.ctx == nil {
return
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return
}
a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign))
lat, lon, ok := gridToLatLon(p.MyGrid)
if !ok {
return
}
a.opLat = lat
a.opLon = lon
a.opSet = true
}
// emitRadioUDP forwards the rig's frequency/mode to any enabled outbound UDP
// emitters (PstRotator, N1MM RadioInfo). Deduped so it only fires when the
// operating frequency or mode actually changes. The send runs off the CAT
// goroutine so a slow/failed datagram can't stall polling.
func (a *App) emitRadioUDP(s cat.RigState) {
if a.udp == nil {
return
}
rx := s.FreqHz
if s.RxFreqHz != 0 { // split: RxFreqHz is the active/listening VFO
rx = s.RxFreqHz
}
if rx <= 0 {
return
}
if rx == a.udpLastFreqHz && s.Mode == a.udpLastMode {
return
}
a.udpLastFreqHz = rx
a.udpLastMode = s.Mode
rs := udp.RadioState{
StationName: a.opCall,
OpCall: a.opCall,
RxFreqHz: rx,
TxFreqHz: s.FreqHz,
Mode: s.Mode,
}
go a.udp.EmitRadioState(rs)
}
// dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface
// without making the lookup package import dxcc.
type dxccAdapter struct{ m *dxcc.Manager }
func (a dxccAdapter) Resolve(call string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool) {
if a.m == nil {
return
}
mm, found := a.m.Lookup(call)
if !found || mm.Entity == nil {
return
}
return dxcc.EntityDXCC(mm.Entity.Name), mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
}
func NewApp() *App { return &App{} }
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
dataDir, err := userDataDir()
if err != nil {
a.startupErr = "cannot resolve data dir: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
return
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
a.startupErr = "cannot create data dir: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
return
}
a.dataDir = dataDir
a.dbPath = filepath.Join(dataDir, "opslog.db")
usingDefault := true
// config.json (in the data dir) may point the database to a user-chosen
// location — e.g. another drive or a synced folder, so it survives a
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
// before opening it.
if custom := readDBPointer(dataDir); custom != "" {
// Honour any chosen database that still exists on disk. Only fall back to
// this folder's own default when the pointed-to file is genuinely GONE —
// the portable folder was copied to a machine without the original drive,
// or the path was deleted — and clear the now-dangling pointer so it stops
// trying. (The old heuristic guessed "folder moved" from the file name and
// wrongly discarded valid logbooks named …/data/opslog.db, sending the user
// back to the default every launch — the save appeared not to stick.)
if _, err := os.Stat(custom); err != nil {
fmt.Printf("OpsLog: chosen database %q not found (%v) — using %s\n", custom, err, a.dbPath)
_ = writeDBPointer(dataDir, "")
} else {
a.dbPath = custom
usingDefault = false
}
}
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
return
}
// One-shot rename for users coming from the HamLog era (default location only).
if usingDefault {
if _, err := os.Stat(a.dbPath); os.IsNotExist(err) {
oldDB := filepath.Join(dataDir, "hamlog.db")
if _, err := os.Stat(oldDB); err == nil {
_ = os.Rename(oldDB, a.dbPath)
}
}
}
if _, err := applog.Init(dataDir); err != nil {
fmt.Println("OpsLog: log init:", err)
}
// Route CAT/OmniRig debug lines into the unified app log (they used to go
// to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
// lists, the lookup cache. Only the QSO logbook itself may live on a shared
// MySQL server (the multi-operator feature). Keeping config local means it
// stays instant even when the shared logbook is on a far-away MySQL.
conn, err := db.Open(a.dbPath)
if err != nil {
a.startupErr = "cannot open db: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
return
}
a.db = conn
// Wire the LOCAL config repos first — they're backed by the already-open
// SQLite file, so the station/profiles/settings are ready instantly. Doing
// this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
// doesn't briefly think the station is unconfigured while MySQL is dialing.
a.settings = settings.NewStore(conn)
a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set
a.profiles = profile.NewRepo(conn)
// Determine the active profile and scope the settings store to it FIRST:
// every setting is per-profile, so all settings-dependent wiring below must
// read the active profile's values.
active, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{
Callsign: keyStationCallsign,
Operator: keyStationOperator,
MyGrid: keyStationMyGrid,
Country: keyStationCountry,
SOTA: keyStationSOTA,
POTA: keyStationPOTA,
})
if err != nil {
fmt.Println("OpsLog: EnsureDefault profile:", err)
}
// A "--profile <name>" command-line argument selects which profile to start
// on (so a desktop shortcut can launch OpsLog straight into F4BPO or TM2Q).
// Match by name, case-insensitive; activate it before any per-profile wiring.
if want := strings.TrimSpace(a.startupProfile); want != "" {
if list, lerr := a.profiles.List(a.ctx); lerr == nil {
for _, p := range list {
if strings.EqualFold(p.Name, want) {
if serr := a.profiles.SetActive(a.ctx, p.ID); serr == nil {
active = p
fmt.Printf("OpsLog: started on profile %q (from --profile)\n", p.Name)
}
break
}
}
}
}
a.settings.SetProfile(active.ID)
a.awardRefs = awardref.NewRepo(conn)
a.qslTemplates = qslcard.NewRepo(conn)
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
a.mirrorAwards() // keep <data>/awards/*.json in step with the database
a.operating = operating.NewRepo(conn)
a.udpRepo = udp.NewRepo(conn)
a.udp = udp.NewManager(a.udpRepo)
go a.consumeUDPEvents()
a.cache = lookup.NewCache(conn, 30*24*time.Hour)
a.lookup = lookup.NewManager(a.cache)
a.reloadLookupProviders()
// The QSO logbook lives where the ACTIVE PROFILE points it: the local SQLite
// file, or a per-profile shared MySQL database. Switching profiles switches
// the logbook (see switchLogbook). One-time: adopt any legacy config.json
// MySQL config into the active profile so existing setups keep working.
a.adoptBootstrapMySQL(&active)
logbookConn, backend, lerr := a.connectLogbook(active.DB)
if lerr != nil {
applog.Printf("startup: logbook open failed (%v) — falling back to SQLite logbook", lerr)
a.dbBackendErr = strings.TrimPrefix(lerr.Error(), "")
logbookConn, backend = conn, "sqlite"
}
a.dbBackend = backend
// db.Dialect describes the LOGBOOK backend — the only place SQL actually
// varies (qso JSON extraction). Config repos always run on SQLite.
db.SetDialect(backend)
applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn)
// cty.dat for offline DXCC / country resolution. Cached on disk; first
// run downloads it from country-files.com in the background so startup
// stays fast even if the network is slow.
a.dxcc = dxcc.NewManager(dataDir)
a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc})
go func() {
if err := a.dxcc.EnsureLoaded(a.ctx); err != nil {
fmt.Println("OpsLog: cty.dat unavailable —", err)
return
}
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
}()
// Offline safety net: with the shared MySQL logbook, losing the network would
// otherwise mean you simply cannot log. Instead, a QSO that can't reach the DB
// is parked in a local ADIF outbox and replayed automatically once the DB
// answers again. Anything already waiting from a previous session (a crash, a
// quit while offline) is replayed at startup.
a.offlineQ = newOfflineQueue(dataDir)
if n := a.offlineQ.Count(); n > 0 {
a.offlineMode = true
applog.Printf("offline: %d QSO(s) waiting in %s", n, a.offlineQ.Path())
}
go a.offlineReplayLoop()
go func() {
if a.offlineQ.Count() > 0 {
if n, err := a.replayOfflineQueue(); err == nil && n > 0 {
applog.Printf("offline: replayed %d QSO(s) left over from a previous session", n)
}
}
}()
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
// demand. Resolution applied only when the user enables it.
a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir)
// LoTW user-activity list (who's a LoTW user + last upload). Loads the cached
// CSV if present, and auto-downloads in the background when it's missing or
// older than a DAY — the list carries each station's last-upload date, so a
// stale cache would misreport recency (a station that uploaded 2 days ago must
// show as recent, which only works if we refresh roughly daily). Manual
// refresh is in Settings → LoTW.
a.lotwUsers = lotwusers.NewManager(dataDir)
go func() {
if a.lotwUsers.Count() == 0 || time.Since(a.lotwUsers.Updated()) > 24*time.Hour {
if n, err := a.lotwUsers.Download(context.Background()); err == nil {
applog.Printf("lotwusers: auto-downloaded %d LoTW users", n)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "lotwusers:updated")
}
} else {
applog.Printf("lotwusers: auto-download failed: %v", err)
}
}
}()
go func() {
_ = a.clublog.EnsureLoaded()
// Auto-refresh a missing/stale country file (ClubLog adds date-ranged
// exceptions constantly — e.g. event calls like IR0WWA get new SARDINIA
// periods). Without this a stale cache resolves recent event QSOs to the
// base country. Best-effort; the on-disk copy still serves if it fails.
if a.clublog.NeedsRefresh(3 * 24 * time.Hour) {
if err := a.clublog.Download(context.Background()); err != nil {
applog.Printf("clublog: auto-refresh failed: %v", err)
}
}
if a.clublog.Loaded() {
d, n := a.clublog.Info()
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
}
}()
// CAT manager: emit pushes state to the frontend via Wails events, and
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
a.cat = cat.NewManager(func(s cat.RigState) {
// DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a
// burst of these lines = the frontend is being re-rendered rapidly (the
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
// or freq/split/mode oscillating between slices during FT8.
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s",
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cat:state", s)
}
a.emitRadioUDP(s)
})
a.reloadCAT()
// POTA: background poller of api.pota.app so cluster spots can be tagged
// when the DX station is currently activating a park. Best-effort.
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
go a.pota.Run(a.ctx)
// DX Cluster (multi-server): the spot callback enriches each spot
// with country + continent via cty.dat BEFORE emitting it, so the UI
// renders the row with all metadata already filled (no flicker of
// empty Country / Cont columns while the batch status fetch runs).
// Cluster events are processed OFF the socket-read goroutine (see clusterEvent /
// clusterEventWorker). Sized large so ordinary traffic and even an SH/DX/100
// burst never fill it; a full queue drops-and-counts rather than block the read
// loop, which was the actual cause of the feed falling behind telnet.
a.clusterEventCh = make(chan clusterEvent, 8192)
go a.clusterEventWorker()
a.cluster = cluster.NewManager(
// onSpot / onLine run on the session's socket-read goroutine, so they must
// do nothing slow: just enqueue and return, keeping the TCP socket drained.
func(s cluster.Spot) { a.enqueueClusterEvent(clusterEvent{spot: &s}) },
func() {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
}
},
// Raw traffic → the cluster console. Spots are parsed out of this stream,
// but SH/DX, WHO, the MOTD and error replies are NOT spots — without this
// they were dropped on the floor and the command box looked inert.
func(l cluster.Line) { a.enqueueClusterEvent(clusterEvent{line: &l}) },
)
a.refreshOperatorGrid()
if cs, _ := a.clusterAutoConnect(); cs {
a.startAllEnabledClusters()
}
if errs := a.udp.Reload(a.ctx); len(errs) > 0 {
for _, e := range errs {
fmt.Println("OpsLog: udp:", e)
}
}
// External-service uploaders (QRZ.com …). The manager is fed config
// from settings and host callbacks to build ADIF, stamp the upload
// status and surface errors to the UI.
a.extsvc = extsvc.NewManager(extsvc.Deps{
BuildADIF: a.buildUploadADIF,
MarkUploaded: a.markExtUploaded,
NotifyError: a.notifyExtError,
ShouldUpload: a.extShouldUpload,
StationCallOf: a.stationCallOf,
CloseUploadIDs: a.closeUploadIDs,
Logf: applog.Printf,
})
a.extsvc.SetConfig(a.loadExternalServices())
// WinKeyer CW keyer (serial). Created idle; the UI connects on demand.
a.winkeyer = winkeyer.NewManager(
func(s winkeyer.Status) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "winkeyer:status", s)
}
},
func(ch string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "winkeyer:echo", ch)
}
},
)
// Live space-weather (solar flux, sunspots, A/K indices) for the header strip
// and per-QSO stamping. Refreshes in the background; pushes a UI event on each
// update so the strip re-reads.
a.solar = solar.NewManager(func() {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "solar:update")
}
})
a.solar.Start()
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
a.audioMgr = audio.NewManager(func() {
st := a.dvkStatus()
// When a voice message finishes (or is stopped), drop the PTT we keyed
// for it — but tag the release with the current key generation so it
// can't cut a transmission a newer message already started.
if !st.Playing {
a.pttMu.Lock()
keyed := a.dvkPttKeyed
gen := a.pttGen
if keyed {
a.dvkPttKeyed = false
}
a.pttMu.Unlock()
if keyed {
go a.dvkUnkeyPTT(gen)
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "audio:status", st)
}
})
a.qsoRec = audio.NewRecorder()
a.startQSORecorderIfEnabled()
// NET Control store (global JSON, shared across logbooks).
if ns, err := netctl.Open(filepath.Join(a.dataDir, "nets.json")); err != nil {
applog.Printf("netctl: open failed: %v", err)
} else {
a.netStore = ns
}
// DX-cluster spot alert rules (global JSON).
if as, err := alerts.Open(filepath.Join(a.dataDir, "alerts.json")); err != nil {
applog.Printf("alerts: open failed: %v", err)
} else {
a.alertStore = as
}
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled.
a.startAntGenius()
// PowerGenius XL amp fan control: connect in the background if enabled.
a.startPGXL()
// Autostart: launch the active profile's configured external programs that
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
// so a slow-launching app never delays the UI.
go func() {
for _, r := range a.LaunchAutostartPrograms() {
applog.Printf("autostart: %s — %s (%s)", r.Name, r.Status, r.Message)
}
}()
// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
// when disabled in Preferences or until the PostHog key is configured.
go a.sendTelemetryHeartbeat()
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
// Reopen where the window was last left (size + position).
a.restoreWindowState()
fmt.Println("OpsLog: db ready at", a.dbPath)
}
// StartupStatus returns a diagnostic snapshot for the frontend.
// dbPath is always populated; err is empty when the app is healthy.
type StartupStatus struct {
OK bool `json:"ok"`
Err string `json:"err"`
DBPath string `json:"db_path"`
}
// GetStartupStatus exposes whatever happened during startup so the UI
// can show a useful error instead of just "db not initialized".
func (a *App) GetStartupStatus() StartupStatus {
return StartupStatus{
OK: a.startupErr == "",
Err: a.startupErr,
DBPath: a.dbPath,
}
}
// beforeClose intercepts the window-close event so we can run shutdown
// tasks (backup, future LoTW upload, ...) while showing a progress modal
// to the user. Returns true the first time to block the close; the
// goroutine eventually calls wruntime.Quit() which re-enters this method
// with shuttingDown=true and we let the close proceed.
func (a *App) beforeClose(ctx context.Context) bool {
if a.shuttingDown {
return false
}
a.shuttingDown = true
// Capture geometry now, before any shutdown UI can resize the window, so the
// next launch reopens exactly here.
a.saveWindowState()
steps := a.plannedShutdownSteps()
if len(steps) == 0 {
// Nothing to do — exit immediately, no need to flash a modal.
return false
}
go a.runShutdownTasks(ctx, steps)
return true
}
// shutdownStep is emitted to the frontend so the progress modal can
// render the task list and update each row's state as work progresses.
type shutdownStep struct {
ID string `json:"id"`
Label string `json:"label"`
Status string `json:"status"` // "pending" | "running" | "done" | "error"
Detail string `json:"detail,omitempty"`
}
// plannedShutdownSteps returns the tasks that will actually run, so the
// UI knows the full checklist up front. Right now that's just the backup
// (when enabled and not yet done today); LoTW upload, eQSL upload, etc.
// will append to this list as they land.
func (a *App) plannedShutdownSteps() []shutdownStep {
var out []shutdownStep
if s, err := a.GetBackupSettings(); err == nil && s.Enabled {
folder := s.Folder
if folder == "" {
folder = s.DefaultFolder
}
if !backup.HasBackupToday(folder) {
out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
}
}
if a.extsvc != nil {
if n := a.extsvc.CloseUploadCount(); n > 0 {
out = append(out, shutdownStep{
ID: "extsvc-upload",
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
Status: "pending",
})
}
}
return out
}
func (a *App) emitShutdownEvent(name string, payload any) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, name, payload)
}
}
// runShutdownTasks executes every planned shutdown task in order,
// emitting progress events at each transition so the frontend modal
// stays in sync. Errors don't abort the sequence — we still want to
// give later steps a chance and ultimately close the app.
func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
a.emitShutdownEvent("shutdown:start", steps)
for i := range steps {
steps[i].Status = "running"
a.emitShutdownEvent("shutdown:update", steps)
var err error
switch steps[i].ID {
case "backup":
err = a.runBackupForShutdown()
case "extsvc-upload":
n := a.extsvc.FlushOnClose()
steps[i].Detail = fmt.Sprintf("%d uploaded", n)
}
if err != nil {
steps[i].Status = "error"
steps[i].Detail = err.Error()
} else {
steps[i].Status = "done"
}
a.emitShutdownEvent("shutdown:update", steps)
}
a.emitShutdownEvent("shutdown:done", steps)
// Give the UI a moment to show the "done" state before we yank the
// window away. 600ms feels purposeful without being annoying.
time.Sleep(600 * time.Millisecond)
wruntime.Quit(ctx)
}
// runBackupForShutdown is the same logic as maybeShutdownBackup but
// returns an error so the shutdown sequence can mark the step as failed.
func (a *App) runBackupForShutdown() error {
if a.settings == nil || a.db == nil {
return fmt.Errorf("db not ready")
}
s, err := a.GetBackupSettings()
if err != nil {
return err
}
folder := s.Folder
if folder == "" {
folder = s.DefaultFolder
}
mysql := a.dbBackend == "mysql"
done := backup.HasBackupToday(folder)
if mysql {
done = backup.HasADIFBackupToday(folder)
}
if done {
return nil
}
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
return err
}
if mysql {
if _, err := a.backupLogADIF(folder, s.Rotation, s.Zip); err != nil {
return err
}
}
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}
// setSetting persists a key/value and logs (rather than silently swallows) a
// failure — used for non-critical settings writes where the caller can't
// surface the error but a lost write would still mislead (stale timestamps,
// seed markers…).
func (a *App) setSetting(key, val string) {
if a.settings == nil {
return
}
if err := a.settings.Set(a.ctx, key, val); err != nil {
applog.Printf("settings: set %q failed: %v", key, err)
}
}
// setSettingGlobal stores a value shared across all profiles (no profile prefix).
func (a *App) setSettingGlobal(key, val string) {
if a.settings == nil {
return
}
if err := a.settings.SetGlobal(a.ctx, key, val); err != nil {
applog.Printf("settings: set global %q failed: %v", key, err)
}
}
func (a *App) shutdown(ctx context.Context) {
// If the user managed to skip beforeClose (force kill, OS shutdown,
// crash recovery) we still try the backup here as a best-effort
// safety net. HasBackupToday makes a double-run a no-op.
if !a.shuttingDown {
a.maybeShutdownBackup()
}
if a.udp != nil {
a.udp.StopAll()
}
if a.solar != nil {
a.solar.Stop()
}
// Stop CAT so the backend disconnects cleanly. Critical for the Icom network
// backend: without this the rig never gets a disconnect and holds its single
// control session for minutes, refusing every new login (even from the Icom
// Remote Utility) until it times out on its own.
if a.cat != nil {
a.cat.Stop()
}
if a.winkeyer != nil {
a.winkeyer.Disconnect()
}
if a.qsoRec != nil {
a.qsoRec.Stop()
}
if a.logDb != nil && a.logDb != a.db {
_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
}
if a.db != nil {
_ = a.db.Close()
}
}
// userDataDir returns the OpsLog data directory: always "<exe dir>/data".
// All data (database, settings, cty.dat, logs) travels with the executable,
// making OpsLog fully portable for USB sticks and PC migrations.
func userDataDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("cannot locate executable: %w", err)
}
return filepath.Join(filepath.Dir(exe), "data"), nil
}
// fileExists reports whether path exists and is a regular file.
func fileExists(path string) bool {
fi, err := os.Stat(path)
return err == nil && fi.Mode().IsRegular()
}
// GetDataDir returns the current data directory path.
func (a *App) GetDataDir() string { return a.dataDir }
// copyDirContents recursively copies all files and subdirectories from src to dst.
func copyDirContents(src, dst string) error {
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dst, e.Name())
if e.IsDir() {
if err := os.MkdirAll(dstPath, 0o755); err != nil {
return err
}
if err := copyDirContents(srcPath, dstPath); err != nil {
return err
}
} else {
if err := copyFileData(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}
// copyFileData copies a single file from src to dst, creating or overwriting dst.
func copyFileData(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
// ── Database location (config.json pointer) ────────────────────────────
// dbPointer is the tiny bootstrap config stored in the data dir. It must
// live outside the database because we read it BEFORE opening any DB to decide
// which backend to use: a local SQLite file (DBPath) or a shared MySQL server
// (MySQL). The MySQL connection lives here — not in the settings table — for
// the same reason: we need it to choose and open the backend at startup.
type dbPointer struct {
DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"`
}
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
// ── Window geometry (window.json) ──────────────────────────────────────
//
// Remembered across restarts so the window reopens where and how you left it.
// Kept in its OWN local file, NOT the database: the DB may be a MySQL server
// shared by several machines, and each has its own screens — persisting geometry
// there would make two installs fight over one position.
type windowState struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Maximised bool `json:"maximised"`
}
func windowStatePath(dataDir string) string { return filepath.Join(dataDir, "window.json") }
func readWindowState(dataDir string) (windowState, bool) {
var w windowState
b, err := os.ReadFile(windowStatePath(dataDir))
if err != nil {
return w, false
}
if json.Unmarshal(b, &w) != nil {
return w, false
}
return w, true
}
func writeWindowState(dataDir string, w windowState) {
if b, err := json.MarshalIndent(w, "", " "); err == nil {
_ = os.WriteFile(windowStatePath(dataDir), b, 0o644)
}
}
// saveWindowState captures the current geometry. Called as the window closes.
// While in compact mode the window is pinned to a tiny fixed size, so we keep the
// previously-saved normal geometry rather than overwrite it with 1240×158 — only
// the maximised flag is meaningful to update there.
func (a *App) saveWindowState() {
if a.ctx == nil || a.dataDir == "" {
return
}
prev, _ := readWindowState(a.dataDir)
max := wruntime.WindowIsMaximised(a.ctx)
ws := prev
ws.Maximised = max
if !max && !a.compact {
w, h := wruntime.WindowGetSize(a.ctx)
x, y := wruntime.WindowGetPosition(a.ctx)
if w > 0 && h > 0 {
ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y
}
}
writeWindowState(a.dataDir, ws)
}
// restoreWindowState reopens the window where it was last left. The window is
// CREATED maximised (WindowStartState in main.go), which is also the first-run
// default — so we only act when a windowed geometry was saved, leaving the
// maximised majority untouched and flash-free.
func (a *App) restoreWindowState() {
if a.ctx == nil {
return
}
ws, ok := readWindowState(a.dataDir)
if !ok || ws.Maximised {
return // first run, or last closed maximised — the default already covers it
}
// Guard against a corrupt / absurd size that would open an unusable window.
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
return
}
wruntime.WindowUnmaximise(a.ctx)
wruntime.WindowSetSize(a.ctx, ws.Width, ws.Height)
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
}
// readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero
// value if the file is missing/unreadable.
func readBootstrap(dataDir string) dbPointer {
var c dbPointer
b, err := os.ReadFile(dbPointerPath(dataDir))
if err != nil {
return c
}
_ = json.Unmarshal(b, &c)
c.DBPath = strings.TrimSpace(c.DBPath)
return c
}
func writeBootstrap(dataDir string, c dbPointer) error {
c.DBPath = strings.TrimSpace(c.DBPath)
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
}
// readDBPointer returns the user-chosen DB path, or "" for the default.
func readDBPointer(dataDir string) string {
return readBootstrap(dataDir).DBPath
}
// writeDBPointer persists the chosen DB path ("" resets to default), keeping
// any saved MySQL config intact.
func writeDBPointer(dataDir, path string) error {
c := readBootstrap(dataDir)
c.DBPath = strings.TrimSpace(path)
return writeBootstrap(dataDir, c)
}
// DatabaseSettings describes the active database file for the Settings UI.
type DatabaseSettings struct {
Path string `json:"path"`
DefaultPath string `json:"default_path"`
IsCustom bool `json:"is_custom"`
}
// GetDatabaseSettings returns where the active database lives.
func (a *App) GetDatabaseSettings() DatabaseSettings {
def := filepath.Join(a.dataDir, "opslog.db")
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
}
// MySQLSettings is the shared-database (multi-operator) connection config. When
// enabled, OpsLog logs to a central MySQL server so several operators see each
// other's QSOs live (à la Log4OM). SQLite stays the default.
type MySQLSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
// DBBackendStatus reports which backend OpsLog actually opened at startup so
// the Settings UI can confirm the shared MySQL connection (or explain a
// fallback to SQLite when the configured server was unreachable).
type DBBackendStatus struct {
Active string `json:"active"` // "sqlite" | "mysql"
Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite
Error string `json:"error"` // the MySQL open error, when Fallback is true
}
// GetDBBackendStatus returns the active backend and any MySQL fallback error.
func (a *App) GetDBBackendStatus() DBBackendStatus {
return DBBackendStatus{
Active: a.dbBackend,
Fallback: a.dbBackendErr != "",
Error: a.dbBackendErr,
}
}
// DBConnectionInfo is a compact description of where the QSO logbook lives, for
// the status bar: a MySQL server endpoint or the local SQLite file path.
type DBConnectionInfo struct {
Backend string `json:"backend"` // "sqlite" | "mysql"
Label string `json:"label"` // "host:port/database" or the .db path
}
// GetDBConnectionInfo reports the logbook connection for display in the status
// bar. For MySQL it shows host:port/database (the shared logbook); for SQLite
// it shows the local database file path.
func (a *App) GetDBConnectionInfo() DBConnectionInfo {
if a.dbBackend == "mysql" {
if p, err := a.profiles.Active(a.ctx); err == nil && p.DB.Backend == "mysql" {
port := p.DB.Port
if port == 0 {
port = 3306
}
return DBConnectionInfo{Backend: "mysql", Label: fmt.Sprintf("%s:%d/%s", p.DB.Host, port, p.DB.Database)}
}
return DBConnectionInfo{Backend: "mysql", Label: "MySQL"}
}
return DBConnectionInfo{Backend: "sqlite", Label: a.dbPath}
}
// connectLogbook opens the logbook connection for a profile's DB target: a
// shared MySQL database, or the local SQLite file (which doubles as the logbook
// when no MySQL is configured). Returns the connection and the backend name.
func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
if cfg.Backend == "mysql" {
c, err := db.OpenMySQL(db.MySQLConfig{
Host: cfg.Host, Port: cfg.Port, User: cfg.User, Password: cfg.Password, Database: cfg.Database,
})
if err != nil {
return nil, "", err
}
return c, "mysql", nil
}
return a.db, "sqlite", nil
}
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
// profile (one-time), so users who set up MySQL before it became per-profile
// keep their logbook. The bootstrap entry is then cleared.
func (a *App) adoptBootstrapMySQL(active *profile.Profile) {
mb := readBootstrap(a.dataDir).MySQL
if mb == nil || !mb.Enabled || active.ID == 0 || active.DB.Backend != "" {
return
}
active.DB = profile.ProfileDB{
Backend: "mysql", Host: mb.Host, Port: mb.Port,
User: mb.User, Password: mb.Password, Database: mb.Database,
}
if err := a.profiles.SetDB(a.ctx, active.ID, active.DB); err != nil {
applog.Printf("adopt bootstrap MySQL into profile: %v", err)
return
}
c := readBootstrap(a.dataDir)
c.MySQL = nil
_ = writeBootstrap(a.dataDir, c)
}
// switchLogbook reconnects the live logbook to the given profile's DB target
// (called when the active profile changes or its DB config is saved), swaps the
// qso repo, and notifies the UI. The previous MySQL connection is closed.
func (a *App) switchLogbook(p profile.Profile) error {
newConn, backend, err := a.connectLogbook(p.DB)
if err != nil {
a.dbBackendErr = "MySQL: " + err.Error()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "logbook:changed")
}
return err
}
old := a.logDb
a.qso = qso.NewRepo(newConn)
a.logDb = newConn
a.dbBackend = backend
a.dbBackendErr = ""
a.invalidateAwardStats() // different logbook → drop memoised award matrices
db.SetDialect(backend)
if old != nil && old != a.db && old != newConn {
_ = old.Close()
}
applog.Printf("logbook switched to %s for profile %q", backend, p.Name)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "logbook:changed")
}
return nil
}
// GetMySQLSettings returns the ACTIVE profile's logbook DB config (Enabled =
// MySQL). Each profile can target its own database.
func (a *App) GetMySQLSettings() (MySQLSettings, error) {
out := MySQLSettings{Port: 3306}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return out, nil
}
d := p.DB
out.Enabled = d.Backend == "mysql"
out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
if d.Port > 0 {
out.Port = d.Port
}
return out, nil
}
// SaveMySQLSettings stores the DB target on the ACTIVE profile and switches the
// live logbook to it immediately (no restart). Enabled=false reverts to the
// local SQLite logbook.
func (a *App) SaveMySQLSettings(s MySQLSettings) error {
p, err := a.profiles.Active(a.ctx)
if err != nil {
return fmt.Errorf("no active profile: %w", err)
}
if s.Port <= 0 {
s.Port = 3306
}
cfg := profile.ProfileDB{Database: strings.TrimSpace(s.Database)}
if s.Enabled {
cfg.Backend = "mysql"
cfg.Host = strings.TrimSpace(s.Host)
cfg.Port = s.Port
cfg.User = strings.TrimSpace(s.User)
cfg.Password = s.Password
}
if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil {
return err
}
p.DB = cfg
return a.switchLogbook(p)
}
// TestMySQLConnection pings the shared MySQL database with the given settings
// (no migrations) so the user can validate connectivity from the UI.
func (a *App) TestMySQLConnection(s MySQLSettings) error {
return db.PingMySQL(db.MySQLConfig{
Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, Database: s.Database,
})
}
// PickOpenDatabase opens a file dialog to choose an existing .db file.
func (a *App) PickOpenDatabase() (string, error) {
if a.ctx == nil {
return "", fmt.Errorf("no app context")
}
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Open an OpsLog database",
DefaultDirectory: filepath.Dir(a.dbPath),
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
})
}
// PickSaveDatabase opens a save dialog to choose where to put a copy.
func (a *App) PickSaveDatabase() (string, error) {
if a.ctx == nil {
return "", fmt.Errorf("no app context")
}
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Save the OpsLog database to…",
DefaultFilename: "opslog.db",
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
})
}
// OpenDatabase points OpsLog at an existing database file. Takes effect on
// the next launch.
func (a *App) OpenDatabase(path string) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("no path given")
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("database file not found: %w", err)
}
return writeDBPointer(a.dataDir, path)
}
// MoveDatabase writes a clean copy of the current database to dest (which
// must not exist yet) and switches OpsLog to it on the next launch. Uses
// VACUUM INTO so the copy is consistent even with an open WAL.
func (a *App) MoveDatabase(dest string) error {
dest = strings.TrimSpace(dest)
if dest == "" {
return fmt.Errorf("no destination given")
}
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
if a.db == nil {
return fmt.Errorf("database not open")
}
// VACUUM INTO takes a string literal; escape single quotes in the path.
safe := strings.ReplaceAll(dest, "'", "''")
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
return fmt.Errorf("copy database: %w", err)
}
return writeDBPointer(a.dataDir, dest)
}
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
// points OpsLog at it for the next launch. dest must not already exist.
func (a *App) CreateDatabase(dest string) error {
dest = strings.TrimSpace(dest)
if dest == "" {
return fmt.Errorf("no path given")
}
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
// db.Open creates the file and runs every migration → ready-to-use schema.
conn, err := db.Open(dest)
if err != nil {
return fmt.Errorf("create database: %w", err)
}
_ = conn.Close()
return writeDBPointer(a.dataDir, dest)
}
// ResetDatabaseToDefault clears the custom location (back to the data dir).
func (a *App) ResetDatabaseToDefault() error {
return writeDBPointer(a.dataDir, "")
}
// GetUIPref / SetUIPref persist portable UI preferences (grid column layout,
// widths, sort…) in the DB settings table under a "ui." namespace, so they
// travel with the logbook and survive a reinstall — unlike the WebView's
// localStorage. Values are opaque JSON blobs owned by the frontend.
func (a *App) GetUIPref(key string) (string, error) {
if a.settings == nil {
return "", nil
}
return a.settings.Get(a.ctx, "ui."+key)
}
func (a *App) SetUIPref(key, value string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
return a.settings.Set(a.ctx, "ui."+key, value)
}
// QuitApp closes OpsLog (used to apply a database change on next launch).
func (a *App) QuitApp() {
if a.ctx != nil {
wruntime.Quit(a.ctx)
}
}
// RestartApp relaunches OpsLog and closes this instance, so a database change
// (the DB pointer is read once at startup) applies without the user manually
// reopening the app. The new process opens the SQLite files while this one is
// closing; SQLite's busy_timeout(5000) covers that brief overlap.
func (a *App) RestartApp() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate executable: %w", err)
}
cmd := exec.Command(exe)
cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err)
}
if a.ctx != nil {
wruntime.Quit(a.ctx)
}
return nil
}
// reloadLookupProviders rebuilds the lookup chain from current settings.
// Called at startup and after the user saves new credentials.
//
// Provider order honours the user's primary/failsafe choice. If they
// haven't picked one yet (fresh install), we default to "primary = first
// provider with creds" so the app still works out of the box.
func (a *App) reloadLookupProviders() {
if a.lookup == nil {
return
}
m, err := a.settings.GetMany(a.ctx,
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
if err != nil {
fmt.Println("OpsLog: settings load error:", err)
return
}
if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 {
a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
}
build := func(name string) lookup.Provider {
switch name {
case "qrz":
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
return lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword])
}
case "hamqth":
if m[keyHQUser] != "" && m[keyHQPassword] != "" {
return lookup.NewHamQTH(m[keyHQUser], m[keyHQPassword])
}
}
return nil
}
primary, failsafe := m[keyLookupPrimary], m[keyLookupFailsafe]
// Fresh install fallback: prefer QRZ over HamQTH when both creds exist.
if primary == "" && failsafe == "" {
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
primary = "qrz"
if m[keyHQUser] != "" && m[keyHQPassword] != "" {
failsafe = "hamqth"
}
} else if m[keyHQUser] != "" && m[keyHQPassword] != "" {
primary = "hamqth"
}
}
var providers []lookup.Provider
if p := build(primary); p != nil {
providers = append(providers, p)
}
if failsafe != "" && failsafe != primary {
if p := build(failsafe); p != nil {
providers = append(providers, p)
}
}
a.lookup.SetProviders(providers...)
}
// --- QSO bindings ---
func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
// Never let a panic in the logging path crash the whole app — the user lost
// QSOs that way (CW + WinKeyer). Surface it as an error instead.
defer func() {
if r := recover(); r != nil {
applog.Printf("PANIC in AddQSO: %v\n%s", r, debug.Stack())
if id == 0 {
err = fmt.Errorf("internal error while logging: %v", r)
}
}
}()
a.applyStationDefaults(&q, true)
a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
// Fill the contacted operator's e-mail from the (cached) lookup so the
// recording can be auto-sent. Cheap: the entry already looked the call up.
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
q.Email = lr.Email
}
}
id, err = a.qso.Add(a.ctx, q)
if err != nil && db.IsConnLost(err) {
// The database is UNREACHABLE (not a data error) — park the QSO in the
// offline outbox rather than lose it. Returns id = -1 so the UI can say
// "saved, waiting to sync" instead of showing a failure. The post-save
// side effects (recording, uploads, UDP) are skipped: they need a real
// row id and will not be replayed — the operator can upload later.
if a.queueOffline(q, err) {
return -1, nil
}
// Couldn't even write the outbox → report the real error, never pretend.
}
if err == nil {
q.ID = id
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
// Forward the ADIF of this QSO to any outbound "ADIF message" UDP rows.
if a.udp != nil {
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
}
// A successful write means the link is healthy again — if QSOs are still
// parked, get them in now rather than waiting for the next tick.
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
go func() { _, _ = a.replayOfflineQueue() }()
}
}
return id, err
}
// applySolar stamps the current space-weather onto a QSO before it's saved: the
// standard ADIF SFI / A_INDEX / K_INDEX fields (only when the QSO doesn't already
// carry them), and the sunspot number as an APP_OPSLOG_SSN extra (SSN has no
// standard ADIF field). No-op when the feed hasn't loaded yet.
func (a *App) applySolar(q *qso.QSO) {
if a.solar == nil {
return
}
d := a.solar.Get()
if !d.OK {
return
}
if q.SFI == nil {
if v, err := strconv.ParseFloat(strings.TrimSpace(d.SFI), 64); err == nil {
q.SFI = &v
}
}
if q.AIndex == nil {
if v, err := strconv.ParseFloat(strings.TrimSpace(d.AIndex), 64); err == nil {
q.AIndex = &v
}
}
if q.KIndex == nil {
if v, err := strconv.ParseFloat(strings.TrimSpace(d.KIndex), 64); err == nil {
q.KIndex = &v
}
}
if ssn := strings.TrimSpace(d.SSN); ssn != "" {
if q.Extras == nil {
q.Extras = map[string]string{}
}
if _, ok := q.Extras["APP_OPSLOG_SSN"]; !ok {
q.Extras["APP_OPSLOG_SSN"] = ssn
}
}
}
// GetSolarData returns the latest space-weather snapshot for the header strip.
func (a *App) GetSolarData() solar.Data {
if a.solar == nil {
return solar.Data{}
}
return a.solar.Get()
}
// RefreshSolar forces an immediate re-fetch of the space-weather feed.
func (a *App) RefreshSolar() {
if a.solar != nil {
go a.solar.Refresh()
}
}
// LoTWUserInfo reports whether a callsign is a LoTW user and how recently it
// uploaded — drives the colour-coded LoTW badge in the entry strip.
func (a *App) LoTWUserInfo(callsign string) lotwusers.Info {
if a.lotwUsers == nil {
return lotwusers.Info{DaysAgo: -1}
}
return a.lotwUsers.Lookup(callsign)
}
// LoTWUsersStatus is the loaded-list summary for Settings (count + last refresh).
type LoTWUsersStatus struct {
Count int `json:"count"`
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
}
// GetLoTWUsersStatus returns how many LoTW callsigns are loaded and when.
func (a *App) GetLoTWUsersStatus() LoTWUsersStatus {
if a.lotwUsers == nil {
return LoTWUsersStatus{}
}
st := LoTWUsersStatus{Count: a.lotwUsers.Count()}
if u := a.lotwUsers.Updated(); !u.IsZero() {
st.Updated = u.UTC().Format(time.RFC3339)
}
return st
}
// DownloadLoTWUsers fetches ARRL's LoTW user-activity list and caches it.
// Returns the number of callsigns loaded.
func (a *App) DownloadLoTWUsers() (int, error) {
if a.lotwUsers == nil {
return 0, fmt.Errorf("not initialized")
}
return a.lotwUsers.Download(a.ctx)
}
// StationInfoComputed bundles the data we resolve live from the
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
// lat/lon. Used by the Settings UI to show the "what will be stamped on
// each QSO" preview next to the editable fields.
type StationInfoComputed struct {
Country string `json:"country"`
DXCC int `json:"dxcc"`
CQZ int `json:"cqz"`
ITUZ int `json:"ituz"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
// ListCountries returns the DXCC entity names for the Country picker, so the
// user selects from a fixed list instead of typing (avoids typos). Empty
// until cty.dat has loaded.
func (a *App) ListCountries() []string {
if a.dxcc == nil {
return nil
}
return a.dxcc.EntityNames()
}
// DXCCForCountry returns the ADIF DXCC entity number for a country/entity
// name (as listed by ListCountries), or 0 if unknown. The QSO editor uses it
// to keep the read-only DXCC field in sync when the user picks a Country.
func (a *App) DXCCForCountry(name string) int {
return dxcc.EntityDXCC(name)
}
// DXCCName returns a display name for a DXCC entity number (or "" if unknown).
// Used by the award editor to label the DXCC-filter chips.
func (a *App) DXCCName(n int) string {
return dxcc.NameForDXCC(n)
}
// ComputeStationInfo resolves a station's structured metadata from the
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
// frontend calls this whenever Callsign or Grid changes in the Station
// Information panel so the user sees the auto-filled values live.
func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed {
var out StationInfoComputed
if a.dxcc != nil && callsign != "" {
if m, ok := a.dxcc.Lookup(callsign); ok && m.Entity != nil {
out.Country = m.Entity.Name
out.CQZ = m.CQZone
out.ITUZ = m.ITUZone
out.Lat = m.Lat
out.Lon = m.Lon
out.DXCC = dxcc.EntityDXCC(m.Entity.Name)
// Refine zones by call district (W6 → CQ3/ITU6) so the entry strip
// shows what will be logged.
if cqz, ituz, ok := dxcc.ZoneByCallDistrict(out.DXCC, callsign); ok {
out.CQZ, out.ITUZ = cqz, ituz
}
}
}
// Grid wins on lat/lon — it's user-set, finer than the DXCC centroid.
if lat, lon, ok := gridToLatLon(grid); ok {
out.Lat = lat
out.Lon = lon
}
// 3 decimals is ~110 m — plenty for a station/grid coordinate, and keeps
// the UI fields tidy.
out.Lat = math.Round(out.Lat*1000) / 1000
out.Lon = math.Round(out.Lon*1000) / 1000
return out
}
// applyDXCCNumber fills DXCC (contacted station) from the cty.dat entity
// name when it's empty. Same lookup as applyStationDefaults does for
// MY_DXCC — uses our entity-name → ADIF DXCC# table since cty.dat itself
// doesn't store the ARRL number.
func (a *App) applyDXCCNumber(q *qso.QSO) {
if q.DXCC == nil && q.Country != "" {
if n := dxcc.EntityDXCC(q.Country); n != 0 {
q.DXCC = &n
}
}
}
// refineDistrictZones sets the CQ/ITU zone from the call district for
// zone-split countries (USA, Australia), so every entry point — manual,
// UDP, import, re-stamp, award scan — agrees on W6 = CQ3/ITU6 instead of the
// coarse per-entity default. Call AFTER the DXCC is finalised.
func (a *App) refineDistrictZones(q *qso.QSO) {
if q.DXCC == nil {
return
}
if cqz, ituz, ok := dxcc.ZoneByCallDistrict(*q.DXCC, q.Callsign); ok {
zc, zi := cqz, ituz
q.CQZ, q.ITUZ = &zc, &zi
}
}
// applyStationDefaults fills any empty MY_* / station field on q with the
// currently-active profile's values. Multi-profile support means a user
// can be /P with a different callsign + grid + SOTA ref than home — the
// QSO carries whichever profile was selected at log time.
func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
if a.profiles == nil {
return
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return
}
// STATION_CALLSIGN drives upload routing, so only stamp it on NEW QSOs — on
// import backfill, stamping the active call onto a QSO that lacked one could
// misroute it in a mixed-call log.
if includeIdentity && q.StationCallsign == "" {
q.StationCallsign = p.Callsign
}
// OPERATOR and OWNER_CALLSIGN are descriptive (not used for routing), so fill
// them whenever empty — including on import.
if q.Operator == "" {
q.Operator = p.Operator
}
if strings.TrimSpace(p.OwnerCallsign) != "" {
if q.Extras == nil {
q.Extras = map[string]string{}
}
if q.Extras["OWNER_CALLSIGN"] == "" {
q.Extras["OWNER_CALLSIGN"] = p.OwnerCallsign
}
}
if q.MyGrid == "" {
q.MyGrid = p.MyGrid
}
if q.MyCountry == "" {
q.MyCountry = p.MyCountry
}
if q.MyState == "" {
q.MyState = p.MyState
}
if q.MyCounty == "" {
q.MyCounty = p.MyCounty
}
if q.MyStreet == "" {
q.MyStreet = p.MyStreet
}
if q.MyCity == "" {
q.MyCity = p.MyCity
}
if q.MyPostalCode == "" {
q.MyPostalCode = p.MyPostalCode
}
if q.MySOTARef == "" {
q.MySOTARef = p.MySOTARef
}
if q.MyPOTARef == "" {
q.MyPOTARef = p.MyPOTARef
}
// Per-band rig/antenna from Operating conditions (the antenna ticked as
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
if a.operating != nil && q.Band != "" && (q.MyRig == "" || q.MyAntenna == "") {
if d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok {
if q.MyRig == "" {
q.MyRig = d.StationName
}
if q.MyAntenna == "" {
q.MyAntenna = d.AntennaName
}
}
}
// Fall back to the profile's static rig/antenna for anything still empty.
if q.MyRig == "" {
q.MyRig = p.MyRig
}
if q.MyAntenna == "" {
q.MyAntenna = p.MyAntenna
}
if q.TXPower == nil && p.TxPower != nil {
v := *p.TxPower
q.TXPower = &v
}
// Profile-stored MY_* DXCC metadata wins (the user can override the
// auto-filled values in Station Information).
if q.MyDXCC == nil && p.MyDXCC != nil {
v := *p.MyDXCC
q.MyDXCC = &v
}
if q.MyCQZone == nil && p.MyCQZone != nil {
v := *p.MyCQZone
q.MyCQZone = &v
}
if q.MyITUZone == nil && p.MyITUZone != nil {
v := *p.MyITUZone
q.MyITUZone = &v
}
if q.MyLat == nil && p.MyLat != nil {
v := *p.MyLat
q.MyLat = &v
}
if q.MyLon == nil && p.MyLon != nil {
v := *p.MyLon
q.MyLon = &v
}
// Resolve any still-missing my zones / lat / lon via cty.dat using the
// profile's callsign — the fallback when the profile didn't store them.
if a.dxcc != nil && p.Callsign != "" {
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
if q.MyCQZone == nil && m.CQZone != 0 {
v := m.CQZone
q.MyCQZone = &v
}
if q.MyITUZone == nil && m.ITUZone != 0 {
v := m.ITUZone
q.MyITUZone = &v
}
if q.MyCountry == "" && m.Entity.Name != "" {
q.MyCountry = m.Entity.Name
}
if q.MyDXCC == nil {
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
q.MyDXCC = &n
}
}
// Lat/Lon: prefer the profile's grid (more precise than the
// DXCC entity centroid). Fall back to cty.dat coordinates.
if q.MyLat == nil || q.MyLon == nil {
if lat, lon, gOK := gridToLatLon(p.MyGrid); gOK {
if q.MyLat == nil {
v := lat
q.MyLat = &v
}
if q.MyLon == nil {
v := lon
q.MyLon = &v
}
} else {
if q.MyLat == nil && m.Lat != 0 {
v := m.Lat
q.MyLat = &v
}
if q.MyLon == nil && m.Lon != 0 {
v := m.Lon
q.MyLon = &v
}
}
}
}
}
}
func (a *App) ListQSO(f qso.ListFilter) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.qso.List(a.ctx, f)
}
func (a *App) CountQSO() (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
return a.qso.Count(a.ctx)
}
// GetLogbookRevision returns a cheap fingerprint of the logbook (count + max id)
// so the UI can poll a shared MySQL logbook and refresh when another OpsLog
// instance has added or removed QSOs.
func (a *App) GetLogbookRevision() (string, error) {
if a.qso == nil {
return "", fmt.Errorf("db not initialized")
}
return a.qso.Revision(a.ctx)
}
// awardDefs returns the user's stored award definitions, seeding the built-in
// defaults on first use.
func (a *App) awardDefs() []award.Def {
if a.settings != nil {
// Award definitions are GLOBAL (shared across every profile) — a custom
// award is the operator's own work, not station config. Read the global
// key first; fall back to a per-profile copy saved before awards became
// global so existing customisations aren't lost (the next save promotes
// them to global).
s, _ := a.settings.GetGlobal(a.ctx, keyAwardDefs)
if strings.TrimSpace(s) == "" {
s, _ = a.settings.Get(a.ctx, keyAwardDefs)
}
if strings.TrimSpace(s) != "" {
var defs []award.Def
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
// Upgrade legacy defs (pre-rich-model) in memory on every load, and
// reconcile with the catalog so a newly shipped (or newly fixed) award
// is visible even before migrateAwardDefs has written it back.
migrated, _ := award.Migrate(defs)
migrated, _, _ = mergeCatalog(migrated)
return migrated
}
}
}
return award.Defaults()
}
// mergeCatalog reconciles the stored award definitions with the catalog OpsLog
// ships. Two distinct jobs:
//
// - ADD an award the catalog has and storage doesn't (FFMA in a new release).
// Without this, awardDefs() would keep returning the stored copy for ever and
// a newly shipped award would simply never appear.
// - UPDATE a shipped award whose catalog Version is higher than the stored one
// — that is how a FIX to an award we already distributed (a corrected OR
// chain, a fixed reference list) actually reaches its users. Distributing an
// award you can never afterwards correct is only half a mechanism.
//
// An award the operator has edited (UserEdited) is NEVER updated: their work
// outranks ours. It keeps its stored version, so if they later reset it the
// update applies then.
//
// Returns the reconciled defs and the codes whose DEFINITION was replaced — the
// caller re-seeds those references from the catalog.
func mergeCatalog(defs []award.Def) ([]award.Def, []string, bool) {
idx := make(map[string]int, len(defs))
for i, d := range defs {
idx[strings.ToUpper(strings.TrimSpace(d.Code))] = i
}
var updated []string
changed := false
for _, cd := range award.Defaults() {
code := strings.ToUpper(strings.TrimSpace(cd.Code))
i, ok := idx[code]
if !ok {
defs = append(defs, cd)
changed = true
continue
}
stored := defs[i]
if stored.UserEdited || cd.Version <= stored.Version {
continue
}
// Keep the operator's own switch: an award they deliberately disabled must
// not come back enabled just because we shipped a fix. That is a preference,
// not a definition.
cd.Valid = stored.Valid
defs[i] = cd
updated = append(updated, code)
changed = true
}
return defs, updated, changed
}
func defByCode(defs []award.Def, code string) award.Def {
for _, d := range defs {
if strings.EqualFold(strings.TrimSpace(d.Code), code) {
return d
}
}
return award.Def{}
}
// reseedRefsFromCatalog replaces an award's reference list with the one the
// catalog ships. Only called for an award whose definition we just updated and
// that the operator has NOT edited, so nothing of theirs is overwritten. Awards
// whose list is generated (DXCC, French departments) or fetched online
// (POTA/SOTA/WWFF) ship none — those are left alone.
func (a *App) reseedRefsFromCatalog(code string) {
if a.awardRefs == nil {
return
}
raw, ok := award.CatalogRefs(code)
if !ok {
return
}
var refs []awardref.Ref
if err := json.Unmarshal(raw, &refs); err != nil || len(refs) == 0 {
if err != nil {
applog.Printf("awards: %s catalog references are malformed: %v", code, err)
}
return
}
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
if err != nil {
applog.Printf("awards: re-seeding %s from the catalog failed: %v", code, err)
return
}
applog.Printf("awards: %s references re-seeded from the catalog — %d", code, n)
}
// GetAwardDefs returns the (editable) award definitions.
func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }
// AwardFields lists the scannable QSO fields for the award editor.
func (a *App) AwardFields() []string { return award.Fields() }
// migrateAwardDefs upgrades legacy award definitions in storage once, so the
// editor and persisted state reflect the new model (enabled awards + filled
// matching/confirmation fields). No-op when there is nothing to migrate.
func (a *App) migrateAwardDefs() {
if a.settings == nil {
return
}
s, _ := a.settings.GetGlobal(a.ctx, keyAwardDefs)
if strings.TrimSpace(s) == "" {
s, _ = a.settings.Get(a.ctx, keyAwardDefs) // legacy per-profile copy
}
if strings.TrimSpace(s) == "" {
return // nothing saved yet → Defaults() (already on the new model)
}
var defs []award.Def
if json.Unmarshal([]byte(s), &defs) != nil || len(defs) == 0 {
return
}
migrated, changed := award.Migrate(defs)
// Back-fill the user-edited flag, once. The flag is what stops a catalog update
// from overwriting the operator's own work — but it only starts being recorded
// from the release that introduced it, so an award customised BEFORE that would
// look pristine and get silently clobbered by the first update we ship.
//
// No award version has ever been bumped yet, so today any shipped award whose
// definition differs from the catalog can only have been changed by its owner.
// That makes the back-fill exact, and it is only ever right to run it once —
// after the first version bump, "differs from the catalog" would just mean
// "older revision".
if v, _ := a.settings.GetGlobal(a.ctx, keyAwardEditsSeeded); v != "1" {
cat := map[string]award.Def{}
for _, d := range award.Defaults() {
cat[strings.ToUpper(strings.TrimSpace(d.Code))] = d
}
for i := range migrated {
c, ok := cat[strings.ToUpper(strings.TrimSpace(migrated[i].Code))]
if ok && !migrated[i].UserEdited && !c.SameContent(migrated[i]) {
migrated[i].UserEdited = true
changed = true
applog.Printf("awards: %s differs from the catalog — marked as yours, catalog updates will leave it alone", migrated[i].Code)
}
}
a.setSettingGlobal(keyAwardEditsSeeded, "1")
}
// Reconcile with the catalog: awards ADDED since this operator last saved, and
// shipped awards whose definition we have since FIXED (a higher Version). Both
// must be written back, or the editor would keep showing the old set.
merged, updated, mc := mergeCatalog(migrated)
if mc {
migrated, changed = merged, true
}
// A replaced definition gets the catalog's reference list back too — a fix is
// usually BOTH (a new OR chain and the references its regexes rely on), and a
// half-applied update is worse than none.
for _, code := range updated {
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
a.reseedRefsFromCatalog(code)
}
// Version-gated correction of the built-in awards' Validate sources, which
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
// from Defaults to protected/built-in awards once.
const defsFixVersion = "2"
if v, _ := a.settings.GetGlobal(a.ctx, keyAwardDefsFixed); v != defsFixVersion {
byCode := map[string]award.Def{}
for _, d := range award.Defaults() {
byCode[strings.ToUpper(d.Code)] = d
}
for i := range migrated {
if d, ok := byCode[strings.ToUpper(migrated[i].Code)]; ok && (migrated[i].Builtin || migrated[i].Protected) {
migrated[i].Confirm = d.Confirm
migrated[i].Validate = d.Validate
changed = true
}
}
a.setSettingGlobal(keyAwardDefsFixed, defsFixVersion)
}
if !changed {
return
}
if b, err := json.Marshal(migrated); err == nil {
a.setSettingGlobal(keyAwardDefs, string(b))
applog.Printf("awards: migrated/fixed %d definitions", len(migrated))
}
}
// markUserEdited flags every award whose definition differs from what was stored
// before this save. The flag is what makes a catalog update safe: it tells us
// which awards carry the operator's own work, so we never overwrite one with a
// newer shipped version. It is sticky — once edited, an award stays theirs until
// they reset it — and it is set here, on the save path, rather than trusted from
// the frontend, which has no reason to reason about it.
func markUserEdited(next, prev []award.Def) {
before := make(map[string]award.Def, len(prev))
for _, d := range prev {
before[strings.ToUpper(strings.TrimSpace(d.Code))] = d
}
for i := range next {
old, ok := before[strings.ToUpper(strings.TrimSpace(next[i].Code))]
if !ok {
continue // brand-new award: not a catalog award, nothing to protect it from
}
if old.UserEdited || !old.SameContent(next[i]) {
next[i].UserEdited = true
}
next[i].Version = old.Version // the version says which SHIPPED revision this came from; a save never bumps it
}
}
// SaveAwardDefs persists edited award definitions.
func (a *App) SaveAwardDefs(defs []award.Def) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
markUserEdited(defs, a.awardDefs())
b, err := json.Marshal(defs)
if err != nil {
return err
}
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
return err
}
go a.mirrorAwardsToFolder(defs)
return nil
}
// mirrorAwards refreshes the awards folder from the database.
//
// It is called from EVERY path that can change an award — the definitions AND the
// references. Mirroring only on "save definitions" was the obvious thing to do and
// the wrong one: add a city regex to a WAPC reference and the definition never
// changes, so the file would silently go stale and you'd share yesterday's award
// believing it current. A mirror that is only sometimes a mirror is worse than no
// mirror, because you trust it.
func (a *App) mirrorAwards() {
if a.settings == nil {
return
}
go a.mirrorAwardsToFolder(a.awardDefs())
}
// mirrorAwardsToFolder writes one JSON per award into <data>/awards/, and removes
// the files of awards that no longer exist.
//
// EVERY award, built-in included. Skipping the built-ins seemed tidy — why mirror
// ten awards the user never wrote? — but it broke the one case that matters: fix
// DDFM's regex and there is no JSON to hand round or drop into the catalog. The
// rule has no exceptions now, so it needs no explaining: the folder holds your
// awards, as files, always current.
//
// The folder is a MIRROR — an output, never an input. Making it an input too (drop
// a file in and it installs) reads well until you delete an award in the UI, the
// file survives, and the award walks back in on the next start. Receiving an award
// is what Import is for, where you get a say.
func (a *App) mirrorAwardsToFolder(defs []award.Def) {
dir := a.AwardsFolder()
if dir == "" || a.awardRefs == nil {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
applog.Printf("awards: cannot create %s: %v", dir, err)
return
}
keep := map[string]bool{}
for _, d := range defs {
code := strings.ToUpper(strings.TrimSpace(d.Code))
if code == "" {
continue
}
refs, err := a.awardRefs.List(a.ctx, d.Code)
if err != nil {
applog.Printf("awards: mirror %s: %v", code, err)
continue
}
bundle := AwardBundle{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Awards: []AwardBundleEntry{{Def: d, References: refs}},
}
b, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
continue
}
name := strings.ToLower(code) + ".json"
keep[name] = true
// Temp file + rename: a crash mid-write must not leave a truncated award.
tmp := filepath.Join(dir, name+".tmp")
if err := os.WriteFile(tmp, b, 0o644); err != nil {
applog.Printf("awards: mirror %s: %v", code, err)
continue
}
if err := os.Rename(tmp, filepath.Join(dir, name)); err != nil {
applog.Printf("awards: mirror %s: %v", code, err)
}
}
// Delete the file of an award that no longer exists — otherwise a deleted award
// leaves a ghost file that looks current and would be shared by mistake.
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, f := range entries {
n := strings.ToLower(f.Name())
if f.IsDir() || !strings.HasSuffix(n, ".json") || keep[n] {
continue
}
if err := os.Remove(filepath.Join(dir, f.Name())); err == nil {
applog.Printf("awards: removed %s (award no longer exists)", f.Name())
}
}
}
// AwardExplain is one QSO run through one award's matching rules, step by step.
// It answers the question the awards grid cannot: not "did it match" but WHY.
type AwardExplain struct {
QSO qso.QSO `json:"qso"`
Explanation award.Explanation `json:"explanation"`
}
// ExplainAward replays an award's rules against the QSOs of one callsign and
// reports every step. Written because three award bugs in a week all presented
// identically — an empty column and no way to see which rule looked where — and
// each took far longer to find than it should have.
//
// Returns the matching QSOs most recent first (a call worked several times can
// match on one QSO and not another, which is itself the answer sometimes).
func (a *App) ExplainAward(code, callsign string) ([]AwardExplain, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return nil, fmt.Errorf("enter a callsign to test against")
}
var def *award.Def
defs := a.awardDefs()
for i := range defs {
if strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) {
def = &defs[i]
break
}
}
if def == nil {
return nil, fmt.Errorf("unknown award %q", code)
}
qsos, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Limit: 20})
if err != nil {
return nil, err
}
if len(qsos) == 0 {
return nil, fmt.Errorf("no QSO with %s in the log", call)
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
out := make([]AwardExplain, 0, len(qsos))
for i := range qsos {
out = append(out, AwardExplain{QSO: qsos[i], Explanation: award.Explain(*def, metas, &qsos[i])})
}
return out, nil
}
// AwardUpdate is a shipped fix an operator has NOT received, because they edited
// the award and we refuse to overwrite their work. Rather than drop the fix
// silently, we offer it: applying is their call, and it costs them their changes.
type AwardUpdate struct {
Code string `json:"code"`
Name string `json:"name"` // the catalog's name — theirs may differ
From int `json:"from"` // version they run
To int `json:"to"` // version we ship
}
// GetAwardUpdates lists the catalog fixes waiting on an operator's decision.
// Awards they have NOT edited are updated automatically at startup and never
// appear here.
func (a *App) GetAwardUpdates() []AwardUpdate {
out := []AwardUpdate{}
stored := map[string]award.Def{}
for _, d := range a.awardDefs() {
stored[strings.ToUpper(strings.TrimSpace(d.Code))] = d
}
for _, c := range award.Defaults() {
s, ok := stored[strings.ToUpper(strings.TrimSpace(c.Code))]
if !ok || !s.UserEdited || c.Version <= s.Version {
continue
}
out = append(out, AwardUpdate{Code: c.Code, Name: c.Name, From: s.Version, To: c.Version})
}
return out
}
// ApplyAwardUpdate takes the catalog's version of an award, DISCARDING the
// operator's changes to it — definition and reference list both. Only ever called
// because they clicked through a warning saying exactly that.
func (a *App) ApplyAwardUpdate(code string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
var fresh award.Def
for _, c := range award.Defaults() {
if strings.EqualFold(strings.TrimSpace(c.Code), strings.TrimSpace(code)) {
fresh = c
}
}
if fresh.Code == "" {
return fmt.Errorf("%s is not an award OpsLog ships", code)
}
defs := a.awardDefs()
found := false
for i := range defs {
if !strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) {
continue
}
fresh.Valid = defs[i].Valid // still their choice whether it is switched on
defs[i] = fresh // UserEdited cleared: it is the catalog's award again
found = true
}
if !found {
return fmt.Errorf("%s not found", code)
}
b, err := json.Marshal(defs)
if err != nil {
return err
}
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
return err
}
a.reseedRefsFromCatalog(fresh.Code)
a.mirrorAwards()
applog.Printf("awards: %s updated to catalog v%d at the operator's request", fresh.Code, fresh.Version)
return nil
}
// DismissAwardUpdate keeps the operator's award as it is and stops offering this
// fix. It records the version they turned down — not the content — so the award
// stays theirs, and a LATER revision is still offered.
func (a *App) DismissAwardUpdate(code string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
var v int
for _, c := range award.Defaults() {
if strings.EqualFold(strings.TrimSpace(c.Code), strings.TrimSpace(code)) {
v = c.Version
}
}
defs := a.awardDefs()
for i := range defs {
if strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) {
defs[i].Version = v
}
}
b, err := json.Marshal(defs)
if err != nil {
return err
}
return a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b))
}
// ResetAwardDefs restores the built-in defaults.
func (a *App) ResetAwardDefs() ([]award.Def, error) {
if a.settings == nil {
return nil, fmt.Errorf("db not initialized")
}
d := award.Defaults()
// Written directly, NOT through SaveAwardDefs: that path marks whatever it
// saves as user-edited, and a reset means the exact opposite — the operator is
// handing the awards back to the catalog, flags cleared, so future updates
// apply again. Their reference lists go back to the shipped ones too.
b, err := json.Marshal(d)
if err != nil {
return nil, err
}
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
return nil, err
}
for _, def := range d {
a.reseedRefsFromCatalog(def.Code)
}
go a.mirrorAwardsToFolder(d)
return d, nil
}
// GetAwards computes progress for EVERY award (whole-log scan). Kept for
// callers that need all results at once; the UI now computes one award at a
// time via GetAward to stay responsive on large logs.
func (a *App) GetAwards() ([]award.Result, error) {
return a.computeAwards(a.awardDefs())
}
// GetAward computes progress for a single award by code (one whole-log scan,
// matching only that award). This is what the awards UI calls when an award is
// selected, so opening the panel doesn't scan every award up front.
func (a *App) GetAward(code string) (award.Result, error) {
for _, d := range a.awardDefs() {
if strings.EqualFold(d.Code, code) {
results, err := a.computeAwards([]award.Def{d})
if err != nil {
return award.Result{}, err
}
if len(results) > 0 {
return results[0], nil
}
return award.Result{}, nil
}
}
return award.Result{}, fmt.Errorf("unknown award %q", code)
}
// computeAwards runs the engine for the given award definitions over the whole
// log and enriches dynamic awards (totals + worked-reference names).
func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
all, err := a.awardSnapshot()
if err != nil {
return nil, err
}
nameOf := func(field, ref string) string {
switch field {
case "dxcc":
if n, err := strconv.Atoi(ref); err == nil {
return dxcc.NameForDXCC(n)
}
case "cont":
return continentName(ref)
}
return ""
}
refMetas := a.awardRefMetas(defs)
results := award.Compute(defs, all, refMetas, nameOf)
// Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their
// list can be huge. Enrich them after the fact: real Total from the stored
// count, and reference names for the worked references only.
if a.awardRefs != nil {
counts, _ := a.awardRefs.Counts(a.ctx)
for i := range results {
r := &results[i]
if _, predef := refMetas[strings.ToUpper(r.Code)]; predef {
continue // predefined awards are already complete (totals + names)
}
if total := counts[strings.ToUpper(r.Code)]; total > 0 {
r.Total = total
}
codes := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
if rf.Name == "" {
codes = append(codes, rf.Ref)
}
}
if len(codes) == 0 {
continue
}
if names, err := a.awardRefs.NamesFor(a.ctx, r.Code, codes); err == nil {
for j := range r.Refs {
if r.Refs[j].Name == "" {
r.Refs[j].Name = names[strings.ToUpper(r.Refs[j].Ref)]
}
}
}
}
}
return results, nil
}
// AwardCellQSOs returns the QSOs that contribute to one award reference,
// optionally on a single band (band="" = all bands). Powers the award-grid
// cell drill-down ("show me every Canada contact on 20m").
func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
var def *award.Def
for i := range defs {
if strings.EqualFold(defs[i].Code, code) {
def = &defs[i]
break
}
}
if def == nil {
return nil, fmt.Errorf("unknown award %q", code)
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
wantRef := strings.ToUpper(strings.TrimSpace(ref))
wantBand := strings.ToLower(strings.TrimSpace(band))
var out []qso.QSO
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
if wantBand != "" && strings.ToLower(strings.TrimSpace(q.Band)) != wantBand {
return nil
}
a.enrichQSOForAwards(&q)
for _, c := range award.MatchQSO(*def, metas, &q) {
if strings.ToUpper(c) == wantRef {
out = append(out, q)
break
}
}
return nil
})
return out, err
}
// AwardMissingQSOs returns the contacts that fall within an award's scope but
// yield NO reference — so they're silently excluded from the award. Example:
// a French QSO (DXCC 227, in DDFM scope) whose note has no "Dxx" department.
// The operator can then open each and add the missing reference.
//
// Only awards with a DXCC scope are meaningful here: without it, "in scope" is
// the whole log, so e.g. POTA would report every non-POTA QSO. Such awards
// return an empty list (the UI explains why).
func (a *App) AwardMissingQSOs(code string) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
var def *award.Def
for i := range defs {
if strings.EqualFold(defs[i].Code, code) {
def = &defs[i]
break
}
}
if def == nil {
return nil, fmt.Errorf("unknown award %q", code)
}
if len(def.DXCCFilter) == 0 {
return []qso.QSO{}, nil // not meaningful without a DXCC scope
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
// Reuse the fast, already-enriched award snapshot (light column scan, cached
// against the logbook revision) instead of a full IterateAll — the latter
// pulled all ~150 columns and took ~20s over a remote MySQL.
snapshot, err := a.awardSnapshot()
if err != nil {
return nil, err
}
var out []qso.QSO
for i := range snapshot {
q := snapshot[i]
// In the award's scope, yet no reference extracted → a gap to fix.
if award.InScope(*def, &q) && len(award.MatchQSO(*def, metas, &q)) == 0 {
out = append(out, q)
}
}
return out, nil
}
// GetPOTAToken returns the stored pota.app session token (for the settings UI).
func (a *App) GetPOTAToken() string {
if a.settings == nil {
return ""
}
t, _ := a.settings.Get(a.ctx, keyExtPotaToken)
return t
}
// SavePOTAToken stores the pota.app session token used to sync the hunter log.
func (a *App) SavePOTAToken(token string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
return a.settings.Set(a.ctx, keyExtPotaToken, strings.TrimSpace(token))
}
// POTAUnmatched is one hunter-log entry that found no local QSO, with a reason
// and (when a near-match exists) the id of the candidate QSO so the UI can open
// it for correction.
type POTAUnmatched struct {
Activator string `json:"activator"`
Date string `json:"date"`
Band string `json:"band"`
Reference string `json:"reference"`
Reason string `json:"reason"`
QSOID int64 `json:"qso_id"` // 0 = no candidate to open
}
// POTASyncResult summarises a hunter-log sync run for the UI.
type POTASyncResult struct {
Fetched int `json:"fetched"` // hunter-log entries downloaded
Updated int `json:"updated"` // QSOs stamped/appended with a park ref
AlreadyTagged int `json:"already_tagged"` // already carried the park
Added int `json:"added"` // new QSOs inserted (addMissing)
Unmatched int `json:"unmatched"` // no local QSO and not added
UnmatchedList []POTAUnmatched `json:"unmatched_list"` // per-entry detail (capped)
SkippedOtherCall int `json:"skipped_other_call"` // hunts made under another callsign (onlyMyCall)
MyCall string `json:"my_call"` // the profile call used for the onlyMyCall filter
}
// SyncPOTAHunterLog downloads the user's POTA hunter log and stamps pota_ref on
// matching local QSOs. Matching is by callsign + band only — time skew between
// the activator's log and yours is ignored (we just need the park reference);
// when several QSOs share a call+band, the closest in time is used. n-fer parks
// (same QSO at several parks, logged within minutes) are appended.
// When addMissing is true, hunter-log entries whose callsign isn't in the log
// at all are inserted as new QSOs (callsign/date/band/mode/park, cty.dat-enriched).
// onlyMyCall, when true, processes only hunts made under the active profile's
// callsign — so hunts you made under another call (e.g. XV9Q, NQ2H) that aren't
// in this logbook are skipped rather than reported as "not in your log".
func (a *App) SyncPOTAHunterLog(addMissing bool, onlyMyCall bool) (POTASyncResult, error) {
if a.qso == nil || a.settings == nil {
return POTASyncResult{}, fmt.Errorf("db not initialized")
}
token, _ := a.settings.Get(a.ctx, keyExtPotaToken)
entries, err := pota.FetchHunterLog(a.ctx, token, applog.Printf)
if err != nil {
return POTASyncResult{}, err
}
// The active profile's callsign drives the onlyMyCall filter (base call, so
// F4BPO/P and F4BPO are the same identity).
myCall := ""
if a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
myCall = pota.BaseCall(p.Callsign)
}
}
var all []qso.QSO
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
all = append(all, q)
return nil
}); err != nil {
return POTASyncResult{}, err
}
idx := map[string][]int{} // baseCall|band → QSO indices
byCall := map[string][]int{} // baseCall → QSO indices
for i := range all {
idx[potaMatchKey(all[i].Callsign, all[i].Band)] = append(idx[potaMatchKey(all[i].Callsign, all[i].Band)], i)
bc := pota.BaseCall(all[i].Callsign)
byCall[bc] = append(byCall[bc], i)
}
const nferWindow = 15 * time.Minute // append a 2nd park only for the same physical QSO
const maxDetail = 300
res := POTASyncResult{Fetched: len(entries), MyCall: myCall}
toUpdate := map[int]struct{}{}
var toAdd []pota.HunterQSO
addUnmatched := func(e pota.HunterQSO, reason string, qsoID int64) {
res.Unmatched++
if len(res.UnmatchedList) < maxDetail {
d := ""
if !e.Date.IsZero() {
d = e.Date.Format("2006-01-02 15:04")
}
res.UnmatchedList = append(res.UnmatchedList, POTAUnmatched{
Activator: e.Worked, Date: d, Band: e.Band, Reference: e.Reference, Reason: reason, QSOID: qsoID,
})
}
}
for _, e := range entries {
// Skip hunts made under another of your callsigns (not this profile's).
// They legitimately aren't in this logbook, so don't flag them as errors.
if onlyMyCall && myCall != "" && e.Hunter != "" && pota.BaseCall(e.Hunter) != myCall {
res.SkippedOtherCall++
continue
}
if e.Date.IsZero() {
addUnmatched(e, "POTA entry has no usable date", 0)
continue
}
cands := idx[potaMatchKey(e.Worked, e.Band)]
// Already covered? (any same call+band QSO carries this park)
covered := false
for _, i := range cands {
if potaRefHas(all[i].POTARef, e.Reference) {
covered = true
break
}
}
// Closest empty + closest non-empty (any time skew — we only need the ref).
emptyBest, nonEmptyBest := -1, -1
var emptyDiff, nonEmptyDiff time.Duration
for _, i := range cands {
diff := all[i].QSODate.Sub(e.Date)
if diff < 0 {
diff = -diff
}
if all[i].POTARef == "" {
if emptyBest < 0 || diff < emptyDiff {
emptyBest, emptyDiff = i, diff
}
} else if nonEmptyBest < 0 || diff < nonEmptyDiff {
nonEmptyBest, nonEmptyDiff = i, diff
}
}
switch {
case covered:
res.AlreadyTagged++
case emptyBest >= 0:
all[emptyBest].POTARef = e.Reference // stamp regardless of time skew
toUpdate[emptyBest] = struct{}{}
case nonEmptyBest >= 0 && nonEmptyDiff <= nferWindow:
// n-fer: same physical QSO at another park.
all[nonEmptyBest].POTARef += "," + e.Reference
toUpdate[nonEmptyBest] = struct{}{}
case len(byCall[pota.BaseCall(e.Worked)]) == 0 && addMissing:
toAdd = append(toAdd, e)
default:
reason, candidate := potaUnmatchReason(e, idx, byCall, all)
addUnmatched(e, reason, candidate)
}
}
// Count only QSOs actually written, and log failures — so the report
// reflects reality (a DB lock / constraint no longer inflates "updated").
for i := range toUpdate {
if err := a.qso.Update(a.ctx, all[i]); err != nil {
applog.Printf("pota: update QSO %s failed: %v", all[i].Callsign, err)
continue
}
res.Updated++
}
if len(toAdd) > 0 {
res.Added = a.insertPOTAQSOs(toAdd)
}
applog.Printf("pota: hunter-log sync — %d fetched, %d updated, %d already, %d added, %d unmatched",
res.Fetched, res.Updated, res.AlreadyTagged, res.Added, res.Unmatched)
return res, nil
}
// insertPOTAQSOs inserts hunter-log entries that aren't in the log as new QSOs,
// grouping n-fer entries (same call+band+minute) into one QSO with several
// parks. Country/DXCC/zones are filled from cty.dat. Returns how many inserted.
func (a *App) insertPOTAQSOs(entries []pota.HunterQSO) int {
type group struct {
e pota.HunterQSO
parks []string
}
groups := map[string]*group{}
var order []string
for _, e := range entries {
key := pota.BaseCall(e.Worked) + "|" + e.Band + "|" + e.Date.Format("2006-01-02T15:04")
g := groups[key]
if g == nil {
g = &group{e: e}
groups[key] = g
order = append(order, key)
}
already := false
for _, p := range g.parks {
if p == e.Reference {
already = true
}
}
if !already {
g.parks = append(g.parks, e.Reference)
}
}
added := 0
for _, key := range order {
g := groups[key]
q := qso.QSO{
Callsign: g.e.Worked,
QSODate: g.e.Date,
Band: g.e.Band,
Mode: g.e.Mode,
POTARef: strings.Join(g.parks, ","),
Comment: "Added from POTA hunter log",
}
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
if _, err := a.qso.Add(a.ctx, q); err == nil {
added++
}
}
return added
}
// potaMatchKey indexes a QSO by base callsign + band for hunter-log matching.
func potaMatchKey(call, band string) string {
return pota.BaseCall(call) + "|" + strings.ToLower(strings.TrimSpace(band))
}
// potaRefHas reports whether a (possibly comma-separated) pota_ref already
// contains the given park reference.
func potaRefHas(existing, ref string) bool {
ref = strings.ToUpper(strings.TrimSpace(ref))
for _, p := range strings.Split(existing, ",") {
if strings.ToUpper(strings.TrimSpace(p)) == ref {
return true
}
}
return false
}
// potaUnmatchReason explains why a hunter-log entry matched no local QSO and,
// when a near-match exists (right band but wrong time, or right call on another
// band), returns the candidate QSO id so the UI can open it for correction.
func potaUnmatchReason(e pota.HunterQSO, idx, byCall map[string][]int, all []qso.QSO) (string, int64) {
closest := func(cands []int) (int, time.Duration) {
best, bestDiff := -1, time.Duration(1<<62-1)
for _, i := range cands {
d := all[i].QSODate.Sub(e.Date)
if d < 0 {
d = -d
}
if d < bestDiff {
best, bestDiff = i, d
}
}
return best, bestDiff
}
if sameBand := idx[potaMatchKey(e.Worked, e.Band)]; len(sameBand) > 0 {
// Same call+band exists but every QSO was outside the ±5 min window.
i, diff := closest(sameBand)
return fmt.Sprintf("same call+band logged, but closest is Δ%s away", roundDur(diff)), all[i].ID
}
others := byCall[pota.BaseCall(e.Worked)]
if len(others) == 0 {
return "this callsign isn't in your log", 0
}
bands := map[string]struct{}{}
for _, i := range others {
if b := strings.ToLower(strings.TrimSpace(all[i].Band)); b != "" {
bands[b] = struct{}{}
}
}
list := make([]string, 0, len(bands))
for b := range bands {
list = append(list, b)
}
sort.Strings(list)
i, _ := closest(others)
return fmt.Sprintf("logged on %s, not %s", strings.Join(list, "/"), e.Band), all[i].ID
}
// roundDur renders a duration compactly (e.g. "3m", "2h5m", "45s").
func roundDur(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
return d.Round(time.Minute).String()
}
// AwardStatRow is one row of the award statistics matrix (e.g. "CONFIRMED CW"):
// distinct-reference counts per band, plus Total (distinct on any band) and
// GrandTotal (sum of the per-band band-slots).
type AwardStatRow struct {
Label string `json:"label"`
Cells []int `json:"cells"`
Total int `json:"total"`
GrandTotal int `json:"grand_total"`
}
// AwardStatsResult is the statistics matrix for one award (Log4OM "Statistics").
type AwardStatsResult struct {
Code string `json:"code"`
Bands []string `json:"bands"`
Rows []AwardStatRow `json:"rows"`
}
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"}
// awardSnapshot returns the logbook as a light-scanned, award-enriched slice,
// reused across award computations. Pulling the whole logbook is the dominant
// cost of every award (≈4s for 27k rows over a remote MySQL link), while the
// matching itself is a few milliseconds — so we pull once and keep the result
// in memory, rebuilding only when the logbook revision moves (a QSO added,
// removed, or edited). The returned slice is treated as read-only by callers.
func (a *App) awardSnapshot() ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
rev, revErr := a.qso.Revision(a.ctx)
if revErr == nil {
a.awardSnapMu.Lock()
if a.awardSnap != nil && a.awardSnapRev == rev {
qs := a.awardSnap
a.awardSnapMu.Unlock()
return qs, nil
}
a.awardSnapMu.Unlock()
}
t0 := time.Now()
var all []qso.QSO
if err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
all = append(all, q)
return nil
}); err != nil {
return nil, err
}
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s)",
len(all), time.Since(t0).Round(time.Millisecond), rev)
if revErr == nil {
a.awardSnapMu.Lock()
a.awardSnap = all
a.awardSnapRev = rev
a.awardSnapMu.Unlock()
}
return all, nil
}
// GetAwardStats computes the worked/confirmed/validated reference counts of one
// award, broken down by band and by mode category (All/CW/Digital/Phone).
func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
if a.qso == nil {
return AwardStatsResult{}, fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
var def *award.Def
for i := range defs {
if strings.EqualFold(defs[i].Code, code) {
def = &defs[i]
break
}
}
if def == nil {
return AwardStatsResult{}, fmt.Errorf("unknown award %q", code)
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
bandIdx := make(map[string]int, len(statsBands))
for i, b := range statsBands {
bandIdx[b] = i
}
cats := []string{"ALL", "CW", "DIGITAL", "PHONE"}
stats := []string{"WORKED", "CONFIRMED", "VALIDATED"}
// acc[cat][stat]: per-band ref sets + an overall (any-band) ref set.
type acc struct {
perBand []map[string]struct{}
overall map[string]struct{}
}
accs := map[string]map[string]*acc{}
for _, c := range cats {
accs[c] = map[string]*acc{}
for _, s := range stats {
pb := make([]map[string]struct{}, len(statsBands))
for i := range pb {
pb[i] = map[string]struct{}{}
}
accs[c][s] = &acc{perBand: pb, overall: map[string]struct{}{}}
}
}
snapshot, err := a.awardSnapshot()
if err != nil {
return AwardStatsResult{}, err
}
for i := range snapshot {
q := &snapshot[i] // read-only; already award-enriched in the snapshot
refs := award.MatchQSO(*def, metas, q)
if len(refs) == 0 {
continue
}
bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))]
isConf := award.Confirmed(q, def.Confirm)
isVal := award.Confirmed(q, def.Validate)
cat := strings.ToUpper(award.EmissionOf(q.Mode))
record := func(c string) {
put := func(stat string) {
ac := accs[c][stat]
for _, r := range refs {
ac.overall[r] = struct{}{}
if hasBand {
ac.perBand[bi][r] = struct{}{}
}
}
}
put("WORKED")
if isConf {
put("CONFIRMED")
}
if isVal {
put("VALIDATED")
}
}
record("ALL")
if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" {
record(cat)
}
}
res := AwardStatsResult{Code: def.Code, Bands: statsBands}
for _, c := range cats {
for _, s := range stats {
ac := accs[c][s]
cells := make([]int, len(statsBands))
grand := 0
for i := range ac.perBand {
cells[i] = len(ac.perBand[i])
grand += cells[i]
}
label := s
if c != "ALL" {
label = s + " " + c
}
res.Rows = append(res.Rows, AwardStatRow{Label: label, Cells: cells, Total: len(ac.overall), GrandTotal: grand})
}
}
return res, nil
}
// invalidateAwardStats drops the in-memory award QSO snapshot. Called after QSO
// edits that don't change the logbook row count (e.g. QSL confirmation updates),
// since those aren't caught by the revision check in awardSnapshot.
func (a *App) invalidateAwardStats() {
a.awardSnapMu.Lock()
a.awardSnap = nil
a.awardSnapRev = ""
a.awardSnapMu.Unlock()
}
// RescanAwards forces the next award computation to re-pull the logbook from the
// database, bypassing the in-memory snapshot. Bound to the Awards panel's
// "Rescan" button so the operator can refresh after an external change the
// revision check can't see (e.g. a LoTW/QRZ confirmation download that only
// flips qsl_rcvd flags on existing rows).
func (a *App) RescanAwards() error {
a.invalidateAwardStats()
return nil
}
// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
// are large and not needed for matching; their names are filled afterwards.
func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta {
out := map[string][]award.RefMeta{}
if a.awardRefs == nil {
return out
}
for _, d := range defs {
if d.Dynamic {
continue
}
code := strings.ToUpper(d.Code)
refs, err := a.awardRefs.List(a.ctx, code)
if err != nil || len(refs) == 0 {
continue
}
metas := make([]award.RefMeta, 0, len(refs))
for _, rf := range refs {
dxccList := rf.DXCCList
if len(dxccList) == 0 && rf.DXCC > 0 {
dxccList = []int{rf.DXCC}
}
metas = append(metas, award.RefMeta{
Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp,
DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid,
})
}
out[code] = metas
}
return out
}
// AssignAwardRefToQSOs assigns one award reference to many QSOs at once — the
// bulk action in the award "missing references" view (e.g. tag every "Urumqi"
// contact as Xinjiang for WAPC). It writes the manual override the award engine
// honours, replacing any previous reference this award had on each QSO. Returns
// the number of QSOs updated.
func (a *App) AssignAwardRefToQSOs(code, ref string, ids []int64) (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
code = strings.ToUpper(strings.TrimSpace(code))
ref = strings.ToUpper(strings.TrimSpace(ref))
if code == "" || ref == "" {
return 0, fmt.Errorf("award code and reference are required")
}
n := 0
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
continue
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras[award.ManualRefsKey] = setOverrideRef(q.Extras[award.ManualRefsKey], code, ref)
if err := a.qso.Update(a.ctx, q); err == nil {
n++
}
}
if n > 0 {
a.invalidateAwardStats()
}
return n, nil
}
// setOverrideRef replaces (or adds) the reference for one award code in a
// "CODE@REF;CODE@REF" override string, leaving other awards' entries intact.
func setOverrideRef(existing, code, ref string) string {
var out []string
for _, entry := range strings.Split(existing, ";") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
at := strings.IndexByte(entry, '@')
if at > 0 && strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
continue // drop this award's previous reference
}
out = append(out, entry)
}
out = append(out, code+"@"+ref)
return strings.Join(out, ";")
}
// QSOAwardRef is one award reference a single QSO contributes to. Pickable
// marks awards backed by a reference list (POTA, SOTA, …) — those are assigned
// manually; the rest (DXCC, WAZ, WPX, DDFM, …) are computed from QSO fields.
type QSOAwardRef struct {
Code string `json:"code"`
Ref string `json:"ref"`
Name string `json:"name,omitempty"`
Pickable bool `json:"pickable"`
}
// enrichQSOForAwards fills in CQ/ITU zone, continent and DXCC entity from
// cty.dat when the QSO doesn't carry them, so computed awards (WAZ/WITUZ/WAC/
// DXCC) work even on records that were never enriched. Non-destructive: it
// mutates the in-memory copy used for award matching only, never the database.
func (a *App) enrichQSOForAwards(q *qso.QSO) {
if a.dxcc == nil {
return
}
// Recover the band from the frequency when missing, so per-band award
// statistics aren't lost for QSOs that carry only a frequency.
if strings.TrimSpace(q.Band) == "" && q.FreqHz != nil && *q.FreqHz > 0 {
if b := bandForHz(*q.FreqHz); b != "" {
q.Band = b
}
}
needCQ := q.CQZ == nil || *q.CQZ == 0
needITU := q.ITUZ == nil || *q.ITUZ == 0
needCont := strings.TrimSpace(q.Continent) == ""
needDXCC := q.DXCC == nil || *q.DXCC == 0
if !needCQ && !needITU && !needCont && !needDXCC {
return
}
m, ok := a.dxcc.Lookup(q.Callsign)
if !ok {
return
}
if needCQ && m.CQZone > 0 {
z := m.CQZone
q.CQZ = &z
}
if needITU && m.ITUZone > 0 {
z := m.ITUZone
q.ITUZ = &z
}
if needCont && m.Continent != "" {
q.Continent = m.Continent
}
if needDXCC && m.Entity != nil {
if n := dxcc.EntityDXCC(m.Entity.Name); n > 0 {
q.DXCC = &n
}
}
// Zone-split countries (USA, Australia): the per-entity default zone is too
// coarse (W6 = CQ5 instead of 3). Apply the call-district rule so awards
// (WAZ / WITUZ) match Log4OM. This OVERRIDES a stored entity-default zone.
a.refineDistrictZones(q)
}
// awardBandPlan maps a frequency (Hz) to its ADIF band. Used to recover the
// band for award statistics when a QSO has a frequency but no band field.
var awardBandPlan = []struct {
name string
lo, hi int64
}{
{"2190m", 135700, 137800}, {"630m", 472000, 479000}, {"160m", 1800000, 2000000},
{"80m", 3500000, 4000000}, {"60m", 5060000, 5450000}, {"40m", 7000000, 7300000},
{"30m", 10100000, 10150000}, {"20m", 14000000, 14350000}, {"17m", 18068000, 18168000},
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
{"13cm", 2300000000, 2450000000},
}
func bandForHz(hz int64) string {
for _, b := range awardBandPlan {
if hz >= b.lo && hz <= b.hi {
return b.name
}
}
return ""
}
// isComputedAwardField reports whether an award's field is auto-derived from
// structured QSO data (entity, zones, prefix, location) rather than a reference
// the operator assigns by hand. Such awards are read-only in the per-QSO editor.
func isComputedAwardField(field string) bool {
switch field {
// Purely derived from the callsign / cty.dat — never assigned by hand.
case "dxcc", "cqz", "ituz", "prefix", "callsign", "cont", "country", "grid", "grid4":
return true
}
// NB: "state" and "cnty" are deliberately NOT computed. They are QSO fields
// the operator often sets by hand (a QRZ lookup rarely fills the JA
// prefecture or VE province), and they drive predefined-list awards
// (WAS / RAC / WAJA / JCC). So they must be pickable in the per-QSO editor.
return false
}
// ComputeQSOAwardRefs returns every award reference a single QSO contributes to
// — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for
// the per-QSO Award Refs editor. Reuses the same engine as GetAwards.
func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
nameOf := func(field, ref string) string {
switch field {
case "dxcc":
if n, err := strconv.Atoi(ref); err == nil {
return dxcc.NameForDXCC(n)
}
case "cont":
return continentName(ref)
}
return ""
}
a.enrichQSOForAwards(&q)
defs := a.awardDefs()
fieldByCode := map[string]string{}
for _, d := range defs {
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
}
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
var out []QSOAwardRef
for i := range results {
r := &results[i]
// "Pickable" = the reference is manually assigned per QSO (POTA, notes…),
// NOT auto-derived from a structured field. DXCC/WAZ/WAS/WAC/WPX are
// computed and belong in the read-only panel even though they now have
// reference lists.
pickable := !isComputedAwardField(fieldByCode[strings.ToUpper(r.Code)])
for _, rf := range r.Refs {
if !rf.Worked {
continue // a single QSO only contributes worked references
}
out = append(out, QSOAwardRef{Code: r.Code, Ref: rf.Ref, Name: rf.Name, Pickable: pickable})
}
}
return out, nil
}
// AwardRefsForQSOs returns, per QSO id, a map of award code → the reference(s)
// that QSO contributes to (joined when several). Powers the per-award columns in
// the Recent QSOs / Worked-before grids. The reference metadata is computed ONCE
// for the whole batch so a page of QSOs stays cheap.
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
out := map[int64]map[string]string{}
if a.qso == nil || len(ids) == 0 {
return out, nil
}
defs := a.awardDefs()
metas := a.awardRefMetas(defs)
fieldByCode := map[string]string{}
dispByCode := map[string]string{}
for _, d := range defs {
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
}
nameOf := func(field, ref string) string {
switch field {
case "dxcc":
if n, err := strconv.Atoi(ref); err == nil {
return dxcc.NameForDXCC(n)
}
case "cont":
return continentName(ref)
}
return ""
}
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf)
m := map[string]string{}
for i := range results {
r := &results[i]
code := strings.ToUpper(r.Code)
dxccField := fieldByCode[code] == "dxcc"
var refs []string
for _, rf := range r.Refs {
if !rf.Worked {
continue
}
// Per-award display choice: ref (default), name (description), or
// both. DXCC keeps showing the country name under the default.
label := rf.Ref
switch dispByCode[code] {
case "name":
if rf.Name != "" {
label = rf.Name
}
case "both":
if rf.Name != "" {
label = rf.Ref + " — " + rf.Name
}
default: // "" or "ref"
if dxccField && rf.Name != "" {
label = rf.Name
}
}
refs = append(refs, label)
}
if len(refs) > 0 {
m[code] = strings.Join(refs, ", ")
}
}
if len(m) > 0 {
out[q.ID] = m
}
return nil
})
return out, err
}
// AwardRefMeta describes a reference list's state for the UI.
type AwardRefMeta struct {
Code string `json:"code"`
Count int `json:"count"`
UpdatedAt string `json:"updated_at"`
CanUpdate bool `json:"can_update"`
}
// GetAwardReferenceMeta returns the reference-list status for every defined
// award (count + last update + whether an online updater exists).
func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
if a.awardRefs == nil {
return nil, fmt.Errorf("db not initialized")
}
counts, err := a.awardRefs.Counts(a.ctx)
if err != nil {
return nil, err
}
var out []AwardRefMeta
for _, d := range a.awardDefs() {
code := strings.ToUpper(d.Code)
updated := ""
if a.settings != nil {
updated, _ = a.settings.Get(a.ctx, keyAwardRefsUpdated+code)
}
out = append(out, AwardRefMeta{
Code: d.Code,
Count: counts[code],
UpdatedAt: updated,
CanUpdate: awardref.CanUpdate(d.Code),
})
}
return out, nil
}
// UpdateAwardReferenceList downloads the latest reference list for an award and
// replaces the stored set. Returns the new reference count.
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
if a.awardRefs == nil {
return AwardRefMeta{}, fmt.Errorf("db not initialized")
}
if !awardref.CanUpdate(code) {
return AwardRefMeta{}, fmt.Errorf("no online reference list for %q", code)
}
refs, err := awardref.Download(a.ctx, code)
if err != nil {
return AwardRefMeta{}, err
}
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
if err != nil {
return AwardRefMeta{}, err
}
now := time.Now().Format("2006-01-02 15:04")
if a.settings != nil {
a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), now)
}
applog.Printf("award-refs: %s updated — %d references", strings.ToUpper(code), n)
return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil
}
// DownloadAllReferenceLists downloads every online award reference list
// (IOTA/POTA/WWFF/SOTA) in one go and returns a per-award summary. Used by the
// first-launch dialog and the Tools → Maintenance entry so the user doesn't
// have to open each award and update it individually.
func (a *App) DownloadAllReferenceLists() (string, error) {
if a.awardRefs == nil {
return "", fmt.Errorf("db not initialized")
}
var parts []string
var firstErr error
for _, code := range []string{"IOTA", "POTA", "WWFF", "SOTA"} {
if !awardref.CanUpdate(code) {
continue
}
meta, err := a.UpdateAwardReferenceList(code)
if err != nil {
parts = append(parts, fmt.Sprintf("%s ✗", code))
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", code, err)
}
continue
}
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
}
return strings.Join(parts, " · "), firstErr
}
// SearchAwardReferences finds references of an award by code/name (for the
// per-QSO reference picker). dxcc>0 restricts to one entity.
func (a *App) SearchAwardReferences(code, query string, dxcc, limit int) ([]awardref.Ref, error) {
if a.awardRefs == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.awardRefs.Search(a.ctx, code, query, dxcc, limit)
}
// ListAwardReferences returns every reference of an award (for the editor).
func (a *App) ListAwardReferences(code string) ([]awardref.Ref, error) {
if a.awardRefs == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.awardRefs.List(a.ctx, code)
}
// SaveAwardReference inserts or updates a single reference.
func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
if a.awardRefs == nil {
return fmt.Errorf("db not initialized")
}
if err := a.awardRefs.Upsert(a.ctx, code, ref); err != nil {
return err
}
a.markAwardEdited(code)
a.mirrorAwards()
return nil
}
// DeleteAwardReference removes one reference from an award.
func (a *App) DeleteAwardReference(code, refCode string) error {
if a.awardRefs == nil {
return fmt.Errorf("db not initialized")
}
if err := a.awardRefs.Delete(a.ctx, code, refCode); err != nil {
return err
}
a.markAwardEdited(code)
a.mirrorAwards()
return nil
}
// markAwardEdited records that the operator has hand-modified an award's
// REFERENCE list, so a catalog update never overwrites it. Only the manual paths
// call this: the online refresh of POTA/SOTA/WWFF replaces the list too, but that
// is a data sync, not a customisation, and treating it as one would freeze those
// awards out of every future fix.
func (a *App) markAwardEdited(code string) {
if a.settings == nil {
return
}
defs := a.awardDefs()
touched := false
for i := range defs {
if strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) && !defs[i].UserEdited {
defs[i].UserEdited = true
touched = true
}
}
if !touched {
return
}
if b, err := json.Marshal(defs); err == nil {
a.setSettingGlobal(keyAwardDefs, string(b))
}
}
// ReplaceAwardReferences atomically replaces an award's whole reference list
// (used by paste / CSV import and presets). Returns the new count.
func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, error) {
if a.awardRefs == nil {
return 0, fmt.Errorf("db not initialized")
}
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
if err != nil {
return 0, err
}
if a.settings != nil {
a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
}
a.markAwardEdited(code)
a.mirrorAwards()
return n, nil
}
// GetAwardPresets returns the catalogue of built-in reference lists.
func (a *App) GetAwardPresets() []awardref.Preset { return awardref.Presets() }
// ApplyAwardPreset replaces an award's reference list with a built-in preset.
// Returns the new reference count.
func (a *App) ApplyAwardPreset(code, presetKey string) (int, error) {
p, ok := awardref.PresetByKey(presetKey)
if !ok {
return 0, fmt.Errorf("unknown preset %q", presetKey)
}
return a.ReplaceAwardReferences(code, p.Refs)
}
// PopulateBuiltinReferences seeds an award's reference list from the built-in
// data (DXCC entities, CQ zones, continents, US states, French departments).
// Returns the new count; ok=false awards (online / custom) yield an error.
func (a *App) PopulateBuiltinReferences(code string) (int, error) {
refs, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
if !ok {
return 0, fmt.Errorf("no built-in reference list for %q", code)
}
return a.ReplaceAwardReferences(code, refs)
}
// HasBuiltinReferences reports whether an award code ships a built-in list.
func (a *App) HasBuiltinReferences(code string) bool {
_, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
return ok
}
// ── Award export / import ──────────────────────────────────────────────
//
// A self-contained JSON bundle of every award definition AND its reference
// list. This is the backup users need so a reinstall / PC change never loses
// the awards they built by hand. It is independent of the database file.
// AwardBundle is the on-disk format for an award export.
type AwardBundle struct {
Version int `json:"version"`
ExportedAt string `json:"exported_at"`
Awards []AwardBundleEntry `json:"awards"`
}
// AwardBundleEntry pairs one award definition with its full reference list.
type AwardBundleEntry struct {
Def award.Def `json:"def"`
References []awardref.Ref `json:"references"`
}
// AwardImportResult summarises an award import for the UI.
type AwardImportResult struct {
Awards int `json:"awards"` // definitions added or updated
References int `json:"references"` // references imported across all awards
}
// ExportAwards shows a Save dialog and writes every award definition plus its
// reference list to a JSON bundle. Returns the path written, or "" if the user
// cancelled.
func (a *App) ExportAwards() (string, error) {
return a.exportAwardBundle(nil, "OpsLog_awards_"+time.Now().UTC().Format("20060102_150405")+".json", "Export awards")
}
// awardsDirName is the drop folder for award JSON files, inside OpsLog's data
// directory.
//
// The embedded catalog is compiled into the binary, so adding an award to it means
// a rebuild and a release — fine for what OpsLog ships officially, useless for
// "someone made an award and wants to hand it round". This folder is the answer:
// drop a JSON in, restart, the award is installed. No recompile, nobody to ask.
//
// It lives in the DATA directory, never in a cloud-synced folder — replicating
// files byte-by-byte is the mess we're avoiding everywhere else.
const awardsDirName = "awards"
// AwardsFolder is where award JSON files can be dropped to install them.
func (a *App) AwardsFolder() string {
if a.dataDir == "" {
return ""
}
return filepath.Join(a.dataDir, awardsDirName)
}
// OpenAwardsFolder reveals the drop folder in the file manager, creating it if
// needed — telling someone a path they then have to build by hand is a poor
// substitute for opening it.
func (a *App) OpenAwardsFolder() error {
dir := a.AwardsFolder()
if dir == "" {
return fmt.Errorf("data dir not initialized")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return exec.Command("explorer", dir).Start()
}
// catalogRefs returns the reference list a SHIPPED award carries, if any. Awards
// whose list is generated from code (DXCC entities, French departments) or fetched
// online (POTA/SOTA/WWFF) carry none.
func (a *App) catalogRefs(code string) (json.RawMessage, bool) {
return award.CatalogRefs(code)
}
// GetCatalogCodes lists the awards SHIPPED with OpsLog. Anything in the database
// that is NOT in this list is YOURS — you created or imported it, and it is not
// part of what OpsLog delivers. The editor flags those so it's obvious at a glance
// which awards are your own work (and therefore which files in the awards folder
// are yours to share).
func (a *App) GetCatalogCodes() []string {
cat := award.Catalog()
out := make([]string, 0, len(cat))
for _, e := range cat {
out = append(out, strings.ToUpper(strings.TrimSpace(e.Def.Code)))
}
return out
}
// ExportAward writes ONE award to its own JSON file — the unit you actually share.
// The whole-catalogue bundle is a backup; a single award is what you send someone.
//
// It reads the DEFINITION AND ITS REFERENCES FROM THE DATABASE, not from the
// embedded catalog: your WAPC is only worth sharing because of the province list
// and the city regexes you added, and those live in the database. Exporting the
// catalog's version would hand over an empty shell.
func (a *App) ExportAward(code string) (string, error) {
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return "", fmt.Errorf("no award selected")
}
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
}
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
if a.awardRefs == nil {
return "", fmt.Errorf("db not initialized")
}
want := map[string]bool{}
for _, c := range codes {
want[strings.ToUpper(strings.TrimSpace(c))] = true
}
defs := a.awardDefs()
bundle := AwardBundle{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Awards: make([]AwardBundleEntry, 0, len(defs)),
}
for _, d := range defs {
if len(want) > 0 && !want[strings.ToUpper(d.Code)] {
continue
}
refs, err := a.awardRefs.List(a.ctx, d.Code)
if err != nil {
return "", fmt.Errorf("list references for %s: %w", d.Code, err)
}
bundle.Awards = append(bundle.Awards, AwardBundleEntry{Def: d, References: refs})
}
if len(bundle.Awards) == 0 {
return "", fmt.Errorf("nothing to export")
}
data, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return "", err
}
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: title,
DefaultFilename: defaultName,
Filters: []wruntime.FileFilter{
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
if err != nil || path == "" {
return "", err
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", fmt.Errorf("write %s: %w", path, err)
}
return path, nil
}
// ImportAwards shows an Open dialog, reads an award bundle and merges it:
// definitions are upserted by code, and any entry that carries references
// replaces that award's list. Returns counts; the user cancelling yields a
// zero result and no error.
func (a *App) ImportAwards() (AwardImportResult, error) {
// Kept for the old call sites: inspect, then apply with the default decision.
// Anything that already exists is SKIPPED, never silently replaced.
p, err := a.InspectAwardImport()
if err != nil || p.Path == "" {
return AwardImportResult{}, err
}
dec := map[string]string{}
for _, e := range p.Awards {
if e.Exists {
dec[e.Code] = "skip"
} else {
dec[e.Code] = "replace"
}
}
return a.ApplyAwardImport(p.Path, dec)
}
// AwardImportPreviewEntry describes one award found in a file to import.
type AwardImportPreviewEntry struct {
Code string `json:"code"`
Name string `json:"name"`
References int `json:"references"`
Exists bool `json:"exists"` // an award with this code is already installed
MineName string `json:"mine_name"` // the name of the one you already have
MineRefs int `json:"mine_refs"` // how many references yours carries
Protected bool `json:"protected"` // yours is a protected built-in
}
// AwardImportPreview is what the file holds, and what would collide.
type AwardImportPreview struct {
Path string `json:"path"`
Awards []AwardImportPreviewEntry `json:"awards"`
}
// InspectAwardImport opens a bundle and reports what it contains WITHOUT touching
// anything — so the UI can ask before overwriting.
//
// The import used to merge by code with "imported wins", silently. Import a WAPC
// someone shared and YOUR WAPC — its province list, its city regexes, its band
// scope — was destroyed without a word. Sharing awards is precisely the feature we
// want people to use, so it must not be a data-loss trap: we look first, then ask.
func (a *App) InspectAwardImport() (AwardImportPreview, error) {
var p AwardImportPreview
if a.awardRefs == nil || a.settings == nil {
return p, fmt.Errorf("db not initialized")
}
path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Import award(s)",
Filters: []wruntime.FileFilter{
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
if err != nil || path == "" {
return p, err
}
bundle, err := readAwardBundle(path)
if err != nil {
return p, err
}
mine := map[string]award.Def{}
for _, d := range a.awardDefs() {
mine[strings.ToUpper(d.Code)] = d
}
p.Path = path
for _, e := range bundle.Awards {
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
if code == "" {
continue
}
entry := AwardImportPreviewEntry{
Code: code, Name: e.Def.Name, References: len(e.References),
}
if d, ok := mine[code]; ok {
entry.Exists = true
entry.MineName = d.Name
entry.Protected = d.Protected
if refs, err := a.awardRefs.List(a.ctx, code); err == nil {
entry.MineRefs = len(refs)
}
}
p.Awards = append(p.Awards, entry)
}
if len(p.Awards) == 0 {
return p, fmt.Errorf("no awards in file")
}
return p, nil
}
// ApplyAwardImport imports a bundle, applying the operator's decision per code:
//
// "replace" — take theirs, overwriting mine
// "skip" — keep mine, ignore the import
// "copy" — install theirs under a free code (WAPC-2), so both exist and can be
// compared before one is deleted
//
// "copy" is the one that earns its keep: it lets you LOOK before choosing, without
// losing anything either way.
func (a *App) ApplyAwardImport(path string, decisions map[string]string) (AwardImportResult, error) {
var res AwardImportResult
if a.awardRefs == nil || a.settings == nil {
return res, fmt.Errorf("db not initialized")
}
bundle, err := readAwardBundle(path)
if err != nil {
return res, err
}
defs := a.awardDefs()
idx := map[string]int{}
for i, d := range defs {
idx[strings.ToUpper(d.Code)] = i
}
// refsToWrite is applied only after the definitions save cleanly — a half-done
// import that swapped the reference list but not the definition would leave the
// award quietly broken.
type pending struct {
code string
refs []awardref.Ref
}
var refsToWrite []pending
for _, e := range bundle.Awards {
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
if code == "" {
continue
}
decision := strings.ToLower(strings.TrimSpace(decisions[code]))
_, exists := idx[code]
if !exists {
decision = "replace" // nothing to collide with
}
switch decision {
case "skip", "":
continue
case "copy":
newCode := freeAwardCode(code, idx)
d := e.Def
d.Code = newCode
// An imported copy is NOT a protected built-in: the operator must be able
// to delete it once they've decided which one they keep.
d.Builtin, d.Protected = false, false
idx[newCode] = len(defs)
defs = append(defs, d)
res.Awards++
if len(e.References) > 0 {
refsToWrite = append(refsToWrite, pending{newCode, e.References})
}
default: // "replace"
d := e.Def
d.Code = code
if i, ok := idx[code]; ok {
defs[i] = d
} else {
idx[code] = len(defs)
defs = append(defs, d)
}
res.Awards++
// An entry with NO references leaves the existing list alone — otherwise
// importing a def-only export would wipe a seeded built-in list.
if len(e.References) > 0 {
refsToWrite = append(refsToWrite, pending{code, e.References})
}
}
}
if res.Awards == 0 {
return res, nil // everything skipped
}
if migrated, changed := award.Migrate(defs); changed {
defs = migrated
}
b, _ := json.Marshal(defs)
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
return res, fmt.Errorf("save award defs: %w", err)
}
for _, p := range refsToWrite {
n, err := a.ReplaceAwardReferences(p.code, p.refs)
if err != nil {
return res, fmt.Errorf("import references for %s: %w", p.code, err)
}
res.References += n
}
a.invalidateAwardStats()
return res, nil
}
// readAwardBundle parses a bundle file. Size-capped: an award bundle is a few
// hundred kB at most, and an unbounded read of a file someone sent you is not a
// risk worth taking.
func readAwardBundle(path string) (AwardBundle, error) {
var bundle AwardBundle
fi, err := os.Stat(path)
if err != nil {
return bundle, fmt.Errorf("read %s: %w", path, err)
}
const maxBundle = 32 << 20 // 32 MB
if fi.Size() > maxBundle {
return bundle, fmt.Errorf("%s is too large for an award bundle (%d bytes)", path, fi.Size())
}
data, err := os.ReadFile(path)
if err != nil {
return bundle, fmt.Errorf("read %s: %w", path, err)
}
if err := json.Unmarshal(data, &bundle); err != nil {
return bundle, fmt.Errorf("parse award bundle: %w", err)
}
if len(bundle.Awards) == 0 {
return bundle, fmt.Errorf("no awards in file")
}
return bundle, nil
}
// freeAwardCode returns the first unused "CODE-n" (WAPC-2, WAPC-3, …).
func freeAwardCode(code string, taken map[string]int) string {
for n := 2; n < 100; n++ {
c := fmt.Sprintf("%s-%d", code, n)
if _, clash := taken[c]; !clash {
return c
}
}
return code + "-COPY"
}
// builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
const builtinRefsVersion = "2"
// seedBuiltinReferences populates the reference lists of built-in awards.
// - First run (no version stored), or already up to date: seed only awards that
// have NO references yet, so an online-loaded list (POTA…), a user list, or an
// edited one isn't clobbered. This is also what gives an award newly added to
// the catalog (FFMA and its 488 grids) its list on an existing install.
// - Version bump (stored != current): RE-SEED the derived built-in lists
// (DXCC, WAZ, WAC, WAS, DDFM) to push data corrections to existing installs.
// These lists are canonical, not user-maintained, so overwriting is safe.
func (a *App) seedBuiltinReferences() {
if a.awardRefs == nil || a.settings == nil {
return
}
counts, err := a.awardRefs.Counts(a.ctx)
if err != nil {
return
}
ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
if ver == "1" {
firstRun = false // already seeded once → treat as a version upgrade
}
if ver == builtinRefsVersion {
// Already on the current data. But an award ADDED to the catalog since the
// last run (FFMA, say) still has an EMPTY list, and bumping the version to
// seed it would re-seed every award and clobber lists the operator has
// edited. So fill only the empty ones.
firstRun = true
}
for _, d := range a.awardDefs() {
code := strings.ToUpper(d.Code)
if firstRun && counts[code] > 0 {
continue // don't overwrite an existing list
}
// A catalog award that SHIPS its own reference list wins: that is how an
// award added as a JSON file (a shared WAPC, with its provinces and their
// city regexes) reaches every user without a line of Go. Awards whose list
// is generated from code (DXCC entities, French departments) or fetched
// online (POTA/SOTA/WWFF) carry none, and fall back below.
if raw, ok := a.catalogRefs(code); ok {
var refs []awardref.Ref
if err := json.Unmarshal(raw, &refs); err == nil && len(refs) > 0 {
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
applog.Printf("award-refs: seeded %s from the catalog — %d references", code, n)
continue
}
} else if err != nil {
applog.Printf("award-refs: %s catalog references are malformed: %v", code, err)
}
}
if refs, ok := awardref.BuiltinRefs(code); ok {
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
applog.Printf("award-refs: seeded %s — %d references", code, n)
}
}
}
a.setSetting(keyAwardRefsSeeded, builtinRefsVersion)
}
// ImportAwardReferencesText parses pasted lines or CSV into references and
// replaces the award's list. Accepted per line (comma/semicolon/tab separated):
//
// CODE
// CODE,Description
// CODE,Description,Group
// CODE,Description,Group,Subgroup
// CODE,Description,Group,Subgroup,DXCC
//
// A leading header row (first field "code"/"ref"/"reference") is skipped.
func (a *App) ImportAwardReferencesText(code, text string) (int, error) {
refs := parseRefLines(text)
if len(refs) == 0 {
return 0, fmt.Errorf("no references found in input")
}
return a.ReplaceAwardReferences(code, refs)
}
// parseRefLines turns pasted/CSV text into references (best-effort, tolerant of
// comma, semicolon or tab delimiters).
func parseRefLines(text string) []awardref.Ref {
var out []awardref.Ref
for i, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if line == "" {
continue
}
var fields []string
switch {
case strings.Contains(line, "\t"):
fields = strings.Split(line, "\t")
case strings.Contains(line, ";"):
fields = strings.Split(line, ";")
default:
fields = strings.Split(line, ",")
}
for j := range fields {
fields[j] = strings.TrimSpace(fields[j])
}
code := strings.ToUpper(fields[0])
if code == "" {
continue
}
// Skip a header row.
if i == 0 {
switch strings.ToLower(fields[0]) {
case "code", "ref", "reference", "ref_code":
continue
}
}
ref := awardref.Ref{Code: code, Valid: true}
if len(fields) > 1 {
ref.Name = fields[1]
}
if len(fields) > 2 {
ref.Group = fields[2]
}
if len(fields) > 3 {
ref.SubGrp = fields[3]
}
if len(fields) > 4 {
if n, err := strconv.Atoi(fields[4]); err == nil {
ref.DXCC = n
}
}
out = append(out, ref)
}
return out
}
func continentName(code string) string {
switch strings.ToUpper(code) {
case "AF":
return "Africa"
case "AN":
return "Antarctica"
case "AS":
return "Asia"
case "EU":
return "Europe"
case "NA":
return "North America"
case "OC":
return "Oceania"
case "SA":
return "South America"
}
return ""
}
// ListQSOFiltered returns QSOs matching the advanced filter builder.
func (a *App) ListQSOFiltered(f qso.QueryFilter) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.qso.ListFiltered(a.ctx, f)
}
// CountQSOFiltered returns how many QSOs match the filter (ignoring the row
// limit) so the UI can show "showing 500 of 1,234 matches".
func (a *App) CountQSOFiltered(f qso.QueryFilter) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
return a.qso.CountFiltered(a.ctx, f)
}
// FilterFields exposes the whitelisted filterable columns to the frontend.
func (a *App) FilterFields() []string {
return qso.FilterableFields()
}
func (a *App) GetQSO(id int64) (qso.QSO, error) {
if a.qso == nil {
return qso.QSO{}, fmt.Errorf("db not initialized")
}
return a.qso.GetByID(a.ctx, id)
}
func (a *App) UpdateQSO(q qso.QSO) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
err := a.qso.Update(a.ctx, q)
if err == nil {
a.invalidateAwardStats()
}
return err
}
// GetLogStats aggregates the logbook for the Statistics dashboard (operators,
// modes, bands, entities, activity over time), restricted to a period.
//
// fromISO/toISO are "YYYY-MM-DD" (or RFC3339); either may be empty for "no
// bound", so two empty strings mean the whole log. `to` is inclusive: a bare date
// is stretched to 23:59:59 of that day, otherwise picking today as the end would
// silently drop today's QSOs.
func (a *App) GetLogStats(fromISO, toISO, contestID string, year int) (qso.Stats, error) {
if a.qso == nil {
return qso.Stats{}, fmt.Errorf("db not initialized")
}
from := parseStatsBound(fromISO, false)
to := parseStatsBound(toISO, true)
return a.qso.Stats(a.ctx, from, to, contestID, year)
}
// GetContestRuns lists the (contest, year) pairs actually present in the log, so
// the Statistics picker only ever offers contests you really entered.
func (a *App) GetContestRuns() ([]qso.ContestRun, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.qso.ContestRuns(a.ctx)
}
// parseStatsBound turns a UI date into a UTC bound. endOfDay stretches a bare
// date to 23:59:59 so an inclusive "to" really includes that day.
func parseStatsBound(s string, endOfDay bool) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
if t, err := time.Parse("2006-01-02", s); err == nil {
if endOfDay {
return t.UTC().Add(24*time.Hour - time.Second)
}
return t.UTC()
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UTC()
}
return time.Time{}
}
func (a *App) DeleteQSO(id int64) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
return a.qso.Delete(a.ctx, id)
}
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
// number actually deleted.
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
return a.qso.DeleteMany(a.ctx, ids)
}
// DuplicateGroup is a set of QSOs the log considers the same contact.
type DuplicateGroup struct {
Key string `json:"key"`
QSOs []qso.QSO `json:"qsos"`
}
// normDupeMode collapses SSB sidebands so USB/LSB count as the same slot; other
// modes (FT8/RTTY/CW…) stay distinct so they aren't wrongly grouped.
func normDupeMode(m string) string {
u := strings.ToUpper(strings.TrimSpace(m))
if u == "USB" || u == "LSB" {
return "SSB"
}
return u
}
// FindDuplicates scans the whole log and returns groups of duplicate QSOs: same
// callsign + band + mode (USB/LSB folded to SSB) logged within windowMinutes of
// each other — the real signature of an accidental double-log, which lands a few
// minutes apart, not necessarily the same minute. QSOs of a call+band+mode are
// sorted by time and split into clusters wherever the gap to the next exceeds
// the window, so two genuinely separate contacts hours apart are NOT grouped.
// windowMinutes <= 0 falls back to same UTC day. Each group's QSOs are
// oldest-first and groups are ordered by date, so the UI can pre-select all but
// the first.
func (a *App) FindDuplicates(windowMinutes int) []DuplicateGroup {
if a.qso == nil {
return nil
}
byKey := map[string][]qso.QSO{}
_ = a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
call := strings.ToUpper(strings.TrimSpace(q.Callsign))
if call == "" {
return nil
}
key := call + "|" + strings.ToLower(strings.TrimSpace(q.Band)) + "|" + normDupeMode(q.Mode)
byKey[key] = append(byKey[key], q)
return nil
})
out := []DuplicateGroup{}
for key, qs := range byKey {
if len(qs) < 2 {
continue
}
sort.Slice(qs, func(i, j int) bool { return qs[i].QSODate.Before(qs[j].QSODate) })
if windowMinutes <= 0 {
// Whole-day fallback: split on a change of UTC calendar day.
i := 0
for i < len(qs) {
j := i + 1
for j < len(qs) && qs[j].QSODate.UTC().Format("2006-01-02") == qs[i].QSODate.UTC().Format("2006-01-02") {
j++
}
if j-i > 1 {
out = append(out, DuplicateGroup{Key: fmt.Sprintf("%s|%d", key, i), QSOs: append([]qso.QSO(nil), qs[i:j]...)})
}
i = j
}
continue
}
win := time.Duration(windowMinutes) * time.Minute
i := 0
for i < len(qs) {
j := i + 1
for j < len(qs) && qs[j].QSODate.Sub(qs[j-1].QSODate) <= win {
j++
}
if j-i > 1 {
out = append(out, DuplicateGroup{Key: fmt.Sprintf("%s|%d", key, i), QSOs: append([]qso.QSO(nil), qs[i:j]...)})
}
i = j
}
}
sort.Slice(out, func(i, j int) bool { return out[i].QSOs[0].QSODate.Before(out[j].QSOs[0].QSODate) })
return out
}
// QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty
// string leaves that field unchanged (so you can set only "received = Y + date"
// without touching the sent side).
type QSLBulkUpdate struct {
SentStatus string `json:"sent_status"` // Y|N|R|I — "" = unchanged
RcvdStatus string `json:"rcvd_status"` // Y|N|R|I — "" = unchanged
SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged
RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged
Via string `json:"via"` // QSL_VIA — "" = unchanged
Notes string `json:"notes"` // NOTES — "" = unchanged
Comment string `json:"comment"` // COMMENT — "" = unchanged
}
// BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the
// given QSOs (used by the QSL Manager "Paper QSL" mode to confirm a stack of
// cards for one callsign at once). Returns how many rows were updated.
func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
up := func(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
n := 0
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
continue
}
changed := false
if v := up(u.SentStatus); v != "" {
q.QSLSent, changed = v, true
}
if v := up(u.RcvdStatus); v != "" {
q.QSLRcvd, changed = v, true
}
if v := strings.TrimSpace(u.SentDate); v != "" {
q.QSLSentDate, changed = v, true
}
if v := strings.TrimSpace(u.RcvdDate); v != "" {
q.QSLRcvdDate, changed = v, true
}
if v := strings.TrimSpace(u.Via); v != "" {
q.QSLVia, changed = v, true
}
if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true
}
if v := strings.TrimSpace(u.Comment); v != "" {
q.Comment, changed = v, true
}
if changed {
if a.qso.Update(a.ctx, q) == nil {
n++
}
}
}
if n > 0 {
a.invalidateAwardStats()
}
return n, nil
}
// bulkFieldColumns maps the UI field ids to their QSO column. Kept in the app
// layer so the frontend works with stable ids, not raw column names.
var bulkFieldColumns = map[string]string{
// QSL / upload status
"lotw_sent": "lotw_sent",
"lotw_rcvd": "lotw_rcvd",
"eqsl_sent": "eqsl_sent",
"eqsl_rcvd": "eqsl_rcvd",
"qsl_sent": "qsl_sent",
"qsl_rcvd": "qsl_rcvd",
"qsl_via": "qsl_via",
"qrz_upload": "qrzcom_qso_upload_status",
"clublog_upload": "clublog_qso_upload_status",
"hrdlog_upload": "hrdlog_qso_upload_status",
// My station / operator
"station_callsign": "station_callsign",
"operator": "operator",
"my_grid": "my_grid",
"my_country": "my_country",
"my_state": "my_state",
"my_cnty": "my_cnty",
"my_iota": "my_iota",
"my_sota_ref": "my_sota_ref",
"my_pota_ref": "my_pota_ref",
"my_wwff_ref": "my_wwff_ref",
"my_street": "my_street",
"my_city": "my_city",
"my_postal_code": "my_postal_code",
"my_rig": "my_rig",
"my_antenna": "my_antenna",
"my_sig": "my_sig",
"my_sig_info": "my_sig_info",
// Contest
"contest_id": "contest_id",
"srx_string": "srx_string",
"stx_string": "stx_string",
"arrl_sect": "arrl_sect",
"precedence": "precedence",
"class": "class",
// Propagation / satellite
"prop_mode": "prop_mode",
"sat_name": "sat_name",
"sat_mode": "sat_mode",
// Contacted station activation refs / SIG
"pota_ref": "pota_ref",
"sota_ref": "sota_ref",
"wwff_ref": "wwff_ref",
"iota": "iota",
"sig": "sig",
"sig_info": "sig_info",
// Misc text
"comment": "comment",
"notes": "notes",
"rig": "rig",
"ant": "ant",
}
// BulkUpdateField sets one QSL/upload status field to value on the given QSOs
// (e.g. flip a filtered set from N to R so they upload). field is one of the
// ids in bulkFieldColumns; value is a status code (Y/N/R/I) or "" to clear.
// Returns how many rows were updated.
func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
col, ok := bulkFieldColumns[field]
if !ok {
return 0, fmt.Errorf("unknown field %q", field)
}
// Trim only — do NOT force case here: status codes arrive already upper from
// the UI, while free-text fields (address, antenna, comment…) must keep
// their case. Callsign/grid uppercasing is handled in the UI.
n, err := a.qso.BulkSetField(a.ctx, ids, col, strings.TrimSpace(value))
if err != nil {
return 0, err
}
if n > 0 {
a.invalidateAwardStats()
}
return n, nil
}
// WorkedBefore returns prior contacts with the given callsign at both
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
// will infer it from past QSOs with the same call when possible.
func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, error) {
if a.qso == nil {
return qso.WorkedBefore{}, fmt.Errorf("db not initialized")
}
// When the frontend lookup didn't carry a DXCC number (a QRZ cache hit may
// have the country name but no number), resolve it from the callsign via
// cty.dat + Clublog exceptions — the same source QSOs are logged with — so
// the entity matrix populates even for a call we've never worked directly.
if dxccHint == 0 && a.dxcc != nil {
if m, ok := a.dxcc.Lookup(callsign); ok && m.Entity != nil {
dxccHint = dxcc.EntityDXCC(m.Entity.Name)
}
}
return a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
}
// SetCompactMode toggles a tiny always-on-top window that exposes just the
// QSO entry — useful when running on a single screen alongside WSJT-X,
// JT-Alert or the cluster.
//
// We can't easily spawn a real second OS window in Wails v2, but a resized
// always-on-top main window does the job from the user's perspective.
// Sizes tuned so the compact entry strip fits in a single row (no wrap).
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
// the previous (larger) min — and increased BEFORE resizing up.
const (
compactW, compactH = 1240, 158
normalW, normalH = 1400, 900
normalMinW, normalMinH = 1100, 700
// Large enough to never constrain a maximised window on big displays.
maxW, maxH = 8000, 6000
)
func (a *App) SetCompactMode(on bool) {
if a.ctx == nil {
return
}
a.compact = on
if on {
// Lock the window to the compact size by pinning min == max. Without
// the max pin, dragging the frameless window (esp. across monitors /
// DPI boundaries) makes Windows snap it back to a large size.
wruntime.WindowSetMinSize(a.ctx, compactW, compactH)
wruntime.WindowSetMaxSize(a.ctx, compactW, compactH)
wruntime.WindowSetSize(a.ctx, compactW, compactH)
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
} else {
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
// Release the lock first (raise the max) before growing back.
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
wruntime.WindowSetSize(a.ctx, normalW, normalH)
}
}
// DeleteAllQSO wipes every QSO. Returns the number of rows removed.
// The frontend MUST gate this behind a strong confirmation prompt.
func (a *App) DeleteAllQSO() (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
return a.qso.DeleteAll(a.ctx)
}
// --- ADIF bindings ---
func (a *App) OpenADIFFile() (string, error) {
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Import ADIF",
Filters: []wruntime.FileFilter{
{DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
}
// ImportADIF imports an ADIF file. dupMode controls how records matching an
// existing QSO (same call + UTC-minute + band + mode) are handled:
// - "skip" : leave the existing QSO untouched (default, safe)
// - "update" : merge the file's non-empty fields onto the existing QSO —
// refreshes QSL/confirmation statuses when re-syncing from
// Log4OM / LoTW without clobbering fields the file omits
// - "all" : insert every record, duplicates included
//
// applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from
// cty.dat for every record, overriding what the file carries — corrects the
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
// Russia). Everything else in the ADIF is still preserved verbatim.
func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool) (adif.ImportResult, error) {
if a.qso == nil {
return adif.ImportResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return adif.ImportResult{}, fmt.Errorf("empty path")
}
// Import preserves the ADIF verbatim by default — confirmation/sent-status
// defaults are NEVER applied (they'd flag old QSOs "LoTW requested" and try to
// re-upload). When applyStation is on, we DO backfill empty MY_* station
// fields (grid/rig/antenna/QTH/address…) from the active profile — those are
// descriptive metadata and safe to fill (identity fields are still left
// alone, see applyStationDefaults).
im := &adif.Importer{Repo: a.qso}
switch dupMode {
case "update":
im.UpdateDuplicates = true
case "all":
// insert everything
default: // "skip"
im.SkipDuplicates = true
}
// When the user opts to fix countries on import, recompute from cty.dat and
// then apply ClubLog's date-ranged exceptions, which take precedence (e.g.
// TO2A on 2012-10-27 → French Guiana, not the cty.dat "TO" → France). We
// apply ClubLog whenever its data is LOADED, regardless of the live
// entry-form toggle: "apply cty" is an explicit request for the most
// accurate entity, and skipping ClubLog would DOWNGRADE DXpedition QSOs the
// source ADIF already had right. If the cache isn't loaded yet, try once.
if applyCty && a.clublog != nil && !a.clublog.Loaded() {
_ = a.clublog.EnsureLoaded()
}
clLoaded := a.clublog != nil && a.clublog.Loaded()
if applyCty || applyStation {
im.Enrich = func(q *qso.QSO) {
if applyCty {
a.enrichContactedFromCtyForce(q)
if clLoaded {
a.applyClublogException(q, true) // force: explicit import-time correction
}
}
if applyStation {
// Backfill empty MY_* descriptive fields from the active profile
// (identity fields left alone to keep mixed-call routing intact).
a.applyStationDefaults(q, false)
// Also stamp the default QSL/LoTW/eQSL confirmation statuses on
// any that are still empty (same defaults new QSOs get).
a.applyQSLDefaults(q)
}
}
}
im.OnProgress = func(processed, total int) {
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
}
return im.ImportFile(a.ctx, path)
}
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
// OpsLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled.
func (a *App) SaveADIFFile() (string, error) {
suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".adi"
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Export ADIF",
DefaultFilename: suggested,
Filters: []wruntime.FileFilter{
{DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
}
// ADIFFields returns the complete ADIF 3.1.7 QSO-field dictionary, for the
// generic "ADIF fields" editor (so any standard field can be viewed/edited)
// and for the export-mode help text.
func (a *App) ADIFFields() []adif.FieldDef { return adif.Fields }
// ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7").
func (a *App) ADIFVersion() string { return adif.ADIFVersion() }
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
// Streams from DB so memory stays flat even with 100k+ records.
// includeAppFields=false → portable standard ADIF (for other loggers);
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) {
if a.qso == nil {
return adif.ExportResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return adif.ExportResult{}, fmt.Errorf("empty path")
}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
return ex.ExportFile(a.ctx, path)
}
// ExportADIFFiltered writes the QSOs matching the current filter to path, with
// NO row limit (the on-screen list is capped by the threshold; this is not).
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter) (adif.ExportResult, error) {
if a.qso == nil {
return adif.ExportResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return adif.ExportResult{}, fmt.Errorf("empty path")
}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
return ex.ExportFileFiltered(a.ctx, path, f)
}
// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
// operator highlighted in the grid).
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64) (adif.ExportResult, error) {
if a.qso == nil {
return adif.ExportResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return adif.ExportResult{}, fmt.Errorf("empty path")
}
if len(ids) == 0 {
return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
return ex.ExportFileByIDs(a.ctx, path, ids)
}
// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
type CabrilloResult struct {
Count int `json:"count"`
Path string `json:"path"`
}
// cabrilloHeader fills the Cabrillo header from the active profile (callsign,
// operator, grid) plus the contest name (taken from the QSOs' ADIF CONTEST_ID).
func (a *App) cabrilloHeader(contest string) cabrillo.Header {
h := cabrillo.Header{Contest: contest}
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
h.Callsign = p.Callsign
h.Operators = p.Callsign
h.GridLocator = p.MyGrid
h.Name = p.OpName
}
}
return h
}
// writeCabrillo collects the QSOs via the given iterator and writes a Cabrillo
// document to path. The contest name is taken from the QSOs' ADIF CONTEST_ID
// (the most common one), so no separate prompt is needed.
func (a *App) writeCabrillo(path string, iterate func(func(qso.QSO) error) error) (CabrilloResult, error) {
if a.qso == nil {
return CabrilloResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return CabrilloResult{}, fmt.Errorf("empty path")
}
var qsos []qso.QSO
contests := map[string]int{}
if err := iterate(func(q qso.QSO) error {
qsos = append(qsos, q)
if c := strings.TrimSpace(q.ContestID); c != "" {
contests[c]++
}
return nil
}); err != nil {
return CabrilloResult{}, err
}
// Pick the most-used CONTEST_ID across the exported QSOs.
contest := ""
best := 0
for c, n := range contests {
if n > best {
contest, best = c, n
}
}
f, err := os.Create(path)
if err != nil {
return CabrilloResult{}, err
}
defer f.Close()
n, err := cabrillo.Generate(f, a.cabrilloHeader(contest), qsos)
if err != nil {
return CabrilloResult{}, err
}
return CabrilloResult{Count: n, Path: path}, nil
}
// ExportCabrillo writes every QSO to a Cabrillo file.
// ListContests returns the built-in contest catalogue (ADIF CONTEST_ID + default
// exchange), sorted by name, for the contest-mode picker.
func (a *App) ListContests() []contest.Def {
return contest.List()
}
// ContestBandRow is one band's QSO count in the contest scoreboard.
type ContestBandRow struct {
Band string `json:"band"`
Count int `json:"count"`
}
// ContestStatsResult is the live contest scoreboard. Score is a best-effort
// ESTIMATE (qsos × mult): real contest scoring is per-contest and rule-heavy, so
// this is a working indicator, not an official claim.
type ContestStatsResult struct {
QSOs int `json:"qsos"`
ByBand []ContestBandRow `json:"by_band"`
Mult int `json:"mult"`
MultLabel string `json:"mult_label"`
Score int `json:"score"`
LastHour int `json:"last_hour"` // QSOs in the last 60 min (rate)
}
// parseTimeISO parses an RFC3339(/nano) timestamp; ok=false for an empty/invalid
// value (meaning "no bound").
func parseTimeISO(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, true
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, true
}
return time.Time{}, false
}
// ContestStats computes the scoreboard for the given CONTEST_ID within the
// [startISO, endISO] window (either bound may be empty = open). exchange is the
// active exchange kind, which decides what counts as a multiplier.
func (a *App) ContestStats(contestID, exchange, startISO, endISO string) ContestStatsResult {
res := ContestStatsResult{}
if a.qso == nil || strings.TrimSpace(contestID) == "" {
return res
}
start, hasStart := parseTimeISO(startISO)
end, hasEnd := parseTimeISO(endISO)
f := qso.QueryFilter{
Conditions: []qso.Condition{{Field: "contest_id", Op: "eq", Value: contestID}},
Match: "AND", Limit: 1_000_000,
}
bandCount := map[string]int{}
multSet := map[string]struct{}{}
label := "DXCC"
cutoff := time.Now().Add(-time.Hour)
_ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error {
if hasStart && q.QSODate.Before(start) {
return nil
}
if hasEnd && q.QSODate.After(end) {
return nil
}
res.QSOs++
if q.Band != "" {
bandCount[q.Band]++
}
if !q.QSODate.IsZero() && q.QSODate.After(cutoff) {
res.LastHour++
}
switch exchange {
case "cq-zone":
label = "Zones"
if q.CQZ != nil && *q.CQZ > 0 {
multSet[fmt.Sprintf("Z%d", *q.CQZ)] = struct{}{}
}
case "itu-zone":
label = "Zones"
if q.ITUZ != nil && *q.ITUZ > 0 {
multSet[fmt.Sprintf("I%d", *q.ITUZ)] = struct{}{}
}
case "dept", "state", "name", "grid", "age", "section":
label = "Mults"
k := strings.ToUpper(strings.TrimSpace(q.SRXString))
if k == "" && q.SRX != nil {
k = fmt.Sprintf("%d", *q.SRX)
}
if k != "" {
multSet[k] = struct{}{}
}
default: // serial and others
if strings.Contains(strings.ToUpper(contestID), "WPX") {
label = "Prefixes"
if p := award.WPXPrefix(q.Callsign); p != "" {
multSet[p] = struct{}{}
}
} else {
label = "DXCC"
if q.DXCC != nil && *q.DXCC > 0 {
multSet[fmt.Sprintf("%d", *q.DXCC)] = struct{}{}
}
}
}
return nil
})
res.Mult = len(multSet)
res.MultLabel = label
if res.Mult > 0 {
res.Score = res.QSOs * res.Mult
} else {
res.Score = res.QSOs
}
for b, c := range bandCount {
res.ByBand = append(res.ByBand, ContestBandRow{Band: b, Count: c})
}
if res.ByBand == nil {
res.ByBand = []ContestBandRow{} // never nil → marshals as [] not null (frontend guard)
}
sort.Slice(res.ByBand, func(i, j int) bool { return res.ByBand[i].Band < res.ByBand[j].Band })
return res
}
// ContestDupe reports whether call was already worked in this contest on the
// same band + mode class since startISO (the usual dupe rule). Empty band/mode/
// startISO widens the check.
func (a *App) ContestDupe(call, contestID, band, mode, startISO string) bool {
if a.qso == nil || strings.TrimSpace(call) == "" || strings.TrimSpace(contestID) == "" {
return false
}
start, hasStart := parseTimeISO(startISO)
f := qso.QueryFilter{
Conditions: []qso.Condition{
{Field: "contest_id", Op: "eq", Value: contestID},
{Field: "callsign", Op: "eq", Value: strings.ToUpper(strings.TrimSpace(call))},
},
Match: "AND", Limit: 1000,
}
if strings.TrimSpace(band) != "" {
f.Conditions = append(f.Conditions, qso.Condition{Field: "band", Op: "eq", Value: band})
}
want := contestModeClass(mode)
dupe := false
_ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error {
if hasStart && q.QSODate.Before(start) {
return nil
}
if want == "" || contestModeClass(q.Mode) == want {
dupe = true
}
return nil
})
return dupe
}
// contestModeClass collapses a mode to the CW/PH/DIG bucket contests dupe on.
func contestModeClass(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "":
return ""
case "CW":
return "CW"
case "SSB", "USB", "LSB", "AM", "FM", "PH", "PHONE":
return "PH"
default:
return "DIG"
}
}
func (a *App) ExportCabrillo(path string) (CabrilloResult, error) {
return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
}
// ExportCabrilloFiltered writes the QSOs matching the current filter.
func (a *App) ExportCabrilloFiltered(path string, f qso.QueryFilter) (CabrilloResult, error) {
return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateFiltered(a.ctx, f, fn) })
}
// ExportCabrilloSelected writes only the highlighted QSOs.
func (a *App) ExportCabrilloSelected(path string, ids []int64) (CabrilloResult, error) {
if len(ids) == 0 {
return CabrilloResult{}, fmt.Errorf("no QSOs selected")
}
return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateByIDs(a.ctx, ids, fn) })
}
// SaveCabrilloFile opens a save dialog for a Cabrillo (.log) file.
func (a *App) SaveCabrilloFile() (string, error) {
suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".log"
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Export Cabrillo",
DefaultFilename: suggested,
Filters: []wruntime.FileFilter{
{DisplayName: "Cabrillo log (*.log, *.cbr)", Pattern: "*.log;*.cbr"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
}
// --- Lookup bindings ---
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
// "callsign not found".
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
if a.lookup == nil {
return lookup.Result{}, fmt.Errorf("lookup not initialized")
}
r, err := a.lookup.Lookup(a.ctx, callsign)
if errors.Is(err, lookup.ErrNotFound) {
return lookup.Result{}, fmt.Errorf("callsign not found")
}
// Respect the user's "Download profile images" setting: even if the
// cache holds the URL we hide it when the toggle is off so the
// frontend doesn't render the <img> (which would still fetch from
// QRZ). Cheap to check per call — settings is in-memory after init.
if err == nil && r.ImageURL != "" {
if s, _ := a.GetLookupSettings(); !s.DownloadImages {
r.ImageURL = ""
}
}
// ClubLog exception override (live entry → today's date): for an active
// DXpedition the entered call gets the right entity/zones immediately.
if a.clublogCtyEnabled() && a.clublog != nil {
if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok {
r.Country = titleEntity(e.Entity)
if e.Cont != "" {
r.Continent = e.Cont
}
if e.ADIF != 0 {
r.DXCC = e.ADIF
}
if e.CQZ != 0 {
r.CQZ = e.CQZ
}
if r.Callsign == "" {
r.Callsign = strings.ToUpper(strings.TrimSpace(callsign))
}
}
}
return r, err
}
// OpenExternalURL opens a URL in the user's default browser. Wails ships
// runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon
// next to the callsign field, the future Clublog/HamQTH shortcuts, etc.
func (a *App) OpenExternalURL(url string) error {
url = strings.TrimSpace(url)
if url == "" {
return fmt.Errorf("empty URL")
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return fmt.Errorf("only http(s) URLs allowed, got %q", url)
}
wruntime.BrowserOpenURL(a.ctx, url)
return nil
}
// GetLookupSettings returns current credentials and cache TTL.
func (a *App) GetLookupSettings() (LookupSettings, error) {
if a.settings == nil {
return LookupSettings{}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx,
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages)
if err != nil {
return LookupSettings{}, err
}
ttl, _ := strconv.Atoi(m[keyCacheTTL])
if ttl <= 0 {
ttl = 30
}
return LookupSettings{
QRZUser: m[keyQRZUser],
QRZPassword: m[keyQRZPassword],
HamQTHUser: m[keyHQUser],
HamQTHPassword: m[keyHQPassword],
Primary: m[keyLookupPrimary],
Failsafe: m[keyLookupFailsafe],
DownloadImages: m[keyLookupImages] == "1",
CacheTTLDays: ttl,
}, nil
}
// SaveLookupSettings persists credentials and rebuilds the provider chain.
func (a *App) SaveLookupSettings(s LookupSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.CacheTTLDays <= 0 {
s.CacheTTLDays = 30
}
// Reject a primary == failsafe routing combo — would just hit the same
// provider twice. Frontend should prevent this but defend in depth.
if s.Primary != "" && s.Primary == s.Failsafe {
s.Failsafe = ""
}
for k, v := range map[string]string{
keyQRZUser: s.QRZUser,
keyQRZPassword: s.QRZPassword,
keyHQUser: s.HamQTHUser,
keyHQPassword: s.HamQTHPassword,
keyCacheTTL: strconv.Itoa(s.CacheTTLDays),
keyLookupPrimary: s.Primary,
keyLookupFailsafe: s.Failsafe,
keyLookupImages: boolStr(s.DownloadImages),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.reloadLookupProviders()
return nil
}
// TestLookupProvider runs a one-shot lookup against a specific provider so
// the user can verify credentials before saving. callsign defaults to the
// active profile's callsign when empty (handy "test against my own call").
// Returns the result on success or a descriptive error.
func (a *App) TestLookupProvider(name, callsign, user, password string) (lookup.Result, error) {
if user == "" || password == "" {
return lookup.Result{}, fmt.Errorf("user and password required")
}
if callsign == "" {
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
callsign = p.Callsign
}
}
if callsign == "" {
callsign = "W1AW" // ARRL HQ — always present in every database
}
}
var p lookup.Provider
switch name {
case "qrz":
p = lookup.NewQRZ(user, password)
case "hamqth":
p = lookup.NewHamQTH(user, password)
default:
return lookup.Result{}, fmt.Errorf("unknown provider %q", name)
}
r, err := p.Lookup(a.ctx, callsign)
if errors.Is(err, lookup.ErrNotFound) {
// A "not found" (vs an auth error) means the login WORKED — the test
// callsign just isn't in this database, common for special-event calls
// (e.g. TM74TFR not on HamQTH). The credentials are valid, so lookups of
// other callsigns will work: report success, not failure.
return lookup.Result{Source: name, Callsign: callsign, Name: "credentials OK — " + callsign + " not in " + name}, nil
}
if err != nil {
return lookup.Result{}, err
}
r.Source = name
return r, nil
}
// --- CAT bindings ---
// GetCATSettings returns the stored CAT configuration (defaults applied).
func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil {
return CATSettings{}, err
}
out := CATSettings{
Enabled: m[keyCATEnabled] == "1",
Backend: m[keyCATBackend],
OmniRigNum: 1,
FlexHost: m[keyCATFlexHost],
FlexPort: 4992,
FlexSpots: m[keyCATFlexSpots] == "1",
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
FlexDecodeSecs: 120,
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default
IcomNetHost: m[keyCATIcomNetHost],
IcomNetUser: m[keyCATIcomNetUser],
IcomNetPass: m[keyCATIcomNetPass],
IcomNetAudio: m[keyCATIcomNetAudio] == "1",
TCIHost: m[keyCATTCIHost],
TCIPort: 40001,
TCISpots: m[keyCATTCISpots] == "1",
PollMs: 250,
DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault],
}
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
}
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
out.FlexDecodeSecs = n
}
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
out.TCIPort = n
}
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n
}
if n, _ := strconv.Atoi(m[keyCATIcomAddr]); n > 0 && n <= 0xFF {
out.IcomAddr = n
}
if out.Backend == "" {
out.Backend = "omnirig"
}
if out.DigitalDefault == "" {
out.DigitalDefault = "FT8"
}
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
out.OmniRigNum = n
}
if n, _ := strconv.Atoi(m[keyCATPollMs]); n >= 50 && n <= 2000 {
out.PollMs = n
}
if n, _ := strconv.Atoi(m[keyCATDelayMs]); n >= 0 && n <= 500 {
out.DelayMs = n
}
return out, nil
}
// SaveCATSettings persists CAT config and restarts the manager accordingly.
func (a *App) SaveCATSettings(s CATSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Backend == "" {
s.Backend = "omnirig"
}
if s.OmniRigNum != 1 && s.OmniRigNum != 2 {
s.OmniRigNum = 1
}
if s.FlexPort <= 0 || s.FlexPort > 65535 {
s.FlexPort = 4992
}
if s.IcomBaud <= 0 {
s.IcomBaud = 115200
}
if s.IcomAddr <= 0 || s.IcomAddr > 0xFF {
s.IcomAddr = 0x98
}
if s.PollMs < 50 || s.PollMs > 2000 {
s.PollMs = 250
}
if s.DelayMs < 0 || s.DelayMs > 500 {
s.DelayMs = 0
}
enabled := "0"
if s.Enabled {
enabled = "1"
}
flexSpots := "0"
if s.FlexSpots {
flexSpots = "1"
}
flexDecodeSpots := "0"
if s.FlexDecodeSpots {
flexDecodeSpots = "1"
}
if s.FlexDecodeSecs < 10 || s.FlexDecodeSecs > 3600 {
s.FlexDecodeSecs = 120
}
tciSpots := "0"
if s.TCISpots {
tciSpots = "1"
}
icomNetAudio := "0"
if s.IcomNetAudio {
icomNetAudio = "1"
}
if s.DigitalDefault == "" {
s.DigitalDefault = "FT8"
}
for k, v := range map[string]string{
keyCATEnabled: enabled,
keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots,
keyCATFlexDecodeSpots: flexDecodeSpots,
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
keyCATIcomNetPass: s.IcomNetPass,
keyCATIcomNetAudio: icomNetAudio,
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
keyCATTCIPort: strconv.Itoa(s.TCIPort),
keyCATTCISpots: tciSpots,
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.reloadCAT()
return nil
}
// ── Audio (Digital Voice Keyer + QSO recorder) ────────────────────────
// AudioSettings is the machine-local audio config for the voice keyer and
// the QSO recorder.
type AudioSettings struct {
FromRadio string `json:"from_radio"` // capture id: rig RX audio
ToRadio string `json:"to_radio"` // render id: into the rig
RecordingDevice string `json:"recording_device"` // capture id: your mic
ListeningDevice string `json:"listening_device"` // render id: preview
QSORecord bool `json:"qso_record"` // auto-record every QSO
QSODir string `json:"qso_dir"` // recordings folder
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
PTTPort string `json:"ptt_port"` // COM port for serial PTT
Format string `json:"format"` // "wav" | "mp3"
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
MicGain int `json:"mic_gain"` // mic mix level %, default 100
}
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
// for the device dropdowns.
func (a *App) ListAudioInputDevices() ([]audio.Device, error) { return audio.ListInputDevices() }
func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.ListOutputDevices() }
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
func (a *App) GetAudioSettings() (AudioSettings, error) {
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
keyAudioFromGain, keyAudioMicGain)
if err != nil {
return out, err
}
if v := m[keyAudioFormat]; v == "mp3" || v == "wav" {
out.Format = v
}
if v := m[keyAudioPTTMethod]; v == "rts" || v == "dtr" || v == "cat" || v == "none" {
out.PTTMethod = v
}
out.PTTPort = m[keyAudioPTTPort]
out.FromRadio = m[keyAudioFromRadio]
out.ToRadio = m[keyAudioToRadio]
out.RecordingDevice = m[keyAudioRecDevice]
out.ListeningDevice = m[keyAudioListenDevice]
out.QSORecord = m[keyAudioQSORecord] == "1"
out.QSODir = m[keyAudioQSODir]
if n, _ := strconv.Atoi(m[keyAudioPreroll]); n >= 0 && n <= 60 {
if n > 0 {
out.PrerollSeconds = n
}
}
if n, _ := strconv.Atoi(m[keyAudioFromGain]); n > 0 && n <= 400 {
out.FromGain = n
}
if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 {
out.MicGain = n
}
return out, nil
}
// SaveAudioSettings persists the audio config.
func (a *App) SaveAudioSettings(s AudioSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.PrerollSeconds < 0 || s.PrerollSeconds > 60 {
s.PrerollSeconds = 8
}
qr := "0"
if s.QSORecord {
qr = "1"
}
pttMethod := s.PTTMethod
if pttMethod != "rts" && pttMethod != "dtr" && pttMethod != "cat" {
pttMethod = "none"
}
format := s.Format
if format != "mp3" {
format = "wav"
}
if s.FromGain <= 0 || s.FromGain > 400 {
s.FromGain = 100
}
if s.MicGain <= 0 || s.MicGain > 400 {
s.MicGain = 100
}
for k, v := range map[string]string{
keyAudioFromRadio: s.FromRadio,
keyAudioToRadio: s.ToRadio,
keyAudioRecDevice: s.RecordingDevice,
keyAudioListenDevice: s.ListeningDevice,
keyAudioQSORecord: qr,
keyAudioQSODir: strings.TrimSpace(s.QSODir),
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
keyAudioPTTMethod: pttMethod,
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
keyAudioFormat: format,
keyAudioFromGain: strconv.Itoa(s.FromGain),
keyAudioMicGain: strconv.Itoa(s.MicGain),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
// Apply device/preroll/enable changes to the running recorder.
a.startQSORecorderIfEnabled()
return nil
}
// PickAudioFolder opens a directory picker for the QSO-recordings folder.
func (a *App) PickAudioFolder() (string, error) {
if a.ctx == nil {
return "", fmt.Errorf("no app context")
}
cur, _ := a.GetAudioSettings()
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Pick a folder for QSO recordings",
DefaultDirectory: firstExistingAncestor(cur.QSODir),
})
}
// ── QSO recorder ──────────────────────────────────────────────────────
// startQSORecorderIfEnabled (re)starts the continuous recorder per the current
// settings. Safe to call repeatedly — it stops any running instance first.
func (a *App) startQSORecorderIfEnabled() {
if a.qsoRec == nil {
return
}
a.qsoRec.Stop()
cfg, _ := a.GetAudioSettings()
if !cfg.QSORecord {
return
}
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
applog.Printf("qso-rec: start failed: %v", err)
return
}
fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
if cfg.FromGain == 0 {
fromGain = 1
}
if cfg.MicGain == 0 {
micGain = 1
}
a.qsoRec.SetGains(fromGain, micGain)
applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain)
}
// qsoRecDir returns the configured recordings folder, defaulting to
// <dataDir>/Recordings, and ensures it exists.
func (a *App) qsoRecDir() string {
cfg, _ := a.GetAudioSettings()
d := strings.TrimSpace(cfg.QSODir)
if d == "" {
d = filepath.Join(a.dataDir, "Recordings")
}
_ = os.MkdirAll(d, 0o755)
return d
}
// saveQSORecording finalises the active recording (if any) into a file named
// CALL_BAND_MODE_YYYYMMDD_HHMMSS.ext, stores the filename on the QSO (so it can
// be e-mailed later), and auto-sends it to the contacted operator when enabled
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
// q must have its ID set.
// recordableMode reports whether a QSO mode is worth an audio recording —
// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no
// useful audio, so they are never recorded.
func recordableMode(mode string) bool {
switch strings.ToUpper(strings.TrimSpace(mode)) {
// CW is intentionally excluded: SmartSDR doesn't route CW audio through DAX,
// so the recording is empty/useless. Phone modes only.
case "SSB", "USB", "LSB", "AM", "FM", "DV":
return true
}
return false
}
func (a *App) saveQSORecording(q *qso.QSO) {
// The logging path must never die because of the recorder. Recover any
// Go-level panic in the snapshot/stamp work below (the encode already runs
// in its own recovered goroutine).
defer func() {
if r := recover(); r != nil {
applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r)
}
}()
if a.qsoRec == nil || !a.qsoRec.Active() {
return
}
if !recordableMode(q.Mode) {
a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
return
}
ext := "wav"
if cfg, _ := a.GetAudioSettings(); cfg.Format == "mp3" {
ext = "mp3"
}
parts := []string{sanitizeFilename(q.Callsign)}
if b := strings.TrimSpace(q.Band); b != "" {
parts = append(parts, sanitizeFilename(b))
}
if m := strings.TrimSpace(q.Mode); m != "" {
parts = append(parts, sanitizeFilename(m))
}
parts = append(parts, time.Now().UTC().Format("20060102_150405"))
name := strings.Join(parts, "_") + "." + ext
path := filepath.Join(a.qsoRecDir(), name)
// Snapshot the audio synchronously (fast — frees the recorder for the next
// QSO). The slow part is encoding the file (a long MP3), which we defer to a
// goroutine so logging stays snappy.
pcm, err := a.qsoRec.TakeQSO()
if err != nil {
applog.Printf("qso-rec: snapshot failed: %v", err)
return
}
// Stamp the recording's path on the QSO now, synchronously, so it's set
// before the eQSL auto-send reads the QSO (their full-row Updates would
// otherwise race and clobber each other's extras).
if q.ID != 0 {
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING"] = name
if err := a.qso.Update(a.ctx, *q); err != nil {
applog.Printf("qso-rec: store recording path: %v", err)
}
}
// Clone the QSO for the goroutine with its OWN copy of the Extras map: a
// struct copy (qc := *q) would still share the underlying map, and a
// concurrent write from a post-log action (eQSL/upload status) racing this
// goroutine's read is a FATAL "concurrent map" error that no recover catches.
qc := *q
if q.Extras != nil {
qc.Extras = make(map[string]string, len(q.Extras))
for k, v := range q.Extras {
qc.Extras[k] = v
}
}
go func() {
// A panic in the pure-Go MP3 encoder (or anywhere here) must NOT crash
// the whole app — recover, log it, and drop just this recording.
defer func() {
if r := recover(); r != nil {
applog.Printf("qso-rec: PANIC encoding %s: %v", path, r)
}
}()
if err := audio.WritePCM(path, pcm); err != nil {
applog.Printf("qso-rec: save failed: %v", err)
return
}
applog.Printf("qso-rec: saved %s", path)
// Auto-send the recording once the file exists. Gated ONLY on its own
// "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
// master "Enabled" flag, mirroring the eQSL-card auto-send. (Requiring
// Enabled silently skipped sending with no log when only this box was on.)
// Give the operator visible feedback either way.
if es, _ := a.GetEmailSettings(); es.AutoSend {
if strings.TrimSpace(qc.Email) == "" {
applog.Printf("qso-rec: %s not e-mailed — no recipient address known", qc.Callsign)
a.toast(fmt.Sprintf("Recording saved — no e-mail for %s, not sent", qc.Callsign))
} else if err := a.sendRecordingEmail(qc, path); err != nil {
a.toast(fmt.Sprintf("Recording e-mail to %s failed", qc.Callsign))
} else {
a.toast(fmt.Sprintf("Recording e-mailed to %s", qc.Callsign))
a.markRecordingSent(qc.ID)
}
}
}()
}
// toast emits a transient message the frontend shows as a toast notification.
// Used for background events (auto-send results) the UI can't otherwise surface.
func (a *App) toast(msg string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", msg)
}
}
// ── DX-cluster spot alerts ─────────────────────────────────────────────────
const keyAlertEmailTo = "alerts.email_to" // where alert e-mails are sent (default: SMTP from)
// ListAlertRules returns every alert rule.
func (a *App) ListAlertRules() []alerts.Rule {
if a.alertStore == nil {
return nil
}
return a.alertStore.List()
}
// SaveAlertRule upserts a rule (empty id → create) and returns it.
func (a *App) SaveAlertRule(r alerts.Rule) (alerts.Rule, error) {
if a.alertStore == nil {
return alerts.Rule{}, fmt.Errorf("alert store unavailable")
}
return a.alertStore.Save(r)
}
// DeleteAlertRule removes a rule by id.
func (a *App) DeleteAlertRule(id string) error {
if a.alertStore == nil {
return fmt.Errorf("alert store unavailable")
}
return a.alertStore.Delete(id)
}
// GetAlertEmailTo returns the address alert e-mails go to (empty = use SMTP from).
func (a *App) GetAlertEmailTo() string {
if a.settings == nil {
return ""
}
v, _ := a.settings.GetGlobal(a.ctx, keyAlertEmailTo)
return v
}
// SetAlertEmailTo stores the alert e-mail recipient.
func (a *App) SetAlertEmailTo(addr string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
return a.settings.SetGlobal(a.ctx, keyAlertEmailTo, strings.TrimSpace(addr))
}
// evaluateAlerts runs a spot through the rules and fires notifications for every
// match (visual/sound via a frontend event, e-mail via SMTP). Cheap when no
// rules exist; the worked-before check only runs for rules that opt into it and
// already matched, so it's off the hot path.
// clusterEvent is one item off the cluster feed — a parsed spot OR a raw console
// line. A single union type on one channel preserves the order lines and spots
// arrived in (the console shows a line, then its parsed spot), which two separate
// channels could not guarantee.
type clusterEvent struct {
spot *cluster.Spot
line *cluster.Line
}
// enqueueClusterEvent hands an event to the worker WITHOUT blocking. It runs on
// the cluster session's socket-read goroutine: blocking here would stop draining
// the TCP socket, the node's send buffer would fill, and the feed would fall
// behind — the exact bug this indirection fixes. If the queue is full (processing
// can't keep up), drop and count rather than stall the whole feed.
func (a *App) enqueueClusterEvent(ev clusterEvent) {
select {
case a.clusterEventCh <- ev:
default:
n := atomic.AddInt64(&a.clusterDropped, 1)
if n == 1 || n%100 == 0 {
applog.Printf("cluster: processing backlog — dropped %d event(s); the feed is faster than enrichment/UI", n)
}
}
}
// clusterEventWorker drains clusterEventCh and does everything that used to run
// inline in the socket-read callback: enrich a spot (DXCC/POTA/distance), emit it
// to the UI, run alert rules (which may query a remote MySQL) and mirror it to the
// Flex panadapter — all serialised on this one goroutine, off the read path.
func (a *App) clusterEventWorker() {
for ev := range a.clusterEventCh {
if ev.line != nil {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line)
}
continue
}
if ev.spot == nil {
continue
}
s := *ev.spot
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
s.Country = m.Entity.Name
s.Continent = m.Continent
s.CQZone = m.CQZone
s.ITUZone = m.ITUZone
if a.opSet && (m.Lat != 0 || m.Lon != 0) {
s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5)
sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon)
s.ShortPath = int(sp + 0.5)
s.LongPath = (s.ShortPath + 180) % 360
}
}
}
// POTA: tag the spot when the DX station is currently activating a park.
if a.pota != nil {
if info, ok := a.pota.Lookup(s.DXCall); ok {
s.POTARef = info.Reference
s.POTAName = info.ParkName
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
}
// A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and
// stops there. It is a replay of the past: firing 100 alerts at once, or
// painting 100 stale stations on the panadapter as if they were on the air
// right now, would be actively misleading.
if s.Historical {
continue
}
// Fire any matching alert rules (sound / visual / e-mail).
a.evaluateAlerts(s)
// Mirror the spot onto the FlexRadio panadapter when enabled.
if a.catFlexSpots && a.cat != nil {
a.cat.SendSpot(cat.SpotInfo{
FreqHz: s.FreqHz,
Callsign: s.DXCall,
Comment: s.Comment,
})
}
}
}
func (a *App) evaluateAlerts(s cluster.Spot) {
if a.alertStore == nil {
return
}
sp := alerts.Spot{
DXCall: s.DXCall,
Band: s.Band,
Mode: alerts.InferMode(s.Comment, s.FreqHz),
Country: s.Country,
Continent: s.Continent,
Spotter: s.Spotter,
}
// The spotter's country/continent (cty.dat) — resolved lazily for Origin rules.
if a.dxcc != nil && s.Spotter != "" {
if m, ok := a.dxcc.Lookup(s.Spotter); ok && m.Entity != nil {
sp.SpotterCountry = m.Entity.Name
sp.SpotterContinent = m.Continent
}
}
matches := a.alertStore.Evaluate(sp, time.Now(), a.isWorkedBandMode)
for _, mt := range matches {
if a.ctx != nil && (mt.Rule.Visual || mt.Rule.Sound) {
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
"rule": mt.Rule.Name,
"call": s.DXCall,
"band": s.Band,
"mode": sp.Mode,
"freq_hz": s.FreqHz,
"country": s.Country,
"comment": s.Comment,
"sound": mt.Rule.Sound,
"visual": mt.Rule.Visual,
})
}
if mt.Rule.Email {
go a.sendAlertEmail(mt.Rule, s, sp.Mode)
}
}
}
// isWorkedBandMode reports whether the exact callsign is already logged on the
// given band+mode (used by rules with "skip worked before").
func (a *App) isWorkedBandMode(call, band, mode string) bool {
if a.qso == nil || strings.TrimSpace(call) == "" {
return false
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Band: band, Mode: mode, Limit: 5})
if err != nil {
return false
}
call = strings.ToUpper(strings.TrimSpace(call))
for _, q := range rows {
if strings.ToUpper(strings.TrimSpace(q.Callsign)) == call {
return true
}
}
return false
}
// sendAlertEmail notifies the operator by e-mail that a wanted station was
// spotted. Best effort; requires SMTP configured (E-mail settings).
func (a *App) sendAlertEmail(r alerts.Rule, s cluster.Spot, mode string) {
es, _ := a.GetEmailSettings()
to := strings.TrimSpace(a.GetAlertEmailTo())
if to == "" {
to = strings.TrimSpace(es.From)
}
if to == "" {
applog.Printf("alert: e-mail skipped (no recipient / SMTP from) for rule %q", r.Name)
return
}
subject := fmt.Sprintf("OpsLog alert: %s spotted on %s", s.DXCall, s.Band)
body := fmt.Sprintf("Rule: %s\n\nCall: %s\nBand: %s\nMode: %s\nFreq: %.1f kHz\nCountry: %s\nSpotter: %s\nComment: %s\n",
r.Name, s.DXCall, s.Band, mode, float64(s.FreqHz)/1000, s.Country, s.Spotter, s.Comment)
if err := email.Send(a.emailConfig(es), to, subject, body, ""); err != nil {
applog.Printf("alert: e-mail to %s failed: %v", to, err)
} else {
applog.Printf("alert: e-mailed %s (rule %q) to %s", s.DXCall, r.Name, to)
}
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if s == "" {
s = "QSO"
}
repl := func(r rune) rune {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ':
return '_'
}
return r
}
return strings.Map(repl, s)
}
// QSOAudioBegin starts accumulating a QSO recording (seeded with the pre-roll).
// Called by the entry strip when a callsign is first entered.
// QSOAudioBegin starts accumulating a recording for the current QSO. It
// returns true when a recording is actually running (recorder enabled and
// capturing), so the UI can show a "REC" indicator.
func (a *App) QSOAudioBegin() bool {
if a.qsoRec == nil {
return false
}
a.qsoRec.BeginQSO()
return a.qsoRec.Active()
}
// QSOAudioRestart starts a fresh recording for a new target even if one is
// already in progress (new call+freq from a clicked spot or external app).
func (a *App) QSOAudioRestart() bool {
if a.qsoRec == nil {
return false
}
a.qsoRec.RestartQSO()
return a.qsoRec.Active()
}
// QSOAudioResetClock restarts the in-progress recording from zero, dropping
// everything captured so far (pre-roll included). Lets the operator click the
// REC timer to record only their own exchange when the station was already in a
// long QSO before they entered the call. Returns whether a recording is active.
func (a *App) QSOAudioResetClock() bool {
if a.qsoRec == nil {
return false
}
a.qsoRec.ResetQSOClock()
return a.qsoRec.Active()
}
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
// abandoned without logging).
func (a *App) QSOAudioCancel() {
if a.qsoRec != nil {
a.qsoRec.DiscardQSO()
}
}
// RestartQSORecorder applies new audio settings to the running recorder.
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
// ── NET Control ────────────────────────────────────────────────────────────
//
// A NET is a named net with a station roster (persisted globally in nets.json).
// Opening a net starts an in-memory live session: stations moved "on the air"
// (NetActivate) accumulate a time_on; moving one back off (NetDeactivate) logs
// the QSO into the active logbook with live CAT freq/mode and removes it from
// the session. The session is RAM-only — closing the app mid-net drops any
// active stations that were never logged.
// NetList returns all nets (with rosters), ordered by name.
func (a *App) NetList() []netctl.Net {
if a.netStore == nil {
return nil
}
return a.netStore.List()
}
// NetCreate adds a new named net (defaults 59/59).
func (a *App) NetCreate(name string) (netctl.Net, error) {
if a.netStore == nil {
return netctl.Net{}, fmt.Errorf("net store unavailable")
}
return a.netStore.Create(name)
}
// NetRename renames a net.
func (a *App) NetRename(id, name string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.Rename(id, name)
}
// NetSetDefaults updates a net's default report/comment values.
func (a *App) NetSetDefaults(id, rstSent, rstRcvd, comment string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.SetDefaults(id, rstSent, rstRcvd, comment)
}
// NetDelete removes a net and its roster (closing it first if it's open).
func (a *App) NetDelete(id string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
a.netMu.Lock()
if a.netOpenID == id {
a.netOpenID, a.netActive = "", nil
}
a.netMu.Unlock()
return a.netStore.Delete(id)
}
// NetRoster returns a net's roster, sorted by callsign.
func (a *App) NetRoster(id string) ([]netctl.Station, error) {
if a.netStore == nil {
return nil, fmt.Errorf("net store unavailable")
}
return a.netStore.Roster(id)
}
// NetRosterUpsert adds or updates a roster station.
func (a *App) NetRosterUpsert(id string, s netctl.Station) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.RosterUpsert(id, s)
}
// NetRosterRemove deletes a callsign from a net's roster.
func (a *App) NetRosterRemove(id, callsign string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.RosterRemove(id, callsign)
}
// NetLookup resolves a callsign via the configured provider (QRZ) so the
// Add-contact dialog can pre-fill name/QTH/country/zones. Best-effort — returns
// just the callsign if no provider or no match.
func (a *App) NetLookup(callsign string) netctl.Station {
st := netctl.Station{Callsign: strings.ToUpper(strings.TrimSpace(callsign))}
if a.lookup == nil || st.Callsign == "" {
return st
}
if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
st.Grid, st.Address, st.State, st.Cnty = lr.Grid, lr.Address, lr.State, lr.County
st.Cont, st.Lat, st.Lon, st.Email = lr.Continent, lr.Lat, lr.Lon, lr.Email
}
return st
}
// NetOpen starts a live session for the given net (clears any prior session).
func (a *App) NetOpen(id string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
if _, ok := a.netStore.Get(id); !ok {
return fmt.Errorf("net not found")
}
a.netMu.Lock()
a.netOpenID, a.netActive = id, nil
a.netMu.Unlock()
return nil
}
// NetClose ends the live session (does NOT log remaining actives — the operator
// moves each station back to the roster side to log it before closing).
func (a *App) NetClose() {
a.netMu.Lock()
a.netOpenID, a.netActive = "", nil
a.netMu.Unlock()
}
// NetOpenID returns the id of the currently open net ("" = none).
func (a *App) NetOpenID() string {
a.netMu.Lock()
defer a.netMu.Unlock()
return a.netOpenID
}
// NetActiveList returns the stations currently on the air, in check-in order.
// Each is a full QSO *draft* (not yet in the DB) carrying a negative transient
// id so the same QSOEditModal as Recent QSOs can edit every field.
func (a *App) NetActiveList() []qso.QSO {
a.netMu.Lock()
defer a.netMu.Unlock()
out := make([]qso.QSO, len(a.netActive))
for i, e := range a.netActive {
out[i] = *e
}
return out
}
// netLiveFreq returns the rig's live freq/band/mode, falling back to the last
// UI-reported values when CAT is off.
func (a *App) netLiveFreq() (freq int64, band, mode string) {
var st cat.RigState
if a.cat != nil {
st = a.cat.State()
}
freq, band, mode = st.FreqHz, st.Band, st.Mode
if freq == 0 {
a.liveActMu.Lock()
freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode
a.liveActMu.Unlock()
}
if band == "" && freq > 0 {
band = bandForHz(freq)
}
return
}
// NetActivate puts a station on the air: it builds a QSO draft (time_on now,
// live freq/mode, defaults + roster info) with a transient negative id and
// returns it. No-op (returns the existing draft) if already active.
func (a *App) NetActivate(callsign string) (qso.QSO, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return qso.QSO{}, fmt.Errorf("callsign required")
}
a.netMu.Lock()
defer a.netMu.Unlock()
if a.netOpenID == "" {
return qso.QSO{}, fmt.Errorf("no net open")
}
for _, e := range a.netActive {
if strings.EqualFold(e.Callsign, call) {
return *e, nil // already on the air
}
}
a.netSeq--
q := &qso.QSO{ID: a.netSeq, Callsign: call, QSODate: time.Now().UTC()}
if net, ok := a.netStore.Get(a.netOpenID); ok {
q.RSTSent, q.RSTRcvd, q.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment
for _, st := range net.Stations {
if strings.EqualFold(st.Callsign, call) {
q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
q.Grid, q.Address, q.State, q.County = st.Grid, st.Address, st.State, st.Cnty
q.Continent, q.Email = st.Cont, st.Email
if st.Lat != 0 {
lat := st.Lat
q.Lat = &lat
}
if st.Lon != 0 {
lon := st.Lon
q.Lon = &lon
}
if st.DXCC != 0 {
d := st.DXCC
q.DXCC = &d
}
if st.CQ != 0 {
c := st.CQ
q.CQZ = &c
}
if st.ITU != 0 {
i := st.ITU
q.ITUZ = &i
}
break
}
}
}
if q.RSTSent == "" {
q.RSTSent = "59"
}
if q.RSTRcvd == "" {
q.RSTRcvd = "59"
}
freq, band, mode := a.netLiveFreq()
q.Band, q.Mode = band, mode
if freq > 0 {
f := freq
q.FreqHz = &f
}
a.applyDXCCNumber(q) // fill country/dxcc/zones for display
a.refineDistrictZones(q)
a.netActive = append(a.netActive, q)
return *q, nil
}
// NetUpdateActive replaces an on-air QSO draft (matched by its transient id)
// with the edited version from the QSOEditModal. Lets the operator change every
// field of a station before it's logged.
func (a *App) NetUpdateActive(q qso.QSO) error {
a.netMu.Lock()
defer a.netMu.Unlock()
for i, cur := range a.netActive {
if cur.ID == q.ID {
qq := q
a.netActive[i] = &qq
return nil
}
}
return fmt.Errorf("station not active")
}
// NetDiscardActive removes an on-air draft (by transient id) WITHOUT logging it
// — i.e. cancel a station added by mistake (the modal's Delete button).
func (a *App) NetDiscardActive(id int64) error {
a.netMu.Lock()
defer a.netMu.Unlock()
for i, e := range a.netActive {
if e.ID == id {
a.netActive = append(a.netActive[:i], a.netActive[i+1:]...)
return nil
}
}
return nil
}
// NetDeactivate ends a station's QSO (by transient id): it logs the draft to the
// active logbook (time_off = now; freq/mode refreshed from the rig only if the
// draft still has none, so manual edits are respected) and removes it from the
// session. Returns the new QSO id.
func (a *App) NetDeactivate(id int64) (int64, error) {
a.netMu.Lock()
var draft *qso.QSO
idx := -1
for i, e := range a.netActive {
if e.ID == id {
draft, idx = e, i
break
}
}
if draft == nil {
a.netMu.Unlock()
return 0, fmt.Errorf("station not active")
}
a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...)
a.netMu.Unlock()
q := *draft
q.ID = 0 // transient id must not reach the DB (AddQSO inserts a fresh row)
q.QSODateOff = time.Now().UTC()
if q.FreqHz == nil && q.Band == "" {
freq, band, mode := a.netLiveFreq()
q.Band, q.Mode = band, mode
if freq > 0 {
f := freq
q.FreqHz = &f
}
}
return a.AddQSO(q)
}
// ── E-mail / SMTP (send QSO recordings) ───────────────────────────────
const (
defaultEmailSubject = "Our QSO recording — {CALL}"
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
)
// EmailSettings is the user's SMTP config + auto-send + message templates.
type EmailSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"smtp_host"`
Port int `json:"smtp_port"`
User string `json:"smtp_user"`
Password string `json:"smtp_password"`
From string `json:"from"`
ReplyTo string `json:"reply_to"` // optional — where correspondents' replies go
Encryption string `json:"encryption"` // "ssl" | "starttls" | "none"
Auth bool `json:"auth"` // SMTP requires authorization
AutoSend bool `json:"auto_send"`
Subject string `json:"subject"`
Body string `json:"body"`
}
// GetEmailSettings returns the stored SMTP config (with sensible defaults).
func (a *App) GetEmailSettings() (EmailSettings, error) {
out := EmailSettings{Port: 587, Encryption: "starttls", Auth: true, Subject: defaultEmailSubject, Body: defaultEmailBody}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword,
keyEmailFrom, keyEmailReplyTo, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody)
if err != nil {
return out, err
}
out.Enabled = m[keyEmailEnabled] == "1"
out.Host = m[keyEmailHost]
if p, _ := strconv.Atoi(m[keyEmailPort]); p > 0 {
out.Port = p
}
out.User = m[keyEmailUser]
out.Password = m[keyEmailPassword]
out.From = m[keyEmailFrom]
out.ReplyTo = m[keyEmailReplyTo]
if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" {
out.Encryption = e
}
out.Auth = m[keyEmailAuth] != "0" // default true (unset → required)
out.AutoSend = m[keyEmailAutoSend] == "1"
if s := m[keyEmailSubject]; s != "" {
out.Subject = s
}
if b := m[keyEmailBody]; b != "" {
out.Body = b
}
return out, nil
}
// SaveEmailSettings persists the SMTP config.
func (a *App) SaveEmailSettings(s EmailSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
enc := s.Encryption
if enc != "ssl" && enc != "none" {
enc = "starttls"
}
if s.Port <= 0 {
s.Port = 587
}
b2s := func(b bool) string {
if b {
return "1"
}
return "0"
}
for k, v := range map[string]string{
keyEmailEnabled: b2s(s.Enabled),
keyEmailHost: strings.TrimSpace(s.Host),
keyEmailPort: strconv.Itoa(s.Port),
keyEmailUser: strings.TrimSpace(s.User),
keyEmailPassword: s.Password,
keyEmailFrom: strings.TrimSpace(s.From),
keyEmailReplyTo: strings.TrimSpace(s.ReplyTo),
keyEmailEncryption: enc,
keyEmailAuth: b2s(s.Auth),
keyEmailAutoSend: b2s(s.AutoSend),
keyEmailSubject: s.Subject,
keyEmailBody: s.Body,
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
return nil
}
func (a *App) emailConfig(s EmailSettings) email.Config {
return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, ReplyTo: s.ReplyTo, Encryption: s.Encryption, Auth: s.Auth}
}
// TestEmail sends a test message to `to` (defaults to the From address) to
// validate the SMTP configuration.
func (a *App) TestEmail(to string) error {
s, _ := a.GetEmailSettings()
if to == "" {
to = s.From
}
if to == "" {
to = s.User
}
return email.Send(a.emailConfig(s), to,
"OpsLog SMTP test", "This is a test message from OpsLog — your SMTP settings work. 73", "")
}
// fillTemplate substitutes {CALL} {DATE} {BAND} {MODE} {MYCALL} in a string.
func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
myCall := ""
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
myCall = p.Callsign
}
}
r := strings.NewReplacer(
"{CALL}", q.Callsign,
"{DATE}", q.QSODate.UTC().Format("2006-01-02 15:04 UTC"),
"{BAND}", q.Band,
"{MODE}", q.Mode,
"{MYCALL}", myCall,
)
return r.Replace(tmpl)
}
// markRecordingSent stamps the QSO so the UI (and the operator) knows its audio
// recording was e-mailed — exposed as the APP_OPSLOG_RECORDING_SENT extra and a
// "Recording sent" column in Recent QSOs. Re-reads the row first so a concurrent
// status update (eQSL/upload) isn't clobbered by writing back the whole row.
func (a *App) markRecordingSent(id int64) {
if a.qso == nil || id == 0 {
return
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
return
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
}
}
// sendRecordingEmail e-mails a QSO recording to the contacted operator.
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
s, _ := a.GetEmailSettings()
to := strings.TrimSpace(q.Email)
if to == "" {
return fmt.Errorf("no e-mail address for %s", q.Callsign)
}
subject := s.Subject
if subject == "" {
subject = defaultEmailSubject
}
body := s.Body
if body == "" {
body = defaultEmailBody
}
err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), attachPath)
if err != nil {
applog.Printf("email: send recording to %s failed: %v", to, err)
} else {
applog.Printf("email: recording sent to %s (%s)", to, q.Callsign)
}
return err
}
// SendQSORecordingEmail e-mails the stored recording for a QSO id (right-click
// "Send recording by e-mail"). Errors if the QSO has no recording or e-mail.
func (a *App) SendQSORecordingEmail(id int64) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
return err
}
name := ""
if q.Extras != nil {
name = q.Extras["APP_OPSLOG_RECORDING"]
}
if name == "" {
return fmt.Errorf("no recording stored for this QSO")
}
path := filepath.Join(a.qsoRecDir(), name)
if _, e := os.Stat(path); e != nil {
return fmt.Errorf("recording file missing: %s", name)
}
if err := a.sendRecordingEmail(q, path); err != nil {
return err
}
a.markRecordingSent(id)
return nil
}
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
// ClublogCtyInfo is the UI status of the ClubLog exception data.
type ClublogCtyInfo struct {
Enabled bool `json:"enabled"`
Loaded bool `json:"loaded"`
Date string `json:"date"`
Count int `json:"count"`
}
func (a *App) clublogCtyEnabled() bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, keyClublogCtyEnabled)
return v == "1"
}
// GetClublogCtyInfo returns the current ClubLog exception status.
func (a *App) GetClublogCtyInfo() ClublogCtyInfo {
info := ClublogCtyInfo{Enabled: a.clublogCtyEnabled()}
if a.clublog != nil {
info.Loaded = a.clublog.Loaded()
info.Date, info.Count = a.clublog.Info()
}
return info
}
// SetClublogCtyEnabled toggles ClubLog exception resolution, loading the cached
// file on first enable.
func (a *App) SetClublogCtyEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
v := "0"
if on {
v = "1"
}
if err := a.settings.Set(a.ctx, keyClublogCtyEnabled, v); err != nil {
return err
}
if on && a.clublog != nil && !a.clublog.Loaded() {
_ = a.clublog.EnsureLoaded() // ok if file not downloaded yet
}
return nil
}
// DownloadClublogCty fetches a fresh ClubLog country file.
func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
if a.clublog == nil {
return ClublogCtyInfo{}, fmt.Errorf("clublog not initialized")
}
if err := a.clublog.Download(a.ctx); err != nil {
return a.GetClublogCtyInfo(), err
}
return a.GetClublogCtyInfo(), nil
}
// applyClublogException overrides a QSO's entity fields from a ClubLog
// exception matching its callsign at its date. force=true ignores the
// enable toggle (used by the explicit "Update from ClubLog" action).
// Returns true if something changed.
func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
if a.clublog == nil || q.Callsign == "" {
return false
}
if !force && !a.clublogCtyEnabled() {
return false
}
date := q.QSODate
if date.IsZero() {
date = time.Now().UTC()
}
e, ok := a.clublog.Resolve(q.Callsign, date)
if !ok {
// No exception COVERS this QSO's date. If the call nonetheless HAS a
// date-ranged exception (e.g. G1T = Scotland only from 2024-02-21), then
// cty.dat's date-blind "=G1T → Scotland" override is WRONG for an older
// QSO — resolve it by ClubLog's date-aware PREFIX table instead (G1 →
// England for a 2012 contact). Ordinary calls (no exception) are left to
// cty.dat.
if a.clublog.HasException(q.Callsign) {
if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
e, ok = pe, true
}
}
if !ok {
return false
}
}
q.Country = titleEntity(e.Entity)
if e.Cont != "" {
q.Continent = e.Cont
}
if e.ADIF != 0 {
n := e.ADIF
q.DXCC = &n
}
if e.CQZ != 0 {
v := e.CQZ
q.CQZ = &v
}
if e.Lat != 0 || e.Lon != 0 {
lat, lon := e.Lat, e.Lon
q.Lat, q.Lon = &lat, &lon
}
return true
}
// UpdateQSOsFromClublog re-resolves the selected QSOs against ClubLog
// exceptions (by their QSO date) and saves any that changed.
func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
if a.clublog == nil || !a.clublog.Loaded() {
return 0, fmt.Errorf("ClubLog data not loaded — download it first")
}
changed := 0
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
continue
}
if a.applyClublogException(&q, true) {
if err := a.qso.Update(a.ctx, q); err == nil {
changed++
}
}
}
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil
}
// titleCaseIfUpper title-cases a string ONLY when it's entirely upper-case
// (e.g. Log4OM/contest ADIF sends "SANTO DOMINGO"); mixed-case values are
// left untouched. Codes like state "DN" stay as-is (no lower-case letters
// to gain, but they're short — callers pick which fields to pass).
func titleCaseIfUpper(s string) string {
t := strings.TrimSpace(s)
if t == "" || t != strings.ToUpper(t) {
return s
}
return titleEntity(t)
}
// titleEntity converts ClubLog's UPPERCASE entity names to title case
// ("LORD HOWE ISLAND" → "Lord Howe Island") for display consistency.
func titleEntity(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
words := strings.Fields(strings.ToLower(s))
for i, w := range words {
r := []rune(w)
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
words[i] = string(r)
}
return strings.Join(words, " ")
}
// ── Digital Voice Keyer (DVK) ─────────────────────────────────────────
//
// Six voice-message slots (F1F6, like the WinKeyer macros). Each message is a
// WAV file in <dataDir>/dvk/dvk<N>.wav; its label lives in settings. Record via
// the configured "Recording mic", transmit via "To Radio", preview via
// "Listening".
const dvkSlots = 6
// DVKMessage is one voice-keyer slot for the UI.
type DVKMessage struct {
Slot int `json:"slot"`
Label string `json:"label"`
HasAudio bool `json:"has_audio"`
DurationSec float64 `json:"duration_sec"`
}
// DVKStatus reflects the live record/playback state for the operating panel.
type DVKStatus struct {
Recording bool `json:"recording"`
Playing bool `json:"playing"`
RecSlot int `json:"rec_slot"`
}
func (a *App) dvkDir() string {
d := filepath.Join(a.dataDir, "dvk")
_ = os.MkdirAll(d, 0o755)
return d
}
func (a *App) dvkPath(slot int) string {
return filepath.Join(a.dvkDir(), fmt.Sprintf("dvk%d.wav", slot))
}
func dvkLabelKey(slot int) string { return fmt.Sprintf("audio.dvk.label%d", slot) }
func (a *App) dvkStatus() DVKStatus {
st := DVKStatus{RecSlot: a.dvkRecSlot}
if a.audioMgr != nil {
st.Recording = a.audioMgr.IsRecording()
st.Playing = a.audioMgr.IsPlaying()
}
return st
}
// GetDVKStatus returns the current record/playback state.
func (a *App) GetDVKStatus() DVKStatus { return a.dvkStatus() }
// GetDVKMessages returns the six voice-keyer slots with their labels, whether
// a recording exists, and its duration.
func (a *App) GetDVKMessages() []DVKMessage {
out := make([]DVKMessage, 0, dvkSlots)
for s := 1; s <= dvkSlots; s++ {
m := DVKMessage{Slot: s}
if a.settings != nil {
if v, _ := a.settings.Get(a.ctx, dvkLabelKey(s)); v != "" {
m.Label = v
}
}
if fi, err := os.Stat(a.dvkPath(s)); err == nil && fi.Size() > 44 {
m.HasAudio = true
m.DurationSec = float64(fi.Size()-44) / 32000.0 // 16 kHz mono 16-bit
}
out = append(out, m)
}
return out
}
// SetDVKLabel renames a voice-keyer slot.
func (a *App) SetDVKLabel(slot int, label string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if slot < 1 || slot > dvkSlots {
return fmt.Errorf("bad slot")
}
return a.settings.Set(a.ctx, dvkLabelKey(slot), strings.TrimSpace(label))
}
// DVKStartRecord begins recording a voice message into the given slot, using
// the configured Recording mic.
func (a *App) DVKStartRecord(slot int) error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
if slot < 1 || slot > dvkSlots {
return fmt.Errorf("bad slot")
}
cfg, _ := a.GetAudioSettings()
a.dvkRecSlot = slot
return a.audioMgr.StartRecording(cfg.RecordingDevice)
}
// DVKStopRecord ends the recording and writes it to the slot's WAV file.
func (a *App) DVKStopRecord() error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
return a.audioMgr.StopRecording(a.dvkPath(a.dvkRecSlot))
}
// DVKCancelRecord aborts a recording without saving.
func (a *App) DVKCancelRecord() {
if a.audioMgr != nil {
a.audioMgr.CancelRecording()
}
}
// DVKPlay transmits a slot's message to the rig ("To Radio"), asserting serial
// PTT (RTS/DTR) first unless the operator uses VOX. PTT is released
// automatically when playback ends (see the audio status callback).
func (a *App) DVKPlay(slot int) error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
path := a.dvkPath(slot)
if fi, err := os.Stat(path); err != nil || fi.Size() <= 44 {
return fmt.Errorf("no recording in slot %d", slot)
}
cfg, _ := a.GetAudioSettings()
if err := a.pttKey(cfg); err != nil {
applog.Printf("dvk: PTT on failed: %v", err)
// Keep going — the audio still reaches the rig; the user may use VOX.
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
a.pttMu.Lock()
a.dvkPttKeyed = true
a.pttMu.Unlock()
}
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
a.pttMu.Lock()
keyed := a.dvkPttKeyed
gen := a.pttGen
a.dvkPttKeyed = false
a.pttMu.Unlock()
if keyed {
go a.dvkUnkeyPTT(gen)
}
return err
}
return nil
}
// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
// of the message — but ONLY if no newer key happened since (gen unchanged). A
// rapid replay (or a Test PTT) starts a fresh transmission whose key must not
// be cut by this stale, delayed release.
func (a *App) dvkUnkeyPTT(gen int64) {
time.Sleep(120 * time.Millisecond)
a.unkeyIfCurrent(gen)
}
// pttGenNow returns the current PTT key generation.
func (a *App) pttGenNow() int64 {
a.pttMu.Lock()
defer a.pttMu.Unlock()
return a.pttGen
}
// unkeyIfCurrent drops PTT only when the key generation hasn't advanced since
// gen was captured — so a delayed release never cuts a transmission the user
// (or a new DVK message) started in the meantime.
func (a *App) unkeyIfCurrent(gen int64) {
a.pttMu.Lock()
stale := a.pttGen != gen
a.pttMu.Unlock()
if stale {
return
}
a.pttUnkey()
}
// pttKey keys the transmitter using the configured method:
// - "cat" → OmniRig (sets the Tx parameter to PM_TX)
// - "rts"/"dtr" → open the COM port and assert that line, held during TX
// - "none" → VOX, nothing to do
func (a *App) pttKey(cfg AudioSettings) error {
switch cfg.PTTMethod {
case "cat":
if a.cat == nil {
return fmt.Errorf("CAT not initialized")
}
if err := a.cat.SetPTT(true); err != nil {
applog.Printf("ptt: CAT SetPTT failed: %v", err)
return err
}
a.pttMu.Lock()
a.pttKeyedMethod = "cat"
a.pttGen++
a.pttMu.Unlock()
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
return nil
case "rts", "dtr":
if strings.TrimSpace(cfg.PTTPort) == "" {
return fmt.Errorf("no PTT COM port configured")
}
a.pttMu.Lock()
defer a.pttMu.Unlock()
if a.pttPort != nil {
return nil // already keyed
}
port, err := serial.Open(cfg.PTTPort, &serial.Mode{BaudRate: 9600})
if err != nil {
return fmt.Errorf("open %s: %w", cfg.PTTPort, err)
}
var lerr error
if cfg.PTTMethod == "rts" {
lerr = port.SetRTS(true)
_ = port.SetDTR(false)
} else {
lerr = port.SetDTR(true)
_ = port.SetRTS(false)
}
if lerr != nil {
_ = port.Close()
return fmt.Errorf("assert %s on %s: %w", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort, lerr)
}
a.pttPort = port
a.pttKeyedMethod = cfg.PTTMethod
a.pttGen++
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
return nil
}
return nil // none / VOX
}
// pttUnkey releases whichever PTT was keyed (CAT back to RX, or drop the
// serial line + close the port).
func (a *App) pttUnkey() {
a.pttMu.Lock()
method := a.pttKeyedMethod
a.pttKeyedMethod = ""
port := a.pttPort
a.pttPort = nil
a.pttMu.Unlock()
switch method {
case "cat":
if a.cat != nil {
if err := a.cat.SetPTT(false); err != nil {
applog.Printf("dvk: PTT off (CAT) failed: %v", err)
}
}
case "rts", "dtr":
if port != nil {
_ = port.SetRTS(false)
_ = port.SetDTR(false)
_ = port.Close()
}
default:
return
}
applog.Printf("dvk: PTT released")
}
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
// TestPTT keys the transmitter for ~600ms using the GIVEN settings (the live
// UI selection), so the user can test a method/port without saving first —
// matching TestRotator / TestUltrabeam.
func (a *App) TestPTT(cfg AudioSettings) error {
if cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr" {
applog.Printf("ptt: TestPTT method=%q port=%q", cfg.PTTMethod, cfg.PTTPort)
} else {
applog.Printf("ptt: TestPTT method=%q (CAT via OmniRig — serial port not used)", cfg.PTTMethod)
}
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
return fmt.Errorf("PTT method is None (VOX) — pick CAT, RTS or DTR first")
}
if (cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr") && strings.TrimSpace(cfg.PTTPort) == "" {
return fmt.Errorf("select a COM port for %s PTT", strings.ToUpper(cfg.PTTMethod))
}
if err := a.pttKey(cfg); err != nil {
return err
}
// Hold long enough to be observable. OmniRig is async and coalesces rapid
// writes to the same parameter: it stores the desired Tx value and flushes
// on its own poll timer, so a key (PM_TX) followed too quickly by an unkey
// (PM_RX) can land in ONE flush cycle — OmniRig then sends only the last
// value (PM_RX) and the rig never actually transmits (while an already-MOX'd
// rig gets dropped by that lone PM_RX). A longer hold guarantees the key
// command is sent and held before the release. Serial RTS/DTR is instant,
// so it keeps the short window.
hold := 600 * time.Millisecond
if cfg.PTTMethod == "cat" {
hold = 1500 * time.Millisecond
}
gen := a.pttGenNow()
go func() { time.Sleep(hold); a.unkeyIfCurrent(gen) }()
return nil
}
// DVKPreview plays a slot's message locally on the "Listening" device.
func (a *App) DVKPreview(slot int) error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
cfg, _ := a.GetAudioSettings()
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot))
}
// DVKStop halts any voice-keyer playback.
func (a *App) DVKStop() {
if a.audioMgr != nil {
a.audioMgr.StopPlayback()
}
}
// AudioStartMonitor pipes live RX audio from the rig into your speakers so you
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a
// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening"
// device. This is the USB half of the audio feature; the network 50003 stream
// will later Push into the same output path (see audio.Manager.PushMonitorAudio).
func (a *App) AudioStartMonitor() error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
cfg, _ := a.GetAudioSettings()
if strings.TrimSpace(cfg.FromRadio) == "" {
return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
}
applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
}
// AudioStopMonitor stops the RX monitor passthrough.
func (a *App) AudioStopMonitor() {
if a.audioMgr != nil {
a.audioMgr.StopMonitor()
applog.Printf("audio: RX monitor stopped")
}
}
// AudioMonitorActive reports whether the RX monitor is running (for the toggle).
func (a *App) AudioMonitorActive() bool {
return a.audioMgr != nil && a.audioMgr.MonitorActive()
}
// AudioStartTX keys PTT and pipes your live mic into the rig ("To Radio" device)
// so you can talk through the PC — the USB half of TX voice. PTT uses the
// configured method (CAT/RTS/DTR); if keying fails the audio route isn't started.
func (a *App) AudioStartTX() error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
cfg, _ := a.GetAudioSettings()
if strings.TrimSpace(cfg.ToRadio) == "" {
return fmt.Errorf(`no "To radio" device set — pick the rig's USB Audio CODEC output in Settings → Audio`)
}
if err := a.pttKey(cfg); err != nil { // key first — no point streaming to a rig that isn't transmitting
return err
}
if err := a.audioMgr.StartTXAudio(cfg.RecordingDevice, cfg.ToRadio); err != nil {
a.pttUnkey()
return err
}
applog.Printf("audio: TX start (mic=%q → to-radio=%q, ptt=%q)", cfg.RecordingDevice, cfg.ToRadio, cfg.PTTMethod)
return nil
}
// AudioStopTX stops the TX passthrough and unkeys PTT.
func (a *App) AudioStopTX() {
if a.audioMgr != nil {
a.audioMgr.StopTXAudio()
}
a.pttUnkey()
applog.Printf("audio: TX stopped")
}
// AudioTXActive reports whether the TX passthrough is running (for the toggle).
func (a *App) AudioTXActive() bool {
return a.audioMgr != nil && a.audioMgr.TXAudioActive()
}
// GetLogFilePath returns where the diagnostic log file lives so the user
// can open it from the Settings UI. Empty when applog hasn't initialised.
func (a *App) GetLogFilePath() string {
return applog.Path()
}
// ── QSL defaults ──────────────────────────────────────────────────────
// GetQSLDefaults returns the stored defaults — empty strings when the
// user hasn't configured anything (= leave QSO fields untouched).
// defaultQSLDefaults is the out-of-the-box confirmation status for a profile
// that hasn't customised them: request the online confirmations (eQSL/LoTW/
// Clublog/HRDLog/QRZ "sent"=R so OpsLog knows they still need uploading), and
// "N" for paper and everything received.
func defaultQSLDefaults() QSLDefaults {
return QSLDefaults{
QSLSent: "N", QSLRcvd: "N",
EQSLSent: "R", EQSLRcvd: "N",
LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", HRDLogStatus: "R",
QRZComStatus: "R", QRZComCfm: "N",
}
}
func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out := QSLDefaults{}
if a.settings == nil {
return out, nil
}
// Fresh profile (never saved confirmations) → sensible defaults.
if !a.profileHasGroup(markerQSL) {
return defaultQSLDefaults(), nil
}
prefix := a.profileScope()
m, err := a.getManyScoped(prefix,
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
)
if err != nil {
return out, err
}
out.QSLSent = m[keyQSLDefaultQSLSent]
out.QSLRcvd = m[keyQSLDefaultQSLRcvd]
out.LOTWSent = m[keyQSLDefaultLOTWSent]
out.LOTWRcvd = m[keyQSLDefaultLOTWRcvd]
out.EQSLSent = m[keyQSLDefaultEQSLSent]
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
return out, nil
}
// SaveQSLDefaults persists the configured defaults. Future QSO inserts
// pick them up automatically — no app restart needed.
func (a *App) SaveQSLDefaults(d QSLDefaults) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
scope := a.profileScope()
for k, v := range map[string]string{
keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)),
keyQSLDefaultQSLRcvd: strings.ToUpper(strings.TrimSpace(d.QSLRcvd)),
keyQSLDefaultLOTWSent: strings.ToUpper(strings.TrimSpace(d.LOTWSent)),
keyQSLDefaultLOTWRcvd: strings.ToUpper(strings.TrimSpace(d.LOTWRcvd)),
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
} {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
return err
}
}
if err := a.settings.Set(a.ctx, scope+markerQSL, "1"); err != nil {
return err
}
return nil
}
// applyQSLDefaults stamps the user-configured defaults onto a QSO when
// the corresponding fields are still empty. Called from every save path
// (manual entry via AddQSO, UDP auto-log via LogUDPLoggedADIF) so the
// confirmations columns always reflect the user's preferences.
func (a *App) applyQSLDefaults(q *qso.QSO) {
if a.settings == nil {
return
}
d, err := a.GetQSLDefaults()
if err != nil {
return
}
if q.QSLSent == "" {
q.QSLSent = d.QSLSent
}
if q.QSLRcvd == "" {
q.QSLRcvd = d.QSLRcvd
}
if q.LOTWSent == "" {
q.LOTWSent = d.LOTWSent
}
if q.LOTWRcvd == "" {
q.LOTWRcvd = d.LOTWRcvd
}
if q.EQSLSent == "" {
q.EQSLSent = d.EQSLSent
}
if q.EQSLRcvd == "" {
q.EQSLRcvd = d.EQSLRcvd
}
if q.ClublogUploadStatus == "" {
q.ClublogUploadStatus = d.ClublogStatus
}
if q.HRDLogUploadStatus == "" {
q.HRDLogUploadStatus = d.HRDLogStatus
}
if q.QRZComUploadStatus == "" {
q.QRZComUploadStatus = d.QRZComStatus
}
if q.QRZComDownloadStatus == "" {
q.QRZComDownloadStatus = d.QRZComCfm
}
}
// ── External services (logbook upload) ─────────────────────────────────
// loadExternalServices reads the configured external-service settings.
// ── Per-profile settings scoping ───────────────────────────────────────
//
// External Services and QSL Confirmations are scoped to the active profile
// so each operating identity (e.g. F4BPO vs TM2Q) uploads to its own
// accounts. They live under a "p<profileID>." key prefix. A per-group marker
// key records that a profile has saved its own copy; until then we
// transparently read the legacy un-prefixed (global) keys as the default —
// a lossless migration for logs created before profiles carried settings.
const (
markerExtsvc = "extsvc._set"
markerQSL = "qsl._set"
)
// profileScope returns the active profile's settings-key prefix ("p<id>.").
func (a *App) profileScope() string {
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil && p.ID > 0 {
return fmt.Sprintf("p%d.", p.ID)
}
}
return "p0."
}
// profileHasGroup reports whether the active profile has saved its own copy
// of a settings group (identified by its marker key).
func (a *App) profileHasGroup(marker string) bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, a.profileScope()+marker)
return v == "1"
}
// getManyScoped fetches base keys with the given prefix, returning a map
// keyed by the BASE key (so callers index with the plain constant).
func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, k := range keys {
v, err := a.settings.Get(a.ctx, prefix+k)
if err != nil {
return nil, err
}
out[k] = v
}
return out, nil
}
// parseUploadFlags resolves the LoTW "treat as unsent" status set: prefer the
// CSV (new multi-select), fall back to the legacy single flag, and default to
// N+R when nothing is configured (covers an imported ADIF still marked unsent).
func parseUploadFlags(csv, legacy string) []string {
add := func(dst []string, seen map[string]bool, raw string) []string {
for _, p := range strings.Split(raw, ",") {
f := strings.ToUpper(strings.TrimSpace(p))
if (f == "N" || f == "R") && !seen[f] {
seen[f] = true
dst = append(dst, f)
}
}
return dst
}
seen := map[string]bool{}
out := add(nil, seen, csv)
if len(out) == 0 {
out = add(out, seen, legacy)
}
if len(out) == 0 {
return []string{"N", "R"}
}
return out
}
func (a *App) loadExternalServices() extsvc.ExternalServices {
var out extsvc.ExternalServices
if a.settings == nil {
return out
}
// Read the active profile's scoped keys once it has saved them; otherwise
// fall back to the legacy global keys as the shared default.
prefix := ""
if a.profileHasGroup(markerExtsvc) {
prefix = a.profileScope()
}
m, err := a.getManyScoped(prefix,
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
keyExtLoTWUsername, keyExtLoTWWebPassword,
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode)
if err != nil {
return out
}
out.QRZ = extsvc.ServiceConfig{
APIKey: m[keyExtQRZAPIKey],
ForceStationCallsign: m[keyExtQRZForceCall],
AutoUpload: m[keyExtQRZAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtQRZUploadMode]),
}
out.Clublog = extsvc.ServiceConfig{
Email: m[keyExtClublogEmail],
Password: m[keyExtClublogPassword],
Callsign: m[keyExtClublogCallsign],
APIKey: m[keyExtClublogAPIKey],
AutoUpload: m[keyExtClublogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtClublogUploadMode]),
}
// Default the Club Log logbook callsign to the active profile's call
// when the user hasn't overridden it.
if out.Clublog.Callsign == "" && a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
out.Clublog.Callsign = p.Callsign
}
}
out.LoTW = extsvc.ServiceConfig{
TQSLPath: m[keyExtLoTWTQSLPath],
StationLocation: m[keyExtLoTWStationLoc],
ForceStationCallsign: m[keyExtLoTWForceCall],
KeyPassword: m[keyExtLoTWKeyPassword],
UploadFlags: parseUploadFlags(m[keyExtLoTWUploadFlags], m[keyExtLoTWUploadFlag]),
WriteLog: m[keyExtLoTWWriteLog] == "1",
Username: m[keyExtLoTWUsername],
Password: m[keyExtLoTWWebPassword],
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
// LoTW only ever uploads as an on-close batch (ARRL discourages per-QSO
// uploads), so the UI offers no other timing. Force it here so configs
// saved by older builds — which stored "immediate" — still batch at close.
UploadMode: extsvc.ModeOnClose,
}
// Default the TQSL path to the standard install location when unset, so
// the field is pre-populated if TQSL is present.
if out.LoTW.TQSLPath == "" {
out.LoTW.TQSLPath = extsvc.DefaultTQSLPath()
}
out.HRDLog = extsvc.ServiceConfig{
Callsign: m[keyExtHRDLogCallsign],
Code: m[keyExtHRDLogCode],
AutoUpload: m[keyExtHRDLogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHRDLogUploadMode]),
}
// Default the HRDLog callsign to the active profile's call when unset.
if out.HRDLog.Callsign == "" && a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
out.HRDLog.Callsign = p.Callsign
}
}
out.EQSL = extsvc.ServiceConfig{
Username: m[keyExtEQSLUsername],
Password: m[keyExtEQSLPassword],
QTHNickname: m[keyExtEQSLQTHNick],
AutoUpload: m[keyExtEQSLAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtEQSLUploadMode]),
}
// Default the eQSL username to the active profile's call when unset.
if out.EQSL.Username == "" && a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
out.EQSL.Username = p.Callsign
}
}
return out
}
// GetExternalServices returns the saved external-service configuration.
func (a *App) GetExternalServices() (extsvc.ExternalServices, error) {
return a.loadExternalServices(), nil
}
// SaveExternalServices persists the config and reloads the live manager so
// the next logged QSO uses the new settings (no restart needed).
func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
// Preserve the chosen upload timing — including "on_close", which the LoTW
// batch flush at shutdown depends on. (A previous version collapsed anything
// that wasn't "delayed" to "immediate", silently disabling on-close upload.)
modeOf := func(m extsvc.UploadMode) string {
switch m {
case extsvc.ModeDelayed:
return string(extsvc.ModeDelayed)
case extsvc.ModeOnClose:
return string(extsvc.ModeOnClose)
default:
return string(extsvc.ModeImmediate)
}
}
mode := modeOf(cfg.QRZ.UploadMode)
auto := "0"
if cfg.QRZ.AutoUpload {
auto = "1"
}
clMode := modeOf(cfg.Clublog.UploadMode)
clAuto := "0"
if cfg.Clublog.AutoUpload {
clAuto = "1"
}
ltMode := modeOf(cfg.LoTW.UploadMode)
ltAuto := "0"
if cfg.LoTW.AutoUpload {
ltAuto = "1"
}
ltFlags := strings.Join(parseUploadFlags(strings.Join(cfg.LoTW.UploadFlags, ","), ""), ",")
ltWriteLog := "0"
if cfg.LoTW.WriteLog {
ltWriteLog = "1"
}
hlMode := modeOf(cfg.HRDLog.UploadMode)
hlAuto := "0"
if cfg.HRDLog.AutoUpload {
hlAuto = "1"
}
eqMode := modeOf(cfg.EQSL.UploadMode)
eqAuto := "0"
if cfg.EQSL.AutoUpload {
eqAuto = "1"
}
scope := a.profileScope() // write under the active profile's prefix
for k, v := range map[string]string{
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
keyExtQRZAutoUpload: auto,
keyExtQRZUploadMode: mode,
keyExtClublogEmail: strings.TrimSpace(cfg.Clublog.Email),
keyExtClublogPassword: cfg.Clublog.Password,
keyExtClublogCallsign: strings.ToUpper(strings.TrimSpace(cfg.Clublog.Callsign)),
keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey),
keyExtClublogAutoUpload: clAuto,
keyExtClublogUploadMode: clMode,
keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath),
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
keyExtLoTWUploadFlags: ltFlags,
keyExtLoTWWriteLog: ltWriteLog,
keyExtLoTWAutoUpload: ltAuto,
keyExtLoTWUploadMode: ltMode,
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
keyExtLoTWWebPassword: cfg.LoTW.Password,
keyExtHRDLogCallsign: strings.ToUpper(strings.TrimSpace(cfg.HRDLog.Callsign)),
keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code),
keyExtHRDLogAutoUpload: hlAuto,
keyExtHRDLogUploadMode: hlMode,
keyExtEQSLUsername: strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)),
keyExtEQSLPassword: cfg.EQSL.Password,
keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname),
keyExtEQSLAutoUpload: eqAuto,
keyExtEQSLUploadMode: eqMode,
} {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
return err
}
}
// Mark this profile as having its own External Services config (so future
// loads read the scoped keys instead of falling back to the global ones).
if err := a.settings.Set(a.ctx, scope+markerExtsvc, "1"); err != nil {
return err
}
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
return nil
}
// TestQRZUpload validates the configured QRZ key by querying the logbook's
// status (ACTION=STATUS). Returns a human-readable message for the UI.
func (a *App) TestQRZUpload() (string, error) {
cfg := a.loadExternalServices().QRZ
return extsvc.TestQRZ(a.ctx, nil, cfg.APIKey)
}
// TestClublogUpload validates that the Club Log credentials are complete.
func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
}
// TestHRDLogUpload validates that the HRDLog credentials are complete.
func (a *App) TestHRDLogUpload() (string, error) {
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
}
// TestEQSLUpload validates the eQSL credentials with a real (no-op) request.
func (a *App) TestEQSLUpload() (string, error) {
return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
}
// ── QSL Manager (manual upload) ────────────────────────────────────────
// uploadColumnFor maps a service id to its QSO sent-status column.
func uploadColumnFor(service string) string {
switch extsvc.Service(service) {
case extsvc.ServiceQRZ:
return "qrzcom_qso_upload_status"
case extsvc.ServiceClublog:
return "clublog_qso_upload_status"
case extsvc.ServiceLoTW:
return "lotw_sent"
case extsvc.ServiceHRDLog:
return "hrdlog_qso_upload_status"
case extsvc.ServiceEQSL:
return "eqsl_sent"
}
return ""
}
// FindQSOsForUpload returns QSOs whose sent status for the given service
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
// FindQSOsForUpload returns FULL QSO rows eligible for upload to a service, so
// the QSL Manager shows the same rich, column-pickable table as Recent QSOs.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
col := uploadColumnFor(service)
if col == "" {
return nil, fmt.Errorf("unknown service %q", service)
}
return a.qso.ListForUploadFull(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
}
// UploadQSOsManual uploads the given QSO ids to a service on demand
// (regardless of their current sent status — the user picked them). Runs in
// the background, emitting "qslmgr:log" lines and a final "qslmgr:done".
func (a *App) UploadQSOsManual(service string, ids []int64) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
if uploadColumnFor(service) == "" {
return fmt.Errorf("unknown service %q", service)
}
cfg := a.loadExternalServices()
go a.runManualUpload(svc, ids, cfg)
return nil
}
func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
ctx := a.ctx
uploaded := 0
if svc == extsvc.ServiceLoTW {
emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids)))
var recs []string
for _, id := range ids {
if rec, ok := a.buildUploadADIF(id, cfg.LoTW.ForceStationCallsign); ok {
recs = append(recs, rec)
}
}
res, err := extsvc.UploadLoTW(ctx, cfg.LoTW, "", strings.Join(recs, "\n"))
if err != nil || !res.OK {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit("LoTW upload failed: " + msg)
} else {
for _, id := range ids {
a.markExtUploaded(svc, id, "")
uploaded++
}
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
}
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog {
statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date"
if svc == extsvc.ServiceHRDLog {
statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date"
}
type item struct {
id int64
rec string
call string
}
// Fetch the selected QSOs in BULK (chunked IN queries) instead of one
// GetByID per QSO — on a remote MySQL, 25k individual SELECTs is what made
// this crawl.
var items []item
const fetchChunk = 1000
for s := 0; s < len(ids); s += fetchChunk {
e := s + fetchChunk
if e > len(ids) {
e = len(ids)
}
_ = a.qso.IterateByIDs(ctx, ids[s:e], func(q qso.QSO) error {
items = append(items, item{id: q.ID, rec: adif.SingleRecordADIF(q), call: q.Callsign})
return nil
})
}
date := time.Now().UTC().Format("20060102")
if svc == extsvc.ServiceClublog {
// Club Log accepts a whole ADIF file (putlogs.php) and dedupes
// server-side → upload in chunks, one HTTP request per 100 QSOs.
const chunk = 100
emit(fmt.Sprintf("Club Log: uploading %d QSO(s) in batches of %d…", len(items), chunk))
for start := 0; start < len(items); start += chunk {
end := start + chunk
if end > len(items) {
end = len(items)
}
batch := items[start:end]
recs := make([]string, len(batch))
batchIDs := make([]int64, len(batch))
for i, it := range batch {
recs[i] = it.rec
batchIDs[i] = it.id
}
res, err := extsvc.UploadClublogADIF(ctx, nil, cfg.Clublog, adif.BatchRecordsADIF(recs))
if err == nil && res.OK {
if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, batchIDs); merr != nil {
applog.Printf("extsvc: Club Log batch mark: %v", merr)
}
uploaded += len(batch)
emit(fmt.Sprintf("Club Log: %d/%d uploaded", end, len(items)))
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
}
}
} else {
// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
// record ADIF, so upload ONE record per request. The DB stays cheap:
// bulk fetch above + the marks flushed in batches (not one per QSO).
emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items)))
var doneIDs []int64
flush := func() {
if len(doneIDs) == 0 {
return
}
if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, doneIDs); merr != nil {
applog.Printf("extsvc: HRDLog batch mark: %v", merr)
}
doneIDs = doneIDs[:0]
}
for i, it := range items {
res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec)
if err == nil && res.OK {
doneIDs = append(doneIDs, it.id)
uploaded++
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(it.call + " — FAILED: " + msg)
}
if len(doneIDs) >= 200 {
flush()
}
if (i+1)%50 == 0 || i+1 == len(items) {
emit(fmt.Sprintf("HRDLog: %d/%d uploaded", uploaded, len(items)))
}
}
flush()
}
} else {
// QRZ.com: one record per request (its logbook API has no batch upload).
for _, id := range ids {
q, gerr := a.qso.GetByID(ctx, id)
call := ""
if gerr == nil {
call = q.Callsign
}
force := ""
if svc == extsvc.ServiceQRZ {
force = cfg.QRZ.ForceStationCallsign
}
rec, ok := a.buildUploadADIF(id, force)
if !ok {
emit(call + " — skipped (no record)")
continue
}
var res extsvc.UploadResult
var err error
switch svc {
case extsvc.ServiceQRZ:
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
case extsvc.ServiceHRDLog:
res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec)
case extsvc.ServiceEQSL:
res, err = extsvc.UploadEQSL(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, rec)
default:
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
}
if err == nil && res.OK {
a.markExtUploaded(svc, id, "")
uploaded++
emit(call + " — OK")
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(call + " — FAILED: " + msg)
}
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
}
}
// ConfirmationItem is one downloaded confirmation shown in the QSL Manager,
// with award-style NEW flags computed against the log's prior confirmations.
type ConfirmationItem struct {
Callsign string `json:"callsign"`
QSODate string `json:"qso_date"` // ISO UTC
Band string `json:"band"`
Mode string `json:"mode"`
Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"`
NewSlot bool `json:"new_slot"`
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same
// "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window.
// since controls the date window: "" = everything, "last" = incremental since
// the service's last successful download, or an explicit "YYYY-MM-DD".
func (a *App) DownloadConfirmations(service string, addNotFound bool, since string) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
cfg := a.loadExternalServices()
go a.runDownloadConfirmations(svc, cfg, addNotFound, since)
return nil
}
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
done := func(matched, total int) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
}
}
ctx := a.ctx
matched, total, added := 0, 0, 0
// resolveSince turns the UI's request into a concrete date (or ""):
// "" → all
// "last" → the service's stored last-download date (incremental)
// "date" → used verbatim (expected YYYY-MM-DD)
resolveSince := func(lastKey string) string {
s := strings.TrimSpace(since)
if strings.EqualFold(s, "last") {
if a.settings != nil {
v, _ := a.settings.Get(ctx, a.profileScope()+lastKey)
return strings.TrimSpace(v)
}
return ""
}
return s
}
switch svc {
case extsvc.ServiceLoTW:
sinceDate := resolveSince(keyExtLoTWLastDownload)
ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
callLabel := ownCall
if callLabel == "" {
callLabel = "all callsigns"
}
if sinceDate != "" {
emit(fmt.Sprintf("Downloading LoTW confirmations for %s received since %s…", callLabel, sinceDate))
} else {
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
}
emit(fmt.Sprintf("Window: since=%q → resolved=%q (scope owncall=%q)", since, sinceDate, ownCall))
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
return
}
emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024))
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
if kerr != nil {
emit("Error reading local log: " + kerr.Error())
done(matched, total)
return
}
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
// that count for ARRL awards) so each incoming one is flagged NEW.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
var items []ConfirmationItem
var unmatched []string
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
}
total++
date := rec["qslrdate"]
if date == "" {
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if id, found := keyIDs[key]; found {
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
matched++
}
} else if addNotFound {
q.LOTWSent = "Y"
q.LOTWRcvd = "Y"
q.LOTWRcvdDate = date
if newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = newID // guard against dup records in the report
added++
}
} else {
// No local QSO matched this confirmation on (call, minute, band,
// mode). Record the specifics so the user can see WHICH one and
// why (time off by a minute, FT4 logged as MFSK, portable call…).
unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
}
// Build the result row + NEW flags (vs the pre-download snapshot),
// then fold this slot into the sets so a repeat in the same batch
// isn't flagged twice.
dxccNum := 0
if q.DXCC != nil {
dxccNum = *q.DXCC
}
it := ConfirmationItem{
Callsign: q.Callsign,
QSODate: q.QSODate.UTC().Format(time.RFC3339),
Band: q.Band,
Mode: q.Mode,
Country: q.Country,
}
if dxccNum != 0 {
it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
}
items = append(items, it)
return nil
})
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
if perr != nil {
emit("Parse error: " + perr.Error())
}
if addNotFound {
emit(fmt.Sprintf("Matched %d, added %d (of %d confirmed QSO(s))", matched, added, total))
} else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
}
// Surface confirmations with no local match so the user sees WHICH one
// and why (time off by a minute, FT4 logged as MFSK, portable call, or
// never logged). Tick "Add not-found" to import them instead.
for _, u := range unmatched {
emit(" ⚠ no local QSO for: " + u)
}
// Remember today so the next pull is incremental (per active profile).
if a.settings != nil {
a.setSetting(a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
}
case extsvc.ServiceQRZ:
// QRZ's FETCH API has no server-side date filter, so we pull the logbook
// and (when a window is requested) skip records older than sinceDate by
// QSO date. sinceDate is "YYYY-MM-DD".
sinceDate := resolveSince(keyExtQRZLastDownload)
emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload))
if sinceDate != "" {
emit("Fetching QRZ.com logbook (will skip QSOs before " + sinceDate + ")…")
} else {
emit("Fetching QRZ.com logbook (full — no since date)…")
}
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
if err != nil {
emit("Fetch failed: " + err.Error())
done(matched, total)
return
}
adifText := fr.ADIF
emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
// Persist the last-download date NOW (right after a successful fetch),
// not at the end: the QRZ logbook can be huge (tens of thousands of
// records) and the user may close the panel mid-processing — storing it
// late meant the date was never saved, so "since last download" kept
// resolving to empty and re-pulled everything.
if a.settings != nil {
a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
}
if snip := strings.TrimSpace(adifText); snip != "" {
if len(snip) > 300 {
snip = snip[:300]
}
emit("ADIF head: " + snip)
}
// Scope to THIS profile's / forced callsign so a QRZ logbook holding
// several of the operator's calls (F4BPO, TM2Q…) only confirms/adds QSOs
// for the active call. Windowed + phone-mode-tolerant matching (same as
// eQSL/LoTW).
qrzOwner := a.uploadOwnerCall(extsvc.ServiceQRZ)
mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner)
const qrzMatchWindow = 10 * time.Minute
qrzSkippedOtherCall := 0
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
// judged only against other QRZ confirmations.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
// Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED",
// without a per-record DB read.
alreadyQrz := map[int64]bool{}
if rs, e := a.logDb.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
for rs.Next() {
var id int64
if rs.Scan(&id) == nil {
alreadyQrz[id] = true
}
}
rs.Close()
}
var items []ConfirmationItem
parsed := 0
allKeys := map[string]bool{} // union of field names seen, for diagnostics
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
// parser treats the stream as records.
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
parsed++
for k := range rec {
allKeys[k] = true
}
if !qrzRecordConfirmed(rec) {
return nil
}
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
}
// Date window (client-side): skip QSOs older than the requested date.
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
return nil
}
// Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
if qrzOwner != "" {
if rc := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])); rc != "" && rc != qrzOwner {
qrzSkippedOtherCall++
return nil
}
}
total++
date := rec["qrzcom_qso_download_date"]
if date == "" {
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q)
line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s",
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)
id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow)
switch {
case found:
if alreadyQrz[id] {
emit(line + " ### ALREADY CONFIRMED ###")
} else if e := a.qso.MarkQRZConfirmed(ctx, id, date); e == nil {
alreadyQrz[id] = true
matched++
emit(line + " ### UPDATED ###")
}
case addNotFound:
q.QRZComUploadStatus = "Y"
q.QRZComDownloadStatus = "Y"
q.QRZComDownloadDate = date
if q.StationCallsign == "" {
q.StationCallsign = qrzOwner
}
if newID, e := a.qso.Add(ctx, q); e == nil {
mIdx.Add(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), newID)
added++
emit(line + " ### ADDED ###")
}
default:
emit(line + " ### NOT IN LOG ###")
}
// Result row + NEW flags.
dxccNum := 0
if q.DXCC != nil {
dxccNum = *q.DXCC
}
it := ConfirmationItem{
Callsign: q.Callsign,
QSODate: q.QSODate.UTC().Format(time.RFC3339),
Band: q.Band, Mode: q.Mode, Country: q.Country,
}
if dxccNum != 0 {
it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
}
items = append(items, it)
return nil
})
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
if perr != nil {
emit("Parse error: " + perr.Error())
}
// Diagnostic: the union of every field name QRZ returned, so we can
// pin the confirmation marker against real data.
keys := make([]string, 0, len(allKeys))
for k := range allKeys {
keys = append(keys, k)
}
sort.Strings(keys)
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
if qrzSkippedOtherCall > 0 {
emit(fmt.Sprintf("Skipped %d QSO(s) logged under another of your callsigns (scoped to %s).", qrzSkippedOtherCall, qrzOwner))
}
// (last-download date already stored right after the fetch above)
case extsvc.ServiceEQSL:
sinceDate := resolveSince(keyExtEQSLLastDownload)
if sinceDate != "" {
emit("Downloading eQSL Inbox (confirmations received since " + sinceDate + ")…")
} else {
emit("Downloading full eQSL Inbox (all received confirmations)…")
}
adifText, err := extsvc.DownloadEQSLConfirmations(ctx, nil, cfg.EQSL, sinceDate)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
return
}
// Scope everything to THIS profile's / forced callsign so an eQSL account
// holding several of the operator's calls (F4BPO, TM2Q…) only confirms/adds
// QSOs for the active call.
eqslOwner := a.uploadOwnerCall(extsvc.ServiceEQSL)
// Tolerant match index: an eQSL confirmation carries the OTHER station's
// logged time, often a minute or two off ours — so we match within a
// ±10-minute window. The mode must still match, except the phone sidebands
// SSB/USB/LSB are treated as one (a station may confirm USB where we logged
// SSB); FT8/FT4/CW/… stay exact.
mIdx, kerr := a.qso.BuildMatchIndex(ctx, eqslOwner)
if kerr != nil {
emit("Error reading local log: " + kerr.Error())
done(matched, total)
return
}
const eqslMatchWindow = 10 * time.Minute
skippedOtherCall := 0
// eQSL confirmations aren't ARRL-award-valid (only LoTW + paper QSL are),
// so NEW is judged only against other eQSL confirmations.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"eqsl_rcvd"})
var items []ConfirmationItem
var unmatched []string
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
}
// Skip a confirmation logged under a DIFFERENT one of the operator's
// callsigns (the eQSL account may hold several) — it belongs to that
// call's log, not this one.
if eqslOwner != "" {
if rc := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])); rc != "" && rc != eqslOwner {
skippedOtherCall++
return nil
}
}
total++
// eQSL stamps the confirmation date in QSLRDATE (fall back to today).
date := rec["qslrdate"]
if date == "" {
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q)
id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), eqslMatchWindow)
if found {
if e := a.qso.MarkEQSLConfirmed(ctx, id, date); e == nil {
matched++
}
} else if addNotFound {
q.EQSLSent = "Y"
q.EQSLRcvd = "Y"
q.EQSLRcvdDate = date
if q.StationCallsign == "" {
q.StationCallsign = eqslOwner // stamp the active call on adds
}
if newID, e := a.qso.Add(ctx, q); e == nil {
mIdx.Add(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), newID)
added++
}
} else {
unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
}
dxccNum := 0
if q.DXCC != nil {
dxccNum = *q.DXCC
}
it := ConfirmationItem{
Callsign: q.Callsign,
QSODate: q.QSODate.UTC().Format(time.RFC3339),
Band: q.Band, Mode: q.Mode, Country: q.Country,
}
if dxccNum != 0 {
it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
}
items = append(items, it)
return nil
})
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
if perr != nil {
emit("Parse error: " + perr.Error())
}
if addNotFound {
emit(fmt.Sprintf("Matched %d, added %d (of %d eQSL confirmation(s))", matched, added, total))
} else {
emit(fmt.Sprintf("Matched %d of %d eQSL confirmation(s)", matched, total))
}
for _, u := range unmatched {
emit(" ⚠ no local QSO for: " + u)
}
if skippedOtherCall > 0 {
emit(fmt.Sprintf("Skipped %d confirmation(s) logged under another of your callsigns (scoped to %s).", skippedOtherCall, eqslOwner))
}
if a.settings != nil {
a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02"))
}
default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
}
// Confirmations flip lotw_rcvd/qsl_rcvd on EXISTING rows, which doesn't move
// the logbook revision (count:maxID) — so the cached award snapshot would
// stay stale. Drop it whenever anything was matched or added so the next
// Awards view reflects the new confirmations.
if matched > 0 || added > 0 {
a.invalidateAwardStats()
}
done(matched+added, total)
}
// qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a
// confirmed QSO. QRZ's confirmation marker isn't clearly documented, so we
// accept the likely candidates; the download's one-time field dump lets us
// pin the exact field against real data and tighten this if needed.
func qrzRecordConfirmed(rec adif.Record) bool {
if strings.EqualFold(rec["qsl_rcvd"], "Y") {
return true
}
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
return true
}
switch strings.ToUpper(strings.TrimSpace(rec["app_qrzlog_status"])) {
case "C", "Y":
return true
}
return false
}
// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
// from cty.dat (offline) — used when adding a not-found confirmation that
// only carries call/band/mode/date.
func (a *App) enrichContactedFromCty(q *qso.QSO) {
if a.dxcc == nil || q.Callsign == "" {
return
}
m, ok := a.dxcc.Lookup(q.Callsign)
if !ok || m.Entity == nil {
return
}
if q.Country == "" {
q.Country = m.Entity.Name
}
if q.Continent == "" {
q.Continent = m.Continent
}
if q.DXCC == nil {
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
q.DXCC = &n
}
}
if q.CQZ == nil && m.CQZone != 0 {
v := m.CQZone
q.CQZ = &v
}
if q.ITUZ == nil && m.ITUZone != 0 {
v := m.ITUZone
q.ITUZ = &v
}
}
// enrichContactedFromCtyForce OVERWRITES the contacted-station country,
// continent, DXCC number and CQ/ITU zones from cty.dat. Unlike
// enrichContactedFromCty (which only fills blanks), this corrects values
// that are present-but-wrong — the case where contest software exports a
// bad COUNTRY/DXCC (e.g. RG2Y tagged "Asiatic Russia" instead of European).
// Returns true if cty.dat had a match.
func (a *App) enrichContactedFromCtyForce(q *qso.QSO) bool {
if a.dxcc == nil || q.Callsign == "" {
return false
}
m, ok := a.dxcc.Lookup(q.Callsign)
if !ok || m.Entity == nil {
return false
}
q.Country = m.Entity.Name
q.Continent = m.Continent
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
q.DXCC = &n
}
if m.CQZone != 0 {
v := m.CQZone
q.CQZ = &v
}
if m.ITUZone != 0 {
v := m.ITUZone
q.ITUZ = &v
}
// Zone-split countries (USA, Australia): refine the per-entity default zone
// to the call-district zone (W6 → CQ3/ITU6), matching Log4OM/DXKeeper.
a.refineDistrictZones(q)
return true
}
// UpdateQSOsFromCty recomputes country / continent / DXCC / CQ / ITU from
// cty.dat for the given QSO ids and saves them. Used by the grid's
// right-click "Update from cty.dat" on a multi-selection. Returns how many
// rows were actually changed.
func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
changed := 0
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
continue
}
before := fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ)
if !a.enrichContactedFromCtyForce(&q) {
continue
}
if fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ) == before {
continue // no change
}
if err := a.qso.Update(a.ctx, q); err == nil {
changed++
}
}
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil
}
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
// the configured providers) for each QSO id and overwrites the geographic
// + entity fields (country, continent, DXCC, zones, grid, state, county)
// plus name/QTH when the provider returns them. Used by the grid's
// right-click "Update from QRZ.com". Returns how many rows were saved.
func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
if a.qso == nil || a.lookup == nil {
return 0, fmt.Errorf("not initialized")
}
changed := 0
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil || q.Callsign == "" {
continue
}
r, err := a.lookup.Lookup(a.ctx, q.Callsign)
if err != nil {
continue
}
if r.Country != "" {
q.Country = r.Country
}
if r.Continent != "" {
q.Continent = r.Continent
}
if r.DXCC != 0 {
n := r.DXCC
q.DXCC = &n
}
if r.CQZ != 0 {
v := r.CQZ
q.CQZ = &v
}
if r.ITUZ != 0 {
v := r.ITUZ
q.ITUZ = &v
}
if r.Grid != "" {
q.Grid = strings.ToUpper(r.Grid)
}
if r.State != "" {
q.State = r.State
}
if r.County != "" {
q.County = r.County
}
if r.Name != "" {
q.Name = r.Name
}
if r.QTH != "" {
q.QTH = r.QTH
}
if err := a.qso.Update(a.ctx, q); err == nil {
changed++
}
}
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil
}
// ListTQSLStationLocations returns the Station Locations defined in TQSL,
// for the LoTW settings dropdown.
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
return extsvc.ListStationLocations(extsvc.DefaultStationDataPath())
}
// TestLoTWUpload validates the LoTW config (TQSL present + station location
// exists).
func (a *App) TestLoTWUpload() (string, error) {
return extsvc.TestLoTW(a.loadExternalServices().LoTW, extsvc.DefaultStationDataPath())
}
// buildUploadADIF builds a single-record ADIF for QSO id, overriding the
// station callsign when forceCall is set (QRZ rejects QSOs whose station
// call differs from the logbook's registered call). ok=false → skip.
func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) {
if a.qso == nil {
return "", false
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
return "", false
}
if forceCall != "" {
q.StationCallsign = forceCall
}
return adif.SingleRecordADIF(q), true
}
// stationCallOf returns the QSO's STATION_CALLSIGN (upper-cased), used by the
// uploader to verify a QSO belongs to the target logbook's callsign.
func (a *App) stationCallOf(id int64) string {
if a.qso == nil {
return ""
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
return ""
}
return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
}
// uploadOwnerCall returns the callsign OpsLog signs/uploads/downloads as for a
// service in the active profile: the configured Force/owner callsign, else the
// active profile's callsign. "" when nothing is known (don't scope by call).
func (a *App) uploadOwnerCall(svc extsvc.Service) string {
cfg := a.loadExternalServices()
owner := ""
switch svc {
case extsvc.ServiceLoTW:
owner = cfg.LoTW.ForceStationCallsign
case extsvc.ServiceQRZ:
owner = cfg.QRZ.ForceStationCallsign
case extsvc.ServiceClublog:
owner = cfg.Clublog.Callsign
case extsvc.ServiceHRDLog:
owner = cfg.HRDLog.Callsign
case extsvc.ServiceEQSL:
// eQSL has no Force-callsign field; its Username is just the account login
// (which may hold several of the operator's calls). Scope by the ACTIVE
// PROFILE's callsign instead — the operating identity — via the fallback
// below, so a download on the F4BPO profile only touches F4BPO QSOs.
}
owner = strings.ToUpper(strings.TrimSpace(owner))
if owner == "" && a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
owner = strings.ToUpper(strings.TrimSpace(p.Callsign))
}
}
return owner
}
// UploadCallsign exposes uploadOwnerCall to the UI so the QSL Manager can show
// which of the operator's callsigns a download/upload targets in this profile.
func (a *App) UploadCallsign(service string) string {
return a.uploadOwnerCall(extsvc.Service(service))
}
// closeUploadIDs returns the QSO ids to upload to a service at app close,
// scanning the whole logbook: LoTW matches the configured sent-status set
// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an
// imported ADIF (old QSOs still unsent) flush on close.
func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
if a.qso == nil {
return nil
}
col := uploadColumnFor(string(svc))
if col == "" {
return nil
}
// owner is the callsign this logbook signs/uploads as. Each external
// logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q)
// we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would
// be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log
// logbook.
var statuses []string
if svc == extsvc.ServiceLoTW {
statuses = a.loadExternalServices().LoTW.UploadFlags
if len(statuses) == 0 {
return nil
}
}
owner := a.uploadOwnerCall(svc)
cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses)
if err != nil {
applog.Printf("extsvc: close-upload candidate scan for %s failed: %v", svc, err)
return nil
}
out := make([]int64, 0, len(cands))
skipped := 0
for _, c := range cands {
// Keep QSOs that belong to this logbook's call. A blank STATION_CALLSIGN
// is assumed to be ours (it gets signed/labelled as owner on upload),
// mirroring the per-QSO guard in extsvc.upload.
if owner == "" || c.StationCallsign == "" || extsvc.SameBaseCall(c.StationCallsign, owner) {
out = append(out, c.ID)
} else {
skipped++
}
}
if skipped > 0 {
applog.Printf("extsvc: %s close-upload skipped %d QSO(s) not matching logbook callsign %q", svc, skipped, owner)
}
return out
}
// extShouldUpload reports whether a QSO is eligible for upload to a service,
// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW
// uploads only QSOs whose lotw_sent matches the configured Upload flag
// ("N" or "R") — the Log4OM rule that must match the Confirmations default.
func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
if a.qso == nil {
return false
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
return false
}
switch svc {
case extsvc.ServiceQRZ:
if strings.EqualFold(q.QRZComUploadStatus, "Y") {
applog.Printf("extsvc: QSO %d not eligible for qrz — QRZComUploadStatus already %q (set Confirmations default to N to upload)", id, q.QRZComUploadStatus)
return false
}
return true
case extsvc.ServiceClublog:
if strings.EqualFold(q.ClublogUploadStatus, "Y") {
applog.Printf("extsvc: QSO %d not eligible for clublog — ClublogUploadStatus already %q (set Confirmations default to N to upload)", id, q.ClublogUploadStatus)
return false
}
return true
case extsvc.ServiceHRDLog:
if strings.EqualFold(q.HRDLogUploadStatus, "Y") {
applog.Printf("extsvc: QSO %d not eligible for hrdlog — HRDLogUploadStatus already %q (set Confirmations default to N to upload)", id, q.HRDLogUploadStatus)
return false
}
return true
case extsvc.ServiceEQSL:
if strings.EqualFold(q.EQSLSent, "Y") {
applog.Printf("extsvc: QSO %d not eligible for eqsl — EQSLSent already %q (set Confirmations default to N to upload)", id, q.EQSLSent)
return false
}
return true
case extsvc.ServiceLoTW:
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
if strings.EqualFold(q.LOTWSent, f) {
return true
}
}
return false
}
return false
}
// markExtUploaded stamps the per-service upload status on the QSO row and
// tells the frontend to refresh that row's confirmation columns.
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
date := time.Now().UTC().Format("20060102")
// Use a fresh background context, NOT a.ctx: this stamp often runs during
// the on-close upload, and a.ctx is cancelled as the app shuts down — which
// would silently abort the UPDATE and leave the QSO at "R" forever despite a
// successful upload.
ctx := context.Background()
if a.qso == nil {
return
}
var err error
switch svc {
case extsvc.ServiceQRZ:
err = a.qso.MarkQRZUploaded(ctx, id, date)
case extsvc.ServiceClublog:
err = a.qso.MarkClublogUploaded(ctx, id, date)
case extsvc.ServiceLoTW:
err = a.qso.MarkLoTWUploaded(ctx, id, date)
case extsvc.ServiceHRDLog:
err = a.qso.MarkHRDLogUploaded(ctx, id, date)
case extsvc.ServiceEQSL:
err = a.qso.MarkEQSLSent(ctx, id, date)
}
if err != nil {
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
} else {
applog.Printf("extsvc: marked %s QSO %d as sent", svc, id)
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
"service": string(svc),
"qso_id": id,
"log_id": logID,
})
}
}
// notifyExtError surfaces a failed upload to the frontend.
func (a *App) notifyExtError(svc extsvc.Service, id int64, err error) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "extsvc:error", map[string]any{
"service": string(svc),
"qso_id": id,
"error": err.Error(),
})
}
}
// ── UDP integrations ───────────────────────────────────────────────────
// ListUDPIntegrations returns every saved UDP connection row.
func (a *App) ListUDPIntegrations() ([]udp.Config, error) {
if a.udpRepo == nil {
return nil, fmt.Errorf("db not initialized")
}
return a.udpRepo.List(a.ctx)
}
// SaveUDPIntegration upserts a UDP connection and reloads the manager so
// inbound listeners pick up the change without an app restart. Reload
// errors are surfaced — a "port already in use" failure should reach the
// user rather than be silently dropped.
func (a *App) SaveUDPIntegration(c udp.Config) (udp.Config, error) {
if a.udpRepo == nil {
return c, fmt.Errorf("db not initialized")
}
if err := a.udpRepo.Save(a.ctx, &c); err != nil {
return c, err
}
if a.udp != nil {
errs := a.udp.Reload(a.ctx)
if len(errs) > 0 {
return c, fmt.Errorf("listener errors: %s", strings.Join(errs, "; "))
}
}
return c, nil
}
// DeleteUDPIntegration removes a row and reloads the manager.
func (a *App) DeleteUDPIntegration(id int64) error {
if a.udpRepo == nil {
return fmt.Errorf("db not initialized")
}
if err := a.udpRepo.Delete(a.ctx, id); err != nil {
return err
}
if a.udp != nil {
a.udp.Reload(a.ctx)
}
return nil
}
// ReloadUDPIntegrations is a no-arg way for the UI to force a restart
// (e.g. after toggling Enabled on a row).
func (a *App) ReloadUDPIntegrations() []string {
if a.udp == nil {
return nil
}
return a.udp.Reload(a.ctx)
}
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
// first record into the local logbook. Returns the ID of the inserted
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
// N1MM — the latter via a synthesised ADIF record from its XML datagram).
func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
// always send a single QSO per UDP packet (no header) but we tolerate
// either form via adif.Parse.
// Pick the field decoder for this payload's encoding (UTF-8 as-is, else
// Windows-1252) so accented NAME/QTH from Log4OM/JTAlert aren't mangled.
// In UTF-8 mode the parser also repairs character-count field lengths.
decode := adif.ValueDecoderFor([]byte(adifText))
var record adif.Record
err := adif.ParseWithDecoder(strings.NewReader(adifText), decode, func(rec adif.Record) error {
if record == nil {
record = rec
}
return nil
})
if err != nil {
return 0, fmt.Errorf("parse adif: %w", err)
}
if record == nil {
// Some senders skip the <EOH> header; try treating the whole
// payload as a single record by prepending a fake header.
err := adif.ParseWithDecoder(strings.NewReader("<EOH>"+adifText), decode, func(rec adif.Record) error {
if record == nil {
record = rec
}
return nil
})
if err != nil || record == nil {
return 0, fmt.Errorf("no valid QSO record in payload")
}
}
q, ok := adif.RecordToQSO(record)
if !ok {
return 0, fmt.Errorf("record missing required fields (call/band/mode/date)")
}
// ── Lookup-based enrichment ──
// WSJT sends only call/freq/mode/RST/date. Fill Name/QTH/Country/
// Grid/CQZ/ITUZ/DXCC/Continent via the lookup chain (QRZ/HamQTH/
// cty.dat). Best-effort: a network failure shouldn't block the log.
if a.lookup != nil {
if lr, lerr := a.lookup.Lookup(a.ctx, q.Callsign); lerr == nil {
if q.Name == "" {
q.Name = lr.Name
}
if q.QTH == "" {
q.QTH = lr.QTH
}
if q.Country == "" {
q.Country = lr.Country
}
if q.Grid == "" {
q.Grid = lr.Grid
}
if q.Continent == "" {
q.Continent = lr.Continent
}
if q.State == "" {
q.State = lr.State
}
if q.County == "" {
q.County = lr.County
}
if q.Address == "" {
q.Address = lr.Address
}
if q.Email == "" {
q.Email = lr.Email
}
if q.DXCC == nil && lr.DXCC != 0 {
v := lr.DXCC
q.DXCC = &v
}
if q.CQZ == nil && lr.CQZ != 0 {
v := lr.CQZ
q.CQZ = &v
}
if q.ITUZ == nil && lr.ITUZ != 0 {
v := lr.ITUZ
q.ITUZ = &v
}
if q.Lat == nil && lr.Lat != 0 {
v := lr.Lat
q.Lat = &v
}
if q.Lon == nil && lr.Lon != 0 {
v := lr.Lon
q.Lon = &v
}
}
}
// ── Name/city normalisation ──
// Log4OM / contest loggers often send NAME and QTH in ALL CAPS. Title-case
// them so UDP-logged QSOs match the manual + lookup paths ("SANTO DOMINGO"
// → "Santo Domingo"). Only all-caps values are touched.
q.Name = titleCaseIfUpper(q.Name)
q.QTH = titleCaseIfUpper(q.QTH)
q.Country = titleCaseIfUpper(q.Country)
q.MyCity = titleCaseIfUpper(q.MyCity)
// ── Operating-conditions stamp ──
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
// this band (if the user has configured Operating conditions).
if a.operating != nil && a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 {
if q.MyRig == "" {
q.MyRig = d.StationName
}
if q.MyAntenna == "" {
q.MyAntenna = d.AntennaName
}
if q.TXPower == nil && d.TXPower != nil {
v := *d.TXPower
q.TXPower = &v
}
}
}
}
// ── Active-profile station stamp ──
// Same as the manual AddQSO path: fill the operator's MY_* fields
// (station callsign, grid, country, zones, and the profile's default
// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP /
// WSJT-X auto-logged QSO carried none of the operator's own data.
a.applyStationDefaults(&q, true)
// ── DXCC# + QSL defaults ──
// applyDXCCNumber stamps the contacted-station DXCC# from the
// entity-name table; QSL defaults are applied last so explicit ADIF
// fields (or what the lookup gave us) always win.
a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // date-ranged DXpedition override
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
// ── Dedup (serialised) ──
// Match by call + band + mode within a ±2-minute window: a QSO logged
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
// a minute (the two apps stamp their own time), so a minute-exact key
// missed it and the contact got duplicated.
//
// The check + insert is guarded by udpLogMu: MSHV/WSJT can deliver the same
// logged-QSO packet twice in quick succession (re-broadcast, or two
// listeners), and without serialisation both goroutines read the dedup set
// BEFORE either inserts, both pass, and the QSO lands twice.
a.udpLogMu.Lock()
defer a.udpLogMu.Unlock()
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
if err == nil {
base := q.QSODate.UTC()
for d := -2; d <= 2; d++ {
min := base.Add(time.Duration(d) * time.Minute).Format("2006-01-02T15:04")
if _, dup := seen[qso.DedupeKey(q.Callsign, min, q.Band, q.Mode)]; dup {
return 0, fmt.Errorf("duplicate (already in log within ±2 min)")
}
}
}
id, err := a.qso.Add(a.ctx, q)
if err != nil {
return 0, fmt.Errorf("insert qso: %w", err)
}
q.ID = id
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
return id, nil
}
// consumeUDPEvents bridges parsed UDP events to the frontend over Wails'
// event bus. The frontend listens on:
//
// udp:dx_call → string callsign (also Grid/Mode/Freq when known)
// udp:logged_qso → ADIF text of a QSO that finished in WSJT-X/JTDX/MSHV
// udp:remote_call → string callsign from a remote-control source
func (a *App) consumeUDPEvents() {
if a.udp == nil {
return
}
// Rate-limit decode spots: an FT8 opening decodes the SAME stations every
// cycle, so spot each call at most once per window (well within its display
// lifetime) instead of blasting the radio with a burst of adds+removes every
// 15 s. Single-goroutine loop → the map needs no lock.
const decodeSpotWindow = 20 * time.Second
lastDecodeSpot := map[string]time.Time{}
for ev := range a.udp.Events() {
if a.ctx == nil {
continue
}
switch {
case ev.DecodeCall != "":
// A WSJT-X decode (heard station). Render it on the FlexRadio
// panadapter when the option is on; green + SNR comment, auto-expiring
// after the configured duration. De-duped per call in the Flex backend.
if a.catFlexDecodeSpots && a.cat != nil {
now := time.Now()
if t, seen := lastDecodeSpot[ev.DecodeCall]; seen && now.Sub(t) < decodeSpotWindow {
continue // spotted this call very recently — don't re-hammer the radio
}
if len(lastDecodeSpot) > 4000 {
lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
}
lastDecodeSpot[ev.DecodeCall] = now
secs := a.catFlexDecodeSecs
if secs <= 0 {
secs = 120
}
a.cat.SendSpot(cat.SpotInfo{
FreqHz: ev.DecodeFreqHz,
Callsign: ev.DecodeCall,
Mode: ev.Mode,
Color: "#FF34C759", // green — distinct from cluster orange
Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR),
LifetimeSec: secs,
})
}
case ev.LoggedADIF != "":
applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF))
wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{
"config_id": ev.ConfigID,
"service": string(ev.Service),
"source": ev.Source,
"adif": ev.LoggedADIF,
})
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
case ev.DXCall != "":
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
"call": ev.DXCall,
"grid": ev.DXGrid,
"mode": ev.Mode,
"freq_hz": ev.FreqHz,
"service": string(ev.Service),
"source": ev.Source,
})
}
}
}
// ── Operating conditions ───────────────────────────────────────────────
// ListOperatingTree returns the stations/antennas/bands tree for the
// active profile. The UI renders the Settings tree from this.
func (a *App) ListOperatingTree() ([]operating.Station, error) {
if a.operating == nil || a.profiles == nil {
return nil, fmt.Errorf("db not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return nil, err
}
return a.operating.ListTree(a.ctx, p.ID)
}
// SaveOperatingStation upserts a station. profile_id is set from the
// active profile if zero so the frontend doesn't have to know about it.
func (a *App) SaveOperatingStation(s operating.Station) (operating.Station, error) {
if a.operating == nil || a.profiles == nil {
return s, fmt.Errorf("db not initialized")
}
if s.ProfileID == 0 {
p, err := a.profiles.Active(a.ctx)
if err != nil {
return s, err
}
s.ProfileID = p.ID
}
if err := a.operating.SaveStation(a.ctx, &s); err != nil {
return s, err
}
return s, nil
}
// DeleteOperatingStation cascades to antennas + bands.
func (a *App) DeleteOperatingStation(id int64) error {
if a.operating == nil {
return fmt.Errorf("db not initialized")
}
return a.operating.DeleteStation(a.ctx, id)
}
// SaveOperatingAntenna upserts an antenna and replaces its band list.
// Setting is_default on a band clears the flag from any other antenna
// on the same band within this profile.
func (a *App) SaveOperatingAntenna(ant operating.Antenna) (operating.Antenna, error) {
if a.operating == nil {
return ant, fmt.Errorf("db not initialized")
}
if err := a.operating.SaveAntenna(a.ctx, &ant); err != nil {
return ant, err
}
return ant, nil
}
// DeleteOperatingAntenna cascades to bands.
func (a *App) DeleteOperatingAntenna(id int64) error {
if a.operating == nil {
return fmt.Errorf("db not initialized")
}
return a.operating.DeleteAntenna(a.ctx, id)
}
// OperatingDefaultForBand returns the (station, antenna) flagged default
// for `band` in the active profile. Used by the entry strip to auto-fill
// MY_RIG and MY_ANTENNA when the user picks a band.
func (a *App) OperatingDefaultForBand(band string) (operating.BandDefault, error) {
if a.operating == nil || a.profiles == nil {
return operating.BandDefault{}, fmt.Errorf("db not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return operating.BandDefault{}, err
}
d, _, err := a.operating.BandDefault(a.ctx, p.ID, band)
return d, err
}
// ── Backup ──────────────────────────────────────────────────────────────
// BackupSettings is the user-tweakable database backup configuration.
type BackupSettings struct {
Enabled bool `json:"enabled"`
Folder string `json:"folder"`
Rotation int `json:"rotation"`
Zip bool `json:"zip"`
LastBackupAt string `json:"last_backup_at"`
DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint
}
// GetBackupSettings returns stored backup config with safe defaults.
func (a *App) GetBackupSettings() (BackupSettings, error) {
out := BackupSettings{
Rotation: 5,
DefaultFolder: backup.DefaultFolder(filepath.Dir(a.dbPath)),
}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupLast)
if err != nil {
return out, err
}
out.Enabled = m[keyBackupEnabled] == "1"
out.Folder = m[keyBackupFolder]
if n, _ := strconv.Atoi(m[keyBackupRotation]); n > 0 {
out.Rotation = n
}
out.Zip = m[keyBackupZip] == "1"
out.LastBackupAt = m[keyBackupLast]
return out, nil
}
// SaveBackupSettings persists backup config (no immediate backup —
// trigger it explicitly with RunBackupNow).
func (a *App) SaveBackupSettings(s BackupSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Rotation <= 0 {
s.Rotation = 5
}
enabled := "0"
if s.Enabled {
enabled = "1"
}
doZip := "0"
if s.Zip {
doZip = "1"
}
for k, v := range map[string]string{
keyBackupEnabled: enabled,
keyBackupFolder: strings.TrimSpace(s.Folder),
keyBackupRotation: strconv.Itoa(s.Rotation),
keyBackupZip: doZip,
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
return nil
}
// RunBackupNow forces an immediate backup using the persisted settings.
// Returns the destination path of the file that was written.
func (a *App) RunBackupNow() (string, error) {
s, err := a.GetBackupSettings()
if err != nil {
return "", err
}
folder := s.Folder
if folder == "" {
folder = s.DefaultFolder
}
// Always snapshot the local SQLite (config + any pre-MySQL local QSOs).
path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip)
if err != nil {
return path, err
}
// On MySQL the live QSO log isn't in the local DB — export it to ADIF so the
// contacts are actually protected. The ADIF path is the one we surface.
if a.dbBackend == "mysql" {
adiPath, aerr := a.backupLogADIF(folder, s.Rotation, s.Zip)
if aerr != nil {
return adiPath, aerr
}
path = adiPath
}
a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
return path, nil
}
// backupLogADIF writes a rotating ADIF export of the (MySQL) logbook into the
// backup folder. The full set of ADIF + app fields is included so the backup is
// a complete, re-importable copy of the log.
func (a *App) backupLogADIF(folder string, rotation int, zip bool) (string, error) {
if a.qso == nil {
return "", fmt.Errorf("logbook not initialized")
}
return backup.RunADIF(folder, rotation, zip, func(p string) error {
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: true}
_, e := ex.ExportFile(a.ctx, p)
return e
})
}
// maybeShutdownBackup runs a backup at shutdown if the user enabled it
// and no backup for today already exists. Running at shutdown (not at
// startup) means the snapshot includes the QSOs the user just logged
// this session — exactly what we want to protect. Errors are printed
// but never block the close.
func (a *App) maybeShutdownBackup() {
if a.settings == nil || a.db == nil {
return
}
s, err := a.GetBackupSettings()
if err != nil || !s.Enabled {
return
}
folder := s.Folder
if folder == "" {
folder = s.DefaultFolder
}
mysql := a.dbBackend == "mysql"
// In MySQL mode the ADIF log export is the backup that matters; gate the
// "already done today" skip on whichever backup type applies.
done := backup.HasBackupToday(folder)
if mysql {
done = backup.HasADIFBackupToday(folder)
}
if done {
return
}
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
fmt.Println("OpsLog: shutdown backup failed:", err)
return
}
if mysql {
if _, err := a.backupLogADIF(folder, s.Rotation, s.Zip); err != nil {
fmt.Println("OpsLog: shutdown ADIF log backup failed:", err)
return
}
}
a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}
// PickBackupFolder opens a native directory picker so the user can browse
// to a backup target rather than typing the path. Returns the absolute
// path (or empty string if the dialog was cancelled).
//
// Windows' shell dialog refuses to open when DefaultDirectory points at
// a path that doesn't exist yet (typical for our default backups folder
// on first launch). We walk up the path until we find an existing
// ancestor and use that as the dialog's starting point.
func (a *App) PickBackupFolder() (string, error) {
if a.ctx == nil {
return "", fmt.Errorf("no app context")
}
current, _ := a.GetBackupSettings()
defaultDir := current.Folder
if defaultDir == "" {
defaultDir = current.DefaultFolder
}
defaultDir = firstExistingAncestor(defaultDir)
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Pick a folder for OpsLog backups",
DefaultDirectory: defaultDir,
})
}
// firstExistingAncestor returns p if it exists, otherwise the closest
// parent directory that does. Returns "" if nothing valid is found (the
// dialog then opens at the OS default location).
func firstExistingAncestor(p string) string {
p = strings.TrimSpace(p)
for p != "" {
if st, err := os.Stat(p); err == nil && st.IsDir() {
return p
}
parent := filepath.Dir(p)
if parent == p {
break
}
p = parent
}
return ""
}
// GetCATState returns the current snapshot from the CAT manager. Used by the
// frontend on mount before any cat:state event has been emitted.
func (a *App) GetCATState() cat.RigState {
if a.cat == nil {
return cat.RigState{}
}
return a.cat.State()
}
// SetCATFrequency lets the frontend push a freq to the rig (cluster click,
// memory recall, …). Returns an error if CAT isn't running or the backend
// refuses (out-of-range, etc.).
func (a *App) SetCATFrequency(hz int64) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
applog.Printf("cat: SetCATFrequency %.3f MHz — deliberate frontend set (spot click / band / memory / entry)", float64(hz)/1e6)
err := a.cat.SetFrequency(hz)
if err != nil {
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
}
// Re-tune the Ultrabeam right away on a DELIBERATE freq set (spot click, band
// button, memory recall) instead of waiting for the ~1.5 s follow poll + the
// CAT echo — the reason it used to only follow after you nudged the VFO.
go a.ultrabeamFollowNow(hz)
return err
}
// ultrabeamFollowNow re-tunes the Ultrabeam to freqHz at once (best-effort),
// honouring the same enabled/follow/in-range/step-deadband rules as the follow
// loop. Called from SetCATFrequency so a spot click moves the antenna instantly.
func (a *App) ultrabeamFollowNow(freqHz int64) {
c := a.motorAnt
if c == nil || freqHz <= 0 {
return
}
s, err := a.GetUltrabeamSettings()
if err != nil || !s.Enabled || !s.Follow {
return
}
applog.Printf("motor-antenna: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6)
step := s.StepKHz
if step <= 0 {
step = 50
}
st := c.Status()
if !st.Connected {
return
}
if st.FreqMin > 0 && st.FreqMax > 0 {
mhz := freqHz / 1_000_000
if mhz < int64(st.FreqMin) || mhz > int64(st.FreqMax) {
return // outside the antenna's tunable range
}
}
khz := int(freqHz / 1000)
ref := st.Frequency
if ref <= 0 {
ref = c.LastSetKHz()
}
diff := khz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < step {
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
return // within the deadband — don't chase a tiny QSY
}
if err := c.SetFrequency(khz, st.Direction); err != nil {
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
} else {
applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d, was ref %d kHz)", khz, st.Direction, ref)
}
}
// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
// translated to backend-specific values by the backend itself.
func (a *App) SetCATMode(mode string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
err := a.cat.SetMode(mode)
if err != nil {
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
}
return err
}
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
// These are no-ops / errors unless the active CAT backend is a FlexRadio.
// GetFlexState returns the radio's transmit/ATU state for the FlexRadio tab.
// Available=false when the active backend isn't a connected Flex.
func (a *App) GetFlexState() cat.FlexTXState {
if a.cat == nil {
return cat.FlexTXState{}
}
st, _ := a.cat.FlexState()
return st
}
// ── Icom CI-V control panel (receive DSP) ──────────────────────────────────
func (a *App) GetIcomState() cat.IcomTXState {
if a.cat == nil {
return cat.IcomTXState{}
}
st, _ := a.cat.IcomState()
return st
}
func (a *App) IcomRefresh() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.RefreshIcom() })
}
func (a *App) IcomSetAFGain(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAFGain(p) })
}
func (a *App) IcomSetRFGain(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRFGain(p) })
}
func (a *App) IcomSetNB(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNB(on) })
}
func (a *App) IcomSetNBLevel(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNBLevel(p) })
}
func (a *App) IcomSetNR(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNR(on) })
}
func (a *App) IcomSetNRLevel(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNRLevel(p) })
}
func (a *App) IcomSetANF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetANF(on) })
}
func (a *App) IcomSetAPF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAPF(on) })
}
func (a *App) IcomSetAGC(mode string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAGC(mode) })
}
func (a *App) IcomSetPreamp(n int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPreamp(n) })
}
func (a *App) IcomSetAtt(db int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAtt(db) })
}
func (a *App) IcomSetFilter(n int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomFilter(n) })
}
func (a *App) IcomSetRFPower(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRFPower(p) })
}
func (a *App) IcomSetMicGain(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMicGain(p) })
}
func (a *App) IcomSetSplit(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomSplit(on) })
}
func (a *App) IcomSetAntenna(n int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAntenna(n) })
}
func (a *App) IcomSetPBTInner(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPBTInner(p) })
}
func (a *App) IcomSetPBTOuter(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPBTOuter(p) })
}
func (a *App) IcomSetManualNotch(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetManualNotch(on) })
}
func (a *App) IcomSetNotchPos(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNotchPos(p) })
}
func (a *App) IcomSetSquelch(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetSquelch(p) })
}
func (a *App) IcomSetComp(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetComp(on) })
}
func (a *App) IcomSetCompLevel(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetCompLevel(p) })
}
func (a *App) IcomSetMonitor(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMonitor(on) })
}
func (a *App) IcomSetMonLevel(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMonLevel(p) })
}
func (a *App) IcomSetVOX(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetVOX(on) })
}
func (a *App) IcomSetVOXGain(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetVOXGain(p) })
}
func (a *App) IcomSetAntiVOX(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAntiVOX(p) })
}
func (a *App) IcomTune() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.TuneATU() })
}
// IcomSetPower turns the radio on or off (manual — the app never wakes the rig
// on connect). ON sends a wake preamble + CI-V 0x18 01; the rig then boots ~15s.
func (a *App) IcomSetPower(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPower(on) })
}
// IcomSetScope enables/disables the spectrum-scope waveform stream.
func (a *App) IcomSetScope(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScope(on) })
}
// IcomScopeData returns the latest reassembled spectrum sweep for the panadapter.
func (a *App) IcomScopeData() cat.ScopeSweep {
if a.cat == nil {
return cat.ScopeSweep{}
}
sw, _ := a.cat.IcomScope()
return sw
}
// IcomSetScopeMode selects fixed-span (true) or center-on-VFO (false) scope mode.
func (a *App) IcomSetScopeMode(fixed bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
}
// IcomSetScopeEdges points the fixed-mode scope at [lowHz, highHz] — the panel's
// "centre on VFO" (VFO±50 kHz) and pan ◀/▶ buttons.
func (a *App) IcomSetScopeEdges(lowHz, highHz int64) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeEdges(lowHz, highHz) })
}
// IcomSetRIT sets the RIT/ΔTX offset in signed Hz.
func (a *App) IcomSetRIT(hz int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRIT(hz) })
}
// IcomSetRITOn toggles RIT.
func (a *App) IcomSetRITOn(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRITOn(on) })
}
// IcomSetXITOn toggles ΔTX (XIT).
func (a *App) IcomSetXITOn(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetXITOn(on) })
}
// IcomSendCW keys a CW message through the rig's internal keyer (CI-V 0x17).
func (a *App) IcomSendCW(text string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
err := a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SendCW(text) })
if err != nil {
applog.Printf("icom cw: IcomSendCW(%q) failed: %v", text, err)
}
return err
}
// IcomStopCW aborts the CW message currently being sent.
func (a *App) IcomStopCW() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.StopCW() })
}
// IcomSetKeySpeed sets the CW keyer speed in WPM.
func (a *App) IcomSetKeySpeed(wpm int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetKeySpeed(wpm) })
}
// IcomSetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL).
func (a *App) IcomSetBreakIn(mode int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetBreakIn(mode) })
}
func (a *App) IcomSetPTT(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.SetPTT(on)
}
func (a *App) FlexSetPower(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(p) })
}
func (a *App) FlexSetTunePower(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTunePower(p) })
}
func (a *App) FlexTune(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTune(on) })
}
func (a *App) FlexSetVox(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOX(on) })
}
func (a *App) FlexSetVoxLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOXLevel(l) })
}
func (a *App) FlexSetVoxDelay(ms int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOXDelay(ms) })
}
func (a *App) FlexAmpOperate(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAmpOperate(on) })
}
func (a *App) FlexSetProcessor(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetProcessor(on) })
}
func (a *App) FlexSetProcessorLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetProcessorLevel(l) })
}
func (a *App) FlexSetMon(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMon(on) })
}
func (a *App) FlexSetMonLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMonLevel(l) })
}
func (a *App) FlexSetMic(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMic(l) })
}
func (a *App) FlexMox(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.SetPTT(on) // MOX = manual transmit (xmit 1/0)
}
func (a *App) FlexATUStart() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ATUStart() })
}
func (a *App) FlexATUBypass() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ATUBypass() })
}
func (a *App) FlexSetATUMemories(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetATUMemories(on) })
}
// RX slice DSP controls (target the active receive slice).
func (a *App) FlexSetAGCMode(m string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAGCMode(m) })
}
func (a *App) FlexSetAGCThreshold(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAGCThreshold(l) })
}
func (a *App) FlexSetAudioLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) })
}
func (a *App) FlexSetMute(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
}
func (a *App) FlexSetRXAntenna(ant string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRXAntenna(ant) })
}
func (a *App) FlexSetTXAntenna(ant string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXAntenna(ant) })
}
func (a *App) FlexSetSplit(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
}
// FlexSetActiveSlice focuses a slice (A/B/C/D…) so all commands target it.
func (a *App) FlexSetActiveSlice(idx int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetActiveSlice(idx) })
}
// FlexSetTXSlice makes a slice the transmitter (e.g. TX on the active slice).
func (a *App) FlexSetTXSlice(idx int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXSlice(idx) })
}
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
const keyFlexBandAnt = "flex.band_antennas"
// FlexBandAnt is the antenna pair to select on a given band.
type FlexBandAnt struct {
RX string `json:"rx"`
TX string `json:"tx"`
}
// GetFlexBandAntennas returns the band→{rx,tx} antenna map (band key uppercased,
// e.g. "20M").
func (a *App) GetFlexBandAntennas() (map[string]FlexBandAnt, error) {
out := map[string]FlexBandAnt{}
if a.settings == nil {
return out, nil
}
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandAnt)
if strings.TrimSpace(v) == "" {
return out, nil
}
_ = json.Unmarshal([]byte(v), &out)
return out, nil
}
// SaveFlexBandAntennas persists the band→antenna map.
func (a *App) SaveFlexBandAntennas(m map[string]FlexBandAnt) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(m)
if err != nil {
return err
}
return a.settings.SetGlobal(a.ctx, keyFlexBandAnt, string(b))
}
// FlexApplyBandAntenna selects the configured RX/TX antennas for the given band
// (no-op if the backend isn't a Flex or the band has no mapping). Called on band
// changes and spot clicks so the right antennas follow the frequency.
func (a *App) FlexApplyBandAntenna(band string) error {
if a.cat == nil {
return nil
}
band = strings.ToUpper(strings.TrimSpace(band))
if band == "" {
return nil
}
m, _ := a.GetFlexBandAntennas()
ant, ok := m[band]
if !ok {
return nil
}
return a.cat.FlexDo(func(fc cat.FlexController) error {
if strings.TrimSpace(ant.RX) != "" {
_ = fc.SetRXAntenna(ant.RX)
}
if strings.TrimSpace(ant.TX) != "" {
_ = fc.SetTXAntenna(ant.TX)
}
return nil
})
}
// RIT/XIT on the active slice. The offset is kept by the radio when the switch is
// off, so turning it back on restores it — the UI must not zero it on toggle.
func (a *App) FlexSetRIT(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRIT(on) })
}
func (a *App) FlexSetRITFreq(hz int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRITFreq(hz) })
}
func (a *App) FlexSetXIT(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetXIT(on) })
}
func (a *App) FlexSetXITFreq(hz int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetXITFreq(hz) })
}
func (a *App) FlexSetNB(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNB(on) })
}
func (a *App) FlexSetNBLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNBLevel(l) })
}
func (a *App) FlexSetNR(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNR(on) })
}
func (a *App) FlexSetNRLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRLevel(l) })
}
func (a *App) FlexSetANF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANF(on) })
}
func (a *App) FlexSetANFLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFLevel(l) })
}
func (a *App) FlexSetAPF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPF(on) })
}
func (a *App) FlexSetAPFLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPFLevel(l) })
}
func (a *App) FlexSetWNB(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNB(on) })
}
func (a *App) FlexSetWNBLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) })
}
func (a *App) FlexSetTXFilter(low, high int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXFilter(low, high) })
}
func (a *App) FlexSetMicProfile(name string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMicProfile(name) })
}
func (a *App) FlexSetCWSpeed(wpm int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
}
func (a *App) FlexSetCWPitch(hz int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWPitch(hz) })
}
func (a *App) FlexSetCWBreakInDelay(ms int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWBreakInDelay(ms) })
}
func (a *App) FlexSetCWSidetone(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSidetone(on) })
}
func (a *App) FlexSetSidetoneLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSidetoneLevel(l) })
}
func (a *App) FlexSetCWFilter(bw int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWFilter(bw) })
}
func (a *App) FlexSetFilter(lo int, hi int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetFilter(lo, hi) })
}
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
// requiring a trip through the full Settings panel. Persists the choice
// so it survives restart.
func (a *App) SwitchCATRig(n int) error {
if n != 1 && n != 2 {
return fmt.Errorf("rig num must be 1 or 2, got %d", n)
}
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
return err
}
a.reloadCAT()
return nil
}
// reloadCAT (re)starts the CAT manager based on the current settings.
// Called at startup and after the user saves new CAT config.
func (a *App) reloadCAT() {
if a.cat == nil {
return
}
s, err := a.GetCATSettings()
if err != nil {
return
}
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
a.catFlexDecodeSecs = s.FlexDecodeSecs
if !s.Enabled {
a.cat.Stop()
return
}
switch s.Backend {
case "omnirig":
// No explicit launch — COM auto-activates OmniRig.exe via its
// LocalServer32 registration when we CreateObject in Connect().
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
// reloadCAT raised the existing instance's window to the front,
// which is what Log4OM avoids by relying entirely on COM activation.
a.cat.Start(cat.NewOmniRig(s.OmniRigNum))
case "flex":
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
// Clicking one of our spots on the panadapter fills the entry form.
fb.OnSpotClick = func(call string, hz int64) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "flex:spot_clicked", map[string]any{"call": call, "freq_hz": hz})
}
}
a.cat.Start(fb)
case "icom":
// Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol the network backend reuses for remote.
a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault))
case "icom-net":
// Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote
// Utility). Reuses the whole IcomController over the network transport.
var audioSink func([]byte)
if s.IcomNetAudio && a.audioMgr != nil {
// Experimental: stream RX audio over 50003. Start the render-only monitor
// sink (no USB capture) and feed each decoded payload into it, so remote
// RX audio plays through the "Listening" device. Decoding is via the PCM
// codec (Opus later). The 50003 payload OFFSET is still pending on-rig
// verification (see icomaudio.go) — hence experimental + opt-in.
acfg, _ := a.GetAudioSettings()
a.audioMgr.StopMonitor() // clear any prior monitor/sink so a re-save restarts cleanly
if err := a.audioMgr.StartMonitorSink(acfg.ListeningDevice); err != nil {
applog.Printf("icom-net audio: cannot start output sink: %v", err)
} else {
codec := audio.NewPCM16Codec()
audioSink = func(payload []byte) {
if pcm, err := codec.Decode(payload); err == nil {
a.audioMgr.PushMonitorAudio(pcm)
}
}
applog.Printf("icom-net audio: RX audio streaming ENABLED (experimental) → %q", acfg.ListeningDevice)
}
}
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink))
case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server.
a.cat.Start(cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots))
default:
// Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop()
}
}
// DiscoverFlexRadios listens for FlexRadio discovery broadcasts on the LAN and
// returns the radios found (for the CAT settings "auto-detect" button).
func (a *App) DiscoverFlexRadios() ([]cat.FlexRadio, error) {
return cat.DiscoverFlex(2500 * time.Millisecond)
}
// ClearLookupCache empties the local callsign cache.
func (a *App) ClearLookupCache() error {
if a.cache == nil {
return fmt.Errorf("cache not initialized")
}
return a.cache.Clear(a.ctx)
}
// CtyDatInfo describes the currently-loaded cty.dat file (or zero values
// if it hasn't been loaded yet). Exposed for the Maintenance menu so the
// user can see what they're working with before triggering a refresh.
type CtyDatInfo struct {
Path string `json:"path"`
Entities int `json:"entities"`
LoadedAt string `json:"loaded_at,omitempty"` // RFC3339, "" if not loaded
FileModTime string `json:"file_mod_time,omitempty"` // RFC3339, "" if missing
}
// GetCtyDatInfo returns metadata about the on-disk cty.dat.
func (a *App) GetCtyDatInfo() CtyDatInfo {
if a.dxcc == nil {
return CtyDatInfo{}
}
src := a.dxcc.Info()
out := CtyDatInfo{Path: src.Path, Entities: src.Entities}
if !src.LoadedAt.IsZero() {
out.LoadedAt = src.LoadedAt.UTC().Format(time.RFC3339)
}
if !src.FileModTime.IsZero() {
out.FileModTime = src.FileModTime.UTC().Format(time.RFC3339)
}
return out
}
// RefreshCtyDat re-downloads cty.dat from country-files.com and reloads it
// into memory. Synchronous so the UI can show a spinner; ~1s typical.
func (a *App) RefreshCtyDat() (CtyDatInfo, error) {
if a.dxcc == nil {
return CtyDatInfo{}, fmt.Errorf("dxcc manager not initialized")
}
if err := a.dxcc.Refresh(a.ctx); err != nil {
return CtyDatInfo{}, err
}
return a.GetCtyDatInfo(), nil
}
// --- Station bindings ---
//
// GetStationSettings/SaveStationSettings now operate on the **currently
// active profile** rather than a flat settings key set. Kept for the
// existing topbar/quick-edit code paths; the full profile CRUD lives in
// the Profile bindings below.
func (a *App) GetStationSettings() (StationSettings, error) {
if a.profiles == nil {
return StationSettings{}, fmt.Errorf("profiles not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return StationSettings{}, err
}
return StationSettings{
Callsign: p.Callsign,
Operator: p.Operator,
MyGrid: p.MyGrid,
MyCountry: p.MyCountry,
MySOTARef: p.MySOTARef,
MyPOTARef: p.MyPOTARef,
}, nil
}
// --- Lists bindings (bands + modes with default RST) ---
// GetListsSettings returns the user-customisable lists. Defaults are
// returned when the user has not customised anything.
func (a *App) GetListsSettings() (ListsSettings, error) {
if a.settings == nil {
return ListsSettings{Bands: defaultBands, Modes: defaultModes}, fmt.Errorf("db not initialized")
}
out := ListsSettings{}
if raw, _ := a.settings.Get(a.ctx, keyListsBands); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.Bands)
}
if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.Modes)
}
if raw, _ := a.settings.Get(a.ctx, keyListsRSTPhone); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.RSTPhone)
}
if raw, _ := a.settings.Get(a.ctx, keyListsRSTCW); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.RSTCW)
}
if raw, _ := a.settings.Get(a.ctx, keyListsRSTDigital); raw != "" {
_ = json.Unmarshal([]byte(raw), &out.RSTDigital)
}
if len(out.Bands) == 0 {
out.Bands = append([]string(nil), defaultBands...)
}
if len(out.Modes) == 0 {
out.Modes = append([]ModePreset(nil), defaultModes...)
}
if len(out.RSTPhone) == 0 {
out.RSTPhone = append([]string(nil), defaultRSTPhone...)
}
if len(out.RSTCW) == 0 {
out.RSTCW = append([]string(nil), defaultRSTCW...)
}
if len(out.RSTDigital) == 0 {
out.RSTDigital = append([]string(nil), defaultRSTDigital...)
}
return out, nil
}
// SaveListsSettings persists the user-customised lists.
func (a *App) SaveListsSettings(l ListsSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(l.Bands)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyListsBands, string(b)); err != nil {
return err
}
m, err := json.Marshal(l.Modes)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyListsModes, string(m)); err != nil {
return err
}
for k, v := range map[string][]string{
keyListsRSTPhone: l.RSTPhone,
keyListsRSTCW: l.RSTCW,
keyListsRSTDigital: l.RSTDigital,
} {
b, err := json.Marshal(v)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, k, string(b)); err != nil {
return err
}
}
return nil
}
// SaveStationSettings updates only the six "basic" fields on the active
// profile. Use the Profile bindings (ListProfiles / SaveProfile…) for
// full multi-profile management.
func (a *App) SaveStationSettings(s StationSettings) error {
if a.profiles == nil {
return fmt.Errorf("profiles not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return err
}
p.Callsign = s.Callsign
p.Operator = s.Operator
p.MyGrid = s.MyGrid
p.MyCountry = s.MyCountry
p.MySOTARef = s.MySOTARef
p.MyPOTARef = s.MyPOTARef
return a.profiles.Save(a.ctx, &p)
}
// --- Profile bindings (multi-profile CRUD) ---
// ListProfiles returns every saved profile, active first.
func (a *App) ListProfiles() ([]profile.Profile, error) {
if a.profiles == nil {
return nil, fmt.Errorf("profiles not initialized")
}
return a.profiles.List(a.ctx)
}
// GetActiveProfile returns the currently-selected profile.
func (a *App) GetActiveProfile() (profile.Profile, error) {
if a.profiles == nil {
return profile.Profile{}, fmt.Errorf("profiles not initialized")
}
return a.profiles.Active(a.ctx)
}
// SaveProfile upserts a profile. Pass id=0 to create a new one.
func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
if a.profiles == nil {
return profile.Profile{}, fmt.Errorf("profiles not initialized")
}
if err := a.profiles.Save(a.ctx, &p); err != nil {
return profile.Profile{}, err
}
a.refreshOperatorGrid()
return p, nil
}
// DeleteProfile removes a profile. Refuses to delete the last remaining
// profile; promotes another to active if the deleted one was selected.
func (a *App) DeleteProfile(id int64) error {
if a.profiles == nil {
return fmt.Errorf("profiles not initialized")
}
return a.profiles.Delete(a.ctx, id)
}
// ActivateProfile switches the selected profile. Subsequent QSOs stamp
// MY_* fields from this one.
func (a *App) ActivateProfile(id int64) error {
if a.profiles == nil {
return fmt.Errorf("profiles not initialized")
}
if err := a.profiles.SetActive(a.ctx, id); err != nil {
return err
}
// EVERY setting is per-profile: re-scope the settings store first, so all
// the reloads below read this profile's values.
a.settings.SetProfile(id)
a.refreshOperatorGrid()
// The logbook follows the active profile: reconnect to this profile's DB
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
if p, err := a.profiles.Get(a.ctx, id); err == nil {
if err := a.switchLogbook(p); err != nil {
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
}
}
// Re-apply every settings-dependent subsystem for the new profile.
a.reloadAfterProfileSwitch()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "profile:changed", id)
}
return nil
}
// reloadAfterProfileSwitch re-applies all settings-derived state for the newly
// active profile: lookup providers, upload-service accounts, CAT connection,
// and the QSO recorder (audio devices). The Winkeyer stays as-is (the operator
// connects it explicitly). The frontend reloads its panels via profile:changed.
func (a *App) reloadAfterProfileSwitch() {
a.reloadLookupProviders()
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
a.reloadCAT()
a.startQSORecorderIfEnabled()
}
// DuplicateProfile clones an existing profile under newName. Useful when
// the user has a "Home" profile and wants to derive "Portable" from it
// without retyping every field.
func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error) {
if a.profiles == nil {
return profile.Profile{}, fmt.Errorf("profiles not initialized")
}
p, err := a.profiles.Duplicate(a.ctx, id, newName)
if err != nil {
return profile.Profile{}, err
}
// A profile is the sum of its identity + DB target (copied by Duplicate) +
// its per-profile settings, its rig/antenna tree, and its QSL templates.
// Copy all of them so the clone is a true, independent duplicate.
if a.settings != nil {
if err := a.settings.CopyProfile(a.ctx, id, p.ID); err != nil {
applog.Printf("duplicate profile: copy settings: %v", err)
}
}
if a.operating != nil {
if err := a.operating.CopyProfile(a.ctx, id, p.ID); err != nil {
applog.Printf("duplicate profile: copy operating: %v", err)
}
}
if _, err := a.db.ExecContext(a.ctx,
`INSERT INTO qsl_templates (name, profile_id, json, is_default, created_at, updated_at)
SELECT name, ?, json, is_default, created_at, updated_at
FROM qsl_templates WHERE profile_id = ?`, p.ID, id); err != nil {
applog.Printf("duplicate profile: copy qsl templates: %v", err)
}
return p, nil
}
// --- Rotator bindings (PstRotator UDP v0) ---
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
type RotatorSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"` // default 127.0.0.1
Port int `json:"port"` // default 12000
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets
}
// GetRotatorSettings returns the persisted rotator config with defaults.
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
out := RotatorSettings{Host: "127.0.0.1", Port: 12000}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx,
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation)
if err != nil {
return out, err
}
out.Enabled = m[keyRotatorEnabled] == "1"
if h := m[keyRotatorHost]; h != "" {
out.Host = h
}
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
out.Port = p
}
out.HasElevation = m[keyRotatorHasElevation] == "1"
return out, nil
}
// SaveRotatorSettings persists the rotator config. Connection is per-call
// (UDP, no socket to (re)open) so no reload step is needed.
func (a *App) SaveRotatorSettings(s RotatorSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Host == "" {
s.Host = "127.0.0.1"
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 12000
}
for k, v := range map[string]string{
keyRotatorEnabled: boolStr(s.Enabled),
keyRotatorHost: s.Host,
keyRotatorPort: strconv.Itoa(s.Port),
keyRotatorHasElevation: boolStr(s.HasElevation),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
return nil
}
// rotatorClient returns a fresh PST UDP client built from current settings,
// or an error if the rotator is disabled / misconfigured.
func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
s, err := a.GetRotatorSettings()
if err != nil {
return nil, s, err
}
if !s.Enabled {
return nil, s, fmt.Errorf("rotator disabled in settings")
}
return pst.New(s.Host, s.Port), s, nil
}
// RotatorHeading is the live antenna heading for the status bar.
type RotatorHeading struct {
Enabled bool `json:"enabled"`
OK bool `json:"ok"`
Azimuth int `json:"azimuth"`
Raw string `json:"raw"`
}
// GetRotatorHeading queries PstRotator for the current azimuth. Returns
// Enabled=false when the rotator isn't configured. Polled by the status bar.
func (a *App) GetRotatorHeading() RotatorHeading {
s, err := a.GetRotatorSettings()
if err != nil || !s.Enabled {
return RotatorHeading{Enabled: false}
}
az, raw, herr := pst.New(s.Host, s.Port).Heading()
if herr != nil {
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
}
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
}
// RotatorGoTo points the antenna at the given azimuth (and optional
// elevation if the rotator is configured for it).
func (a *App) RotatorGoTo(az int, el int) error {
c, s, err := a.rotatorClient()
if err != nil {
return err
}
return c.GoTo(az, s.HasElevation, el)
}
// RotatorStop interrupts any in-progress rotation.
func (a *App) RotatorStop() error {
c, _, err := a.rotatorClient()
if err != nil {
return err
}
return c.Stop()
}
// RotatorPark moves the antenna to its parked position (configured in
// PstRotator itself).
func (a *App) RotatorPark() error {
c, _, err := a.rotatorClient()
if err != nil {
return err
}
return c.Park()
}
// TestRotator sends a no-op GoTo to the rotator's current heading to
// verify the UDP link without actually moving the antenna. We use 0° as
// the test target — pick a known direction the user expects to see.
// Returns nil on success or a descriptive error.
func (a *App) TestRotator(s RotatorSettings) error {
if s.Host == "" {
s.Host = "127.0.0.1"
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 12000
}
return pst.New(s.Host, s.Port).GoTo(0, false, -1)
}
func boolStr(b bool) string {
if b {
return "1"
}
return "0"
}
// ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────
// motorAntenna is the shared control surface of a motorized antenna. The
// Ultrabeam and SteppIR clients differ only in wire protocol; everything the app
// does — poll, follow the rig, switch pattern, retract — is the same, so it runs
// against this interface and neither the follow loop nor the UI cares which is on
// the other end.
type motorStatus struct {
Connected bool
Direction int // 0 normal, 1 180°, 2 bidirectional
Frequency int // kHz
Band int
Moving bool
FreqMin int // MHz, 0 = unknown (SteppIR does not report a tunable range)
FreqMax int // MHz, 0 = unknown
}
type motorAntenna interface {
Start() error
Stop()
SetFrequency(khz, dir int) error
SetDirection(dir int) error
Retract() error
LastSetKHz() int
Status() motorStatus
}
// ubAdapter / steppirAdapter wrap each concrete client to the shared interface,
// translating only the status shape (both already use the same 0/1/2 direction
// convention, so commands pass straight through).
type ubAdapter struct{ c *ultrabeam.Client }
func (a ubAdapter) Start() error { return a.c.Start() }
func (a ubAdapter) Stop() { a.c.Stop() }
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a ubAdapter) Status() motorStatus {
st, err := a.c.GetStatus()
if err != nil || st == nil {
return motorStatus{}
}
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: st.FreqMin, FreqMax: st.FreqMax}
}
type steppirAdapter struct{ c *steppir.Client }
func (a steppirAdapter) Start() error { return a.c.Start() }
func (a steppirAdapter) Stop() { a.c.Stop() }
func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a steppirAdapter) Retract() error { return a.c.Retract() }
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a steppirAdapter) Status() motorStatus {
st, err := a.c.GetStatus()
if err != nil || st == nil {
return motorStatus{}
}
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0}
}
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel. The name
// is kept (bindings + frontend) though it now covers SteppIR too.
type UltrabeamSettings struct {
Enabled bool `json:"enabled"`
Type string `json:"type"` // "ultrabeam" | "steppir"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // tcp
Port int `json:"port"` // tcp
COM string `json:"com"` // serial device
Baud int `json:"baud"` // serial baud
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
}
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
// to the pre-SteppIR behaviour (Ultrabeam over TCP) so an existing install is
// unchanged.
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud)
if err != nil {
return out, err
}
out.Enabled = m[keyUltrabeamEnabled] == "1"
if t := m[keyMotorType]; t == "steppir" || t == "ultrabeam" {
out.Type = t
}
if tr := m[keyMotorTransport]; tr == "serial" || tr == "tcp" {
out.Transport = tr
}
out.Host = m[keyUltrabeamHost]
if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 {
out.Port = p
}
out.COM = m[keyMotorCOM]
if b, _ := strconv.Atoi(m[keyMotorBaud]); b >= 1200 && b <= 115200 {
out.Baud = b
}
out.Follow = m[keyUltrabeamFollow] == "1"
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
out.StepKHz = st
}
return out, nil
}
// SaveUltrabeamSettings persists the config and (re)starts or stops the TCP
// poller so the change takes effect immediately.
func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
if s.StepKHz != 25 && s.StepKHz != 50 && s.StepKHz != 100 {
s.StepKHz = 50
}
if s.Type != "steppir" {
s.Type = "ultrabeam"
}
if s.Transport != "serial" {
s.Transport = "tcp"
}
if s.Baud < 1200 || s.Baud > 115200 {
s.Baud = 9600
}
for k, v := range map[string]string{
keyUltrabeamEnabled: boolStr(s.Enabled),
keyUltrabeamHost: strings.TrimSpace(s.Host),
keyUltrabeamPort: strconv.Itoa(s.Port),
keyUltrabeamFollow: boolStr(s.Follow),
keyUltrabeamStep: strconv.Itoa(s.StepKHz),
keyMotorType: s.Type,
keyMotorTransport: s.Transport,
keyMotorCOM: strings.TrimSpace(s.COM),
keyMotorBaud: strconv.Itoa(s.Baud),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.startUltrabeam()
return nil
}
// newMotorClient builds the concrete client for the configured antenna type and
// transport, wrapped in the shared interface. Returns nil if nothing usable is
// configured (no host for TCP, no COM for serial).
func newMotorClient(s UltrabeamSettings) motorAntenna {
if s.Type == "steppir" {
tr := steppir.Transport{Mode: s.Transport, Host: strings.TrimSpace(s.Host), Port: s.Port, COM: strings.TrimSpace(s.COM), Baud: s.Baud}
if tr.Mode == "serial" && tr.COM == "" {
return nil
}
if tr.Mode != "serial" && tr.Host == "" {
return nil
}
return steppirAdapter{steppir.New(tr)}
}
// Ultrabeam is TCP only.
if strings.TrimSpace(s.Host) == "" {
return nil
}
return ubAdapter{ultrabeam.New(s.Host, s.Port)}
}
// startUltrabeam stops any existing client and starts a fresh one if the
// antenna is enabled and configured. Safe to call repeatedly (on startup and
// after a settings save).
func (a *App) startUltrabeam() {
// Stop any running follow loop first.
if a.ubFollowStop != nil {
close(a.ubFollowStop)
a.ubFollowStop = nil
}
if a.motorAnt != nil {
// Background teardown so saving Settings doesn't block on an in-progress
// connect (Stop waits for the dial timeout).
go a.motorAnt.Stop()
a.motorAnt = nil
}
s, err := a.GetUltrabeamSettings()
if err != nil || !s.Enabled {
return
}
c := newMotorClient(s)
if c == nil {
return // not configured (missing host/COM)
}
a.motorAnt = c
_ = a.motorAnt.Start()
if s.Follow {
stop := make(chan struct{})
a.ubFollowStop = stop
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop)
}
}
// ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency
// whenever it drifts at least stepKHz from what the antenna is set to — so the
// elements track the band without the motors chasing every small QSY. Runs
// until stop is closed (a settings change or shutdown).
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) {
if stepKHz <= 0 {
stepKHz = 50
}
ticker := time.NewTicker(1500 * time.Millisecond)
defer ticker.Stop()
lastRigKHz := 0 // only log when the followed rig frequency actually changes
for {
select {
case <-stop:
return
case <-ticker.C:
if a.cat == nil {
continue
}
rs := a.cat.State()
if !rs.Connected || rs.FreqHz <= 0 {
continue
}
st := c.Status()
if !st.Connected {
continue
}
rigKHz := int(rs.FreqHz / 1000)
// Log the moment the poll sees a NEW rig frequency — this is what the
// follow loop chases. If the antenna QSYs unexpectedly, this shows the
// CAT backend started reporting that frequency (e.g. WSJT-X moved the
// dial, or the active slice changed) even though you didn't touch the VFO.
if rigKHz != lastRigKHz {
applog.Printf("ultrabeam: follow loop reads rig freq %.3f MHz (mode %s) — antenna at %d kHz, step %d kHz",
float64(rs.FreqHz)/1e6, rs.Mode, st.Frequency, stepKHz)
lastRigKHz = rigKHz
}
// Skip frequencies outside the antenna's tunable range (other band).
if st.FreqMin > 0 && st.FreqMax > 0 {
rigMHz := rs.FreqHz / 1_000_000
if rigMHz < int64(st.FreqMin) || rigMHz > int64(st.FreqMax) {
continue
}
}
// Deadband reference = the antenna's reported freq, or (when it hasn't
// reported one yet) the last freq we commanded — so a 0/blank status
// doesn't bypass the deadband and re-tune on every small QSY.
ref := st.Frequency
if ref <= 0 {
ref = c.LastSetKHz()
}
diff := rigKHz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < stepKHz {
continue // within the deadband — leave the motors alone
}
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
} else {
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz)
}
}
}
}
// UltrabeamStatusInfo is the live antenna status for the UI (status bar +
// direction control). Enabled mirrors the setting; the rest comes from the
// device's most recent status poll.
type UltrabeamStatusInfo struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
Frequency int `json:"frequency"` // KHz
Band int `json:"band"`
Moving bool `json:"moving"`
}
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out := UltrabeamStatusInfo{}
s, _ := a.GetUltrabeamSettings()
out.Enabled = s.Enabled
if a.motorAnt == nil {
return out
}
st := a.motorAnt.Status()
out.Connected = st.Connected
out.Direction = st.Direction
out.Frequency = st.Frequency
out.Band = st.Band
out.Moving = st.Moving
return out
}
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
// 2=bidirectional (re-issues the current frequency with the new direction).
func (a *App) SetUltrabeamDirection(direction int) error {
if a.motorAnt == nil {
return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
}
if direction < 0 || direction > 2 {
return fmt.Errorf("invalid direction %d", direction)
}
// Neither controller has a standalone direction command: both re-issue the
// current frequency with the new direction byte. If the antenna hasn't reported
// a frequency yet (just connected / link settling), fall back to the rig's CAT
// frequency so the control still works.
st := a.motorAnt.Status()
if st.Frequency <= 0 && a.cat != nil {
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
return a.motorAnt.SetFrequency(int(rs.FreqHz/1000), direction)
}
}
return a.motorAnt.SetDirection(direction)
}
// UltrabeamRetract retracts all elements (storage / safe position).
func (a *App) UltrabeamRetract() error {
if a.motorAnt == nil {
return fmt.Errorf("antenna not connected")
}
return a.motorAnt.Retract()
}
// TestUltrabeam opens a one-shot connection and reads one status frame to verify
// the transport (TCP host/port or serial COM) without disturbing the running poller.
func (a *App) TestUltrabeam(s UltrabeamSettings) error {
if s.Transport == "serial" {
if strings.TrimSpace(s.COM) == "" {
return fmt.Errorf("serial port required")
}
} else if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("host required")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
c := newMotorClient(s)
if c == nil {
return fmt.Errorf("antenna not configured")
}
if err := c.Start(); err != nil {
return err
}
defer c.Stop()
// The poller connects + reads status on its tick; give it a few cycles to come
// up, then check we got a live status frame.
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if st := c.Status(); st.Connected {
return nil
}
}
if s.Transport == "serial" {
return fmt.Errorf("no response on %s", s.COM)
}
return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
}
// ── Antenna Genius (4O3A) antenna switch (TCP, port fixed 9007) ─────────────
// AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel.
// The TCP port is fixed at 9007 on the device, so only the IP is configurable.
type AntGeniusSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Password string `json:"password"` // remote-access password; leave blank on LAN (no AUTH)
}
// GetAntGeniusSettings returns the persisted Antenna Genius config.
func (a *App) GetAntGeniusSettings() (AntGeniusSettings, error) {
out := AntGeniusSettings{}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost, keyAntGeniusPassword)
if err != nil {
return out, err
}
out.Enabled = m[keyAntGeniusEnabled] == "1"
out.Host = m[keyAntGeniusHost]
out.Password = m[keyAntGeniusPassword]
return out, nil
}
// SaveAntGeniusSettings persists the config and (re)starts or stops the client.
func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
for k, v := range map[string]string{
keyAntGeniusEnabled: boolStr(s.Enabled),
keyAntGeniusHost: strings.TrimSpace(s.Host),
keyAntGeniusPassword: s.Password,
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.startAntGenius()
return nil
}
// startAntGenius stops any existing client and starts a fresh one if enabled.
func (a *App) startAntGenius() {
if a.antgenius != nil {
// Background teardown so saving Settings doesn't block on an in-progress
// connect (Stop waits for the dial timeout).
go a.antgenius.Stop()
a.antgenius = nil
}
s, err := a.GetAntGeniusSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
return
}
a.antgenius = antgenius.New(s.Host, 9007, s.Password)
_ = a.antgenius.Start()
}
// GetAntGeniusStatus returns the switch's current state for the UI poll
// (connection, active antenna per port, and the configured antenna list).
func (a *App) GetAntGeniusStatus() antgenius.Status {
if a.antgenius == nil {
return antgenius.Status{}
}
return a.antgenius.GetStatus()
}
// AntGeniusActivate selects an antenna on a port (1 = A, 2 = B).
func (a *App) AntGeniusActivate(port, antenna int) error {
if a.antgenius == nil {
return fmt.Errorf("Antenna Genius not connected — enable it in Settings → Antenna Genius")
}
return a.antgenius.Activate(port, antenna)
}
// AntGeniusDeselect clears the active antenna on a port (sets it to "None").
func (a *App) AntGeniusDeselect(port int) error {
if a.antgenius == nil {
return fmt.Errorf("Antenna Genius not connected")
}
return a.antgenius.Activate(port, 0)
}
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────
// PGXLSettings is the JSON shape for the Hardware → PowerGenius panel.
type PGXLSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
}
func (a *App) GetPGXLSettings() (PGXLSettings, error) {
out := PGXLSettings{Port: 9008}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort)
if err != nil {
return out, err
}
out.Enabled = m[keyPGXLEnabled] == "1"
out.Host = m[keyPGXLHost]
if p, _ := strconv.Atoi(m[keyPGXLPort]); p > 0 && p <= 65535 {
out.Port = p
}
return out, nil
}
func (a *App) SavePGXLSettings(s PGXLSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 9008
}
for k, v := range map[string]string{
keyPGXLEnabled: boolStr(s.Enabled),
keyPGXLHost: strings.TrimSpace(s.Host),
keyPGXLPort: strconv.Itoa(s.Port),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.startPGXL()
return nil
}
// startPGXL stops any existing client and starts a fresh one if enabled.
func (a *App) startPGXL() {
if a.pgxl != nil {
// Stop() can block up to the dial timeout waiting for an in-progress
// connect; tear down in the background so saving Settings (this runs on
// the Wails RPC goroutine) doesn't freeze the UI.
go a.pgxl.Stop()
a.pgxl = nil
}
s, err := a.GetPGXLSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
return
}
a.pgxl = powergenius.New(s.Host, s.Port)
_ = a.pgxl.Start()
}
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
func (a *App) GetPGXLStatus() powergenius.Status {
if a.pgxl == nil {
return powergenius.Status{}
}
return a.pgxl.GetStatus()
}
// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
func (a *App) PGXLSetFanMode(mode string) error {
if a.pgxl == nil {
return fmt.Errorf("PowerGenius not connected — enable it in Settings → PowerGenius")
}
return a.pgxl.SetFanMode(mode)
}
// --- WinKeyer (CW keyer) bindings ---
// WKMacro is one CW message slot (F1…): a short label + the macro text, which
// may contain <VARIABLE> tokens resolved by the frontend before sending.
type WKMacro struct {
Label string `json:"label"`
Text string `json:"text"`
}
// WinkeyerSettings is the Hardware → CW Keyer panel shape. It embeds the
// engine Config (keying parameters) plus the enable flag and message macros.
type WinkeyerSettings struct {
Enabled bool `json:"enabled"`
winkeyer.Config
Engine string `json:"engine"` // keyer backend: "winkeyer" | "icom" (rig keyer via CI-V) | "tci"
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
SendOnType bool `json:"send_on_type"` // key chars live as typed
Macros []WKMacro `json:"macros"`
}
// ListSerialPorts returns the available COM ports for the keyer dropdown.
func (a *App) ListSerialPorts() ([]string, error) {
return winkeyer.ListPorts()
}
// GetWinkeyerSettings returns the persisted keyer config (with sane defaults).
func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
out := WinkeyerSettings{
Config: winkeyer.Config{
Baud: 1200, WPM: 25, Weight: 50, LeadInMs: 10, TailMs: 50,
Ratio: 50, Sidetone: 600, Mode: winkeyer.ModeIambicB, AutoSpace: true,
SerialEcho: true, // so the panel shows text as it's transmitted
},
Engine: "winkeyer",
EscClearsCall: true,
Macros: defaultWKMacros(),
}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
keyWKEngine, keyWKEscClears, keyWKSendOnType)
if err != nil {
return out, err
}
if v := m[keyWKEngine]; v != "" {
out.Engine = v
}
if v := m[keyWKEscClears]; v != "" {
out.EscClearsCall = v == "1"
}
out.SendOnType = m[keyWKSendOnType] == "1"
out.Enabled = m[keyWKEnabled] == "1"
if v := m[keyWKPort]; v != "" {
out.Port = v
}
atoiInto(m[keyWKBaud], &out.Baud)
atoiInto(m[keyWKWPM], &out.WPM)
atoiInto(m[keyWKWeight], &out.Weight)
atoiInto(m[keyWKLeadIn], &out.LeadInMs)
atoiInto(m[keyWKTail], &out.TailMs)
atoiInto(m[keyWKRatio], &out.Ratio)
atoiInto(m[keyWKFarnsworth], &out.Farnsworth)
atoiInto(m[keyWKSidetone], &out.Sidetone)
if v := m[keyWKMode]; v != "" {
out.Mode = winkeyer.Mode(v)
}
out.Swap = m[keyWKSwap] == "1"
if v := m[keyWKAutoSpace]; v != "" {
out.AutoSpace = v == "1"
}
out.UsePTT = m[keyWKUsePTT] == "1"
// Only override the default (true) when the key is actually stored — otherwise
// settings saved before serial_echo existed would silently disable the echo,
// and the TX text would stop showing as it's keyed.
if v := m[keyWKSerialEcho]; v != "" {
out.SerialEcho = v == "1"
}
if v := m[keyWKMacros]; v != "" {
var mac []WKMacro
if json.Unmarshal([]byte(v), &mac) == nil && len(mac) > 0 {
out.Macros = mac
}
}
return out, nil
}
// SaveWinkeyerSettings persists the keyer config; if a link is open and the
// keying params changed, the caller can reconnect to apply them.
func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
macJSON, _ := json.Marshal(s.Macros)
for k, v := range map[string]string{
keyWKEnabled: boolStr(s.Enabled),
keyWKPort: strings.TrimSpace(s.Port),
keyWKBaud: strconv.Itoa(s.Baud),
keyWKWPM: strconv.Itoa(s.WPM),
keyWKWeight: strconv.Itoa(s.Weight),
keyWKLeadIn: strconv.Itoa(s.LeadInMs),
keyWKTail: strconv.Itoa(s.TailMs),
keyWKRatio: strconv.Itoa(s.Ratio),
keyWKFarnsworth: strconv.Itoa(s.Farnsworth),
keyWKSidetone: strconv.Itoa(s.Sidetone),
keyWKMode: string(s.Mode),
keyWKSwap: boolStr(s.Swap),
keyWKAutoSpace: boolStr(s.AutoSpace),
keyWKUsePTT: boolStr(s.UsePTT),
keyWKSerialEcho: boolStr(s.SerialEcho),
keyWKMacros: string(macJSON),
keyWKEngine: strings.TrimSpace(s.Engine),
keyWKEscClears: boolStr(s.EscClearsCall),
keyWKSendOnType: boolStr(s.SendOnType),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
return nil
}
// WinkeyerConnect opens the serial link using the saved config.
func (a *App) WinkeyerConnect() error {
if a.winkeyer == nil {
return fmt.Errorf("winkeyer not initialized")
}
s, err := a.GetWinkeyerSettings()
if err != nil {
return err
}
return a.winkeyer.Connect(s.Config)
}
// WinkeyerDisconnect closes the serial link.
func (a *App) WinkeyerDisconnect() error {
if a.winkeyer != nil {
a.winkeyer.Disconnect()
}
return nil
}
// WinkeyerSend keys the (already variable-resolved) text as Morse.
func (a *App) WinkeyerSend(text string) error {
if a.winkeyer == nil {
return fmt.Errorf("winkeyer not initialized")
}
return a.winkeyer.Send(text)
}
// WinkeyerStop aborts the current message immediately.
func (a *App) WinkeyerStop() error {
if a.winkeyer == nil {
return fmt.Errorf("winkeyer not initialized")
}
return a.winkeyer.Stop()
}
// WinkeyerBackspace removes the last not-yet-keyed character (send-on-type).
func (a *App) WinkeyerBackspace() error {
if a.winkeyer == nil {
return fmt.Errorf("winkeyer not initialized")
}
return a.winkeyer.Backspace()
}
// WinkeyerSetSpeed changes the keying speed (WPM) live.
func (a *App) WinkeyerSetSpeed(wpm int) error {
if a.winkeyer == nil {
return fmt.Errorf("winkeyer not initialized")
}
return a.winkeyer.SetSpeed(wpm)
}
// GetWinkeyerStatus returns the current link status (used on mount).
func (a *App) GetWinkeyerStatus() winkeyer.Status {
if a.winkeyer == nil {
return winkeyer.Status{}
}
return a.winkeyer.Snapshot()
}
// defaultWKMacros mirrors the classic F-key set (CQ / answer / reports / 73).
func defaultWKMacros() []WKMacro {
return []WKMacro{
{Label: "CQ", Text: "CQ CQ DE <MY_CALL> <MY_CALL> K"},
{Label: "His call", Text: "<CALL> "},
{Label: "Report", Text: "<CALL> UR <STX> <STX> = "},
{Label: "Answer", Text: "<CALL> DE <MY_CALL> TU UR <RST_R> = "},
{Label: "Name/QTH", Text: "NAME <MY_NAME> QTH <MY_QTH> = "},
{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
{Label: "QRL?", Text: "QRL? "},
{Label: "AGN", Text: "AGN "},
}
}
func atoiInto(s string, dst *int) {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
*dst = n
}
}
// --- DX Cluster bindings (multi-server) ---
// resolveClusterLogin returns the login callsign for a server: explicit
// override on the row, else the active profile's callsign.
func (a *App) resolveClusterLogin(override string) string {
if override != "" {
return strings.ToUpper(strings.TrimSpace(override))
}
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil {
return strings.ToUpper(strings.TrimSpace(p.Callsign))
}
}
return ""
}
// clusterAutoConnect reads the global "auto-connect on startup" toggle.
// Stored in settings (key/value) since it's a single bool, not per-row.
func (a *App) clusterAutoConnect() (bool, error) {
if a.settings == nil {
return false, fmt.Errorf("db not initialized")
}
v, err := a.settings.Get(a.ctx, keyClusterAutoConnect)
if err != nil {
return false, err
}
return v == "1", nil
}
// startAllEnabledClusters opens a session for every enabled server.
func (a *App) startAllEnabledClusters() {
servers, err := a.listClusterServers()
if err != nil {
fmt.Println("OpsLog: list cluster servers:", err)
return
}
for _, s := range servers {
if s.Enabled {
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
}
}
}
// listClusterServers reads the cluster_servers table ordered for display
// (sort_order asc, id asc). The first row with Enabled=true is the master.
func (a *App) listClusterServers() ([]cluster.ServerConfig, error) {
if a.db == nil {
return nil, fmt.Errorf("db not initialized")
}
rows, err := a.db.QueryContext(a.ctx, `
SELECT id, name, host, port, login_override, password, init_commands, enabled, sort_order
FROM cluster_servers
ORDER BY sort_order ASC, id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []cluster.ServerConfig
for rows.Next() {
var s cluster.ServerConfig
var enabled int
if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.Port, &s.LoginOverride,
&s.Password, &s.InitCommands, &enabled, &s.SortOrder); err != nil {
return nil, err
}
s.Enabled = enabled == 1
out = append(out, s)
}
return out, rows.Err()
}
// ListClusterServers returns all saved cluster nodes.
func (a *App) ListClusterServers() ([]cluster.ServerConfig, error) {
return a.listClusterServers()
}
// SaveClusterServer upserts one row. id=0 inserts a new server. Restarts
// the session if the row was already running (so config edits take effect
// immediately).
func (a *App) SaveClusterServer(s cluster.ServerConfig) (cluster.ServerConfig, error) {
if a.db == nil {
return cluster.ServerConfig{}, fmt.Errorf("db not initialized")
}
if strings.TrimSpace(s.Name) == "" {
return cluster.ServerConfig{}, fmt.Errorf("server name required")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 7300
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
enabled := 0
if s.Enabled {
enabled = 1
}
if s.ID == 0 {
res, err := a.db.ExecContext(a.ctx, `
INSERT INTO cluster_servers
(name, host, port, login_override, password, init_commands, enabled, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, now)
if err != nil {
return cluster.ServerConfig{}, err
}
id, _ := res.LastInsertId()
s.ID = id
} else {
_, err := a.db.ExecContext(a.ctx, `
UPDATE cluster_servers SET name=?, host=?, port=?, login_override=?, password=?,
init_commands=?, enabled=?, sort_order=?, updated_at=?
WHERE id=?`,
s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, s.ID)
if err != nil {
return cluster.ServerConfig{}, err
}
}
// Apply runtime change: stop and restart if enabled, else just stop.
a.cluster.StopServer(s.ID)
if s.Enabled {
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
}
return s, nil
}
// DeleteClusterServer drops a row and closes its session.
func (a *App) DeleteClusterServer(id int64) error {
if a.db == nil {
return fmt.Errorf("db not initialized")
}
a.cluster.StopServer(id)
_, err := a.db.ExecContext(a.ctx, `DELETE FROM cluster_servers WHERE id=?`, id)
return err
}
// SetClusterAutoConnect persists the global auto-connect toggle.
func (a *App) SetClusterAutoConnect(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
return a.settings.Set(a.ctx, keyClusterAutoConnect, boolStr(on))
}
// GetClusterAutoConnect reads the persisted toggle.
func (a *App) GetClusterAutoConnect() (bool, error) {
return a.clusterAutoConnect()
}
// ConnectClusterServer opens a session for one specific saved server.
func (a *App) ConnectClusterServer(id int64) error {
if a.cluster == nil {
return fmt.Errorf("cluster not initialized")
}
servers, err := a.listClusterServers()
if err != nil {
return err
}
for _, s := range servers {
if s.ID == id {
if !s.Enabled {
return fmt.Errorf("server %q is disabled — enable it first", s.Name)
}
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
return nil
}
}
return fmt.Errorf("no saved server with id %d", id)
}
// DisconnectClusterServer closes the session for one server.
func (a *App) DisconnectClusterServer(id int64) error {
if a.cluster == nil {
return fmt.Errorf("cluster not initialized")
}
a.cluster.StopServer(id)
return nil
}
// ConnectAllClusters opens sessions for every enabled server.
func (a *App) ConnectAllClusters() error {
if a.cluster == nil {
return fmt.Errorf("cluster not initialized")
}
a.startAllEnabledClusters()
return nil
}
// DisconnectAllClusters closes every running session.
func (a *App) DisconnectAllClusters() error {
if a.cluster == nil {
return fmt.Errorf("cluster not initialized")
}
a.cluster.StopAll()
return nil
}
// SendClusterCommand writes `cmd` to the **master** cluster — the first
// enabled server by sort_order. Returns an error if the master is not
// currently connected (the UI should grey the input out in that case).
func (a *App) SendClusterCommand(cmd string) error {
if a.cluster == nil {
return fmt.Errorf("cluster not initialized")
}
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return fmt.Errorf("empty command")
}
servers, err := a.listClusterServers()
if err != nil {
return err
}
for _, s := range servers {
if s.Enabled {
return a.cluster.SendCommand(s.ID, cmd)
}
}
return fmt.Errorf("no enabled cluster server to send to")
}
// SendClusterSpot announces a DX spot on the **master** cluster (first
// enabled server). Format is the universal DXSpider/AR-Cluster command
// `DX <freq_khz> <call> <comment>`. The frequency is taken in kHz; call is
// upper-cased; comment is optional (commonly the mode, e.g. "CW").
func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) error {
call = strings.ToUpper(strings.TrimSpace(call))
if call == "" {
return fmt.Errorf("callsign required")
}
if freqKHz <= 0 {
return fmt.Errorf("invalid frequency")
}
// Trim a trailing ".0" so integer kHz stay clean (14205 not 14205.0),
// but keep sub-kHz precision when present (e.g. 10138.7).
freqStr := strconv.FormatFloat(freqKHz, 'f', -1, 64)
cmd := fmt.Sprintf("DX %s %s", freqStr, call)
if c := strings.TrimSpace(comment); c != "" {
cmd += " " + c
}
applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
return a.SendClusterCommand(cmd)
}
// GetClusterStatus returns a snapshot of every active session. Used by
// the UI on mount and to hydrate after a `cluster:state` event.
func (a *App) GetClusterStatus() []cluster.ServerStatus {
if a.cluster == nil {
return nil
}
return a.cluster.Status()
}
// SpotQuery is one (call, band, mode) tuple sent for status colouring.
type SpotQuery struct {
Call string `json:"call"`
Band string `json:"band"`
Mode string `json:"mode"`
}
// SpotStatus is the per-tuple result. Status is one of:
//
// "new" — entity never worked
// "new-band" — entity worked but never on this band
// "new-mode" — band worked, but this MODE never worked on the entity (any band)
// "new-slot" — band & mode each worked before, but not this band+mode together
// "worked" — exact band+mode already in the log
// "" — couldn't resolve the entity (no cty.dat match)
type SpotStatus struct {
Call string `json:"call"`
Band string `json:"band"`
Mode string `json:"mode"`
Country string `json:"country,omitempty"`
Continent string `json:"continent,omitempty"`
Status string `json:"status"`
// WorkedCall is true when this exact callsign exists in the log
// (any band, any mode). Drives the per-call text highlight, in
// addition to the entity-level Status (NEW / NEW BAND / …).
WorkedCall bool `json:"worked_call"`
}
// ClusterSpotStatuses takes a batch of spots and returns slot status for
// each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT
// / WORKED). One cty.dat lookup + one DB scan, regardless of batch size.
//
// Mode handling: when the caller passes an empty Mode (cluster comment
// was ambiguous and the frontend couldn't infer) we degrade gracefully
// to band-only — saying "worked" rather than wrongly flagging "new-slot"
// just because we don't know the mode.
func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out := make([]SpotStatus, len(spots))
if a.qso == nil {
return out
}
// Compare by DXCC entity NUMBER, not name. For each logged QSO the key is
// its stored DXCC if present (the authoritative value set at log time, incl.
// ClubLog date exceptions), else the stored country resolved to a number,
// else the cty.dat prefix lookup. This fixes false NEWs where cty.dat
// re-resolves a logged callsign to a different entity than how it was logged
// (e.g. VK2/SP9FIH logged as Lord Howe Island, but its prefix is Australia —
// so a VJ2L Lord Howe spot must still count as worked).
keyFor := func(call string, storedDXCC int, country string) int {
if storedDXCC > 0 {
return storedDXCC
}
if n := dxcc.EntityDXCC(country); n > 0 {
return n
}
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
return dxcc.EntityDXCC(m.Entity.Name)
}
}
return 0
}
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor)
if err != nil {
return out
}
// Per-call worked set — separate from the entity check so we can flag
// "I've already QSO'd this exact station" even when the band/mode
// makes the entity check say "new-band" or "new-slot".
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
for i, q := range spots {
out[i] = SpotStatus{
Call: q.Call,
Band: strings.ToLower(q.Band),
Mode: strings.ToUpper(q.Mode),
}
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
out[i].WorkedCall = true
}
if a.dxcc == nil {
continue
}
m, ok := a.dxcc.Lookup(q.Call)
if !ok || m.Entity == nil {
continue
}
out[i].Country = m.Entity.Name
out[i].Continent = m.Continent
dxccNum := dxcc.EntityDXCC(m.Entity.Name)
if dxccNum == 0 {
continue // can't resolve the spot's entity number → don't guess
}
e, worked := entities[dxccNum]
if !worked {
out[i].Status = "new"
continue
}
if _, b := e.Bands[out[i].Band]; !b {
out[i].Status = "new-band"
continue
}
// Without a mode we can't distinguish the rest from "worked";
// the safer default is "worked" so we never falsely claim "new".
if out[i].Mode == "" {
out[i].Status = "worked"
continue
}
// Band already worked. If this MODE was never worked on the entity (any
// band) → new-mode. If the mode was worked elsewhere but not on THIS
// band+mode → new-slot. Otherwise → worked.
if _, m := e.Modes[out[i].Mode]; !m {
out[i].Status = "new-mode"
continue
}
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
out[i].Status = "new-slot"
continue
}
out[i].Status = "worked"
}
return out
}