2825 lines
88 KiB
Go
2825 lines
88 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlog/internal/adif"
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/backup"
|
|
"hamlog/internal/cat"
|
|
"hamlog/internal/cluster"
|
|
"hamlog/internal/db"
|
|
"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/settings"
|
|
|
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// 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"
|
|
|
|
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
|
|
|
|
keyRotatorEnabled = "rotator.enabled"
|
|
keyRotatorHost = "rotator.host"
|
|
keyRotatorPort = "rotator.port"
|
|
keyRotatorHasElevation = "rotator.has_elevation"
|
|
|
|
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"
|
|
|
|
// 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"
|
|
|
|
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
|
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
|
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"
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
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"},
|
|
}
|
|
|
|
// 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
|
|
operating *operating.Repo
|
|
udp *udp.Manager
|
|
udpRepo *udp.Repo
|
|
extsvc *extsvc.Manager
|
|
startupErr string // captured for surfacing to the frontend
|
|
dbPath string
|
|
|
|
// 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) (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 mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
|
|
}
|
|
|
|
func NewApp() *App { return &App{} }
|
|
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
|
|
dataDir, err := userDataDir()
|
|
if err != nil {
|
|
a.startupErr = "cannot resolve data dir: " + err.Error()
|
|
fmt.Println("OpsLog:", a.startupErr)
|
|
return
|
|
}
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
a.startupErr = "cannot create data dir: " + err.Error()
|
|
fmt.Println("OpsLog:", a.startupErr)
|
|
return
|
|
}
|
|
a.dbPath = filepath.Join(dataDir, "opslog.db")
|
|
// One-shot rename for users coming from the HamLog era.
|
|
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)
|
|
}
|
|
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.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")
|
|
}()
|
|
// 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()
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
Logf: applog.Printf,
|
|
})
|
|
a.extsvc.SetConfig(a.loadExternalServices())
|
|
|
|
fmt.Println("OpsLog: db ready at", a.dbPath)
|
|
}
|
|
|
|
// StartupStatus returns a diagnostic snapshot for the frontend.
|
|
// dbPath is always populated; err is empty when the app is healthy.
|
|
type StartupStatus struct {
|
|
OK bool `json:"ok"`
|
|
Err string `json:"err"`
|
|
DBPath string `json:"db_path"`
|
|
}
|
|
|
|
// GetStartupStatus exposes whatever happened during startup so the UI
|
|
// can show a useful error instead of just "db not initialized".
|
|
func (a *App) GetStartupStatus() StartupStatus {
|
|
return StartupStatus{
|
|
OK: a.startupErr == "",
|
|
Err: a.startupErr,
|
|
DBPath: a.dbPath,
|
|
}
|
|
}
|
|
|
|
// beforeClose intercepts the window-close event so we can run shutdown
|
|
// tasks (backup, future LoTW upload, ...) while showing a progress modal
|
|
// to the user. Returns true the first time to block the close; the
|
|
// goroutine eventually calls wruntime.Quit() which re-enters this method
|
|
// with shuttingDown=true and we let the close proceed.
|
|
func (a *App) beforeClose(ctx context.Context) bool {
|
|
if a.shuttingDown {
|
|
return false
|
|
}
|
|
a.shuttingDown = true
|
|
|
|
steps := a.plannedShutdownSteps()
|
|
if len(steps) == 0 {
|
|
// Nothing to do — exit immediately, no need to flash a modal.
|
|
return false
|
|
}
|
|
go a.runShutdownTasks(ctx, steps)
|
|
return true
|
|
}
|
|
|
|
// shutdownStep is emitted to the frontend so the progress modal can
|
|
// render the task list and update each row's state as work progresses.
|
|
type shutdownStep struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Status string `json:"status"` // "pending" | "running" | "done" | "error"
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
// plannedShutdownSteps returns the tasks that will actually run, so the
|
|
// UI knows the full checklist up front. Right now that's just the backup
|
|
// (when enabled and not yet done today); LoTW upload, eQSL upload, etc.
|
|
// will append to this list as they land.
|
|
func (a *App) plannedShutdownSteps() []shutdownStep {
|
|
var out []shutdownStep
|
|
if s, err := a.GetBackupSettings(); err == nil && s.Enabled {
|
|
folder := s.Folder
|
|
if folder == "" {
|
|
folder = s.DefaultFolder
|
|
}
|
|
if !backup.HasBackupToday(folder) {
|
|
out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
|
|
}
|
|
}
|
|
if a.extsvc != nil {
|
|
if n := a.extsvc.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.db != nil {
|
|
_ = a.db.Close()
|
|
}
|
|
}
|
|
|
|
// userDataDir returns the OpsLog data directory under the user's config
|
|
// dir. The app was previously called HamLog — if the old folder exists
|
|
// and the new one doesn't, we rename it atomically so the user keeps
|
|
// their database, settings and cluster history through the rebrand.
|
|
func userDataDir() (string, error) {
|
|
base, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
newDir := filepath.Join(base, "OpsLog")
|
|
oldDir := filepath.Join(base, "HamLog")
|
|
if _, err := os.Stat(newDir); os.IsNotExist(err) {
|
|
if _, err := os.Stat(oldDir); err == nil {
|
|
// One-shot migration: HamLog → OpsLog. Best-effort: on
|
|
// failure we fall through and create OpsLog fresh.
|
|
_ = os.Rename(oldDir, newDir)
|
|
}
|
|
}
|
|
return newDir, nil
|
|
}
|
|
|
|
// reloadLookupProviders rebuilds the lookup chain from current settings.
|
|
// Called at startup and after the user saves new credentials.
|
|
//
|
|
// Provider order honours the user's primary/failsafe choice. If they
|
|
// haven't picked one yet (fresh install), we default to "primary = first
|
|
// provider with creds" so the app still works out of the box.
|
|
func (a *App) reloadLookupProviders() {
|
|
if a.lookup == nil {
|
|
return
|
|
}
|
|
m, err := a.settings.GetMany(a.ctx,
|
|
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
|
|
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
|
|
if err != nil {
|
|
fmt.Println("OpsLog: settings load error:", err)
|
|
return
|
|
}
|
|
if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 {
|
|
a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
|
|
}
|
|
|
|
build := func(name string) lookup.Provider {
|
|
switch name {
|
|
case "qrz":
|
|
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
|
|
return lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword])
|
|
}
|
|
case "hamqth":
|
|
if m[keyHQUser] != "" && m[keyHQPassword] != "" {
|
|
return lookup.NewHamQTH(m[keyHQUser], m[keyHQPassword])
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
primary, failsafe := m[keyLookupPrimary], m[keyLookupFailsafe]
|
|
// Fresh install fallback: prefer QRZ over HamQTH when both creds exist.
|
|
if primary == "" && failsafe == "" {
|
|
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
|
|
primary = "qrz"
|
|
if m[keyHQUser] != "" && m[keyHQPassword] != "" {
|
|
failsafe = "hamqth"
|
|
}
|
|
} else if m[keyHQUser] != "" && m[keyHQPassword] != "" {
|
|
primary = "hamqth"
|
|
}
|
|
}
|
|
|
|
var providers []lookup.Provider
|
|
if p := build(primary); p != nil {
|
|
providers = append(providers, p)
|
|
}
|
|
if failsafe != "" && failsafe != primary {
|
|
if p := build(failsafe); p != nil {
|
|
providers = append(providers, p)
|
|
}
|
|
}
|
|
a.lookup.SetProviders(providers...)
|
|
}
|
|
|
|
// --- QSO bindings ---
|
|
|
|
func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
|
if a.qso == nil {
|
|
return 0, fmt.Errorf("db not initialized")
|
|
}
|
|
a.applyStationDefaults(&q)
|
|
a.applyDXCCNumber(&q)
|
|
a.applyQSLDefaults(&q)
|
|
id, err := a.qso.Add(a.ctx, q)
|
|
if err == nil && 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"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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 = 980, 140
|
|
normalW, normalH = 1400, 900
|
|
normalMinW, normalMinH = 1100, 700
|
|
)
|
|
|
|
func (a *App) SetCompactMode(on bool) {
|
|
if a.ctx == nil {
|
|
return
|
|
}
|
|
if on {
|
|
wruntime.WindowSetMinSize(a.ctx, compactW, compactH)
|
|
wruntime.WindowSetSize(a.ctx, compactW, compactH)
|
|
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
|
|
} else {
|
|
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
|
|
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: "*.*"},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (a *App) ImportADIF(path string, skipDuplicates 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")
|
|
}
|
|
im := &adif.Importer{Repo: a.qso, SkipDuplicates: skipDuplicates}
|
|
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.
|
|
func (a *App) ExportADIF(path string) (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"}
|
|
return ex.ExportFile(a.ctx, path)
|
|
}
|
|
|
|
// --- 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 = ""
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
m, err := a.settings.GetMany(a.ctx,
|
|
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
|
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
|
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
|
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
|
keyQSLDefaultQRZComStatus,
|
|
)
|
|
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]
|
|
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")
|
|
}
|
|
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)),
|
|
} {
|
|
if err := a.settings.Set(a.ctx, k, v); 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 }
|
|
}
|
|
|
|
// ── External services (logbook upload) ─────────────────────────────────
|
|
|
|
// loadExternalServices reads the configured external-service settings.
|
|
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|
var out extsvc.ExternalServices
|
|
if a.settings == nil {
|
|
return out
|
|
}
|
|
m, err := a.settings.GetMany(a.ctx,
|
|
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
|
|
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
|
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
|
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword,
|
|
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
|
keyExtLoTWAutoUpload, keyExtLoTWUploadMode)
|
|
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],
|
|
KeyPassword: m[keyExtLoTWKeyPassword],
|
|
UploadFlag: m[keyExtLoTWUploadFlag],
|
|
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
|
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"
|
|
}
|
|
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),
|
|
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
|
keyExtLoTWUploadFlag: ltFlag,
|
|
keyExtLoTWWriteLog: ltWriteLog,
|
|
keyExtLoTWAutoUpload: ltAuto,
|
|
keyExtLoTWUploadMode: ltMode,
|
|
} {
|
|
if err := a.settings.Set(a.ctx, k, v); 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, ""); 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)})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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:
|
|
return !strings.EqualFold(q.QRZComUploadStatus, "Y")
|
|
case extsvc.ServiceClublog:
|
|
return !strings.EqualFold(q.ClublogUploadStatus, "Y")
|
|
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.
|
|
var record adif.Record
|
|
err := adif.Parse(strings.NewReader(adifText), 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.Parse(strings.NewReader("<EOH>"+adifText), 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 }
|
|
}
|
|
}
|
|
|
|
// ── 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 }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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.applyQSLDefaults(&q)
|
|
|
|
// ── Dedup ──
|
|
// Match by call + minute + band + mode (same key the importer uses).
|
|
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
|
if err == nil {
|
|
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
|
if _, dup := seen[key]; dup {
|
|
return 0, fmt.Errorf("duplicate (already in log)")
|
|
}
|
|
}
|
|
|
|
id, err := a.qso.Add(a.ctx, q)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("insert qso: %w", err)
|
|
}
|
|
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")
|
|
}
|
|
return a.cat.SetFrequency(hz)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
return a.cat.SetMode(mode)
|
|
}
|
|
|
|
// 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 len(out.Bands) == 0 {
|
|
out.Bands = append([]string(nil), defaultBands...)
|
|
}
|
|
if len(out.Modes) == 0 {
|
|
out.Modes = append([]ModePreset(nil), defaultModes...)
|
|
}
|
|
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
|
|
}
|
|
return a.settings.Set(a.ctx, keyListsModes, string(m))
|
|
}
|
|
|
|
// 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()
|
|
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
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// --- 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")
|
|
}
|
|
|
|
// 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
|
|
}
|