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" ) // 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, 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"}) } } 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() } 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 } 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 } // Resolve my zones / lat / lon via cty.dat using the profile's // callsign. The profile only stores the human-friendly fields // (callsign, grid, country name); cty.dat fills the structured // DXCC metadata that the ADIF spec wants for every QSO. 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 (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) 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 } } 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" } 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, } { 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) } // 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 } // 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) } } } 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 header; try treating the whole // payload as a single record by prepending a fake header. err := adif.Parse(strings.NewReader(""+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 }