Files
OpsLog/app.go
T
2026-06-20 20:18:28 +02:00

8273 lines
268 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"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"time"
"hamlog/internal/adif"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/award"
"hamlog/internal/awardref"
"hamlog/internal/backup"
"hamlog/internal/cat"
"hamlog/internal/clublog"
"hamlog/internal/cwdecode"
"hamlog/internal/cluster"
"hamlog/internal/db"
"hamlog/internal/dxcc"
"hamlog/internal/email"
"hamlog/internal/extsvc"
"hamlog/internal/integrations/udp"
"hamlog/internal/lookup"
"hamlog/internal/operating"
"hamlog/internal/pota"
"hamlog/internal/profile"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/rotator/pst"
"hamlog/internal/settings"
"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
keyCATPollMs = "cat.poll_ms"
keyCATDelayMs = "cat.delay_ms" // pause between commands
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
// 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
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"
// Ultrabeam antenna (TCP, e.g. via an RS232↔Ethernet adapter) — Hardware → Antenna.
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
// 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" | "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
)
// 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"
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
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
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
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
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
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
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
// 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
}
// 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
}
lat, lon, ok := gridToLatLon(p.MyGrid)
if !ok {
return
}
a.opLat = lat
a.opLon = lon
a.opSet = true
}
// 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 != "" {
// Portability guard: a pointer that is merely ANOTHER folder's default DB
// location ("…/<other>/data/opslog.db") means the portable folder was
// renamed or copied — its config.json still points at the original. Ignore
// it and use THIS folder's own data (and clear the stale pointer so it
// stops happening). A genuine custom location — another drive, a different
// filename — is NOT default-style, so it's still honoured.
stale := strings.EqualFold(filepath.Base(custom), "opslog.db") &&
strings.EqualFold(filepath.Base(filepath.Dir(custom)), "data") &&
!strings.EqualFold(filepath.Clean(filepath.Dir(custom)), filepath.Clean(dataDir))
if stale {
fmt.Printf("OpsLog: ignoring stale DB pointer %q (folder moved) — using %s\n", custom, a.dbPath)
_ = writeDBPointer(dataDir, "")
} else {
a.dbPath = custom
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
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.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")
}()
// 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)
go func() {
if err := a.clublog.EnsureLoaded(); err == nil {
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.
a.cat = cat.NewManager(func(s cat.RigState) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cat:state", 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).
a.cluster = cluster.NewManager(
func(s cluster.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)
}
// Mirror the spot onto the FlexRadio panadapter when enabled. The
// Color is left to the backend default for now — status-based
// colouring can be filled in here later (new entity / worked / …).
if a.catFlexSpots && a.cat != nil {
a.cat.SendSpot(cat.SpotInfo{
FreqHz: s.FreqHz,
Callsign: s.DXCall,
Comment: s.Comment,
})
}
},
func() {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
}
},
)
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)
}
},
)
// 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()
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// 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
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
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.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") }
// 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)
}
}
// 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)
// 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 {
q.ID = id
a.saveQSORecording(&q)
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
}
return id, err
}
// 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
}
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.
migrated, _ := award.Migrate(defs)
return migrated
}
}
}
return award.Defaults()
}
// 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)
// 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))
}
}
// SaveAwardDefs persists edited award definitions.
func (a *App) SaveAwardDefs(defs []award.Def) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
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) {
d := award.Defaults()
if err := a.SaveAwardDefs(d); err != nil {
return nil, err
}
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{}
for _, d := range defs {
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
}
nameOf := func(field, ref string) string {
switch field {
case "dxcc":
if n, err := strconv.Atoi(ref); err == nil {
return dxcc.NameForDXCC(n)
}
case "cont":
return continentName(ref)
}
return ""
}
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf)
m := map[string]string{}
for i := range results {
r := &results[i]
code := strings.ToUpper(r.Code)
dxccField := fieldByCode[code] == "dxcc"
var refs []string
for _, rf := range r.Refs {
if !rf.Worked {
continue
}
// DXCC's ref is a number → show the country name instead.
label := rf.Ref
if dxccField && rf.Name != "" {
label = rf.Name
}
refs = append(refs, label)
}
if len(refs) > 0 {
m[code] = strings.Join(refs, ", ")
}
}
if len(m) > 0 {
out[q.ID] = m
}
return nil
})
return out, err
}
// AwardRefMeta describes a reference list's state for the UI.
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")
}
return a.awardRefs.Upsert(a.ctx, code, ref)
}
// 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")
}
return a.awardRefs.Delete(a.ctx, code, refCode)
}
// 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"))
}
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) {
if a.awardRefs == nil {
return "", fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
bundle := AwardBundle{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Awards: make([]AwardBundleEntry, 0, len(defs)),
}
for _, d := range defs {
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})
}
data, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return "", err
}
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Export awards",
DefaultFilename: "OpsLog_awards_" + time.Now().UTC().Format("20060102_150405") + ".json",
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) {
var res AwardImportResult
if a.awardRefs == nil || a.settings == nil {
return res, fmt.Errorf("db not initialized")
}
path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Import awards",
Filters: []wruntime.FileFilter{
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
if err != nil || path == "" {
return res, err
}
data, err := os.ReadFile(path)
if err != nil {
return res, fmt.Errorf("read %s: %w", path, err)
}
var bundle AwardBundle
if err := json.Unmarshal(data, &bundle); err != nil {
return res, fmt.Errorf("parse award bundle: %w", err)
}
if len(bundle.Awards) == 0 {
return res, fmt.Errorf("no awards in file")
}
// Merge definitions: upsert by code (imported wins), keep the rest.
defs := a.awardDefs()
byCode := map[string]int{}
for i, d := range defs {
byCode[strings.ToUpper(d.Code)] = i
}
for _, e := range bundle.Awards {
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
if code == "" {
continue
}
if i, ok := byCode[code]; ok {
defs[i] = e.Def
} else {
byCode[code] = len(defs)
defs = append(defs, e.Def)
}
res.Awards++
}
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)
}
// Replace reference lists for entries that carry them (skip empty so we
// don't wipe built-in-seeded lists for a def exported without refs).
for _, e := range bundle.Awards {
if len(e.References) == 0 {
continue
}
n, err := a.ReplaceAwardReferences(e.Def.Code, e.References)
if err != nil {
return res, fmt.Errorf("import references for %s: %w", e.Def.Code, err)
}
res.References += n
}
return res, nil
}
// 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): seed only awards that have NO references
// yet, so an online-loaded list (POTA…) or a user list isn't clobbered.
// - 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
}
ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
if ver == builtinRefsVersion {
return
}
firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
if ver == "1" {
firstRun = false // already seeded once → treat as a version upgrade
}
counts, err := a.awardRefs.Counts(a.ctx)
if err != nil {
return
}
for _, d := range a.awardDefs() {
code := strings.ToUpper(d.Code)
if firstRun && counts[code] > 0 {
continue // don't overwrite an existing list on a fresh install
}
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
}
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)
}
// 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
}
// 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 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",
// 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
}
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)
}
}
}
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)
}
// --- 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) {
return lookup.Result{}, fmt.Errorf("%s reachable but %q not found (creds look OK)", name, callsign)
}
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, 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",
PollMs: 250,
DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault],
}
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = 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.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"
}
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,
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.
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(qc.Email) != "" {
_ = a.sendRecordingEmail(qc, path)
}
}()
}
// 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()
}
// 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() }
// ── 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)
}
// 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)
}
return a.sendRecordingEmail(q, path)
}
// ── 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 {
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
}
gen := a.pttGenNow()
go func() { time.Sleep(600 * time.Millisecond); 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()
}
}
// 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.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, 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.ListForUpload(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))
}
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
return
}
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)
}
keyIDs, _ := a.qso.DedupeKeyIDs(ctx)
// 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
}
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)
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
id, found := keyIDs[key]
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 newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = 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))
// (last-download date already stored right after the fetch above)
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:
owner = cfg.EQSL.Username
}
owner = strings.ToUpper(strings.TrimSpace(owner))
if owner == "" && a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
owner = strings.ToUpper(strings.TrimSpace(p.Callsign))
}
}
return owner
}
// UploadCallsign exposes uploadOwnerCall to the UI so the QSL Manager can show
// which of the operator's callsigns a download/upload targets in this profile.
func (a *App) UploadCallsign(service string) string {
return a.uploadOwnerCall(extsvc.Service(service))
}
// closeUploadIDs returns the QSO ids to upload to a service at app close,
// scanning the whole logbook: LoTW matches the configured sent-status set
// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an
// imported ADIF (old QSOs still unsent) flush on close.
func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
if a.qso == nil {
return nil
}
col := uploadColumnFor(string(svc))
if col == "" {
return nil
}
// owner is the callsign this logbook signs/uploads as. Each external
// logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q)
// we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would
// be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log
// logbook.
var statuses []string
if svc == extsvc.ServiceLoTW {
statuses = a.loadExternalServices().LoTW.UploadFlags
if len(statuses) == 0 {
return nil
}
}
owner := a.uploadOwnerCall(svc)
cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses)
if err != nil {
applog.Printf("extsvc: close-upload candidate scan for %s failed: %v", svc, err)
return nil
}
out := make([]int64, 0, len(cands))
skipped := 0
for _, c := range cands {
// Keep QSOs that belong to this logbook's call. A blank STATION_CALLSIGN
// is assumed to be ours (it gets signed/labelled as owner on upload),
// mirroring the per-QSO guard in extsvc.upload.
if owner == "" || c.StationCallsign == "" || extsvc.SameBaseCall(c.StationCallsign, owner) {
out = append(out, c.ID)
} else {
skipped++
}
}
if skipped > 0 {
applog.Printf("extsvc: %s close-upload skipped %d QSO(s) not matching logbook callsign %q", svc, skipped, owner)
}
return out
}
// extShouldUpload reports whether a QSO is eligible for upload to a service,
// 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
}
for ev := range a.udp.Events() {
if a.ctx == nil {
continue
}
switch {
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")
}
err := a.cat.SetFrequency(hz)
if err != nil {
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
}
return err
}
// 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
}
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) 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) FlexSetCWSpeed(wpm int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
}
func (a *App) FlexSetCWPitch(hz int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWPitch(hz) })
}
func (a *App) FlexSetCWBreakInDelay(ms int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWBreakInDelay(ms) })
}
func (a *App) FlexSetCWSidetone(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSidetone(on) })
}
func (a *App) FlexSetSidetoneLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSidetoneLevel(l) })
}
func (a *App) FlexSetCWFilter(bw int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWFilter(bw) })
}
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
// 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
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)
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"
}
// ── Ultrabeam antenna (TCP) ────────────────────────────────────────────
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel.
type UltrabeamSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
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 Ultrabeam config with defaults.
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out := UltrabeamSettings{Port: 23, 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)
if err != nil {
return out, err
}
out.Enabled = m[keyUltrabeamEnabled] == "1"
out.Host = m[keyUltrabeamHost]
if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 {
out.Port = p
}
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
}
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),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.startUltrabeam()
return nil
}
// 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.ultrabeam != nil {
a.ultrabeam.Stop()
a.ultrabeam = nil
}
s, err := a.GetUltrabeamSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
return
}
a.ultrabeam = ultrabeam.New(s.Host, s.Port)
_ = a.ultrabeam.Start()
if s.Follow {
stop := make(chan struct{})
a.ubFollowStop = stop
go a.ultrabeamFollowLoop(a.ultrabeam, 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 *ultrabeam.Client, stepKHz int, stop <-chan struct{}) {
if stepKHz <= 0 {
stepKHz = 50
}
ticker := time.NewTicker(1500 * time.Millisecond)
defer ticker.Stop()
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, err := c.GetStatus()
if err != nil || st == nil || !st.Connected {
continue
}
rigKHz := int(rs.FreqHz / 1000)
// 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
}
}
diff := rigKHz - st.Frequency
if diff < 0 {
diff = -diff
}
if st.Frequency > 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)", rigKHz, st.Direction)
}
}
}
}
// 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.ultrabeam == nil {
return out
}
st, err := a.ultrabeam.GetStatus()
if err != nil || st == nil {
return out
}
out.Connected = st.Connected
out.Direction = st.Direction
out.Frequency = st.Frequency
out.Band = st.Band
out.Moving = st.MotorsMoving != 0
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.ultrabeam == nil {
return fmt.Errorf("Ultrabeam not connected — enable it in Settings → Antenna")
}
if direction < 0 || direction > 2 {
return fmt.Errorf("invalid direction %d", direction)
}
// The device has no standalone direction command: it re-issues the current
// frequency with the new direction byte. If the antenna hasn't reported a
// frequency yet (just connected / remote link still settling), fall back to
// the rig's current CAT frequency so the control still works.
st, _ := a.ultrabeam.GetStatus()
if (st == nil || st.Frequency <= 0) && a.cat != nil {
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
return a.ultrabeam.SetFrequency(int(rs.FreqHz/1000), direction)
}
}
return a.ultrabeam.SetDirection(direction)
}
// UltrabeamRetract retracts all elements (storage / safe position).
func (a *App) UltrabeamRetract() error {
if a.ultrabeam == nil {
return fmt.Errorf("Ultrabeam not connected")
}
return a.ultrabeam.Retract()
}
// TestUltrabeam opens a one-shot TCP connection and reads one status frame to
// verify host/port without disturbing the running poller.
func (a *App) TestUltrabeam(s UltrabeamSettings) error {
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("host required")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
c := ultrabeam.New(s.Host, s.Port)
if err := c.Start(); err != nil {
return err
}
defer c.Stop()
// The poller connects + reads status on its 2s tick; give it a couple of
// cycles to come up, then check we got a live status frame.
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if st, err := c.GetStatus(); err == nil && st != nil && st.Connected {
return nil
}
}
return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
}
// --- 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" | "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-slot" — entity worked on this band but not in this mode
// "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 "new slot" from "worked";
// the safer default is "worked" so we never falsely claim "new".
if out[i].Mode == "" {
out[i].Status = "worked"
continue
}
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
out[i].Status = "new-slot"
continue
}
out[i].Status = "worked"
}
return out
}