Files
OpsLog/app.go
T
2026-06-06 11:59:32 +02:00

6249 lines
198 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"
"sort"
"strconv"
"strings"
"sync"
"time"
"hamlog/internal/adif"
"hamlog/internal/applog"
"hamlog/internal/backup"
"hamlog/internal/audio"
"hamlog/internal/cat"
"hamlog/internal/clublog"
"hamlog/internal/award"
"hamlog/internal/awardref"
"hamlog/internal/cluster"
"hamlog/internal/pota"
"hamlog/internal/db"
"hamlog/internal/email"
"hamlog/internal/extsvc"
"hamlog/internal/integrations/udp"
"hamlog/internal/operating"
"hamlog/internal/dxcc"
"hamlog/internal/lookup"
"hamlog/internal/profile"
"hamlog/internal/qso"
"hamlog/internal/rotator/pst"
"hamlog/internal/winkeyer"
"hamlog/internal/settings"
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" (only one for now)
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
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"
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"
// 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"
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"
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
)
// 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"` // currently always "omnirig"
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
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
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
migratedFromAppData bool // true when we auto-copied AppData on first portable launch
// 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
}
// First-launch migration: if the portable data dir has no database yet,
// copy whatever is in AppData/OpsLog (or AppData/HamLog) so the user
// keeps their log after the switch to fully-portable layout.
if migrated, migrErr := autoMigrateFromAppData(dataDir); migrated {
a.migratedFromAppData = true
if migrErr != nil {
fmt.Println("OpsLog: migration warning:", migrErr)
}
}
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 != "" {
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
applog.Printf("startup: data dir = %s", dataDir)
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
a.qso = qso.NewRepo(conn)
a.settings = settings.NewStore(conn)
a.profiles = profile.NewRepo(conn)
a.awardRefs = awardref.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()
// On first run, copy the legacy single-station settings into a
// "Default" profile so the user's existing config carries over without
// any manual step. Subsequent runs just confirm an active profile.
if _, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{
Callsign: keyStationCallsign,
Operator: keyStationOperator,
MyGrid: keyStationMyGrid,
Country: keyStationCountry,
SOTA: keyStationSOTA,
POTA: keyStationPOTA,
}); err != nil {
fmt.Println("OpsLog: EnsureDefault profile:", err)
}
a.cache = lookup.NewCache(conn, 30*24*time.Hour)
a.lookup = lookup.NewManager(a.cache)
a.reloadLookupProviders()
// 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(context.Background()); 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)
}
},
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,
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 CAT PTT.
if !st.Playing && a.dvkPttKeyed {
a.dvkPttKeyed = false
go a.dvkUnkeyPTT()
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "audio:status", st)
}
})
a.qsoRec = audio.NewRecorder()
a.startQSORecorderIfEnabled()
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"`
MigratedFromAppData bool `json:"migrated_from_app_data"`
}
// 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,
MigratedFromAppData: a.migratedFromAppData,
}
}
// 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.PendingCount(); 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
}
if backup.HasBackupToday(folder) {
return nil
}
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
return err
}
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}
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.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
}
// autoMigrateFromAppData copies existing AppData/OpsLog (or AppData/HamLog)
// data into targetDir the first time the portable layout is used (i.e. when
// targetDir has no database yet). Returns true when a migration was performed.
func autoMigrateFromAppData(targetDir string) (bool, error) {
// Already have a database — nothing to migrate.
if fileExists(filepath.Join(targetDir, "opslog.db")) ||
fileExists(filepath.Join(targetDir, "hamlog.db")) {
return false, nil
}
base, err := os.UserConfigDir()
if err != nil {
return false, nil
}
var srcDir string
for _, name := range []string{"OpsLog", "HamLog"} {
d := filepath.Join(base, name)
if _, err := os.Stat(d); err == nil {
srcDir = d
break
}
}
if srcDir == "" {
return false, nil // fresh install — no AppData to migrate
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return false, err
}
return true, copyDirContents(srcDir, targetDir)
}
// 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 to decide which DB to open.
type dbPointer struct {
DBPath string `json:"db_path"`
}
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
// readDBPointer returns the user-chosen DB path, or "" for the default.
func readDBPointer(dataDir string) string {
b, err := os.ReadFile(dbPointerPath(dataDir))
if err != nil {
return ""
}
var c dbPointer
if json.Unmarshal(b, &c) != nil {
return ""
}
return strings.TrimSpace(c.DBPath)
}
// writeDBPointer persists the chosen DB path ("" resets to default).
func writeDBPointer(dataDir, path string) error {
b, _ := json.MarshalIndent(dbPointer{DBPath: strings.TrimSpace(path)}, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
}
// 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}
}
// 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) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
a.applyStationDefaults(&q)
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)
}
}
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) {
if a.profiles == nil {
return
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return
}
if q.StationCallsign == "" {
q.StationCallsign = p.Callsign
}
if q.Operator == "" {
q.Operator = p.Operator
}
// OWNER_CALLSIGN is a valid ADIF field but not a promoted column, so it
// lives in Extras (exported verbatim, round-trips, and is filterable via
// json_extract). Stamp it from the active profile when set.
if strings.TrimSpace(p.OwnerCallsign) != "" {
if 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)
}
// 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 {
if s, _ := a.settings.Get(a.ctx, keyAwardDefs); 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.Get(a.ctx, keyAwardDefs)
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.Get(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.settings.Set(a.ctx, keyAwardDefsFixed, defsFixVersion)
}
if !changed {
return
}
if b, err := json.Marshal(migrated); err == nil {
_ = a.settings.Set(a.ctx, 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.Set(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")
}
var all []qso.QSO
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
all = append(all, q)
return nil
}); 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
}
// 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)
}
// 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).
func (a *App) SyncPOTAHunterLog(addMissing 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
}
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)}
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 {
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{}{}
res.Updated++
case nonEmptyBest >= 0 && nonEmptyDiff <= nferWindow:
// n-fer: same physical QSO at another park.
all[nonEmptyBest].POTARef += "," + e.Reference
toUpdate[nonEmptyBest] = struct{}{}
res.Updated++
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)
}
}
for i := range toUpdate {
_ = a.qso.Update(a.ctx, all[i])
}
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"}
// 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{}{}}
}
}
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
refs := award.MatchQSO(*def, metas, &q)
if len(refs) == 0 {
return nil
}
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)
}
return nil
})
if err != nil {
return AwardStatsResult{}, err
}
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
}
// 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
}
// 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 {
case "dxcc", "cqz", "ituz", "prefix", "callsign", "state", "cont", "country", "grid":
return true
}
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
}
// 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.settings.Set(a.ctx, 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
}
// 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.settings.Set(a.ctx, 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
}
// 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.settings.Set(a.ctx, 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")
}
return a.qso.Update(a.ctx, q)
}
func (a *App) DeleteQSO(id int64) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
return a.qso.Delete(a.ctx, id)
}
// 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++
}
}
}
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")
}
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) (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 — NO station / confirmation defaults
// are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log);
// stamping them on a historical import would, e.g., flag old QSOs as
// "LoTW requested" and try to re-upload them.
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) if
// ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim.
clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded()
if applyCty {
im.Enrich = func(q *qso.QSO) {
a.enrichContactedFromCtyForce(q)
if clEnabled {
a.applyClublogException(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: "*.*"},
},
})
}
// 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, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil {
return CATSettings{}, err
}
out := CATSettings{
Enabled: m[keyCATEnabled] == "1",
Backend: m[keyCATBackend],
OmniRigNum: 1,
PollMs: 250,
DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault],
}
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.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"
}
if s.DigitalDefault == "" {
s.DigitalDefault = "FT8"
}
for k, v := range map[string]string{
keyCATEnabled: enabled,
keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
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)) {
case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
return true
}
return false
}
func (a *App) saveQSORecording(q *qso.QSO) {
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)
if err := a.qsoRec.SaveQSO(path); err != nil {
applog.Printf("qso-rec: save failed: %v", err)
return
}
applog.Printf("qso-rec: saved %s", path)
// Remember the recording on the QSO so it can be e-mailed later.
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)
}
}
// Auto-send to the correspondent when enabled and an e-mail is known.
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" {
qc := *q
go func() { _ = 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()
}
// 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"`
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, 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]
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),
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, 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++
}
}
}
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.dvkPttKeyed = true
}
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
if a.dvkPttKeyed {
a.dvkPttKeyed = false
go a.dvkUnkeyPTT()
}
return err
}
return nil
}
// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip
// the end of the message.
func (a *App) dvkUnkeyPTT() {
time.Sleep(120 * time.Millisecond)
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 {
return err
}
a.pttMu.Lock()
a.pttKeyedMethod = "cat"
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
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.
func (a *App) TestPTT() error {
cfg, _ := a.GetAudioSettings()
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
return fmt.Errorf("PTT method is None (VOX) — nothing to test")
}
if err := a.pttKey(cfg); err != nil {
return err
}
go func() { time.Sleep(600 * time.Millisecond); a.pttUnkey() }()
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).
func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out := QSLDefaults{}
if a.settings == nil {
return out, nil
}
prefix := ""
if a.profileHasGroup(markerQSL) {
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
}
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, keyExtLoTWWriteLog,
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
keyExtLoTWUsername, keyExtLoTWWebPassword)
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],
UploadFlag: m[keyExtLoTWUploadFlag],
WriteLog: m[keyExtLoTWWriteLog] == "1",
Username: m[keyExtLoTWUsername],
Password: m[keyExtLoTWWebPassword],
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
}
// 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()
}
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")
}
mode := string(extsvc.ModeImmediate)
if cfg.QRZ.UploadMode == extsvc.ModeDelayed {
mode = string(extsvc.ModeDelayed)
}
auto := "0"
if cfg.QRZ.AutoUpload {
auto = "1"
}
clMode := string(extsvc.ModeImmediate)
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
clMode = string(extsvc.ModeDelayed)
}
clAuto := "0"
if cfg.Clublog.AutoUpload {
clAuto = "1"
}
ltMode := string(extsvc.ModeImmediate)
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
ltMode = string(extsvc.ModeDelayed)
}
ltAuto := "0"
if cfg.LoTW.AutoUpload {
ltAuto = "1"
}
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag))
if ltFlag != "N" && ltFlag != "R" {
ltFlag = "R"
}
ltWriteLog := "0"
if cfg.LoTW.WriteLog {
ltWriteLog = "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,
keyExtLoTWUploadFlag: ltFlag,
keyExtLoTWWriteLog: ltWriteLog,
keyExtLoTWAutoUpload: ltAuto,
keyExtLoTWUploadMode: ltMode,
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
keyExtLoTWWebPassword: cfg.LoTW.Password,
} {
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)
}
// ── 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"
}
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 := context.Background()
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 {
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.ServiceClublog:
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.
func (a *App) DownloadConfirmations(service string, addNotFound bool) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
cfg := a.loadExternalServices()
go a.runDownloadConfirmations(svc, cfg, addNotFound)
return nil
}
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool) {
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 := context.Background()
matched, total, added := 0, 0, 0
switch svc {
case extsvc.ServiceLoTW:
since := ""
if a.settings != nil {
// Scoped to the active profile — each identity tracks its own
// LoTW account's last incremental-download date.
since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload)
}
if since != "" {
emit("Downloading LoTW confirmations received since " + since + "…")
} else {
emit("Downloading all LoTW confirmations…")
}
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, since)
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
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++
}
}
// 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))
}
// Remember today so the next pull is incremental (per active profile).
if a.settings != nil {
_ = a.settings.Set(ctx, a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
}
case extsvc.ServiceQRZ:
emit("Fetching QRZ.com logbook…")
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)))
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.db.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
}
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))
default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
}
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++
}
}
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++
}
}
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))
}
// 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.ServiceLoTW:
flag := "R"
if a.settings != nil {
if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil {
if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" {
flag = v
}
}
}
return strings.EqualFold(q.LOTWSent, flag)
}
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")
switch svc {
case extsvc.ServiceQRZ:
if a.qso != nil {
if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err)
}
}
case extsvc.ServiceClublog:
if a.qso != nil {
if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
}
}
case extsvc.ServiceLoTW:
if a.qso != nil {
if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err)
}
}
}
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)
// ── 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 ──
// 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.
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)
}
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
}
path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip)
if err != nil {
return path, err
}
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
return path, nil
}
// 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
}
if backup.HasBackupToday(folder) {
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
}
_ = a.settings.Set(a.ctx, 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
}
// 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)
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))
default:
// Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop()
}
}
// 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
}
a.refreshOperatorGrid()
// Per-profile config follows the active identity: reload the external-
// services manager so uploads now use this profile's accounts, and tell
// the frontend to refresh its settings panels.
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "profile:changed", id)
}
return nil
}
// 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")
}
return a.profiles.Duplicate(a.ctx, id, newName)
}
// --- 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"
}
// --- 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"
out.SerialEcho = m[keyWKSerialEcho] == "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
}
// Pass a cty.dat-backed resolver so the past-QSO map uses the SAME
// entity name we'll compare each spot against. Without it QRZ-stored
// "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW.
resolveEntity := func(callsign string) string {
if a.dxcc == nil {
return ""
}
m, ok := a.dxcc.Lookup(callsign)
if !ok || m.Entity == nil {
return ""
}
return m.Entity.Name
}
entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity)
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
}
country := strings.ToLower(m.Entity.Name)
out[i].Country = m.Entity.Name
out[i].Continent = m.Continent
e, worked := entities[country]
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
}