package main import ( "context" "database/sql" "encoding/json" "errors" "fmt" "math" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "hamlog/internal/adif" "hamlog/internal/applog" "hamlog/internal/backup" "hamlog/internal/audio" "hamlog/internal/cat" "hamlog/internal/clublog" "hamlog/internal/cluster" "hamlog/internal/db" "hamlog/internal/email" "hamlog/internal/extsvc" "hamlog/internal/integrations/udp" "hamlog/internal/operating" "hamlog/internal/dxcc" "hamlog/internal/lookup" "hamlog/internal/profile" "hamlog/internal/qso" "hamlog/internal/rotator/pst" "hamlog/internal/winkeyer" "hamlog/internal/settings" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" "go.bug.st/serial" ) // Setting keys. const ( keyQRZUser = "lookup.qrz.user" keyQRZPassword = "lookup.qrz.password" keyHQUser = "lookup.hamqth.user" keyHQPassword = "lookup.hamqth.password" keyCacheTTL = "lookup.cache.ttl_days" // Provider routing. Each value is a provider name (qrz | hamqth) // or empty to disable that slot. Primary is consulted first; // Failsafe is the fallback when Primary returns not-found or errs. keyLookupPrimary = "lookup.primary" keyLookupFailsafe = "lookup.failsafe" keyLookupImages = "lookup.download_images" // 1 = expose QRZ ImageURL to UI keyStationCallsign = "station.callsign" keyStationOperator = "station.operator" keyStationMyGrid = "station.my_grid" keyStationCountry = "station.my_country" keyStationSOTA = "station.my_sota_ref" keyStationPOTA = "station.my_pota_ref" keyListsBands = "lists.bands" keyListsModes = "lists.modes" keyListsRSTPhone = "lists.rst_phone" keyListsRSTCW = "lists.rst_cw" keyListsRSTDigital = "lists.rst_digital" keyCATEnabled = "cat.enabled" keyCATBackend = "cat.backend" // "omnirig" (only one for now) keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 keyCATPollMs = "cat.poll_ms" keyCATDelayMs = "cat.delay_ms" // pause between commands keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA // Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so // global (not per-profile) like CAT/rotator. Device fields store the // WASAPI endpoint id; the UI resolves it to a friendly name. keyAudioFromRadio = "audio.from_radio" // capture: rig RX audio in keyAudioToRadio = "audio.to_radio" // render: DVK plays into rig keyAudioRecDevice = "audio.rec_device" // capture: your mic (record DVK msgs) keyAudioListenDevice = "audio.listen_device" // render: local preview speakers keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr" keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT keyAudioFormat = "audio.qso_format" // "wav" | "mp3" keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent keyAudioMicGain = "audio.mic_gain" // mic mix level, percent keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions // E-mail / SMTP — send QSO recordings to the correspondent. keyEmailEnabled = "email.enabled" keyEmailHost = "email.smtp_host" keyEmailPort = "email.smtp_port" keyEmailUser = "email.smtp_user" keyEmailPassword = "email.smtp_password" keyEmailFrom = "email.from" keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none" keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password) keyEmailAutoSend = "email.auto_send" // "1" → auto-send recording on log when an e-mail is known keyEmailSubject = "email.subject" keyEmailBody = "email.body" // clublogAppAPIKey is OpsLog's ClubLog API key, also used for the country // file download. Visible in the binary but must not be exposed publicly. clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38" keyRotatorEnabled = "rotator.enabled" keyRotatorHost = "rotator.host" keyRotatorPort = "rotator.port" keyRotatorHasElevation = "rotator.has_elevation" // WinKeyer CW keyer (serial) — Hardware → CW Keyer. keyWKEnabled = "winkeyer.enabled" keyWKPort = "winkeyer.port" keyWKBaud = "winkeyer.baud" keyWKWPM = "winkeyer.wpm" keyWKWeight = "winkeyer.weight" keyWKLeadIn = "winkeyer.lead_in_ms" keyWKTail = "winkeyer.tail_ms" keyWKRatio = "winkeyer.ratio" keyWKFarnsworth = "winkeyer.farnsworth" keyWKSidetone = "winkeyer.sidetone_hz" keyWKMode = "winkeyer.mode" keyWKSwap = "winkeyer.swap" keyWKAutoSpace = "winkeyer.autospace" keyWKUsePTT = "winkeyer.use_ptt" keyWKSerialEcho = "winkeyer.serial_echo" keyWKMacros = "winkeyer.macros" // JSON array of {label,text} keyWKEngine = "winkeyer.engine" // "winkeyer" | "tci" keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start keyBackupEnabled = "backup.enabled" keyBackupFolder = "backup.folder" keyBackupRotation = "backup.rotation" keyBackupZip = "backup.zip" keyBackupLast = "backup.last_at" keyQSLDefaultQSLSent = "qsl.qsl_sent" keyQSLDefaultQSLRcvd = "qsl.qsl_rcvd" keyQSLDefaultLOTWSent = "qsl.lotw_sent" keyQSLDefaultLOTWRcvd = "qsl.lotw_rcvd" keyQSLDefaultEQSLSent = "qsl.eqsl_sent" keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd" keyQSLDefaultClublogStatus = "qsl.clublog_status" keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status" keyQSLDefaultQRZComStatus = "qsl.qrzcom_status" keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed" // External services (logbook upload). QRZ.com first; Clublog / LoTW // will add their own keys under the same extsvc.* prefix. keyExtQRZAPIKey = "extsvc.qrz.api_key" keyExtQRZForceCall = "extsvc.qrz.force_station_callsign" keyExtQRZAutoUpload = "extsvc.qrz.auto_upload" keyExtQRZUploadMode = "extsvc.qrz.upload_mode" keyExtClublogEmail = "extsvc.clublog.email" keyExtClublogPassword = "extsvc.clublog.password" keyExtClublogCallsign = "extsvc.clublog.callsign" keyExtClublogAPIKey = "extsvc.clublog.api_key" keyExtClublogAutoUpload = "extsvc.clublog.auto_upload" keyExtClublogUploadMode = "extsvc.clublog.upload_mode" keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" keyExtLoTWStationLoc = "extsvc.lotw.station_location" keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert) keyExtLoTWKeyPassword = "extsvc.lotw.key_password" keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" keyExtLoTWWriteLog = "extsvc.lotw.write_log" keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload" keyExtLoTWUploadMode = "extsvc.lotw.upload_mode" keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download) keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download) keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull ) // QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload // status fields. Applied to every QSO when the corresponding field is // empty — both manual entry and UDP auto-log. Values are ADIF status // codes: "Y" yes, "N" no, "R" requested, "Q" queued, "I" ignore, "" // (empty) leaves the field untouched. type QSLDefaults struct { QSLSent string `json:"qsl_sent"` QSLRcvd string `json:"qsl_rcvd"` LOTWSent string `json:"lotw_sent"` LOTWRcvd string `json:"lotw_rcvd"` EQSLSent string `json:"eqsl_sent"` EQSLRcvd string `json:"eqsl_rcvd"` ClublogStatus string `json:"clublog_status"` HRDLogStatus string `json:"hrdlog_status"` QRZComStatus string `json:"qrzcom_status"` QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status } // CATSettings is the user-tweakable rig-control configuration. Stored as // individual key/value pairs to keep the settings table flat. type CATSettings struct { Enabled bool `json:"enabled"` Backend string `json:"backend"` // currently always "omnirig" OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot) PollMs int `json:"poll_ms"` // poll interval in ms (default 250) DelayMs int `json:"delay_ms"` // pause between commands (default 0) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) } // ModePreset is a mode entry with default RST values to auto-populate // the entry form when the user picks this mode. type ModePreset struct { Name string `json:"name"` DefaultRSTSent string `json:"default_rst_sent,omitempty"` DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"` } // ListsSettings holds the user-customisable dropdown lists used by the // entry form. Default values match common HF/VHF practice. type ListsSettings struct { Bands []string `json:"bands"` Modes []ModePreset `json:"modes"` RSTPhone []string `json:"rst_phone"` // RS reports for phone modes RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT… } var defaultBands = []string{ "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "70cm", "23cm", } var defaultModes = []ModePreset{ {Name: "SSB", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, {Name: "CW", DefaultRSTSent: "599", DefaultRSTRcvd: "599"}, {Name: "FT8", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"}, {Name: "FT4", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"}, {Name: "RTTY", DefaultRSTSent: "599", DefaultRSTRcvd: "599"}, {Name: "PSK31", DefaultRSTSent: "599", DefaultRSTRcvd: "599"}, {Name: "AM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, {Name: "FM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, {Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, } // Default RST report lists, editable in Settings → Modes. Phone carries the // over-S9 reports (59+10…59+60) plus the full RS grid; CW the full RST grid; // digital the dB reports +30…-30. var defaultRSTPhone = buildPhoneRST() var defaultRSTCW = buildCWRST() var defaultRSTDigital = buildDigitalRST() func buildPhoneRST() []string { out := []string{"59+60", "59+50", "59+40", "59+30", "59+20", "59+10"} for r := 5; r >= 1; r-- { for s := 9; s >= 1; s-- { out = append(out, fmt.Sprintf("%d%d", r, s)) } } return out } func buildCWRST() []string { var out []string for r := 5; r >= 1; r-- { for s := 9; s >= 1; s-- { for t := 9; t >= 1; t-- { out = append(out, fmt.Sprintf("%d%d%d", r, s, t)) } } } return out } func buildDigitalRST() []string { var out []string for db := 30; db >= -30; db-- { sign := "+" if db < 0 { sign = "-" } n := db if n < 0 { n = -n } out = append(out, fmt.Sprintf("%s%02d", sign, n)) } return out } // StationSettings holds the active operator profile. Used to stamp every // new QSO so we don't ask the user to retype it for each contact. // Multi-profile support (portable / SOTA …) will layer on top of this. type StationSettings struct { Callsign string `json:"callsign"` Operator string `json:"operator"` MyGrid string `json:"my_grid"` MyCountry string `json:"my_country"` MySOTARef string `json:"my_sota_ref"` MyPOTARef string `json:"my_pota_ref"` } // LookupSettings is the JSON shape exchanged with the frontend. // Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to // route lookups: primary first, failsafe on not-found / error. type LookupSettings struct { QRZUser string `json:"qrz_user"` QRZPassword string `json:"qrz_password"` HamQTHUser string `json:"hamqth_user"` HamQTHPassword string `json:"hamqth_password"` Primary string `json:"primary"` Failsafe string `json:"failsafe"` DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI CacheTTLDays int `json:"cache_ttl_days"` } // App is the application context bound to the Wails runtime. type App struct { ctx context.Context db *sql.DB qso *qso.Repo settings *settings.Store profiles *profile.Repo lookup *lookup.Manager cache *lookup.Cache cat *cat.Manager dxcc *dxcc.Manager cluster *cluster.Manager operating *operating.Repo udp *udp.Manager udpRepo *udp.Repo extsvc *extsvc.Manager winkeyer *winkeyer.Manager clublog *clublog.Manager audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends pttMu sync.Mutex pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle startupErr string // captured for surfacing to the frontend dbPath string // active database file (may be a user-chosen location) dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while // blocking the window close; the subsequent programmatic Quit() call // must be allowed through. shuttingDown bool // Cached operator location used to compute distance/bearing for // cluster spots. Refreshed on profile activation; zero means // "unknown" and we skip the per-spot computation. opLat float64 opLon float64 opSet bool } // gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the // centre lat/lon in degrees. Returns ok=false on malformed input. func gridToLatLon(grid string) (lat, lon float64, ok bool) { g := strings.ToUpper(strings.TrimSpace(grid)) if len(g) < 4 { return 0, 0, false } A := g[0] - 'A' B := g[1] - 'A' C := g[2] - '0' D := g[3] - '0' if A > 17 || B > 17 || C > 9 || D > 9 { return 0, 0, false } lon = -180 + float64(A)*20 + float64(C)*2 lat = -90 + float64(B)*10 + float64(D)*1 if len(g) >= 6 { E := g[4] - 'A' F := g[5] - 'A' if E <= 23 && F <= 23 { lon += float64(E)*(5.0/60.0) + 2.5/60.0 lat += float64(F)*(2.5/60.0) + 1.25/60.0 return lat, lon, true } } // 4-char locator: aim at the centre of the square. lon += 1 lat += 0.5 return lat, lon, true } // haversineKm returns the great-circle distance between two lat/lon pairs // in kilometres. Standard Haversine, mean Earth radius 6371 km. func haversineKm(lat1, lon1, lat2, lon2 float64) float64 { const R = 6371.0 rad := math.Pi / 180.0 dLat := (lat2 - lat1) * rad dLon := (lon2 - lon1) * rad a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2) c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return R * c } // initialBearingDeg returns the initial great-circle bearing (azimuth) in // degrees [0, 360) from (lat1, lon1) towards (lat2, lon2). This is the // "short path" heading. func initialBearingDeg(lat1, lon1, lat2, lon2 float64) float64 { rad := math.Pi / 180.0 dLon := (lon2 - lon1) * rad y := math.Sin(dLon) * math.Cos(lat2*rad) x := math.Cos(lat1*rad)*math.Sin(lat2*rad) - math.Sin(lat1*rad)*math.Cos(lat2*rad)*math.Cos(dLon) deg := math.Atan2(y, x) / rad if deg < 0 { deg += 360 } return deg } // refreshOperatorGrid reloads the active profile and caches its grid as // lat/lon. Called at startup and after profile activation so the cluster // onSpot callback can compute distance/bearing without hitting the DB // per spot. func (a *App) refreshOperatorGrid() { a.opSet = false if a.profiles == nil || a.ctx == nil { return } p, err := a.profiles.Active(a.ctx) if err != nil { return } lat, lon, ok := gridToLatLon(p.MyGrid) if !ok { return } a.opLat = lat a.opLon = lon a.opSet = true } // dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface // without making the lookup package import dxcc. type dxccAdapter struct{ m *dxcc.Manager } func (a dxccAdapter) Resolve(call string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool) { if a.m == nil { return } mm, found := a.m.Lookup(call) if !found || mm.Entity == nil { return } return dxcc.EntityDXCC(mm.Entity.Name), mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true } func NewApp() *App { return &App{} } func (a *App) startup(ctx context.Context) { a.ctx = ctx dataDir, err := userDataDir() if err != nil { a.startupErr = "cannot resolve data dir: " + err.Error() fmt.Println("OpsLog:", a.startupErr) return } if err := os.MkdirAll(dataDir, 0o755); err != nil { a.startupErr = "cannot create data dir: " + err.Error() fmt.Println("OpsLog:", a.startupErr) return } a.dataDir = dataDir a.dbPath = filepath.Join(dataDir, "opslog.db") usingDefault := true // config.json (in the data dir) may point the database to a user-chosen // location — e.g. another drive or a synced folder, so it survives a // Windows reinstall. It lives OUTSIDE the DB since we must know the path // before opening it. if custom := readDBPointer(dataDir); custom != "" { a.dbPath = custom usingDefault = false } if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil { a.startupErr = "cannot create db folder: " + err.Error() fmt.Println("OpsLog:", a.startupErr) return } // One-shot rename for users coming from the HamLog era (default location only). if usingDefault { if _, err := os.Stat(a.dbPath); os.IsNotExist(err) { oldDB := filepath.Join(dataDir, "hamlog.db") if _, err := os.Stat(oldDB); err == nil { _ = os.Rename(oldDB, a.dbPath) } } } if _, err := applog.Init(dataDir); err != nil { fmt.Println("OpsLog: log init:", err) } // Route CAT/OmniRig debug lines into the unified app log (they used to go // to a separate cat.log in the old HamLog folder, which users couldn't find). cat.LogSink = applog.Printf applog.Printf("startup: data dir = %s", dataDir) conn, err := db.Open(a.dbPath) if err != nil { a.startupErr = "cannot open db: " + err.Error() fmt.Println("OpsLog:", a.startupErr) return } a.db = conn a.qso = qso.NewRepo(conn) a.settings = settings.NewStore(conn) a.profiles = profile.NewRepo(conn) a.operating = operating.NewRepo(conn) a.udpRepo = udp.NewRepo(conn) a.udp = udp.NewManager(a.udpRepo) go a.consumeUDPEvents() // On first run, copy the legacy single-station settings into a // "Default" profile so the user's existing config carries over without // any manual step. Subsequent runs just confirm an active profile. if _, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{ Callsign: keyStationCallsign, Operator: keyStationOperator, MyGrid: keyStationMyGrid, Country: keyStationCountry, SOTA: keyStationSOTA, POTA: keyStationPOTA, }); err != nil { fmt.Println("OpsLog: EnsureDefault profile:", err) } a.cache = lookup.NewCache(conn, 30*24*time.Hour) a.lookup = lookup.NewManager(a.cache) a.reloadLookupProviders() // cty.dat for offline DXCC / country resolution. Cached on disk; first // run downloads it from country-files.com in the background so startup // stays fast even if the network is slow. a.dxcc = dxcc.NewManager(dataDir) a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc}) go func() { if err := a.dxcc.EnsureLoaded(context.Background()); err != nil { fmt.Println("OpsLog: cty.dat unavailable —", err) return } fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities") }() // ClubLog Country File (cty.xml) — date-ranged callsign exceptions that // cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on // demand. Resolution applied only when the user enables it. a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir) go func() { if err := a.clublog.EnsureLoaded(); err == nil { d, n := a.clublog.Info() fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d) } }() // CAT manager: emit pushes state to the frontend via Wails events. a.cat = cat.NewManager(func(s cat.RigState) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cat:state", s) } }) a.reloadCAT() // 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, StationCallOf: a.stationCallOf, Logf: applog.Printf, }) a.extsvc.SetConfig(a.loadExternalServices()) // WinKeyer CW keyer (serial). Created idle; the UI connects on demand. a.winkeyer = winkeyer.NewManager( func(s winkeyer.Status) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "winkeyer:status", s) } }, func(ch string) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "winkeyer:echo", ch) } }, ) // Digital Voice Keyer + QSO recorder (WASAPI). Idle until used. a.audioMgr = audio.NewManager(func() { st := a.dvkStatus() // When a voice message finishes (or is stopped), drop CAT PTT. if !st.Playing && a.dvkPttKeyed { a.dvkPttKeyed = false go a.dvkUnkeyPTT() } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "audio:status", st) } }) a.qsoRec = audio.NewRecorder() a.startQSORecorderIfEnabled() fmt.Println("OpsLog: db ready at", a.dbPath) } // StartupStatus returns a diagnostic snapshot for the frontend. // dbPath is always populated; err is empty when the app is healthy. type StartupStatus struct { OK bool `json:"ok"` Err string `json:"err"` DBPath string `json:"db_path"` } // 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.winkeyer != nil { a.winkeyer.Disconnect() } if a.qsoRec != nil { a.qsoRec.Stop() } if a.db != nil { _ = a.db.Close() } } // userDataDir returns the OpsLog data directory 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 } // ── Database location (config.json pointer) ──────────────────────────── // dbPointer is the tiny bootstrap config stored in the data dir. It must // live outside the database because we read it to decide which DB to open. type dbPointer struct { DBPath string `json:"db_path"` } func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") } // readDBPointer returns the user-chosen DB path, or "" for the default. func readDBPointer(dataDir string) string { b, err := os.ReadFile(dbPointerPath(dataDir)) if err != nil { return "" } var c dbPointer if json.Unmarshal(b, &c) != nil { return "" } return strings.TrimSpace(c.DBPath) } // writeDBPointer persists the chosen DB path ("" resets to default). func writeDBPointer(dataDir, path string) error { b, _ := json.MarshalIndent(dbPointer{DBPath: strings.TrimSpace(path)}, "", " ") return os.WriteFile(dbPointerPath(dataDir), b, 0o644) } // DatabaseSettings describes the active database file for the Settings UI. type DatabaseSettings struct { Path string `json:"path"` DefaultPath string `json:"default_path"` IsCustom bool `json:"is_custom"` } // GetDatabaseSettings returns where the active database lives. func (a *App) GetDatabaseSettings() DatabaseSettings { def := filepath.Join(a.dataDir, "opslog.db") return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def} } // PickOpenDatabase opens a file dialog to choose an existing .db file. func (a *App) PickOpenDatabase() (string, error) { if a.ctx == nil { return "", fmt.Errorf("no app context") } return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ Title: "Open an OpsLog database", DefaultDirectory: filepath.Dir(a.dbPath), Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, }) } // PickSaveDatabase opens a save dialog to choose where to put a copy. func (a *App) PickSaveDatabase() (string, error) { if a.ctx == nil { return "", fmt.Errorf("no app context") } return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ Title: "Save the OpsLog database to…", DefaultFilename: "opslog.db", Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, }) } // OpenDatabase points OpsLog at an existing database file. Takes effect on // the next launch. func (a *App) OpenDatabase(path string) error { path = strings.TrimSpace(path) if path == "" { return fmt.Errorf("no path given") } if _, err := os.Stat(path); err != nil { return fmt.Errorf("database file not found: %w", err) } return writeDBPointer(a.dataDir, path) } // MoveDatabase writes a clean copy of the current database to dest (which // must not exist yet) and switches OpsLog to it on the next launch. Uses // VACUUM INTO so the copy is consistent even with an open WAL. func (a *App) MoveDatabase(dest string) error { dest = strings.TrimSpace(dest) if dest == "" { return fmt.Errorf("no destination given") } if _, err := os.Stat(dest); err == nil { return fmt.Errorf("a file already exists at %s — pick a new name", dest) } if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return fmt.Errorf("create folder: %w", err) } if a.db == nil { return fmt.Errorf("database not open") } // VACUUM INTO takes a string literal; escape single quotes in the path. safe := strings.ReplaceAll(dest, "'", "''") if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil { return fmt.Errorf("copy database: %w", err) } return writeDBPointer(a.dataDir, dest) } // CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and // points OpsLog at it for the next launch. dest must not already exist. func (a *App) CreateDatabase(dest string) error { dest = strings.TrimSpace(dest) if dest == "" { return fmt.Errorf("no path given") } if _, err := os.Stat(dest); err == nil { return fmt.Errorf("a file already exists at %s — pick a new name", dest) } if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return fmt.Errorf("create folder: %w", err) } // db.Open creates the file and runs every migration → ready-to-use schema. conn, err := db.Open(dest) if err != nil { return fmt.Errorf("create database: %w", err) } _ = conn.Close() return writeDBPointer(a.dataDir, dest) } // ResetDatabaseToDefault clears the custom location (back to the data dir). func (a *App) ResetDatabaseToDefault() error { return writeDBPointer(a.dataDir, "") } // GetUIPref / SetUIPref persist portable UI preferences (grid column layout, // widths, sort…) in the DB settings table under a "ui." namespace, so they // travel with the logbook and survive a reinstall — unlike the WebView's // localStorage. Values are opaque JSON blobs owned by the frontend. func (a *App) GetUIPref(key string) (string, error) { if a.settings == nil { return "", nil } return a.settings.Get(a.ctx, "ui."+key) } func (a *App) SetUIPref(key, value string) error { if a.settings == nil { return fmt.Errorf("db not initialized") } return a.settings.Set(a.ctx, "ui."+key, value) } // QuitApp closes OpsLog (used to apply a database change on next launch). func (a *App) QuitApp() { if a.ctx != nil { wruntime.Quit(a.ctx) } } // reloadLookupProviders rebuilds the lookup chain from current settings. // Called at startup and after the user saves new credentials. // // Provider order honours the user's primary/failsafe choice. If they // haven't picked one yet (fresh install), we default to "primary = first // provider with creds" so the app still works out of the box. func (a *App) reloadLookupProviders() { if a.lookup == nil { return } m, err := a.settings.GetMany(a.ctx, keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword, keyCacheTTL, keyLookupPrimary, keyLookupFailsafe) if err != nil { fmt.Println("OpsLog: settings load error:", err) return } if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 { a.cache.SetTTL(time.Duration(days) * 24 * time.Hour) } build := func(name string) lookup.Provider { switch name { case "qrz": if m[keyQRZUser] != "" && m[keyQRZPassword] != "" { return lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword]) } case "hamqth": if m[keyHQUser] != "" && m[keyHQPassword] != "" { return lookup.NewHamQTH(m[keyHQUser], m[keyHQPassword]) } } return nil } primary, failsafe := m[keyLookupPrimary], m[keyLookupFailsafe] // Fresh install fallback: prefer QRZ over HamQTH when both creds exist. if primary == "" && failsafe == "" { if m[keyQRZUser] != "" && m[keyQRZPassword] != "" { primary = "qrz" if m[keyHQUser] != "" && m[keyHQPassword] != "" { failsafe = "hamqth" } } else if m[keyHQUser] != "" && m[keyHQPassword] != "" { primary = "hamqth" } } var providers []lookup.Provider if p := build(primary); p != nil { providers = append(providers, p) } if failsafe != "" && failsafe != primary { if p := build(failsafe); p != nil { providers = append(providers, p) } } a.lookup.SetProviders(providers...) } // --- QSO bindings --- func (a *App) AddQSO(q qso.QSO) (int64, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } a.applyStationDefaults(&q) a.applyDXCCNumber(&q) a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions a.applyQSLDefaults(&q) // Fill the contacted operator's e-mail from the (cached) lookup so the // recording can be auto-sent. Cheap: the entry already looked the call up. if strings.TrimSpace(q.Email) == "" && a.lookup != nil { if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" { q.Email = lr.Email } } id, err := a.qso.Add(a.ctx, q) if err == nil { q.ID = id a.saveQSORecording(&q) if a.extsvc != nil { a.extsvc.OnQSOLogged(id) } } return id, err } // StationInfoComputed bundles the data we resolve live from the // profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone, // lat/lon. Used by the Settings UI to show the "what will be stamped on // each QSO" preview next to the editable fields. type StationInfoComputed struct { Country string `json:"country"` DXCC int `json:"dxcc"` CQZ int `json:"cqz"` ITUZ int `json:"ituz"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } // ListCountries returns the DXCC entity names for the Country picker, so the // user selects from a fixed list instead of typing (avoids typos). Empty // until cty.dat has loaded. func (a *App) ListCountries() []string { if a.dxcc == nil { return nil } return a.dxcc.EntityNames() } // DXCCForCountry returns the ADIF DXCC entity number for a country/entity // name (as listed by ListCountries), or 0 if unknown. The QSO editor uses it // to keep the read-only DXCC field in sync when the user picks a Country. func (a *App) DXCCForCountry(name string) int { return dxcc.EntityDXCC(name) } // 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 = 1240, 158 normalW, normalH = 1400, 900 normalMinW, normalMinH = 1100, 700 // Large enough to never constrain a maximised window on big displays. maxW, maxH = 8000, 6000 ) func (a *App) SetCompactMode(on bool) { if a.ctx == nil { return } if on { // Lock the window to the compact size by pinning min == max. Without // the max pin, dragging the frameless window (esp. across monitors / // DPI boundaries) makes Windows snap it back to a large size. wruntime.WindowSetMinSize(a.ctx, compactW, compactH) wruntime.WindowSetMaxSize(a.ctx, compactW, compactH) wruntime.WindowSetSize(a.ctx, compactW, compactH) wruntime.WindowSetAlwaysOnTop(a.ctx, true) } else { wruntime.WindowSetAlwaysOnTop(a.ctx, false) // Release the lock first (raise the max) before growing back. wruntime.WindowSetMaxSize(a.ctx, maxW, maxH) wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH) wruntime.WindowSetSize(a.ctx, normalW, normalH) } } // DeleteAllQSO wipes every QSO. Returns the number of rows removed. // The frontend MUST gate this behind a strong confirmation prompt. func (a *App) DeleteAllQSO() (int64, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } return a.qso.DeleteAll(a.ctx) } // --- ADIF bindings --- func (a *App) OpenADIFFile() (string, error) { return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ Title: "Import ADIF", Filters: []wruntime.FileFilter{ {DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"}, {DisplayName: "All files (*.*)", Pattern: "*.*"}, }, }) } // ImportADIF imports an ADIF file. dupMode controls how records matching an // existing QSO (same call + UTC-minute + band + mode) are handled: // - "skip" : leave the existing QSO untouched (default, safe) // - "update" : merge the file's non-empty fields onto the existing QSO — // refreshes QSL/confirmation statuses when re-syncing from // Log4OM / LoTW without clobbering fields the file omits // - "all" : insert every record, duplicates included // // applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from // cty.dat for every record, overriding what the file carries — corrects the // wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic // Russia). Everything else in the ADIF is still preserved verbatim. func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.ImportResult, error) { if a.qso == nil { return adif.ImportResult{}, fmt.Errorf("db not initialized") } if path == "" { return adif.ImportResult{}, fmt.Errorf("empty path") } // Import preserves the ADIF verbatim — NO station / confirmation defaults // are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log); // stamping them on a historical import would, e.g., flag old QSOs as // "LoTW requested" and try to re-upload them. im := &adif.Importer{Repo: a.qso} switch dupMode { case "update": im.UpdateDuplicates = true case "all": // insert everything default: // "skip" im.SkipDuplicates = true } // When the user opts to fix countries on import, recompute from cty.dat and // then apply ClubLog's date-ranged exceptions (which take precedence) if // ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim. clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded() if applyCty { im.Enrich = func(q *qso.QSO) { a.enrichContactedFromCtyForce(q) if clEnabled { a.applyClublogException(q, false) } } } im.OnProgress = func(processed, total int) { wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total}) } return im.ImportFile(a.ctx, path) } // SaveADIFFile shows a native Save-As dialog suggesting a timestamped // OpsLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled. func (a *App) SaveADIFFile() (string, error) { suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".adi" return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ Title: "Export ADIF", DefaultFilename: suggested, Filters: []wruntime.FileFilter{ {DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"}, {DisplayName: "All files (*.*)", Pattern: "*.*"}, }, }) } // ExportADIF writes every QSO to the given file path in ADIF 3.1 format. // Streams from DB so memory stays flat even with 100k+ records. // includeAppFields=false → portable standard ADIF (for other loggers); // true → full export keeping OpsLog/app-specific APP_* fields (round-trip). func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) { if a.qso == nil { return adif.ExportResult{}, fmt.Errorf("db not initialized") } if path == "" { return adif.ExportResult{}, fmt.Errorf("empty path") } ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields} return ex.ExportFile(a.ctx, path) } // --- 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 = "" } } // ClubLog exception override (live entry → today's date): for an active // DXpedition the entered call gets the right entity/zones immediately. if a.clublogCtyEnabled() && a.clublog != nil { if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok { r.Country = titleEntity(e.Entity) if e.Cont != "" { r.Continent = e.Cont } if e.ADIF != 0 { r.DXCC = e.ADIF } if e.CQZ != 0 { r.CQZ = e.CQZ } if r.Callsign == "" { r.Callsign = strings.ToUpper(strings.TrimSpace(callsign)) } } } return r, err } // OpenExternalURL opens a URL in the user's default browser. Wails ships // runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon // next to the callsign field, the future Clublog/HamQTH shortcuts, etc. func (a *App) OpenExternalURL(url string) error { url = strings.TrimSpace(url) if url == "" { return fmt.Errorf("empty URL") } if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { return fmt.Errorf("only http(s) URLs allowed, got %q", url) } wruntime.BrowserOpenURL(a.ctx, url) return nil } // GetLookupSettings returns current credentials and cache TTL. func (a *App) GetLookupSettings() (LookupSettings, error) { if a.settings == nil { return LookupSettings{}, fmt.Errorf("db not initialized") } m, err := a.settings.GetMany(a.ctx, keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword, keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages) if err != nil { return LookupSettings{}, err } ttl, _ := strconv.Atoi(m[keyCacheTTL]) if ttl <= 0 { ttl = 30 } return LookupSettings{ QRZUser: m[keyQRZUser], QRZPassword: m[keyQRZPassword], HamQTHUser: m[keyHQUser], HamQTHPassword: m[keyHQPassword], Primary: m[keyLookupPrimary], Failsafe: m[keyLookupFailsafe], DownloadImages: m[keyLookupImages] == "1", CacheTTLDays: ttl, }, nil } // SaveLookupSettings persists credentials and rebuilds the provider chain. func (a *App) SaveLookupSettings(s LookupSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.CacheTTLDays <= 0 { s.CacheTTLDays = 30 } // Reject a primary == failsafe routing combo — would just hit the same // provider twice. Frontend should prevent this but defend in depth. if s.Primary != "" && s.Primary == s.Failsafe { s.Failsafe = "" } for k, v := range map[string]string{ keyQRZUser: s.QRZUser, keyQRZPassword: s.QRZPassword, keyHQUser: s.HamQTHUser, keyHQPassword: s.HamQTHPassword, keyCacheTTL: strconv.Itoa(s.CacheTTLDays), keyLookupPrimary: s.Primary, keyLookupFailsafe: s.Failsafe, keyLookupImages: boolStr(s.DownloadImages), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } a.reloadLookupProviders() return nil } // TestLookupProvider runs a one-shot lookup against a specific provider so // the user can verify credentials before saving. callsign defaults to the // active profile's callsign when empty (handy "test against my own call"). // Returns the result on success or a descriptive error. func (a *App) TestLookupProvider(name, callsign, user, password string) (lookup.Result, error) { if user == "" || password == "" { return lookup.Result{}, fmt.Errorf("user and password required") } if callsign == "" { if a.profiles != nil { if p, err := a.profiles.Active(a.ctx); err == nil { callsign = p.Callsign } } if callsign == "" { callsign = "W1AW" // ARRL HQ — always present in every database } } var p lookup.Provider switch name { case "qrz": p = lookup.NewQRZ(user, password) case "hamqth": p = lookup.NewHamQTH(user, password) default: return lookup.Result{}, fmt.Errorf("unknown provider %q", name) } r, err := p.Lookup(a.ctx, callsign) if errors.Is(err, lookup.ErrNotFound) { return lookup.Result{}, fmt.Errorf("%s reachable but %q not found (creds look OK)", name, callsign) } if err != nil { return lookup.Result{}, err } r.Source = name return r, nil } // --- CAT bindings --- // GetCATSettings returns the stored CAT configuration (defaults applied). func (a *App) GetCATSettings() (CATSettings, error) { if a.settings == nil { return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") } m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) if err != nil { return CATSettings{}, err } out := CATSettings{ Enabled: m[keyCATEnabled] == "1", Backend: m[keyCATBackend], OmniRigNum: 1, PollMs: 250, DelayMs: 0, DigitalDefault: m[keyCATDigitalDefault], } if out.Backend == "" { out.Backend = "omnirig" } if out.DigitalDefault == "" { out.DigitalDefault = "FT8" } if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 { out.OmniRigNum = n } if n, _ := strconv.Atoi(m[keyCATPollMs]); n >= 50 && n <= 2000 { out.PollMs = n } if n, _ := strconv.Atoi(m[keyCATDelayMs]); n >= 0 && n <= 500 { out.DelayMs = n } return out, nil } // SaveCATSettings persists CAT config and restarts the manager accordingly. func (a *App) SaveCATSettings(s CATSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.Backend == "" { s.Backend = "omnirig" } if s.OmniRigNum != 1 && s.OmniRigNum != 2 { s.OmniRigNum = 1 } if s.PollMs < 50 || s.PollMs > 2000 { s.PollMs = 250 } if s.DelayMs < 0 || s.DelayMs > 500 { s.DelayMs = 0 } enabled := "0" if s.Enabled { enabled = "1" } if s.DigitalDefault == "" { s.DigitalDefault = "FT8" } for k, v := range map[string]string{ keyCATEnabled: enabled, keyCATBackend: s.Backend, keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), keyCATPollMs: strconv.Itoa(s.PollMs), keyCATDelayMs: strconv.Itoa(s.DelayMs), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } a.reloadCAT() return nil } // ── Audio (Digital Voice Keyer + QSO recorder) ──────────────────────── // AudioSettings is the machine-local audio config for the voice keyer and // the QSO recorder. type AudioSettings struct { FromRadio string `json:"from_radio"` // capture id: rig RX audio ToRadio string `json:"to_radio"` // render id: into the rig RecordingDevice string `json:"recording_device"` // capture id: your mic ListeningDevice string `json:"listening_device"` // render id: preview QSORecord bool `json:"qso_record"` // auto-record every QSO QSODir string `json:"qso_dir"` // recordings folder PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8) PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr" PTTPort string `json:"ptt_port"` // COM port for serial PTT Format string `json:"format"` // "wav" | "mp3" FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100 MicGain int `json:"mic_gain"` // mic mix level %, default 100 } // ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints // for the device dropdowns. func (a *App) ListAudioInputDevices() ([]audio.Device, error) { return audio.ListInputDevices() } func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.ListOutputDevices() } // GetAudioSettings returns the stored audio config (preroll defaults to 8s). func (a *App) GetAudioSettings() (AudioSettings, error) { out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100} if a.settings == nil { return out, nil } m, err := a.settings.GetMany(a.ctx, keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice, keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat, keyAudioFromGain, keyAudioMicGain) if err != nil { return out, err } if v := m[keyAudioFormat]; v == "mp3" || v == "wav" { out.Format = v } if v := m[keyAudioPTTMethod]; v == "rts" || v == "dtr" || v == "cat" || v == "none" { out.PTTMethod = v } out.PTTPort = m[keyAudioPTTPort] out.FromRadio = m[keyAudioFromRadio] out.ToRadio = m[keyAudioToRadio] out.RecordingDevice = m[keyAudioRecDevice] out.ListeningDevice = m[keyAudioListenDevice] out.QSORecord = m[keyAudioQSORecord] == "1" out.QSODir = m[keyAudioQSODir] if n, _ := strconv.Atoi(m[keyAudioPreroll]); n >= 0 && n <= 60 { if n > 0 { out.PrerollSeconds = n } } if n, _ := strconv.Atoi(m[keyAudioFromGain]); n > 0 && n <= 400 { out.FromGain = n } if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 { out.MicGain = n } return out, nil } // SaveAudioSettings persists the audio config. func (a *App) SaveAudioSettings(s AudioSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.PrerollSeconds < 0 || s.PrerollSeconds > 60 { s.PrerollSeconds = 8 } qr := "0" if s.QSORecord { qr = "1" } pttMethod := s.PTTMethod if pttMethod != "rts" && pttMethod != "dtr" && pttMethod != "cat" { pttMethod = "none" } format := s.Format if format != "mp3" { format = "wav" } if s.FromGain <= 0 || s.FromGain > 400 { s.FromGain = 100 } if s.MicGain <= 0 || s.MicGain > 400 { s.MicGain = 100 } for k, v := range map[string]string{ keyAudioFromRadio: s.FromRadio, keyAudioToRadio: s.ToRadio, keyAudioRecDevice: s.RecordingDevice, keyAudioListenDevice: s.ListeningDevice, keyAudioQSORecord: qr, keyAudioQSODir: strings.TrimSpace(s.QSODir), keyAudioPreroll: strconv.Itoa(s.PrerollSeconds), keyAudioPTTMethod: pttMethod, keyAudioPTTPort: strings.TrimSpace(s.PTTPort), keyAudioFormat: format, keyAudioFromGain: strconv.Itoa(s.FromGain), keyAudioMicGain: strconv.Itoa(s.MicGain), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } // Apply device/preroll/enable changes to the running recorder. a.startQSORecorderIfEnabled() return nil } // PickAudioFolder opens a directory picker for the QSO-recordings folder. func (a *App) PickAudioFolder() (string, error) { if a.ctx == nil { return "", fmt.Errorf("no app context") } cur, _ := a.GetAudioSettings() return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ Title: "Pick a folder for QSO recordings", DefaultDirectory: firstExistingAncestor(cur.QSODir), }) } // ── QSO recorder ────────────────────────────────────────────────────── // startQSORecorderIfEnabled (re)starts the continuous recorder per the current // settings. Safe to call repeatedly — it stops any running instance first. func (a *App) startQSORecorderIfEnabled() { if a.qsoRec == nil { return } a.qsoRec.Stop() cfg, _ := a.GetAudioSettings() if !cfg.QSORecord { return } if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil { applog.Printf("qso-rec: start failed: %v", err) return } fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100 if cfg.FromGain == 0 { fromGain = 1 } if cfg.MicGain == 0 { micGain = 1 } a.qsoRec.SetGains(fromGain, micGain) applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain) } // qsoRecDir returns the configured recordings folder, defaulting to // /Recordings, and ensures it exists. func (a *App) qsoRecDir() string { cfg, _ := a.GetAudioSettings() d := strings.TrimSpace(cfg.QSODir) if d == "" { d = filepath.Join(a.dataDir, "Recordings") } _ = os.MkdirAll(d, 0o755) return d } // saveQSORecording finalises the active recording (if any) into a file named // CALL_BAND_MODE_YYYYMMDD_HHMMSS.ext, stores the filename on the QSO (so it can // be e-mailed later), and auto-sends it to the contacted operator when enabled // and an e-mail is known. Called right after a QSO is inserted (manual + UDP); // q must have its ID set. // recordableMode reports whether a QSO mode is worth an audio recording — // only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no // useful audio, so they are never recorded. func recordableMode(mode string) bool { switch strings.ToUpper(strings.TrimSpace(mode)) { case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW": return true } return false } func (a *App) saveQSORecording(q *qso.QSO) { if a.qsoRec == nil || !a.qsoRec.Active() { return } if !recordableMode(q.Mode) { a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio return } ext := "wav" if cfg, _ := a.GetAudioSettings(); cfg.Format == "mp3" { ext = "mp3" } parts := []string{sanitizeFilename(q.Callsign)} if b := strings.TrimSpace(q.Band); b != "" { parts = append(parts, sanitizeFilename(b)) } if m := strings.TrimSpace(q.Mode); m != "" { parts = append(parts, sanitizeFilename(m)) } parts = append(parts, time.Now().UTC().Format("20060102_150405")) name := strings.Join(parts, "_") + "." + ext path := filepath.Join(a.qsoRecDir(), name) if err := a.qsoRec.SaveQSO(path); err != nil { applog.Printf("qso-rec: save failed: %v", err) return } applog.Printf("qso-rec: saved %s", path) // Remember the recording on the QSO so it can be e-mailed later. if q.ID != 0 { if q.Extras == nil { q.Extras = map[string]string{} } q.Extras["APP_OPSLOG_RECORDING"] = name if err := a.qso.Update(a.ctx, *q); err != nil { applog.Printf("qso-rec: store recording path: %v", err) } } // Auto-send to the correspondent when enabled and an e-mail is known. if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" { qc := *q go func() { _ = a.sendRecordingEmail(qc, path) }() } } // sanitizeFilename makes a callsign safe for a filename (slashes etc.). func sanitizeFilename(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) if s == "" { s = "QSO" } repl := func(r rune) rune { switch r { case '/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ': return '_' } return r } return strings.Map(repl, s) } // QSOAudioBegin starts accumulating a QSO recording (seeded with the pre-roll). // Called by the entry strip when a callsign is first entered. // QSOAudioBegin starts accumulating a recording for the current QSO. It // returns true when a recording is actually running (recorder enabled and // capturing), so the UI can show a "REC" indicator. func (a *App) QSOAudioBegin() bool { if a.qsoRec == nil { return false } a.qsoRec.BeginQSO() return a.qsoRec.Active() } // QSOAudioCancel drops the in-progress recording (callsign cleared, QSO // abandoned without logging). func (a *App) QSOAudioCancel() { if a.qsoRec != nil { a.qsoRec.DiscardQSO() } } // RestartQSORecorder applies new audio settings to the running recorder. func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() } // ── E-mail / SMTP (send QSO recordings) ─────────────────────────────── const ( defaultEmailSubject = "Our QSO recording — {CALL}" defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}" ) // EmailSettings is the user's SMTP config + auto-send + message templates. type EmailSettings struct { Enabled bool `json:"enabled"` Host string `json:"smtp_host"` Port int `json:"smtp_port"` User string `json:"smtp_user"` Password string `json:"smtp_password"` From string `json:"from"` Encryption string `json:"encryption"` // "ssl" | "starttls" | "none" Auth bool `json:"auth"` // SMTP requires authorization AutoSend bool `json:"auto_send"` Subject string `json:"subject"` Body string `json:"body"` } // GetEmailSettings returns the stored SMTP config (with sensible defaults). func (a *App) GetEmailSettings() (EmailSettings, error) { out := EmailSettings{Port: 587, Encryption: "starttls", Auth: true, Subject: defaultEmailSubject, Body: defaultEmailBody} if a.settings == nil { return out, nil } m, err := a.settings.GetMany(a.ctx, keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword, keyEmailFrom, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody) if err != nil { return out, err } out.Enabled = m[keyEmailEnabled] == "1" out.Host = m[keyEmailHost] if p, _ := strconv.Atoi(m[keyEmailPort]); p > 0 { out.Port = p } out.User = m[keyEmailUser] out.Password = m[keyEmailPassword] out.From = m[keyEmailFrom] if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" { out.Encryption = e } out.Auth = m[keyEmailAuth] != "0" // default true (unset → required) out.AutoSend = m[keyEmailAutoSend] == "1" if s := m[keyEmailSubject]; s != "" { out.Subject = s } if b := m[keyEmailBody]; b != "" { out.Body = b } return out, nil } // SaveEmailSettings persists the SMTP config. func (a *App) SaveEmailSettings(s EmailSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } enc := s.Encryption if enc != "ssl" && enc != "none" { enc = "starttls" } if s.Port <= 0 { s.Port = 587 } b2s := func(b bool) string { if b { return "1" } return "0" } for k, v := range map[string]string{ keyEmailEnabled: b2s(s.Enabled), keyEmailHost: strings.TrimSpace(s.Host), keyEmailPort: strconv.Itoa(s.Port), keyEmailUser: strings.TrimSpace(s.User), keyEmailPassword: s.Password, keyEmailFrom: strings.TrimSpace(s.From), keyEmailEncryption: enc, keyEmailAuth: b2s(s.Auth), keyEmailAutoSend: b2s(s.AutoSend), keyEmailSubject: s.Subject, keyEmailBody: s.Body, } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } return nil } func (a *App) emailConfig(s EmailSettings) email.Config { return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, Encryption: s.Encryption, Auth: s.Auth} } // TestEmail sends a test message to `to` (defaults to the From address) to // validate the SMTP configuration. func (a *App) TestEmail(to string) error { s, _ := a.GetEmailSettings() if to == "" { to = s.From } if to == "" { to = s.User } return email.Send(a.emailConfig(s), to, "OpsLog SMTP test", "This is a test message from OpsLog — your SMTP settings work. 73", "") } // fillTemplate substitutes {CALL} {DATE} {BAND} {MODE} {MYCALL} in a string. func (a *App) fillTemplate(tmpl string, q qso.QSO) string { myCall := "" if a.profiles != nil { if p, err := a.profiles.Active(a.ctx); err == nil { myCall = p.Callsign } } r := strings.NewReplacer( "{CALL}", q.Callsign, "{DATE}", q.QSODate.UTC().Format("2006-01-02 15:04 UTC"), "{BAND}", q.Band, "{MODE}", q.Mode, "{MYCALL}", myCall, ) return r.Replace(tmpl) } // sendRecordingEmail e-mails a QSO recording to the contacted operator. func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error { s, _ := a.GetEmailSettings() to := strings.TrimSpace(q.Email) if to == "" { return fmt.Errorf("no e-mail address for %s", q.Callsign) } subject := s.Subject if subject == "" { subject = defaultEmailSubject } body := s.Body if body == "" { body = defaultEmailBody } err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), attachPath) if err != nil { applog.Printf("email: send recording to %s failed: %v", to, err) } else { applog.Printf("email: recording sent to %s (%s)", to, q.Callsign) } return err } // SendQSORecordingEmail e-mails the stored recording for a QSO id (right-click // "Send recording by e-mail"). Errors if the QSO has no recording or e-mail. func (a *App) SendQSORecordingEmail(id int64) error { if a.qso == nil { return fmt.Errorf("db not initialized") } q, err := a.qso.GetByID(a.ctx, id) if err != nil { return err } name := "" if q.Extras != nil { name = q.Extras["APP_OPSLOG_RECORDING"] } if name == "" { return fmt.Errorf("no recording stored for this QSO") } path := filepath.Join(a.qsoRecDir(), name) if _, e := os.Stat(path); e != nil { return fmt.Errorf("recording file missing: %s", name) } return a.sendRecordingEmail(q, path) } // ── ClubLog Country File (cty.xml) exceptions ───────────────────────── // ClublogCtyInfo is the UI status of the ClubLog exception data. type ClublogCtyInfo struct { Enabled bool `json:"enabled"` Loaded bool `json:"loaded"` Date string `json:"date"` Count int `json:"count"` } func (a *App) clublogCtyEnabled() bool { if a.settings == nil { return false } v, _ := a.settings.Get(a.ctx, keyClublogCtyEnabled) return v == "1" } // GetClublogCtyInfo returns the current ClubLog exception status. func (a *App) GetClublogCtyInfo() ClublogCtyInfo { info := ClublogCtyInfo{Enabled: a.clublogCtyEnabled()} if a.clublog != nil { info.Loaded = a.clublog.Loaded() info.Date, info.Count = a.clublog.Info() } return info } // SetClublogCtyEnabled toggles ClubLog exception resolution, loading the cached // file on first enable. func (a *App) SetClublogCtyEnabled(on bool) error { if a.settings == nil { return fmt.Errorf("db not initialized") } v := "0" if on { v = "1" } if err := a.settings.Set(a.ctx, keyClublogCtyEnabled, v); err != nil { return err } if on && a.clublog != nil && !a.clublog.Loaded() { _ = a.clublog.EnsureLoaded() // ok if file not downloaded yet } return nil } // DownloadClublogCty fetches a fresh ClubLog country file. func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) { if a.clublog == nil { return ClublogCtyInfo{}, fmt.Errorf("clublog not initialized") } if err := a.clublog.Download(a.ctx); err != nil { return a.GetClublogCtyInfo(), err } return a.GetClublogCtyInfo(), nil } // applyClublogException overrides a QSO's entity fields from a ClubLog // exception matching its callsign at its date. force=true ignores the // enable toggle (used by the explicit "Update from ClubLog" action). // Returns true if something changed. func (a *App) applyClublogException(q *qso.QSO, force bool) bool { if a.clublog == nil || q.Callsign == "" { return false } if !force && !a.clublogCtyEnabled() { return false } date := q.QSODate if date.IsZero() { date = time.Now().UTC() } e, ok := a.clublog.Resolve(q.Callsign, date) if !ok { return false } q.Country = titleEntity(e.Entity) if e.Cont != "" { q.Continent = e.Cont } if e.ADIF != 0 { n := e.ADIF q.DXCC = &n } if e.CQZ != 0 { v := e.CQZ q.CQZ = &v } if e.Lat != 0 || e.Lon != 0 { lat, lon := e.Lat, e.Lon q.Lat, q.Lon = &lat, &lon } return true } // UpdateQSOsFromClublog re-resolves the selected QSOs against ClubLog // exceptions (by their QSO date) and saves any that changed. func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } if a.clublog == nil || !a.clublog.Loaded() { return 0, fmt.Errorf("ClubLog data not loaded — download it first") } changed := 0 for _, id := range ids { q, err := a.qso.GetByID(a.ctx, id) if err != nil { continue } if a.applyClublogException(&q, true) { if err := a.qso.Update(a.ctx, q); err == nil { changed++ } } } return changed, nil } // titleCaseIfUpper title-cases a string ONLY when it's entirely upper-case // (e.g. Log4OM/contest ADIF sends "SANTO DOMINGO"); mixed-case values are // left untouched. Codes like state "DN" stay as-is (no lower-case letters // to gain, but they're short — callers pick which fields to pass). func titleCaseIfUpper(s string) string { t := strings.TrimSpace(s) if t == "" || t != strings.ToUpper(t) { return s } return titleEntity(t) } // titleEntity converts ClubLog's UPPERCASE entity names to title case // ("LORD HOWE ISLAND" → "Lord Howe Island") for display consistency. func titleEntity(s string) string { s = strings.TrimSpace(s) if s == "" { return s } words := strings.Fields(strings.ToLower(s)) for i, w := range words { r := []rune(w) r[0] = []rune(strings.ToUpper(string(r[0])))[0] words[i] = string(r) } return strings.Join(words, " ") } // ── Digital Voice Keyer (DVK) ───────────────────────────────────────── // // Six voice-message slots (F1–F6, like the WinKeyer macros). Each message is a // WAV file in /dvk/dvk.wav; its label lives in settings. Record via // the configured "Recording mic", transmit via "To Radio", preview via // "Listening". const dvkSlots = 6 // DVKMessage is one voice-keyer slot for the UI. type DVKMessage struct { Slot int `json:"slot"` Label string `json:"label"` HasAudio bool `json:"has_audio"` DurationSec float64 `json:"duration_sec"` } // DVKStatus reflects the live record/playback state for the operating panel. type DVKStatus struct { Recording bool `json:"recording"` Playing bool `json:"playing"` RecSlot int `json:"rec_slot"` } func (a *App) dvkDir() string { d := filepath.Join(a.dataDir, "dvk") _ = os.MkdirAll(d, 0o755) return d } func (a *App) dvkPath(slot int) string { return filepath.Join(a.dvkDir(), fmt.Sprintf("dvk%d.wav", slot)) } func dvkLabelKey(slot int) string { return fmt.Sprintf("audio.dvk.label%d", slot) } func (a *App) dvkStatus() DVKStatus { st := DVKStatus{RecSlot: a.dvkRecSlot} if a.audioMgr != nil { st.Recording = a.audioMgr.IsRecording() st.Playing = a.audioMgr.IsPlaying() } return st } // GetDVKStatus returns the current record/playback state. func (a *App) GetDVKStatus() DVKStatus { return a.dvkStatus() } // GetDVKMessages returns the six voice-keyer slots with their labels, whether // a recording exists, and its duration. func (a *App) GetDVKMessages() []DVKMessage { out := make([]DVKMessage, 0, dvkSlots) for s := 1; s <= dvkSlots; s++ { m := DVKMessage{Slot: s} if a.settings != nil { if v, _ := a.settings.Get(a.ctx, dvkLabelKey(s)); v != "" { m.Label = v } } if fi, err := os.Stat(a.dvkPath(s)); err == nil && fi.Size() > 44 { m.HasAudio = true m.DurationSec = float64(fi.Size()-44) / 32000.0 // 16 kHz mono 16-bit } out = append(out, m) } return out } // SetDVKLabel renames a voice-keyer slot. func (a *App) SetDVKLabel(slot int, label string) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if slot < 1 || slot > dvkSlots { return fmt.Errorf("bad slot") } return a.settings.Set(a.ctx, dvkLabelKey(slot), strings.TrimSpace(label)) } // DVKStartRecord begins recording a voice message into the given slot, using // the configured Recording mic. func (a *App) DVKStartRecord(slot int) error { if a.audioMgr == nil { return fmt.Errorf("audio not initialized") } if slot < 1 || slot > dvkSlots { return fmt.Errorf("bad slot") } cfg, _ := a.GetAudioSettings() a.dvkRecSlot = slot return a.audioMgr.StartRecording(cfg.RecordingDevice) } // DVKStopRecord ends the recording and writes it to the slot's WAV file. func (a *App) DVKStopRecord() error { if a.audioMgr == nil { return fmt.Errorf("audio not initialized") } return a.audioMgr.StopRecording(a.dvkPath(a.dvkRecSlot)) } // DVKCancelRecord aborts a recording without saving. func (a *App) DVKCancelRecord() { if a.audioMgr != nil { a.audioMgr.CancelRecording() } } // DVKPlay transmits a slot's message to the rig ("To Radio"), asserting serial // PTT (RTS/DTR) first unless the operator uses VOX. PTT is released // automatically when playback ends (see the audio status callback). func (a *App) DVKPlay(slot int) error { if a.audioMgr == nil { return fmt.Errorf("audio not initialized") } path := a.dvkPath(slot) if fi, err := os.Stat(path); err != nil || fi.Size() <= 44 { return fmt.Errorf("no recording in slot %d", slot) } cfg, _ := a.GetAudioSettings() if err := a.pttKey(cfg); err != nil { applog.Printf("dvk: PTT on failed: %v", err) // Keep going — the audio still reaches the rig; the user may use VOX. } else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" { a.dvkPttKeyed = true } if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil { if a.dvkPttKeyed { a.dvkPttKeyed = false go a.dvkUnkeyPTT() } return err } return nil } // dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip // the end of the message. func (a *App) dvkUnkeyPTT() { time.Sleep(120 * time.Millisecond) a.pttUnkey() } // pttKey keys the transmitter using the configured method: // - "cat" → OmniRig (sets the Tx parameter to PM_TX) // - "rts"/"dtr" → open the COM port and assert that line, held during TX // - "none" → VOX, nothing to do func (a *App) pttKey(cfg AudioSettings) error { switch cfg.PTTMethod { case "cat": if a.cat == nil { return fmt.Errorf("CAT not initialized") } if err := a.cat.SetPTT(true); err != nil { return err } a.pttMu.Lock() a.pttKeyedMethod = "cat" a.pttMu.Unlock() applog.Printf("dvk: PTT keyed (CAT/OmniRig)") return nil case "rts", "dtr": if strings.TrimSpace(cfg.PTTPort) == "" { return fmt.Errorf("no PTT COM port configured") } a.pttMu.Lock() defer a.pttMu.Unlock() if a.pttPort != nil { return nil // already keyed } port, err := serial.Open(cfg.PTTPort, &serial.Mode{BaudRate: 9600}) if err != nil { return fmt.Errorf("open %s: %w", cfg.PTTPort, err) } var lerr error if cfg.PTTMethod == "rts" { lerr = port.SetRTS(true) _ = port.SetDTR(false) } else { lerr = port.SetDTR(true) _ = port.SetRTS(false) } if lerr != nil { _ = port.Close() return fmt.Errorf("assert %s on %s: %w", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort, lerr) } a.pttPort = port a.pttKeyedMethod = cfg.PTTMethod applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort) return nil } return nil // none / VOX } // pttUnkey releases whichever PTT was keyed (CAT back to RX, or drop the // serial line + close the port). func (a *App) pttUnkey() { a.pttMu.Lock() method := a.pttKeyedMethod a.pttKeyedMethod = "" port := a.pttPort a.pttPort = nil a.pttMu.Unlock() switch method { case "cat": if a.cat != nil { if err := a.cat.SetPTT(false); err != nil { applog.Printf("dvk: PTT off (CAT) failed: %v", err) } } case "rts", "dtr": if port != nil { _ = port.SetRTS(false) _ = port.SetDTR(false) _ = port.Close() } default: return } applog.Printf("dvk: PTT released") } // TestPTT keys PTT for ~600ms so the user can confirm the rig transmits. func (a *App) TestPTT() error { cfg, _ := a.GetAudioSettings() if cfg.PTTMethod == "" || cfg.PTTMethod == "none" { return fmt.Errorf("PTT method is None (VOX) — nothing to test") } if err := a.pttKey(cfg); err != nil { return err } go func() { time.Sleep(600 * time.Millisecond); a.pttUnkey() }() return nil } // DVKPreview plays a slot's message locally on the "Listening" device. func (a *App) DVKPreview(slot int) error { if a.audioMgr == nil { return fmt.Errorf("audio not initialized") } cfg, _ := a.GetAudioSettings() return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot)) } // DVKStop halts any voice-keyer playback. func (a *App) DVKStop() { if a.audioMgr != nil { a.audioMgr.StopPlayback() } } // GetLogFilePath returns where the diagnostic log file lives so the user // can open it from the Settings UI. Empty when applog hasn't initialised. func (a *App) GetLogFilePath() string { return applog.Path() } // ── QSL defaults ────────────────────────────────────────────────────── // GetQSLDefaults returns the stored defaults — empty strings when the // user hasn't configured anything (= leave QSO fields untouched). func (a *App) GetQSLDefaults() (QSLDefaults, error) { out := QSLDefaults{} if a.settings == nil { return out, nil } prefix := "" if a.profileHasGroup(markerQSL) { prefix = a.profileScope() } m, err := a.getManyScoped(prefix, keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd, keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd, keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd, keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus, keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm, ) if err != nil { return out, err } out.QSLSent = m[keyQSLDefaultQSLSent] out.QSLRcvd = m[keyQSLDefaultQSLRcvd] out.LOTWSent = m[keyQSLDefaultLOTWSent] out.LOTWRcvd = m[keyQSLDefaultLOTWRcvd] out.EQSLSent = m[keyQSLDefaultEQSLSent] out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd] out.ClublogStatus = m[keyQSLDefaultClublogStatus] out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus] out.QRZComStatus = m[keyQSLDefaultQRZComStatus] out.QRZComCfm = m[keyQSLDefaultQRZComCfm] return out, nil } // SaveQSLDefaults persists the configured defaults. Future QSO inserts // pick them up automatically — no app restart needed. func (a *App) SaveQSLDefaults(d QSLDefaults) error { if a.settings == nil { return fmt.Errorf("db not initialized") } scope := a.profileScope() for k, v := range map[string]string{ keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)), keyQSLDefaultQSLRcvd: strings.ToUpper(strings.TrimSpace(d.QSLRcvd)), keyQSLDefaultLOTWSent: strings.ToUpper(strings.TrimSpace(d.LOTWSent)), keyQSLDefaultLOTWRcvd: strings.ToUpper(strings.TrimSpace(d.LOTWRcvd)), keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)), keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)), keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)), keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)), keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)), keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)), } { if err := a.settings.Set(a.ctx, scope+k, v); err != nil { return err } } if err := a.settings.Set(a.ctx, scope+markerQSL, "1"); err != nil { return err } return nil } // applyQSLDefaults stamps the user-configured defaults onto a QSO when // the corresponding fields are still empty. Called from every save path // (manual entry via AddQSO, UDP auto-log via LogUDPLoggedADIF) so the // confirmations columns always reflect the user's preferences. func (a *App) applyQSLDefaults(q *qso.QSO) { if a.settings == nil { return } d, err := a.GetQSLDefaults() if err != nil { return } if q.QSLSent == "" { q.QSLSent = d.QSLSent } if q.QSLRcvd == "" { q.QSLRcvd = d.QSLRcvd } if q.LOTWSent == "" { q.LOTWSent = d.LOTWSent } if q.LOTWRcvd == "" { q.LOTWRcvd = d.LOTWRcvd } if q.EQSLSent == "" { q.EQSLSent = d.EQSLSent } if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd } if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus } if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus } if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus } if q.QRZComDownloadStatus == "" { q.QRZComDownloadStatus = d.QRZComCfm } } // ── External services (logbook upload) ───────────────────────────────── // loadExternalServices reads the configured external-service settings. // ── Per-profile settings scoping ─────────────────────────────────────── // // External Services and QSL Confirmations are scoped to the active profile // so each operating identity (e.g. F4BPO vs TM2Q) uploads to its own // accounts. They live under a "p." key prefix. A per-group marker // key records that a profile has saved its own copy; until then we // transparently read the legacy un-prefixed (global) keys as the default — // a lossless migration for logs created before profiles carried settings. const ( markerExtsvc = "extsvc._set" markerQSL = "qsl._set" ) // profileScope returns the active profile's settings-key prefix ("p."). func (a *App) profileScope() string { if a.profiles != nil { if p, err := a.profiles.Active(a.ctx); err == nil && p.ID > 0 { return fmt.Sprintf("p%d.", p.ID) } } return "p0." } // profileHasGroup reports whether the active profile has saved its own copy // of a settings group (identified by its marker key). func (a *App) profileHasGroup(marker string) bool { if a.settings == nil { return false } v, _ := a.settings.Get(a.ctx, a.profileScope()+marker) return v == "1" } // getManyScoped fetches base keys with the given prefix, returning a map // keyed by the BASE key (so callers index with the plain constant). func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, error) { out := make(map[string]string, len(keys)) for _, k := range keys { v, err := a.settings.Get(a.ctx, prefix+k) if err != nil { return nil, err } out[k] = v } return out, nil } func (a *App) loadExternalServices() extsvc.ExternalServices { var out extsvc.ExternalServices if a.settings == nil { return out } // Read the active profile's scoped keys once it has saved them; otherwise // fall back to the legacy global keys as the shared default. prefix := "" if a.profileHasGroup(markerExtsvc) { prefix = a.profileScope() } m, err := a.getManyScoped(prefix, keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode, keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign, keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode, keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword, keyExtLoTWUploadFlag, keyExtLoTWWriteLog, keyExtLoTWAutoUpload, keyExtLoTWUploadMode, keyExtLoTWUsername, keyExtLoTWWebPassword) if err != nil { return out } out.QRZ = extsvc.ServiceConfig{ APIKey: m[keyExtQRZAPIKey], ForceStationCallsign: m[keyExtQRZForceCall], AutoUpload: m[keyExtQRZAutoUpload] == "1", UploadMode: extsvc.UploadMode(m[keyExtQRZUploadMode]), } out.Clublog = extsvc.ServiceConfig{ Email: m[keyExtClublogEmail], Password: m[keyExtClublogPassword], Callsign: m[keyExtClublogCallsign], APIKey: m[keyExtClublogAPIKey], AutoUpload: m[keyExtClublogAutoUpload] == "1", UploadMode: extsvc.UploadMode(m[keyExtClublogUploadMode]), } // Default the Club Log logbook callsign to the active profile's call // when the user hasn't overridden it. if out.Clublog.Callsign == "" && a.profiles != nil { if p, perr := a.profiles.Active(a.ctx); perr == nil { out.Clublog.Callsign = p.Callsign } } out.LoTW = extsvc.ServiceConfig{ TQSLPath: m[keyExtLoTWTQSLPath], StationLocation: m[keyExtLoTWStationLoc], ForceStationCallsign: m[keyExtLoTWForceCall], KeyPassword: m[keyExtLoTWKeyPassword], UploadFlag: m[keyExtLoTWUploadFlag], WriteLog: m[keyExtLoTWWriteLog] == "1", Username: m[keyExtLoTWUsername], Password: m[keyExtLoTWWebPassword], AutoUpload: m[keyExtLoTWAutoUpload] == "1", UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]), } // Default the TQSL path to the standard install location when unset, so // the field is pre-populated if TQSL is present. if out.LoTW.TQSLPath == "" { out.LoTW.TQSLPath = extsvc.DefaultTQSLPath() } return out } // GetExternalServices returns the saved external-service configuration. func (a *App) GetExternalServices() (extsvc.ExternalServices, error) { return a.loadExternalServices(), nil } // SaveExternalServices persists the config and reloads the live manager so // the next logged QSO uses the new settings (no restart needed). func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { if a.settings == nil { return fmt.Errorf("db not initialized") } mode := string(extsvc.ModeImmediate) if cfg.QRZ.UploadMode == extsvc.ModeDelayed { mode = string(extsvc.ModeDelayed) } auto := "0" if cfg.QRZ.AutoUpload { auto = "1" } clMode := string(extsvc.ModeImmediate) if cfg.Clublog.UploadMode == extsvc.ModeDelayed { clMode = string(extsvc.ModeDelayed) } clAuto := "0" if cfg.Clublog.AutoUpload { clAuto = "1" } ltMode := string(extsvc.ModeImmediate) if cfg.LoTW.UploadMode == extsvc.ModeDelayed { ltMode = string(extsvc.ModeDelayed) } ltAuto := "0" if cfg.LoTW.AutoUpload { ltAuto = "1" } ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag)) if ltFlag != "N" && ltFlag != "R" { ltFlag = "R" } ltWriteLog := "0" if cfg.LoTW.WriteLog { ltWriteLog = "1" } scope := a.profileScope() // write under the active profile's prefix for k, v := range map[string]string{ keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey), keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)), keyExtQRZAutoUpload: auto, keyExtQRZUploadMode: mode, keyExtClublogEmail: strings.TrimSpace(cfg.Clublog.Email), keyExtClublogPassword: cfg.Clublog.Password, keyExtClublogCallsign: strings.ToUpper(strings.TrimSpace(cfg.Clublog.Callsign)), keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey), keyExtClublogAutoUpload: clAuto, keyExtClublogUploadMode: clMode, keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath), keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation), keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)), keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword, keyExtLoTWUploadFlag: ltFlag, keyExtLoTWWriteLog: ltWriteLog, keyExtLoTWAutoUpload: ltAuto, keyExtLoTWUploadMode: ltMode, keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username), keyExtLoTWWebPassword: cfg.LoTW.Password, } { if err := a.settings.Set(a.ctx, scope+k, v); err != nil { return err } } // Mark this profile as having its own External Services config (so future // loads read the scoped keys instead of falling back to the global ones). if err := a.settings.Set(a.ctx, scope+markerExtsvc, "1"); err != nil { return err } if a.extsvc != nil { a.extsvc.SetConfig(a.loadExternalServices()) } return nil } // TestQRZUpload validates the configured QRZ key by querying the logbook's // status (ACTION=STATUS). Returns a human-readable message for the UI. func (a *App) TestQRZUpload() (string, error) { cfg := a.loadExternalServices().QRZ return extsvc.TestQRZ(a.ctx, nil, cfg.APIKey) } // TestClublogUpload validates that the Club Log credentials are complete. func (a *App) TestClublogUpload() (string, error) { return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) } // ── QSL Manager (manual upload) ──────────────────────────────────────── // uploadColumnFor maps a service id to its QSO sent-status column. func uploadColumnFor(service string) string { switch extsvc.Service(service) { case extsvc.ServiceQRZ: return "qrzcom_qso_upload_status" case extsvc.ServiceClublog: return "clublog_qso_upload_status" case extsvc.ServiceLoTW: return "lotw_sent" } return "" } // FindQSOsForUpload returns QSOs whose sent status for the given service // matches sentStatus ("" = blank). Powers the QSL Manager's Select required. func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) { if a.qso == nil { return nil, fmt.Errorf("db not initialized") } col := uploadColumnFor(service) if col == "" { return nil, fmt.Errorf("unknown service %q", service) } return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus))) } // UploadQSOsManual uploads the given QSO ids to a service on demand // (regardless of their current sent status — the user picked them). Runs in // the background, emitting "qslmgr:log" lines and a final "qslmgr:done". func (a *App) UploadQSOsManual(service string, ids []int64) error { if a.qso == nil { return fmt.Errorf("db not initialized") } svc := extsvc.Service(service) if uploadColumnFor(service) == "" { return fmt.Errorf("unknown service %q", service) } cfg := a.loadExternalServices() go a.runManualUpload(svc, ids, cfg) return nil } func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) { emit := func(line string) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "qslmgr:log", line) } } ctx := context.Background() uploaded := 0 if svc == extsvc.ServiceLoTW { emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids))) var recs []string for _, id := range ids { if rec, ok := a.buildUploadADIF(id, cfg.LoTW.ForceStationCallsign); ok { recs = append(recs, rec) } } res, err := extsvc.UploadLoTW(ctx, cfg.LoTW, "", strings.Join(recs, "\n")) if err != nil || !res.OK { msg := res.Message if err != nil { msg = err.Error() } emit("LoTW upload failed: " + msg) } else { for _, id := range ids { a.markExtUploaded(svc, id, "") uploaded++ } emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded)) } } else { for _, id := range ids { q, gerr := a.qso.GetByID(ctx, id) call := "" if gerr == nil { call = q.Callsign } force := "" if svc == extsvc.ServiceQRZ { force = cfg.QRZ.ForceStationCallsign } rec, ok := a.buildUploadADIF(id, force) if !ok { emit(call + " — skipped (no record)") continue } var res extsvc.UploadResult var err error switch svc { case extsvc.ServiceQRZ: res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec) case extsvc.ServiceClublog: res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec) } if err == nil && res.OK { a.markExtUploaded(svc, id, "") uploaded++ emit(call + " — OK") } else { msg := res.Message if err != nil { msg = err.Error() } emit(call + " — FAILED: " + msg) } } } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)}) } } // ConfirmationItem is one downloaded confirmation shown in the QSL Manager, // with award-style NEW flags computed against the log's prior confirmations. type ConfirmationItem struct { Callsign string `json:"callsign"` QSODate string `json:"qso_date"` // ISO UTC Band string `json:"band"` Mode string `json:"mode"` Country string `json:"country"` NewDXCC bool `json:"new_dxcc"` NewBand bool `json:"new_band"` NewSlot bool `json:"new_slot"` } // DownloadConfirmations pulls confirmed QSOs from a service and updates the // matching local QSOs' received status. LoTW only for now (the canonical // confirmation system); runs in the background emitting the same // "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window. func (a *App) DownloadConfirmations(service string, addNotFound bool) error { if a.qso == nil { return fmt.Errorf("db not initialized") } svc := extsvc.Service(service) cfg := a.loadExternalServices() go a.runDownloadConfirmations(svc, cfg, addNotFound) return nil } func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool) { emit := func(line string) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "qslmgr:log", line) } } done := func(matched, total int) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total}) } } ctx := context.Background() matched, total, added := 0, 0, 0 switch svc { case extsvc.ServiceLoTW: since := "" if a.settings != nil { // Scoped to the active profile — each identity tracks its own // LoTW account's last incremental-download date. since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload) } if since != "" { emit("Downloading LoTW confirmations received since " + since + "…") } else { emit("Downloading all LoTW confirmations…") } adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, since) if err != nil { emit("Download failed: " + err.Error()) done(matched, total) return } keyIDs, kerr := a.qso.DedupeKeyIDs(ctx) if kerr != nil { emit("Error reading local log: " + kerr.Error()) done(matched, total) return } // Snapshot award-valid confirmations (LoTW + paper QSL — the only two // that count for ARRL awards) so each incoming one is flagged NEW. sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"}) var items []ConfirmationItem perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { q, ok := adif.RecordToQSO(rec) if !ok { return nil } total++ date := rec["qslrdate"] if date == "" { date = time.Now().UTC().Format("20060102") } a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) if id, found := keyIDs[key]; found { if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil { matched++ } } else if addNotFound { q.LOTWSent = "Y" q.LOTWRcvd = "Y" q.LOTWRcvdDate = date if newID, e := a.qso.Add(ctx, q); e == nil { keyIDs[key] = newID // guard against dup records in the report added++ } } // Build the result row + NEW flags (vs the pre-download snapshot), // then fold this slot into the sets so a repeat in the same batch // isn't flagged twice. dxccNum := 0 if q.DXCC != nil { dxccNum = *q.DXCC } it := ConfirmationItem{ Callsign: q.Callsign, QSODate: q.QSODate.UTC().Format(time.RFC3339), Band: q.Band, Mode: q.Mode, Country: q.Country, } if dxccNum != 0 { it.NewDXCC = !sets.DXCC[dxccNum] it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] sets.DXCC[dxccNum] = true sets.Band[qso.BandKey(dxccNum, q.Band)] = true sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true } items = append(items, it) return nil }) if a.ctx != nil { wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items) } if perr != nil { emit("Parse error: " + perr.Error()) } if addNotFound { emit(fmt.Sprintf("Matched %d, added %d (of %d confirmed QSO(s))", matched, added, total)) } else { emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total)) } // Remember today so the next pull is incremental (per active profile). if a.settings != nil { _ = a.settings.Set(ctx, a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02")) } case extsvc.ServiceQRZ: emit("Fetching QRZ.com logbook…") fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL") if err != nil { emit("Fetch failed: " + err.Error()) done(matched, total) return } adifText := fr.ADIF emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText))) if snip := strings.TrimSpace(adifText); snip != "" { if len(snip) > 300 { snip = snip[:300] } emit("ADIF head: " + snip) } keyIDs, _ := a.qso.DedupeKeyIDs(ctx) // QRZ confirmations are QRZ-specific (not award-valid), so NEW is // judged only against other QRZ confirmations. sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"}) // Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED", // without a per-record DB read. alreadyQrz := map[int64]bool{} if rs, e := a.db.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil { for rs.Next() { var id int64 if rs.Scan(&id) == nil { alreadyQrz[id] = true } } rs.Close() } var items []ConfirmationItem parsed := 0 allKeys := map[string]bool{} // union of field names seen, for diagnostics // QRZ FETCH returns headerless ADIF (no ); prepend one so the // parser treats the stream as records. perr := adif.Parse(strings.NewReader("\n"+adifText), func(rec adif.Record) error { parsed++ for k := range rec { allKeys[k] = true } if !qrzRecordConfirmed(rec) { return nil } q, ok := adif.RecordToQSO(rec) if !ok { return nil } total++ date := rec["qrzcom_qso_download_date"] if date == "" { date = time.Now().UTC().Format("20060102") } a.enrichContactedFromCty(&q) line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s", q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode) key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) id, found := keyIDs[key] switch { case found: if alreadyQrz[id] { emit(line + " ### ALREADY CONFIRMED ###") } else if e := a.qso.MarkQRZConfirmed(ctx, id, date); e == nil { alreadyQrz[id] = true matched++ emit(line + " ### UPDATED ###") } case addNotFound: q.QRZComUploadStatus = "Y" q.QRZComDownloadStatus = "Y" q.QRZComDownloadDate = date if newID, e := a.qso.Add(ctx, q); e == nil { keyIDs[key] = newID added++ emit(line + " ### ADDED ###") } default: emit(line + " ### NOT IN LOG ###") } // Result row + NEW flags. dxccNum := 0 if q.DXCC != nil { dxccNum = *q.DXCC } it := ConfirmationItem{ Callsign: q.Callsign, QSODate: q.QSODate.UTC().Format(time.RFC3339), Band: q.Band, Mode: q.Mode, Country: q.Country, } if dxccNum != 0 { it.NewDXCC = !sets.DXCC[dxccNum] it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] sets.DXCC[dxccNum] = true sets.Band[qso.BandKey(dxccNum, q.Band)] = true sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true } items = append(items, it) return nil }) if a.ctx != nil { wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items) } if perr != nil { emit("Parse error: " + perr.Error()) } // Diagnostic: the union of every field name QRZ returned, so we can // pin the confirmation marker against real data. keys := make([]string, 0, len(allKeys)) for k := range allKeys { keys = append(keys, k) } sort.Strings(keys) emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", "))) emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total)) default: emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) } done(matched+added, total) } // qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a // confirmed QSO. QRZ's confirmation marker isn't clearly documented, so we // accept the likely candidates; the download's one-time field dump lets us // pin the exact field against real data and tighten this if needed. func qrzRecordConfirmed(rec adif.Record) bool { if strings.EqualFold(rec["qsl_rcvd"], "Y") { return true } if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") { return true } switch strings.ToUpper(strings.TrimSpace(rec["app_qrzlog_status"])) { case "C", "Y": return true } return false } // enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones // from cty.dat (offline) — used when adding a not-found confirmation that // only carries call/band/mode/date. func (a *App) enrichContactedFromCty(q *qso.QSO) { if a.dxcc == nil || q.Callsign == "" { return } m, ok := a.dxcc.Lookup(q.Callsign) if !ok || m.Entity == nil { return } if q.Country == "" { q.Country = m.Entity.Name } if q.Continent == "" { q.Continent = m.Continent } if q.DXCC == nil { if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 { q.DXCC = &n } } if q.CQZ == nil && m.CQZone != 0 { v := m.CQZone q.CQZ = &v } if q.ITUZ == nil && m.ITUZone != 0 { v := m.ITUZone q.ITUZ = &v } } // enrichContactedFromCtyForce OVERWRITES the contacted-station country, // continent, DXCC number and CQ/ITU zones from cty.dat. Unlike // enrichContactedFromCty (which only fills blanks), this corrects values // that are present-but-wrong — the case where contest software exports a // bad COUNTRY/DXCC (e.g. RG2Y tagged "Asiatic Russia" instead of European). // Returns true if cty.dat had a match. func (a *App) enrichContactedFromCtyForce(q *qso.QSO) bool { if a.dxcc == nil || q.Callsign == "" { return false } m, ok := a.dxcc.Lookup(q.Callsign) if !ok || m.Entity == nil { return false } q.Country = m.Entity.Name q.Continent = m.Continent if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 { q.DXCC = &n } if m.CQZone != 0 { v := m.CQZone q.CQZ = &v } if m.ITUZone != 0 { v := m.ITUZone q.ITUZ = &v } return true } // UpdateQSOsFromCty recomputes country / continent / DXCC / CQ / ITU from // cty.dat for the given QSO ids and saves them. Used by the grid's // right-click "Update from cty.dat" on a multi-selection. Returns how many // rows were actually changed. func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } changed := 0 for _, id := range ids { q, err := a.qso.GetByID(a.ctx, id) if err != nil { continue } before := fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ) if !a.enrichContactedFromCtyForce(&q) { continue } if fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ) == before { continue // no change } if err := a.qso.Update(a.ctx, q); err == nil { changed++ } } return changed, nil } // UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per // the configured providers) for each QSO id and overwrites the geographic // + entity fields (country, continent, DXCC, zones, grid, state, county) // plus name/QTH when the provider returns them. Used by the grid's // right-click "Update from QRZ.com". Returns how many rows were saved. func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) { if a.qso == nil || a.lookup == nil { return 0, fmt.Errorf("not initialized") } changed := 0 for _, id := range ids { q, err := a.qso.GetByID(a.ctx, id) if err != nil || q.Callsign == "" { continue } r, err := a.lookup.Lookup(a.ctx, q.Callsign) if err != nil { continue } if r.Country != "" { q.Country = r.Country } if r.Continent != "" { q.Continent = r.Continent } if r.DXCC != 0 { n := r.DXCC q.DXCC = &n } if r.CQZ != 0 { v := r.CQZ q.CQZ = &v } if r.ITUZ != 0 { v := r.ITUZ q.ITUZ = &v } if r.Grid != "" { q.Grid = strings.ToUpper(r.Grid) } if r.State != "" { q.State = r.State } if r.County != "" { q.County = r.County } if r.Name != "" { q.Name = r.Name } if r.QTH != "" { q.QTH = r.QTH } if err := a.qso.Update(a.ctx, q); err == nil { changed++ } } return changed, nil } // ListTQSLStationLocations returns the Station Locations defined in TQSL, // for the LoTW settings dropdown. func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) { return extsvc.ListStationLocations(extsvc.DefaultStationDataPath()) } // TestLoTWUpload validates the LoTW config (TQSL present + station location // exists). func (a *App) TestLoTWUpload() (string, error) { return extsvc.TestLoTW(a.loadExternalServices().LoTW, extsvc.DefaultStationDataPath()) } // buildUploadADIF builds a single-record ADIF for QSO id, overriding the // station callsign when forceCall is set (QRZ rejects QSOs whose station // call differs from the logbook's registered call). ok=false → skip. func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) { if a.qso == nil { return "", false } q, err := a.qso.GetByID(a.ctx, id) if err != nil { return "", false } if forceCall != "" { q.StationCallsign = forceCall } return adif.SingleRecordADIF(q), true } // stationCallOf returns the QSO's STATION_CALLSIGN (upper-cased), used by the // uploader to verify a QSO belongs to the target logbook's callsign. func (a *App) stationCallOf(id int64) string { if a.qso == nil { return "" } q, err := a.qso.GetByID(a.ctx, id) if err != nil { return "" } return strings.ToUpper(strings.TrimSpace(q.StationCallsign)) } // extShouldUpload reports whether a QSO is eligible for upload to a service, // based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW // uploads only QSOs whose lotw_sent matches the configured Upload flag // ("N" or "R") — the Log4OM rule that must match the Confirmations default. func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool { if a.qso == nil { return false } q, err := a.qso.GetByID(a.ctx, id) if err != nil { return false } switch svc { case extsvc.ServiceQRZ: if strings.EqualFold(q.QRZComUploadStatus, "Y") { applog.Printf("extsvc: QSO %d not eligible for qrz — QRZComUploadStatus already %q (set Confirmations default to N to upload)", id, q.QRZComUploadStatus) return false } return true case extsvc.ServiceClublog: if strings.EqualFold(q.ClublogUploadStatus, "Y") { applog.Printf("extsvc: QSO %d not eligible for clublog — ClublogUploadStatus already %q (set Confirmations default to N to upload)", id, q.ClublogUploadStatus) return false } return true case extsvc.ServiceLoTW: flag := "R" if a.settings != nil { if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil { if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" { flag = v } } } return strings.EqualFold(q.LOTWSent, flag) } return false } // markExtUploaded stamps the per-service upload status on the QSO row and // tells the frontend to refresh that row's confirmation columns. func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { date := time.Now().UTC().Format("20060102") switch svc { case extsvc.ServiceQRZ: if a.qso != nil { if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil { applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err) } } case extsvc.ServiceClublog: if a.qso != nil { if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil { applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err) } } case extsvc.ServiceLoTW: if a.qso != nil { if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil { applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err) } } } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{ "service": string(svc), "qso_id": id, "log_id": logID, }) } } // notifyExtError surfaces a failed upload to the frontend. func (a *App) notifyExtError(svc extsvc.Service, id int64, err error) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "extsvc:error", map[string]any{ "service": string(svc), "qso_id": id, "error": err.Error(), }) } } // ── UDP integrations ─────────────────────────────────────────────────── // ListUDPIntegrations returns every saved UDP connection row. func (a *App) ListUDPIntegrations() ([]udp.Config, error) { if a.udpRepo == nil { return nil, fmt.Errorf("db not initialized") } return a.udpRepo.List(a.ctx) } // SaveUDPIntegration upserts a UDP connection and reloads the manager so // inbound listeners pick up the change without an app restart. Reload // errors are surfaced — a "port already in use" failure should reach the // user rather than be silently dropped. func (a *App) SaveUDPIntegration(c udp.Config) (udp.Config, error) { if a.udpRepo == nil { return c, fmt.Errorf("db not initialized") } if err := a.udpRepo.Save(a.ctx, &c); err != nil { return c, err } if a.udp != nil { errs := a.udp.Reload(a.ctx) if len(errs) > 0 { return c, fmt.Errorf("listener errors: %s", strings.Join(errs, "; ")) } } return c, nil } // DeleteUDPIntegration removes a row and reloads the manager. func (a *App) DeleteUDPIntegration(id int64) error { if a.udpRepo == nil { return fmt.Errorf("db not initialized") } if err := a.udpRepo.Delete(a.ctx, id); err != nil { return err } if a.udp != nil { a.udp.Reload(a.ctx) } return nil } // ReloadUDPIntegrations is a no-arg way for the UI to force a restart // (e.g. after toggling Enabled on a row). func (a *App) ReloadUDPIntegrations() []string { if a.udp == nil { return nil } return a.udp.Reload(a.ctx) } // LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the // first record into the local logbook. Returns the ID of the inserted // row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert / // N1MM — the latter via a synthesised ADIF record from its XML datagram). func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } // Pull the first record out of the payload. WSJT-X / JTDX / MSHV // always send a single QSO per UDP packet (no header) but we tolerate // either form via adif.Parse. // Pick the field decoder for this payload's encoding (UTF-8 as-is, else // Windows-1252) so accented NAME/QTH from Log4OM/JTAlert aren't mangled. // In UTF-8 mode the parser also repairs character-count field lengths. decode := adif.ValueDecoderFor([]byte(adifText)) var record adif.Record err := adif.ParseWithDecoder(strings.NewReader(adifText), decode, func(rec adif.Record) error { if record == nil { record = rec } return nil }) if err != nil { return 0, fmt.Errorf("parse adif: %w", err) } if record == nil { // Some senders skip the header; try treating the whole // payload as a single record by prepending a fake header. err := adif.ParseWithDecoder(strings.NewReader(""+adifText), decode, func(rec adif.Record) error { if record == nil { record = rec } return nil }) if err != nil || record == nil { return 0, fmt.Errorf("no valid QSO record in payload") } } q, ok := adif.RecordToQSO(record) if !ok { return 0, fmt.Errorf("record missing required fields (call/band/mode/date)") } // ── Lookup-based enrichment ── // WSJT sends only call/freq/mode/RST/date. Fill Name/QTH/Country/ // Grid/CQZ/ITUZ/DXCC/Continent via the lookup chain (QRZ/HamQTH/ // cty.dat). Best-effort: a network failure shouldn't block the log. if a.lookup != nil { if lr, lerr := a.lookup.Lookup(a.ctx, q.Callsign); lerr == nil { if q.Name == "" { q.Name = lr.Name } if q.QTH == "" { q.QTH = lr.QTH } if q.Country == "" { q.Country = lr.Country } if q.Grid == "" { q.Grid = lr.Grid } if q.Continent == "" { q.Continent = lr.Continent } if q.State == "" { q.State = lr.State } if q.County == "" { q.County = lr.County } if q.Address == "" { q.Address = lr.Address } if q.Email == "" { q.Email = lr.Email } if q.DXCC == nil && lr.DXCC != 0 { v := lr.DXCC; q.DXCC = &v } if q.CQZ == nil && lr.CQZ != 0 { v := lr.CQZ; q.CQZ = &v } if q.ITUZ == nil && lr.ITUZ != 0 { v := lr.ITUZ; q.ITUZ = &v } if q.Lat == nil && lr.Lat != 0 { v := lr.Lat; q.Lat = &v } if q.Lon == nil && lr.Lon != 0 { v := lr.Lon; q.Lon = &v } } } // ── Name/city normalisation ── // Log4OM / contest loggers often send NAME and QTH in ALL CAPS. Title-case // them so UDP-logged QSOs match the manual + lookup paths ("SANTO DOMINGO" // → "Santo Domingo"). Only all-caps values are touched. q.Name = titleCaseIfUpper(q.Name) q.QTH = titleCaseIfUpper(q.QTH) q.Country = titleCaseIfUpper(q.Country) q.MyCity = titleCaseIfUpper(q.MyCity) // ── Operating-conditions stamp ── // Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for // this band (if the user has configured Operating conditions). if a.operating != nil && a.profiles != nil { if p, err := a.profiles.Active(a.ctx); err == nil { if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 { if q.MyRig == "" { q.MyRig = d.StationName } if q.MyAntenna == "" { q.MyAntenna = d.AntennaName } if q.TXPower == nil && d.TXPower != nil { v := *d.TXPower; q.TXPower = &v } } } } // ── Active-profile station stamp ── // Same as the manual AddQSO path: fill the operator's MY_* fields // (station callsign, grid, country, zones, and the profile's default // MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP / // WSJT-X auto-logged QSO carried none of the operator's own data. a.applyStationDefaults(&q) // ── DXCC# + QSL defaults ── // applyDXCCNumber stamps the contacted-station DXCC# from the // entity-name table; QSL defaults are applied last so explicit ADIF // fields (or what the lookup gave us) always win. a.applyDXCCNumber(&q) a.applyClublogException(&q, false) // date-ranged DXpedition override a.applyQSLDefaults(&q) // ── Dedup ── // Match by call + band + mode within a ±2-minute window: a QSO logged // manually in OpsLog and re-broadcast by Log4OM over UDP often differs by // a minute (the two apps stamp their own time), so a minute-exact key // missed it and the contact got duplicated. seen, err := a.qso.ExistingDedupeKeys(a.ctx) if err == nil { base := q.QSODate.UTC() for d := -2; d <= 2; d++ { min := base.Add(time.Duration(d) * time.Minute).Format("2006-01-02T15:04") if _, dup := seen[qso.DedupeKey(q.Callsign, min, q.Band, q.Mode)]; dup { return 0, fmt.Errorf("duplicate (already in log within ±2 min)") } } } id, err := a.qso.Add(a.ctx, q) if err != nil { return 0, fmt.Errorf("insert qso: %w", err) } q.ID = id a.saveQSORecording(&q) if a.extsvc != nil { a.extsvc.OnQSOLogged(id) } return id, nil } // consumeUDPEvents bridges parsed UDP events to the frontend over Wails' // event bus. The frontend listens on: // udp:dx_call → string callsign (also Grid/Mode/Freq when known) // udp:logged_qso → ADIF text of a QSO that finished in WSJT-X/JTDX/MSHV // udp:remote_call → string callsign from a remote-control source func (a *App) consumeUDPEvents() { if a.udp == nil { return } for ev := range a.udp.Events() { if a.ctx == nil { continue } switch { case ev.LoggedADIF != "": applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF)) wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{ "config_id": ev.ConfigID, "service": string(ev.Service), "source": ev.Source, "adif": ev.LoggedADIF, }) case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall: applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall) wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall) case ev.DXCall != "": applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz) wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{ "call": ev.DXCall, "grid": ev.DXGrid, "mode": ev.Mode, "freq_hz": ev.FreqHz, "service": string(ev.Service), "source": ev.Source, }) } } } // ── Operating conditions ─────────────────────────────────────────────── // ListOperatingTree returns the stations/antennas/bands tree for the // active profile. The UI renders the Settings tree from this. func (a *App) ListOperatingTree() ([]operating.Station, error) { if a.operating == nil || a.profiles == nil { return nil, fmt.Errorf("db not initialized") } p, err := a.profiles.Active(a.ctx) if err != nil { return nil, err } return a.operating.ListTree(a.ctx, p.ID) } // SaveOperatingStation upserts a station. profile_id is set from the // active profile if zero so the frontend doesn't have to know about it. func (a *App) SaveOperatingStation(s operating.Station) (operating.Station, error) { if a.operating == nil || a.profiles == nil { return s, fmt.Errorf("db not initialized") } if s.ProfileID == 0 { p, err := a.profiles.Active(a.ctx) if err != nil { return s, err } s.ProfileID = p.ID } if err := a.operating.SaveStation(a.ctx, &s); err != nil { return s, err } return s, nil } // DeleteOperatingStation cascades to antennas + bands. func (a *App) DeleteOperatingStation(id int64) error { if a.operating == nil { return fmt.Errorf("db not initialized") } return a.operating.DeleteStation(a.ctx, id) } // SaveOperatingAntenna upserts an antenna and replaces its band list. // Setting is_default on a band clears the flag from any other antenna // on the same band within this profile. func (a *App) SaveOperatingAntenna(ant operating.Antenna) (operating.Antenna, error) { if a.operating == nil { return ant, fmt.Errorf("db not initialized") } if err := a.operating.SaveAntenna(a.ctx, &ant); err != nil { return ant, err } return ant, nil } // DeleteOperatingAntenna cascades to bands. func (a *App) DeleteOperatingAntenna(id int64) error { if a.operating == nil { return fmt.Errorf("db not initialized") } return a.operating.DeleteAntenna(a.ctx, id) } // OperatingDefaultForBand returns the (station, antenna) flagged default // for `band` in the active profile. Used by the entry strip to auto-fill // MY_RIG and MY_ANTENNA when the user picks a band. func (a *App) OperatingDefaultForBand(band string) (operating.BandDefault, error) { if a.operating == nil || a.profiles == nil { return operating.BandDefault{}, fmt.Errorf("db not initialized") } p, err := a.profiles.Active(a.ctx) if err != nil { return operating.BandDefault{}, err } d, _, err := a.operating.BandDefault(a.ctx, p.ID, band) return d, err } // ── Backup ────────────────────────────────────────────────────────────── // BackupSettings is the user-tweakable database backup configuration. type BackupSettings struct { Enabled bool `json:"enabled"` Folder string `json:"folder"` Rotation int `json:"rotation"` Zip bool `json:"zip"` LastBackupAt string `json:"last_backup_at"` DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint } // GetBackupSettings returns stored backup config with safe defaults. func (a *App) GetBackupSettings() (BackupSettings, error) { out := BackupSettings{ Rotation: 5, DefaultFolder: backup.DefaultFolder(filepath.Dir(a.dbPath)), } if a.settings == nil { return out, nil } m, err := a.settings.GetMany(a.ctx, keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupLast) if err != nil { return out, err } out.Enabled = m[keyBackupEnabled] == "1" out.Folder = m[keyBackupFolder] if n, _ := strconv.Atoi(m[keyBackupRotation]); n > 0 { out.Rotation = n } out.Zip = m[keyBackupZip] == "1" out.LastBackupAt = m[keyBackupLast] return out, nil } // SaveBackupSettings persists backup config (no immediate backup — // trigger it explicitly with RunBackupNow). func (a *App) SaveBackupSettings(s BackupSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.Rotation <= 0 { s.Rotation = 5 } enabled := "0" if s.Enabled { enabled = "1" } doZip := "0" if s.Zip { doZip = "1" } for k, v := range map[string]string{ keyBackupEnabled: enabled, keyBackupFolder: strings.TrimSpace(s.Folder), keyBackupRotation: strconv.Itoa(s.Rotation), keyBackupZip: doZip, } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } return nil } // RunBackupNow forces an immediate backup using the persisted settings. // Returns the destination path of the file that was written. func (a *App) RunBackupNow() (string, error) { s, err := a.GetBackupSettings() if err != nil { return "", err } folder := s.Folder if folder == "" { folder = s.DefaultFolder } path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip) if err != nil { return path, err } _ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) return path, nil } // maybeShutdownBackup runs a backup at shutdown if the user enabled it // and no backup for today already exists. Running at shutdown (not at // startup) means the snapshot includes the QSOs the user just logged // this session — exactly what we want to protect. Errors are printed // but never block the close. func (a *App) maybeShutdownBackup() { if a.settings == nil || a.db == nil { return } s, err := a.GetBackupSettings() if err != nil || !s.Enabled { return } folder := s.Folder if folder == "" { folder = s.DefaultFolder } if backup.HasBackupToday(folder) { return } if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { fmt.Println("OpsLog: shutdown backup failed:", err) return } _ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) } // PickBackupFolder opens a native directory picker so the user can browse // to a backup target rather than typing the path. Returns the absolute // path (or empty string if the dialog was cancelled). // // Windows' shell dialog refuses to open when DefaultDirectory points at // a path that doesn't exist yet (typical for our default backups folder // on first launch). We walk up the path until we find an existing // ancestor and use that as the dialog's starting point. func (a *App) PickBackupFolder() (string, error) { if a.ctx == nil { return "", fmt.Errorf("no app context") } current, _ := a.GetBackupSettings() defaultDir := current.Folder if defaultDir == "" { defaultDir = current.DefaultFolder } defaultDir = firstExistingAncestor(defaultDir) return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ Title: "Pick a folder for OpsLog backups", DefaultDirectory: defaultDir, }) } // firstExistingAncestor returns p if it exists, otherwise the closest // parent directory that does. Returns "" if nothing valid is found (the // dialog then opens at the OS default location). func firstExistingAncestor(p string) string { p = strings.TrimSpace(p) for p != "" { if st, err := os.Stat(p); err == nil && st.IsDir() { return p } parent := filepath.Dir(p) if parent == p { break } p = parent } return "" } // GetCATState returns the current snapshot from the CAT manager. Used by the // frontend on mount before any cat:state event has been emitted. func (a *App) GetCATState() cat.RigState { if a.cat == nil { return cat.RigState{} } return a.cat.State() } // SetCATFrequency lets the frontend push a freq to the rig (cluster click, // memory recall, …). Returns an error if CAT isn't running or the backend // refuses (out-of-range, etc.). func (a *App) SetCATFrequency(hz int64) error { if a.cat == nil { return fmt.Errorf("cat not initialized") } err := a.cat.SetFrequency(hz) if err != nil { applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err) } return err } // SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are // translated to backend-specific values by the backend itself. func (a *App) SetCATMode(mode string) error { if a.cat == nil { return fmt.Errorf("cat not initialized") } err := a.cat.SetMode(mode) if err != nil { applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err) } return err } // SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without // requiring a trip through the full Settings panel. Persists the choice // so it survives restart. func (a *App) SwitchCATRig(n int) error { if n != 1 && n != 2 { return fmt.Errorf("rig num must be 1 or 2, got %d", n) } if a.settings == nil { return fmt.Errorf("db not initialized") } if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil { return err } a.reloadCAT() return nil } // reloadCAT (re)starts the CAT manager based on the current settings. // Called at startup and after the user saves new CAT config. func (a *App) reloadCAT() { if a.cat == nil { return } s, err := a.GetCATSettings() if err != nil { return } a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond) a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond) if !s.Enabled { a.cat.Stop() return } switch s.Backend { case "omnirig": // No explicit launch — COM auto-activates OmniRig.exe via its // LocalServer32 registration when we CreateObject in Connect(). // Spawning OmniRig.exe ourselves (even with /Embedding) on every // reloadCAT raised the existing instance's window to the front, // which is what Log4OM avoids by relying entirely on COM activation. a.cat.Start(cat.NewOmniRig(s.OmniRigNum)) default: // Unknown backend → stop and emit a dummy state so the UI shows it. a.cat.Stop() } } // ClearLookupCache empties the local callsign cache. func (a *App) ClearLookupCache() error { if a.cache == nil { return fmt.Errorf("cache not initialized") } return a.cache.Clear(a.ctx) } // CtyDatInfo describes the currently-loaded cty.dat file (or zero values // if it hasn't been loaded yet). Exposed for the Maintenance menu so the // user can see what they're working with before triggering a refresh. type CtyDatInfo struct { Path string `json:"path"` Entities int `json:"entities"` LoadedAt string `json:"loaded_at,omitempty"` // RFC3339, "" if not loaded FileModTime string `json:"file_mod_time,omitempty"` // RFC3339, "" if missing } // GetCtyDatInfo returns metadata about the on-disk cty.dat. func (a *App) GetCtyDatInfo() CtyDatInfo { if a.dxcc == nil { return CtyDatInfo{} } src := a.dxcc.Info() out := CtyDatInfo{Path: src.Path, Entities: src.Entities} if !src.LoadedAt.IsZero() { out.LoadedAt = src.LoadedAt.UTC().Format(time.RFC3339) } if !src.FileModTime.IsZero() { out.FileModTime = src.FileModTime.UTC().Format(time.RFC3339) } return out } // RefreshCtyDat re-downloads cty.dat from country-files.com and reloads it // into memory. Synchronous so the UI can show a spinner; ~1s typical. func (a *App) RefreshCtyDat() (CtyDatInfo, error) { if a.dxcc == nil { return CtyDatInfo{}, fmt.Errorf("dxcc manager not initialized") } if err := a.dxcc.Refresh(a.ctx); err != nil { return CtyDatInfo{}, err } return a.GetCtyDatInfo(), nil } // --- Station bindings --- // // GetStationSettings/SaveStationSettings now operate on the **currently // active profile** rather than a flat settings key set. Kept for the // existing topbar/quick-edit code paths; the full profile CRUD lives in // the Profile bindings below. func (a *App) GetStationSettings() (StationSettings, error) { if a.profiles == nil { return StationSettings{}, fmt.Errorf("profiles not initialized") } p, err := a.profiles.Active(a.ctx) if err != nil { return StationSettings{}, err } return StationSettings{ Callsign: p.Callsign, Operator: p.Operator, MyGrid: p.MyGrid, MyCountry: p.MyCountry, MySOTARef: p.MySOTARef, MyPOTARef: p.MyPOTARef, }, nil } // --- Lists bindings (bands + modes with default RST) --- // GetListsSettings returns the user-customisable lists. Defaults are // returned when the user has not customised anything. func (a *App) GetListsSettings() (ListsSettings, error) { if a.settings == nil { return ListsSettings{Bands: defaultBands, Modes: defaultModes}, fmt.Errorf("db not initialized") } out := ListsSettings{} if raw, _ := a.settings.Get(a.ctx, keyListsBands); raw != "" { _ = json.Unmarshal([]byte(raw), &out.Bands) } if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" { _ = json.Unmarshal([]byte(raw), &out.Modes) } if raw, _ := a.settings.Get(a.ctx, keyListsRSTPhone); raw != "" { _ = json.Unmarshal([]byte(raw), &out.RSTPhone) } if raw, _ := a.settings.Get(a.ctx, keyListsRSTCW); raw != "" { _ = json.Unmarshal([]byte(raw), &out.RSTCW) } if raw, _ := a.settings.Get(a.ctx, keyListsRSTDigital); raw != "" { _ = json.Unmarshal([]byte(raw), &out.RSTDigital) } if len(out.Bands) == 0 { out.Bands = append([]string(nil), defaultBands...) } if len(out.Modes) == 0 { out.Modes = append([]ModePreset(nil), defaultModes...) } if len(out.RSTPhone) == 0 { out.RSTPhone = append([]string(nil), defaultRSTPhone...) } if len(out.RSTCW) == 0 { out.RSTCW = append([]string(nil), defaultRSTCW...) } if len(out.RSTDigital) == 0 { out.RSTDigital = append([]string(nil), defaultRSTDigital...) } return out, nil } // SaveListsSettings persists the user-customised lists. func (a *App) SaveListsSettings(l ListsSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } b, err := json.Marshal(l.Bands) if err != nil { return err } if err := a.settings.Set(a.ctx, keyListsBands, string(b)); err != nil { return err } m, err := json.Marshal(l.Modes) if err != nil { return err } if err := a.settings.Set(a.ctx, keyListsModes, string(m)); err != nil { return err } for k, v := range map[string][]string{ keyListsRSTPhone: l.RSTPhone, keyListsRSTCW: l.RSTCW, keyListsRSTDigital: l.RSTDigital, } { b, err := json.Marshal(v) if err != nil { return err } if err := a.settings.Set(a.ctx, k, string(b)); err != nil { return err } } return nil } // SaveStationSettings updates only the six "basic" fields on the active // profile. Use the Profile bindings (ListProfiles / SaveProfile…) for // full multi-profile management. func (a *App) SaveStationSettings(s StationSettings) error { if a.profiles == nil { return fmt.Errorf("profiles not initialized") } p, err := a.profiles.Active(a.ctx) if err != nil { return err } p.Callsign = s.Callsign p.Operator = s.Operator p.MyGrid = s.MyGrid p.MyCountry = s.MyCountry p.MySOTARef = s.MySOTARef p.MyPOTARef = s.MyPOTARef return a.profiles.Save(a.ctx, &p) } // --- Profile bindings (multi-profile CRUD) --- // ListProfiles returns every saved profile, active first. func (a *App) ListProfiles() ([]profile.Profile, error) { if a.profiles == nil { return nil, fmt.Errorf("profiles not initialized") } return a.profiles.List(a.ctx) } // GetActiveProfile returns the currently-selected profile. func (a *App) GetActiveProfile() (profile.Profile, error) { if a.profiles == nil { return profile.Profile{}, fmt.Errorf("profiles not initialized") } return a.profiles.Active(a.ctx) } // SaveProfile upserts a profile. Pass id=0 to create a new one. func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) { if a.profiles == nil { return profile.Profile{}, fmt.Errorf("profiles not initialized") } if err := a.profiles.Save(a.ctx, &p); err != nil { return profile.Profile{}, err } a.refreshOperatorGrid() return p, nil } // DeleteProfile removes a profile. Refuses to delete the last remaining // profile; promotes another to active if the deleted one was selected. func (a *App) DeleteProfile(id int64) error { if a.profiles == nil { return fmt.Errorf("profiles not initialized") } return a.profiles.Delete(a.ctx, id) } // ActivateProfile switches the selected profile. Subsequent QSOs stamp // MY_* fields from this one. func (a *App) ActivateProfile(id int64) error { if a.profiles == nil { return fmt.Errorf("profiles not initialized") } if err := a.profiles.SetActive(a.ctx, id); err != nil { return err } a.refreshOperatorGrid() // Per-profile config follows the active identity: reload the external- // services manager so uploads now use this profile's accounts, and tell // the frontend to refresh its settings panels. if a.extsvc != nil { a.extsvc.SetConfig(a.loadExternalServices()) } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "profile:changed", id) } return nil } // DuplicateProfile clones an existing profile under newName. Useful when // the user has a "Home" profile and wants to derive "Portable" from it // without retyping every field. func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error) { if a.profiles == nil { return profile.Profile{}, fmt.Errorf("profiles not initialized") } return a.profiles.Duplicate(a.ctx, id, newName) } // --- Rotator bindings (PstRotator UDP v0) --- // RotatorSettings is the JSON shape for the Hardware → Rotator panel. type RotatorSettings struct { Enabled bool `json:"enabled"` Host string `json:"host"` // default 127.0.0.1 Port int `json:"port"` // default 12000 HasElevation bool `json:"has_elevation"` // include EL in GoTo packets } // GetRotatorSettings returns the persisted rotator config with defaults. func (a *App) GetRotatorSettings() (RotatorSettings, error) { out := RotatorSettings{Host: "127.0.0.1", Port: 12000} if a.settings == nil { return out, fmt.Errorf("db not initialized") } m, err := a.settings.GetMany(a.ctx, keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation) if err != nil { return out, err } out.Enabled = m[keyRotatorEnabled] == "1" if h := m[keyRotatorHost]; h != "" { out.Host = h } if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 { out.Port = p } out.HasElevation = m[keyRotatorHasElevation] == "1" return out, nil } // SaveRotatorSettings persists the rotator config. Connection is per-call // (UDP, no socket to (re)open) so no reload step is needed. func (a *App) SaveRotatorSettings(s RotatorSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.Host == "" { s.Host = "127.0.0.1" } if s.Port <= 0 || s.Port > 65535 { s.Port = 12000 } for k, v := range map[string]string{ keyRotatorEnabled: boolStr(s.Enabled), keyRotatorHost: s.Host, keyRotatorPort: strconv.Itoa(s.Port), keyRotatorHasElevation: boolStr(s.HasElevation), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } return nil } // rotatorClient returns a fresh PST UDP client built from current settings, // or an error if the rotator is disabled / misconfigured. func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) { s, err := a.GetRotatorSettings() if err != nil { return nil, s, err } if !s.Enabled { return nil, s, fmt.Errorf("rotator disabled in settings") } return pst.New(s.Host, s.Port), s, nil } // RotatorHeading is the live antenna heading for the status bar. type RotatorHeading struct { Enabled bool `json:"enabled"` OK bool `json:"ok"` Azimuth int `json:"azimuth"` Raw string `json:"raw"` } // GetRotatorHeading queries PstRotator for the current azimuth. Returns // Enabled=false when the rotator isn't configured. Polled by the status bar. func (a *App) GetRotatorHeading() RotatorHeading { s, err := a.GetRotatorSettings() if err != nil || !s.Enabled { return RotatorHeading{Enabled: false} } az, raw, herr := pst.New(s.Host, s.Port).Heading() if herr != nil { return RotatorHeading{Enabled: true, OK: false, Raw: raw} } return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw} } // RotatorGoTo points the antenna at the given azimuth (and optional // elevation if the rotator is configured for it). func (a *App) RotatorGoTo(az int, el int) error { c, s, err := a.rotatorClient() if err != nil { return err } return c.GoTo(az, s.HasElevation, el) } // RotatorStop interrupts any in-progress rotation. func (a *App) RotatorStop() error { c, _, err := a.rotatorClient() if err != nil { return err } return c.Stop() } // RotatorPark moves the antenna to its parked position (configured in // PstRotator itself). func (a *App) RotatorPark() error { c, _, err := a.rotatorClient() if err != nil { return err } return c.Park() } // TestRotator sends a no-op GoTo to the rotator's current heading to // verify the UDP link without actually moving the antenna. We use 0° as // the test target — pick a known direction the user expects to see. // Returns nil on success or a descriptive error. func (a *App) TestRotator(s RotatorSettings) error { if s.Host == "" { s.Host = "127.0.0.1" } if s.Port <= 0 || s.Port > 65535 { s.Port = 12000 } return pst.New(s.Host, s.Port).GoTo(0, false, -1) } func boolStr(b bool) string { if b { return "1" } return "0" } // --- WinKeyer (CW keyer) bindings --- // WKMacro is one CW message slot (F1…): a short label + the macro text, which // may contain tokens resolved by the frontend before sending. type WKMacro struct { Label string `json:"label"` Text string `json:"text"` } // WinkeyerSettings is the Hardware → CW Keyer panel shape. It embeds the // engine Config (keying parameters) plus the enable flag and message macros. type WinkeyerSettings struct { Enabled bool `json:"enabled"` winkeyer.Config Engine string `json:"engine"` // keyer backend: "winkeyer" | "tci" EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign SendOnType bool `json:"send_on_type"` // key chars live as typed Macros []WKMacro `json:"macros"` } // ListSerialPorts returns the available COM ports for the keyer dropdown. func (a *App) ListSerialPorts() ([]string, error) { return winkeyer.ListPorts() } // GetWinkeyerSettings returns the persisted keyer config (with sane defaults). func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) { out := WinkeyerSettings{ Config: winkeyer.Config{ Baud: 1200, WPM: 25, Weight: 50, LeadInMs: 10, TailMs: 50, Ratio: 50, Sidetone: 600, Mode: winkeyer.ModeIambicB, AutoSpace: true, SerialEcho: true, // so the panel shows text as it's transmitted }, Engine: "winkeyer", EscClearsCall: true, Macros: defaultWKMacros(), } if a.settings == nil { return out, nil } m, err := a.settings.GetMany(a.ctx, keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn, keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode, keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros, keyWKEngine, keyWKEscClears, keyWKSendOnType) if err != nil { return out, err } if v := m[keyWKEngine]; v != "" { out.Engine = v } if v := m[keyWKEscClears]; v != "" { out.EscClearsCall = v == "1" } out.SendOnType = m[keyWKSendOnType] == "1" out.Enabled = m[keyWKEnabled] == "1" if v := m[keyWKPort]; v != "" { out.Port = v } atoiInto(m[keyWKBaud], &out.Baud) atoiInto(m[keyWKWPM], &out.WPM) atoiInto(m[keyWKWeight], &out.Weight) atoiInto(m[keyWKLeadIn], &out.LeadInMs) atoiInto(m[keyWKTail], &out.TailMs) atoiInto(m[keyWKRatio], &out.Ratio) atoiInto(m[keyWKFarnsworth], &out.Farnsworth) atoiInto(m[keyWKSidetone], &out.Sidetone) if v := m[keyWKMode]; v != "" { out.Mode = winkeyer.Mode(v) } out.Swap = m[keyWKSwap] == "1" if v := m[keyWKAutoSpace]; v != "" { out.AutoSpace = v == "1" } out.UsePTT = m[keyWKUsePTT] == "1" out.SerialEcho = m[keyWKSerialEcho] == "1" if v := m[keyWKMacros]; v != "" { var mac []WKMacro if json.Unmarshal([]byte(v), &mac) == nil && len(mac) > 0 { out.Macros = mac } } return out, nil } // SaveWinkeyerSettings persists the keyer config; if a link is open and the // keying params changed, the caller can reconnect to apply them. func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } macJSON, _ := json.Marshal(s.Macros) for k, v := range map[string]string{ keyWKEnabled: boolStr(s.Enabled), keyWKPort: strings.TrimSpace(s.Port), keyWKBaud: strconv.Itoa(s.Baud), keyWKWPM: strconv.Itoa(s.WPM), keyWKWeight: strconv.Itoa(s.Weight), keyWKLeadIn: strconv.Itoa(s.LeadInMs), keyWKTail: strconv.Itoa(s.TailMs), keyWKRatio: strconv.Itoa(s.Ratio), keyWKFarnsworth: strconv.Itoa(s.Farnsworth), keyWKSidetone: strconv.Itoa(s.Sidetone), keyWKMode: string(s.Mode), keyWKSwap: boolStr(s.Swap), keyWKAutoSpace: boolStr(s.AutoSpace), keyWKUsePTT: boolStr(s.UsePTT), keyWKSerialEcho: boolStr(s.SerialEcho), keyWKMacros: string(macJSON), keyWKEngine: strings.TrimSpace(s.Engine), keyWKEscClears: boolStr(s.EscClearsCall), keyWKSendOnType: boolStr(s.SendOnType), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } return nil } // WinkeyerConnect opens the serial link using the saved config. func (a *App) WinkeyerConnect() error { if a.winkeyer == nil { return fmt.Errorf("winkeyer not initialized") } s, err := a.GetWinkeyerSettings() if err != nil { return err } return a.winkeyer.Connect(s.Config) } // WinkeyerDisconnect closes the serial link. func (a *App) WinkeyerDisconnect() error { if a.winkeyer != nil { a.winkeyer.Disconnect() } return nil } // WinkeyerSend keys the (already variable-resolved) text as Morse. func (a *App) WinkeyerSend(text string) error { if a.winkeyer == nil { return fmt.Errorf("winkeyer not initialized") } return a.winkeyer.Send(text) } // WinkeyerStop aborts the current message immediately. func (a *App) WinkeyerStop() error { if a.winkeyer == nil { return fmt.Errorf("winkeyer not initialized") } return a.winkeyer.Stop() } // WinkeyerBackspace removes the last not-yet-keyed character (send-on-type). func (a *App) WinkeyerBackspace() error { if a.winkeyer == nil { return fmt.Errorf("winkeyer not initialized") } return a.winkeyer.Backspace() } // WinkeyerSetSpeed changes the keying speed (WPM) live. func (a *App) WinkeyerSetSpeed(wpm int) error { if a.winkeyer == nil { return fmt.Errorf("winkeyer not initialized") } return a.winkeyer.SetSpeed(wpm) } // GetWinkeyerStatus returns the current link status (used on mount). func (a *App) GetWinkeyerStatus() winkeyer.Status { if a.winkeyer == nil { return winkeyer.Status{} } return a.winkeyer.Snapshot() } // defaultWKMacros mirrors the classic F-key set (CQ / answer / reports / 73). func defaultWKMacros() []WKMacro { return []WKMacro{ {Label: "CQ", Text: "CQ CQ DE K"}, {Label: "His call", Text: " "}, {Label: "Report", Text: " UR = "}, {Label: "Answer", Text: " DE TU UR = "}, {Label: "Name/QTH", Text: "NAME QTH = "}, {Label: "73", Text: " TU 73 DE "}, {Label: "QRL?", Text: "QRL? "}, {Label: "AGN", Text: "AGN "}, } } func atoiInto(s string, dst *int) { if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { *dst = n } } // --- DX Cluster bindings (multi-server) --- // resolveClusterLogin returns the login callsign for a server: explicit // override on the row, else the active profile's callsign. func (a *App) resolveClusterLogin(override string) string { if override != "" { return strings.ToUpper(strings.TrimSpace(override)) } if a.profiles != nil { if p, err := a.profiles.Active(a.ctx); err == nil { return strings.ToUpper(strings.TrimSpace(p.Callsign)) } } return "" } // clusterAutoConnect reads the global "auto-connect on startup" toggle. // Stored in settings (key/value) since it's a single bool, not per-row. func (a *App) clusterAutoConnect() (bool, error) { if a.settings == nil { return false, fmt.Errorf("db not initialized") } v, err := a.settings.Get(a.ctx, keyClusterAutoConnect) if err != nil { return false, err } return v == "1", nil } // startAllEnabledClusters opens a session for every enabled server. func (a *App) startAllEnabledClusters() { servers, err := a.listClusterServers() if err != nil { fmt.Println("OpsLog: list cluster servers:", err) return } for _, s := range servers { if s.Enabled { a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) } } } // listClusterServers reads the cluster_servers table ordered for display // (sort_order asc, id asc). The first row with Enabled=true is the master. func (a *App) listClusterServers() ([]cluster.ServerConfig, error) { if a.db == nil { return nil, fmt.Errorf("db not initialized") } rows, err := a.db.QueryContext(a.ctx, ` SELECT id, name, host, port, login_override, password, init_commands, enabled, sort_order FROM cluster_servers ORDER BY sort_order ASC, id ASC`) if err != nil { return nil, err } defer rows.Close() var out []cluster.ServerConfig for rows.Next() { var s cluster.ServerConfig var enabled int if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.Port, &s.LoginOverride, &s.Password, &s.InitCommands, &enabled, &s.SortOrder); err != nil { return nil, err } s.Enabled = enabled == 1 out = append(out, s) } return out, rows.Err() } // ListClusterServers returns all saved cluster nodes. func (a *App) ListClusterServers() ([]cluster.ServerConfig, error) { return a.listClusterServers() } // SaveClusterServer upserts one row. id=0 inserts a new server. Restarts // the session if the row was already running (so config edits take effect // immediately). func (a *App) SaveClusterServer(s cluster.ServerConfig) (cluster.ServerConfig, error) { if a.db == nil { return cluster.ServerConfig{}, fmt.Errorf("db not initialized") } if strings.TrimSpace(s.Name) == "" { return cluster.ServerConfig{}, fmt.Errorf("server name required") } if s.Port <= 0 || s.Port > 65535 { s.Port = 7300 } now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") enabled := 0 if s.Enabled { enabled = 1 } if s.ID == 0 { res, err := a.db.ExecContext(a.ctx, ` INSERT INTO cluster_servers (name, host, port, login_override, password, init_commands, enabled, sort_order, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, now) if err != nil { return cluster.ServerConfig{}, err } id, _ := res.LastInsertId() s.ID = id } else { _, err := a.db.ExecContext(a.ctx, ` UPDATE cluster_servers SET name=?, host=?, port=?, login_override=?, password=?, init_commands=?, enabled=?, sort_order=?, updated_at=? WHERE id=?`, s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, s.ID) if err != nil { return cluster.ServerConfig{}, err } } // Apply runtime change: stop and restart if enabled, else just stop. a.cluster.StopServer(s.ID) if s.Enabled { a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) } return s, nil } // DeleteClusterServer drops a row and closes its session. func (a *App) DeleteClusterServer(id int64) error { if a.db == nil { return fmt.Errorf("db not initialized") } a.cluster.StopServer(id) _, err := a.db.ExecContext(a.ctx, `DELETE FROM cluster_servers WHERE id=?`, id) return err } // SetClusterAutoConnect persists the global auto-connect toggle. func (a *App) SetClusterAutoConnect(on bool) error { if a.settings == nil { return fmt.Errorf("db not initialized") } return a.settings.Set(a.ctx, keyClusterAutoConnect, boolStr(on)) } // GetClusterAutoConnect reads the persisted toggle. func (a *App) GetClusterAutoConnect() (bool, error) { return a.clusterAutoConnect() } // ConnectClusterServer opens a session for one specific saved server. func (a *App) ConnectClusterServer(id int64) error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } servers, err := a.listClusterServers() if err != nil { return err } for _, s := range servers { if s.ID == id { if !s.Enabled { return fmt.Errorf("server %q is disabled — enable it first", s.Name) } a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) return nil } } return fmt.Errorf("no saved server with id %d", id) } // DisconnectClusterServer closes the session for one server. func (a *App) DisconnectClusterServer(id int64) error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } a.cluster.StopServer(id) return nil } // ConnectAllClusters opens sessions for every enabled server. func (a *App) ConnectAllClusters() error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } a.startAllEnabledClusters() return nil } // DisconnectAllClusters closes every running session. func (a *App) DisconnectAllClusters() error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } a.cluster.StopAll() return nil } // SendClusterCommand writes `cmd` to the **master** cluster — the first // enabled server by sort_order. Returns an error if the master is not // currently connected (the UI should grey the input out in that case). func (a *App) SendClusterCommand(cmd string) error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } cmd = strings.TrimSpace(cmd) if cmd == "" { return fmt.Errorf("empty command") } servers, err := a.listClusterServers() if err != nil { return err } for _, s := range servers { if s.Enabled { return a.cluster.SendCommand(s.ID, cmd) } } return fmt.Errorf("no enabled cluster server to send to") } // SendClusterSpot announces a DX spot on the **master** cluster (first // enabled server). Format is the universal DXSpider/AR-Cluster command // `DX `. The frequency is taken in kHz; call is // upper-cased; comment is optional (commonly the mode, e.g. "CW"). func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) error { call = strings.ToUpper(strings.TrimSpace(call)) if call == "" { return fmt.Errorf("callsign required") } if freqKHz <= 0 { return fmt.Errorf("invalid frequency") } // Trim a trailing ".0" so integer kHz stay clean (14205 not 14205.0), // but keep sub-kHz precision when present (e.g. 10138.7). freqStr := strconv.FormatFloat(freqKHz, 'f', -1, 64) cmd := fmt.Sprintf("DX %s %s", freqStr, call) if c := strings.TrimSpace(comment); c != "" { cmd += " " + c } applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd) return a.SendClusterCommand(cmd) } // GetClusterStatus returns a snapshot of every active session. Used by // the UI on mount and to hydrate after a `cluster:state` event. func (a *App) GetClusterStatus() []cluster.ServerStatus { if a.cluster == nil { return nil } return a.cluster.Status() } // SpotQuery is one (call, band, mode) tuple sent for status colouring. type SpotQuery struct { Call string `json:"call"` Band string `json:"band"` Mode string `json:"mode"` } // SpotStatus is the per-tuple result. Status is one of: // // "new" — entity never worked // "new-band" — entity worked but never on this band // "new-slot" — entity worked on this band but not in this mode // "worked" — exact band+mode already in the log // "" — couldn't resolve the entity (no cty.dat match) type SpotStatus struct { Call string `json:"call"` Band string `json:"band"` Mode string `json:"mode"` Country string `json:"country,omitempty"` Continent string `json:"continent,omitempty"` Status string `json:"status"` // WorkedCall is true when this exact callsign exists in the log // (any band, any mode). Drives the per-call text highlight, in // addition to the entity-level Status (NEW / NEW BAND / …). WorkedCall bool `json:"worked_call"` } // ClusterSpotStatuses takes a batch of spots and returns slot status for // each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT // / WORKED). One cty.dat lookup + one DB scan, regardless of batch size. // // Mode handling: when the caller passes an empty Mode (cluster comment // was ambiguous and the frontend couldn't infer) we degrade gracefully // to band-only — saying "worked" rather than wrongly flagging "new-slot" // just because we don't know the mode. func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { out := make([]SpotStatus, len(spots)) if a.qso == nil { return out } // Pass a cty.dat-backed resolver so the past-QSO map uses the SAME // entity name we'll compare each spot against. Without it QRZ-stored // "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW. resolveEntity := func(callsign string) string { if a.dxcc == nil { return "" } m, ok := a.dxcc.Lookup(callsign) if !ok || m.Entity == nil { return "" } return m.Entity.Name } entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity) if err != nil { return out } // Per-call worked set — separate from the entity check so we can flag // "I've already QSO'd this exact station" even when the band/mode // makes the entity check say "new-band" or "new-slot". workedCalls, _ := a.qso.WorkedCallsigns(a.ctx) for i, q := range spots { out[i] = SpotStatus{ Call: q.Call, Band: strings.ToLower(q.Band), Mode: strings.ToUpper(q.Mode), } if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok { out[i].WorkedCall = true } if a.dxcc == nil { continue } m, ok := a.dxcc.Lookup(q.Call) if !ok || m.Entity == nil { continue } country := strings.ToLower(m.Entity.Name) out[i].Country = m.Entity.Name out[i].Continent = m.Continent e, worked := entities[country] if !worked { out[i].Status = "new" continue } if _, b := e.Bands[out[i].Band]; !b { out[i].Status = "new-band" continue } // Without a mode we can't distinguish "new slot" from "worked"; // the safer default is "worked" so we never falsely claim "new". if out[i].Mode == "" { out[i].Status = "worked" continue } if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok { out[i].Status = "new-slot" continue } out[i].Status = "worked" } return out }