5849 lines
185 KiB
Go
5849 lines
185 KiB
Go
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/award"
|
||
"hamlog/internal/awardref"
|
||
"hamlog/internal/cluster"
|
||
"hamlog/internal/pota"
|
||
"hamlog/internal/db"
|
||
"hamlog/internal/email"
|
||
"hamlog/internal/extsvc"
|
||
"hamlog/internal/integrations/udp"
|
||
"hamlog/internal/operating"
|
||
"hamlog/internal/dxcc"
|
||
"hamlog/internal/lookup"
|
||
"hamlog/internal/profile"
|
||
"hamlog/internal/qso"
|
||
"hamlog/internal/rotator/pst"
|
||
"hamlog/internal/winkeyer"
|
||
"hamlog/internal/settings"
|
||
|
||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||
"go.bug.st/serial"
|
||
)
|
||
|
||
// Setting keys.
|
||
const (
|
||
keyQRZUser = "lookup.qrz.user"
|
||
keyQRZPassword = "lookup.qrz.password"
|
||
keyHQUser = "lookup.hamqth.user"
|
||
keyHQPassword = "lookup.hamqth.password"
|
||
keyCacheTTL = "lookup.cache.ttl_days"
|
||
// Provider routing. Each value is a provider name (qrz | hamqth)
|
||
// or empty to disable that slot. Primary is consulted first;
|
||
// Failsafe is the fallback when Primary returns not-found or errs.
|
||
keyLookupPrimary = "lookup.primary"
|
||
keyLookupFailsafe = "lookup.failsafe"
|
||
keyLookupImages = "lookup.download_images" // 1 = expose QRZ ImageURL to UI
|
||
|
||
keyStationCallsign = "station.callsign"
|
||
keyStationOperator = "station.operator"
|
||
keyStationMyGrid = "station.my_grid"
|
||
keyStationCountry = "station.my_country"
|
||
keyStationSOTA = "station.my_sota_ref"
|
||
keyStationPOTA = "station.my_pota_ref"
|
||
|
||
keyListsBands = "lists.bands"
|
||
keyListsModes = "lists.modes"
|
||
keyListsRSTPhone = "lists.rst_phone"
|
||
keyListsRSTCW = "lists.rst_cw"
|
||
keyListsRSTDigital = "lists.rst_digital"
|
||
|
||
keyCATEnabled = "cat.enabled"
|
||
keyCATBackend = "cat.backend" // "omnirig" (only one for now)
|
||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||
keyCATPollMs = "cat.poll_ms"
|
||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||
|
||
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
|
||
// global (not per-profile) like CAT/rotator. Device fields store the
|
||
// WASAPI endpoint id; the UI resolves it to a friendly name.
|
||
keyAudioFromRadio = "audio.from_radio" // capture: rig RX audio in
|
||
keyAudioToRadio = "audio.to_radio" // render: DVK plays into rig
|
||
keyAudioRecDevice = "audio.rec_device" // capture: your mic (record DVK msgs)
|
||
keyAudioListenDevice = "audio.listen_device" // render: local preview speakers
|
||
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
|
||
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
||
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||
|
||
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
|
||
keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp
|
||
keyAwardRefsSeeded = "awards.refs.seeded" // built-in reference-list seed version
|
||
keyAwardDefsFixed = "awards.defs.fixed" // built-in award def correction version
|
||
|
||
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
||
|
||
// E-mail / SMTP — send QSO recordings to the correspondent.
|
||
keyEmailEnabled = "email.enabled"
|
||
keyEmailHost = "email.smtp_host"
|
||
keyEmailPort = "email.smtp_port"
|
||
keyEmailUser = "email.smtp_user"
|
||
keyEmailPassword = "email.smtp_password"
|
||
keyEmailFrom = "email.from"
|
||
keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none"
|
||
keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password)
|
||
keyEmailAutoSend = "email.auto_send" // "1" → auto-send recording on log when an e-mail is known
|
||
keyEmailSubject = "email.subject"
|
||
keyEmailBody = "email.body"
|
||
|
||
// clublogAppAPIKey is OpsLog's ClubLog API key, also used for the country
|
||
// file download. Visible in the binary but must not be exposed publicly.
|
||
clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
|
||
|
||
keyRotatorEnabled = "rotator.enabled"
|
||
keyRotatorHost = "rotator.host"
|
||
keyRotatorPort = "rotator.port"
|
||
keyRotatorHasElevation = "rotator.has_elevation"
|
||
|
||
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
|
||
keyWKEnabled = "winkeyer.enabled"
|
||
keyWKPort = "winkeyer.port"
|
||
keyWKBaud = "winkeyer.baud"
|
||
keyWKWPM = "winkeyer.wpm"
|
||
keyWKWeight = "winkeyer.weight"
|
||
keyWKLeadIn = "winkeyer.lead_in_ms"
|
||
keyWKTail = "winkeyer.tail_ms"
|
||
keyWKRatio = "winkeyer.ratio"
|
||
keyWKFarnsworth = "winkeyer.farnsworth"
|
||
keyWKSidetone = "winkeyer.sidetone_hz"
|
||
keyWKMode = "winkeyer.mode"
|
||
keyWKSwap = "winkeyer.swap"
|
||
keyWKAutoSpace = "winkeyer.autospace"
|
||
keyWKUsePTT = "winkeyer.use_ptt"
|
||
keyWKSerialEcho = "winkeyer.serial_echo"
|
||
keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
|
||
keyWKEngine = "winkeyer.engine" // "winkeyer" | "tci"
|
||
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
|
||
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
|
||
|
||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||
|
||
keyBackupEnabled = "backup.enabled"
|
||
keyBackupFolder = "backup.folder"
|
||
keyBackupRotation = "backup.rotation"
|
||
keyBackupZip = "backup.zip"
|
||
keyBackupLast = "backup.last_at"
|
||
|
||
keyQSLDefaultQSLSent = "qsl.qsl_sent"
|
||
keyQSLDefaultQSLRcvd = "qsl.qsl_rcvd"
|
||
keyQSLDefaultLOTWSent = "qsl.lotw_sent"
|
||
keyQSLDefaultLOTWRcvd = "qsl.lotw_rcvd"
|
||
keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
|
||
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
|
||
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
||
|
||
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
||
// will add their own keys under the same extsvc.* prefix.
|
||
keyExtQRZAPIKey = "extsvc.qrz.api_key"
|
||
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
|
||
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
|
||
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
|
||
|
||
keyExtClublogEmail = "extsvc.clublog.email"
|
||
keyExtClublogPassword = "extsvc.clublog.password"
|
||
keyExtClublogCallsign = "extsvc.clublog.callsign"
|
||
keyExtClublogAPIKey = "extsvc.clublog.api_key"
|
||
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
|
||
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
|
||
|
||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
||
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
|
||
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
|
||
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
||
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
|
||
keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download)
|
||
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
|
||
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
|
||
)
|
||
|
||
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
|
||
// status fields. Applied to every QSO when the corresponding field is
|
||
// empty — both manual entry and UDP auto-log. Values are ADIF status
|
||
// codes: "Y" yes, "N" no, "R" requested, "Q" queued, "I" ignore, ""
|
||
// (empty) leaves the field untouched.
|
||
type QSLDefaults struct {
|
||
QSLSent string `json:"qsl_sent"`
|
||
QSLRcvd string `json:"qsl_rcvd"`
|
||
LOTWSent string `json:"lotw_sent"`
|
||
LOTWRcvd string `json:"lotw_rcvd"`
|
||
EQSLSent string `json:"eqsl_sent"`
|
||
EQSLRcvd string `json:"eqsl_rcvd"`
|
||
ClublogStatus string `json:"clublog_status"`
|
||
HRDLogStatus string `json:"hrdlog_status"`
|
||
QRZComStatus string `json:"qrzcom_status"`
|
||
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
|
||
}
|
||
|
||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||
// individual key/value pairs to keep the settings table flat.
|
||
type CATSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Backend string `json:"backend"` // currently always "omnirig"
|
||
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
||
}
|
||
|
||
// ModePreset is a mode entry with default RST values to auto-populate
|
||
// the entry form when the user picks this mode.
|
||
type ModePreset struct {
|
||
Name string `json:"name"`
|
||
DefaultRSTSent string `json:"default_rst_sent,omitempty"`
|
||
DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"`
|
||
}
|
||
|
||
// ListsSettings holds the user-customisable dropdown lists used by the
|
||
// entry form. Default values match common HF/VHF practice.
|
||
type ListsSettings struct {
|
||
Bands []string `json:"bands"`
|
||
Modes []ModePreset `json:"modes"`
|
||
RSTPhone []string `json:"rst_phone"` // RS reports for phone modes
|
||
RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK
|
||
RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT…
|
||
}
|
||
|
||
var defaultBands = []string{
|
||
"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m",
|
||
"12m", "10m", "6m", "2m", "70cm", "23cm",
|
||
}
|
||
var defaultModes = []ModePreset{
|
||
{Name: "SSB", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||
{Name: "CW", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
|
||
{Name: "FT8", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
|
||
{Name: "FT4", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
|
||
{Name: "RTTY", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
|
||
{Name: "PSK31", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
|
||
{Name: "AM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||
{Name: "FM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||
{Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||
}
|
||
|
||
// Default RST report lists, editable in Settings → Modes. Phone carries the
|
||
// over-S9 reports (59+10…59+60) plus the full RS grid; CW the full RST grid;
|
||
// digital the dB reports +30…-30.
|
||
var defaultRSTPhone = buildPhoneRST()
|
||
var defaultRSTCW = buildCWRST()
|
||
var defaultRSTDigital = buildDigitalRST()
|
||
|
||
func buildPhoneRST() []string {
|
||
out := []string{"59+60", "59+50", "59+40", "59+30", "59+20", "59+10"}
|
||
for r := 5; r >= 1; r-- {
|
||
for s := 9; s >= 1; s-- {
|
||
out = append(out, fmt.Sprintf("%d%d", r, s))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
func buildCWRST() []string {
|
||
var out []string
|
||
for r := 5; r >= 1; r-- {
|
||
for s := 9; s >= 1; s-- {
|
||
for t := 9; t >= 1; t-- {
|
||
out = append(out, fmt.Sprintf("%d%d%d", r, s, t))
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
func buildDigitalRST() []string {
|
||
var out []string
|
||
for db := 30; db >= -30; db-- {
|
||
sign := "+"
|
||
if db < 0 {
|
||
sign = "-"
|
||
}
|
||
n := db
|
||
if n < 0 {
|
||
n = -n
|
||
}
|
||
out = append(out, fmt.Sprintf("%s%02d", sign, n))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// StationSettings holds the active operator profile. Used to stamp every
|
||
// new QSO so we don't ask the user to retype it for each contact.
|
||
// Multi-profile support (portable / SOTA …) will layer on top of this.
|
||
type StationSettings struct {
|
||
Callsign string `json:"callsign"`
|
||
Operator string `json:"operator"`
|
||
MyGrid string `json:"my_grid"`
|
||
MyCountry string `json:"my_country"`
|
||
MySOTARef string `json:"my_sota_ref"`
|
||
MyPOTARef string `json:"my_pota_ref"`
|
||
}
|
||
|
||
// LookupSettings is the JSON shape exchanged with the frontend.
|
||
// Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to
|
||
// route lookups: primary first, failsafe on not-found / error.
|
||
type LookupSettings struct {
|
||
QRZUser string `json:"qrz_user"`
|
||
QRZPassword string `json:"qrz_password"`
|
||
HamQTHUser string `json:"hamqth_user"`
|
||
HamQTHPassword string `json:"hamqth_password"`
|
||
Primary string `json:"primary"`
|
||
Failsafe string `json:"failsafe"`
|
||
DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI
|
||
CacheTTLDays int `json:"cache_ttl_days"`
|
||
}
|
||
|
||
// App is the application context bound to the Wails runtime.
|
||
type App struct {
|
||
ctx context.Context
|
||
db *sql.DB
|
||
qso *qso.Repo
|
||
settings *settings.Store
|
||
profiles *profile.Repo
|
||
lookup *lookup.Manager
|
||
cache *lookup.Cache
|
||
cat *cat.Manager
|
||
dxcc *dxcc.Manager
|
||
cluster *cluster.Manager
|
||
pota *pota.Cache
|
||
awardRefs *awardref.Repo
|
||
operating *operating.Repo
|
||
udp *udp.Manager
|
||
udpRepo *udp.Repo
|
||
extsvc *extsvc.Manager
|
||
winkeyer *winkeyer.Manager
|
||
clublog *clublog.Manager
|
||
audioMgr *audio.Manager
|
||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||
pttMu sync.Mutex
|
||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||
startupErr string // captured for surfacing to the frontend
|
||
dbPath string // active database file (may be a user-chosen location)
|
||
dataDir string // %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.awardRefs = awardref.NewRepo(conn)
|
||
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
|
||
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
|
||
a.operating = operating.NewRepo(conn)
|
||
a.udpRepo = udp.NewRepo(conn)
|
||
a.udp = udp.NewManager(a.udpRepo)
|
||
go a.consumeUDPEvents()
|
||
// On first run, copy the legacy single-station settings into a
|
||
// "Default" profile so the user's existing config carries over without
|
||
// any manual step. Subsequent runs just confirm an active profile.
|
||
if _, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{
|
||
Callsign: keyStationCallsign,
|
||
Operator: keyStationOperator,
|
||
MyGrid: keyStationMyGrid,
|
||
Country: keyStationCountry,
|
||
SOTA: keyStationSOTA,
|
||
POTA: keyStationPOTA,
|
||
}); err != nil {
|
||
fmt.Println("OpsLog: EnsureDefault profile:", err)
|
||
}
|
||
a.cache = lookup.NewCache(conn, 30*24*time.Hour)
|
||
a.lookup = lookup.NewManager(a.cache)
|
||
a.reloadLookupProviders()
|
||
|
||
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||
// run downloads it from country-files.com in the background so startup
|
||
// stays fast even if the network is slow.
|
||
a.dxcc = dxcc.NewManager(dataDir)
|
||
a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc})
|
||
go func() {
|
||
if err := a.dxcc.EnsureLoaded(context.Background()); err != nil {
|
||
fmt.Println("OpsLog: cty.dat unavailable —", err)
|
||
return
|
||
}
|
||
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
||
}()
|
||
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
|
||
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
||
// demand. Resolution applied only when the user enables it.
|
||
a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir)
|
||
go func() {
|
||
if err := a.clublog.EnsureLoaded(); err == nil {
|
||
d, n := a.clublog.Info()
|
||
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
||
}
|
||
}()
|
||
// CAT manager: emit pushes state to the frontend via Wails events.
|
||
a.cat = cat.NewManager(func(s cat.RigState) {
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||
}
|
||
})
|
||
a.reloadCAT()
|
||
|
||
// POTA: background poller of api.pota.app so cluster spots can be tagged
|
||
// when the DX station is currently activating a park. Best-effort.
|
||
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
|
||
go a.pota.Run(a.ctx)
|
||
|
||
// DX Cluster (multi-server): the spot callback enriches each spot
|
||
// with country + continent via cty.dat BEFORE emitting it, so the UI
|
||
// renders the row with all metadata already filled (no flicker of
|
||
// empty Country / Cont columns while the batch status fetch runs).
|
||
a.cluster = cluster.NewManager(
|
||
func(s cluster.Spot) {
|
||
if a.dxcc != nil {
|
||
if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
|
||
s.Country = m.Entity.Name
|
||
s.Continent = m.Continent
|
||
s.CQZone = m.CQZone
|
||
s.ITUZone = m.ITUZone
|
||
if a.opSet && (m.Lat != 0 || m.Lon != 0) {
|
||
s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5)
|
||
sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon)
|
||
s.ShortPath = int(sp + 0.5)
|
||
s.LongPath = (s.ShortPath + 180) % 360
|
||
}
|
||
}
|
||
}
|
||
// POTA: tag the spot when the DX station is currently activating a park.
|
||
if a.pota != nil {
|
||
if info, ok := a.pota.Lookup(s.DXCall); ok {
|
||
s.POTARef = info.Reference
|
||
s.POTAName = info.ParkName
|
||
}
|
||
}
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
|
||
}
|
||
},
|
||
func() {
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
|
||
}
|
||
},
|
||
)
|
||
a.refreshOperatorGrid()
|
||
if cs, _ := a.clusterAutoConnect(); cs {
|
||
a.startAllEnabledClusters()
|
||
}
|
||
if errs := a.udp.Reload(a.ctx); len(errs) > 0 {
|
||
for _, e := range errs {
|
||
fmt.Println("OpsLog: udp:", e)
|
||
}
|
||
}
|
||
|
||
// External-service uploaders (QRZ.com …). The manager is fed config
|
||
// from settings and host callbacks to build ADIF, stamp the upload
|
||
// status and surface errors to the UI.
|
||
a.extsvc = extsvc.NewManager(extsvc.Deps{
|
||
BuildADIF: a.buildUploadADIF,
|
||
MarkUploaded: a.markExtUploaded,
|
||
NotifyError: a.notifyExtError,
|
||
ShouldUpload: a.extShouldUpload,
|
||
StationCallOf: a.stationCallOf,
|
||
Logf: applog.Printf,
|
||
})
|
||
a.extsvc.SetConfig(a.loadExternalServices())
|
||
|
||
// WinKeyer CW keyer (serial). Created idle; the UI connects on demand.
|
||
a.winkeyer = winkeyer.NewManager(
|
||
func(s winkeyer.Status) {
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "winkeyer:status", s)
|
||
}
|
||
},
|
||
func(ch string) {
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "winkeyer:echo", ch)
|
||
}
|
||
},
|
||
)
|
||
|
||
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
|
||
a.audioMgr = audio.NewManager(func() {
|
||
st := a.dvkStatus()
|
||
// When a voice message finishes (or is stopped), drop CAT PTT.
|
||
if !st.Playing && a.dvkPttKeyed {
|
||
a.dvkPttKeyed = false
|
||
go a.dvkUnkeyPTT()
|
||
}
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
||
}
|
||
})
|
||
a.qsoRec = audio.NewRecorder()
|
||
a.startQSORecorderIfEnabled()
|
||
|
||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||
}
|
||
|
||
// StartupStatus returns a diagnostic snapshot for the frontend.
|
||
// dbPath is always populated; err is empty when the app is healthy.
|
||
type StartupStatus struct {
|
||
OK bool `json:"ok"`
|
||
Err string `json:"err"`
|
||
DBPath string `json:"db_path"`
|
||
}
|
||
|
||
// 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.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||
a.applyQSLDefaults(&q)
|
||
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
||
if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" {
|
||
q.Email = lr.Email
|
||
}
|
||
}
|
||
id, err := a.qso.Add(a.ctx, q)
|
||
if err == nil {
|
||
q.ID = id
|
||
a.saveQSORecording(&q)
|
||
if a.extsvc != nil {
|
||
a.extsvc.OnQSOLogged(id)
|
||
}
|
||
}
|
||
return id, err
|
||
}
|
||
|
||
// StationInfoComputed bundles the data we resolve live from the
|
||
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
|
||
// lat/lon. Used by the Settings UI to show the "what will be stamped on
|
||
// each QSO" preview next to the editable fields.
|
||
type StationInfoComputed struct {
|
||
Country string `json:"country"`
|
||
DXCC int `json:"dxcc"`
|
||
CQZ int `json:"cqz"`
|
||
ITUZ int `json:"ituz"`
|
||
Lat float64 `json:"lat"`
|
||
Lon float64 `json:"lon"`
|
||
}
|
||
|
||
// ListCountries returns the DXCC entity names for the Country picker, so the
|
||
// user selects from a fixed list instead of typing (avoids typos). Empty
|
||
// until cty.dat has loaded.
|
||
func (a *App) ListCountries() []string {
|
||
if a.dxcc == nil {
|
||
return nil
|
||
}
|
||
return a.dxcc.EntityNames()
|
||
}
|
||
|
||
// DXCCForCountry returns the ADIF DXCC entity number for a country/entity
|
||
// name (as listed by ListCountries), or 0 if unknown. The QSO editor uses it
|
||
// to keep the read-only DXCC field in sync when the user picks a Country.
|
||
func (a *App) DXCCForCountry(name string) int {
|
||
return dxcc.EntityDXCC(name)
|
||
}
|
||
|
||
// DXCCName returns a display name for a DXCC entity number (or "" if unknown).
|
||
// Used by the award editor to label the DXCC-filter chips.
|
||
func (a *App) DXCCName(n int) string {
|
||
return dxcc.NameForDXCC(n)
|
||
}
|
||
|
||
// ComputeStationInfo resolves a station's structured metadata from the
|
||
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
|
||
// frontend calls this whenever Callsign or Grid changes in the Station
|
||
// Information panel so the user sees the auto-filled values live.
|
||
func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed {
|
||
var out StationInfoComputed
|
||
if a.dxcc != nil && callsign != "" {
|
||
if m, ok := a.dxcc.Lookup(callsign); ok && m.Entity != nil {
|
||
out.Country = m.Entity.Name
|
||
out.CQZ = m.CQZone
|
||
out.ITUZ = m.ITUZone
|
||
out.Lat = m.Lat
|
||
out.Lon = m.Lon
|
||
out.DXCC = dxcc.EntityDXCC(m.Entity.Name)
|
||
// Refine zones by call district (W6 → CQ3/ITU6) so the entry strip
|
||
// shows what will be logged.
|
||
if cqz, ituz, ok := dxcc.ZoneByCallDistrict(out.DXCC, callsign); ok {
|
||
out.CQZ, out.ITUZ = cqz, ituz
|
||
}
|
||
}
|
||
}
|
||
// Grid wins on lat/lon — it's user-set, finer than the DXCC centroid.
|
||
if lat, lon, ok := gridToLatLon(grid); ok {
|
||
out.Lat = lat
|
||
out.Lon = lon
|
||
}
|
||
// 3 decimals is ~110 m — plenty for a station/grid coordinate, and keeps
|
||
// the UI fields tidy.
|
||
out.Lat = math.Round(out.Lat*1000) / 1000
|
||
out.Lon = math.Round(out.Lon*1000) / 1000
|
||
return out
|
||
}
|
||
|
||
// applyDXCCNumber fills DXCC (contacted station) from the cty.dat entity
|
||
// name when it's empty. Same lookup as applyStationDefaults does for
|
||
// MY_DXCC — uses our entity-name → ADIF DXCC# table since cty.dat itself
|
||
// doesn't store the ARRL number.
|
||
func (a *App) applyDXCCNumber(q *qso.QSO) {
|
||
if q.DXCC == nil && q.Country != "" {
|
||
if n := dxcc.EntityDXCC(q.Country); n != 0 {
|
||
q.DXCC = &n
|
||
}
|
||
}
|
||
}
|
||
|
||
// refineDistrictZones sets the CQ/ITU zone from the call district for
|
||
// zone-split countries (USA, Australia), so every entry point — manual,
|
||
// UDP, import, re-stamp, award scan — agrees on W6 = CQ3/ITU6 instead of the
|
||
// coarse per-entity default. Call AFTER the DXCC is finalised.
|
||
func (a *App) refineDistrictZones(q *qso.QSO) {
|
||
if q.DXCC == nil {
|
||
return
|
||
}
|
||
if cqz, ituz, ok := dxcc.ZoneByCallDistrict(*q.DXCC, q.Callsign); ok {
|
||
zc, zi := cqz, ituz
|
||
q.CQZ, q.ITUZ = &zc, &zi
|
||
}
|
||
}
|
||
|
||
// applyStationDefaults fills any empty MY_* / station field on q with the
|
||
// currently-active profile's values. Multi-profile support means a user
|
||
// can be /P with a different callsign + grid + SOTA ref than home — the
|
||
// QSO carries whichever profile was selected at log time.
|
||
func (a *App) applyStationDefaults(q *qso.QSO) {
|
||
if a.profiles == nil {
|
||
return
|
||
}
|
||
p, err := a.profiles.Active(a.ctx)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if q.StationCallsign == "" {
|
||
q.StationCallsign = p.Callsign
|
||
}
|
||
if q.Operator == "" {
|
||
q.Operator = p.Operator
|
||
}
|
||
// OWNER_CALLSIGN is a valid ADIF field but not a promoted column, so it
|
||
// lives in Extras (exported verbatim, round-trips, and is filterable via
|
||
// json_extract). Stamp it from the active profile when set.
|
||
if strings.TrimSpace(p.OwnerCallsign) != "" {
|
||
if q.Extras == nil {
|
||
q.Extras = map[string]string{}
|
||
}
|
||
if q.Extras["OWNER_CALLSIGN"] == "" {
|
||
q.Extras["OWNER_CALLSIGN"] = p.OwnerCallsign
|
||
}
|
||
}
|
||
if q.MyGrid == "" {
|
||
q.MyGrid = p.MyGrid
|
||
}
|
||
if q.MyCountry == "" {
|
||
q.MyCountry = p.MyCountry
|
||
}
|
||
if q.MyState == "" {
|
||
q.MyState = p.MyState
|
||
}
|
||
if q.MyCounty == "" {
|
||
q.MyCounty = p.MyCounty
|
||
}
|
||
if q.MyStreet == "" {
|
||
q.MyStreet = p.MyStreet
|
||
}
|
||
if q.MyCity == "" {
|
||
q.MyCity = p.MyCity
|
||
}
|
||
if q.MyPostalCode == "" {
|
||
q.MyPostalCode = p.MyPostalCode
|
||
}
|
||
if q.MySOTARef == "" {
|
||
q.MySOTARef = p.MySOTARef
|
||
}
|
||
if q.MyPOTARef == "" {
|
||
q.MyPOTARef = p.MyPOTARef
|
||
}
|
||
if q.MyRig == "" {
|
||
q.MyRig = p.MyRig
|
||
}
|
||
if q.MyAntenna == "" {
|
||
q.MyAntenna = p.MyAntenna
|
||
}
|
||
if q.TXPower == nil && p.TxPower != nil {
|
||
v := *p.TxPower
|
||
q.TXPower = &v
|
||
}
|
||
// Profile-stored MY_* DXCC metadata wins (the user can override the
|
||
// auto-filled values in Station Information).
|
||
if q.MyDXCC == nil && p.MyDXCC != nil {
|
||
v := *p.MyDXCC
|
||
q.MyDXCC = &v
|
||
}
|
||
if q.MyCQZone == nil && p.MyCQZone != nil {
|
||
v := *p.MyCQZone
|
||
q.MyCQZone = &v
|
||
}
|
||
if q.MyITUZone == nil && p.MyITUZone != nil {
|
||
v := *p.MyITUZone
|
||
q.MyITUZone = &v
|
||
}
|
||
if q.MyLat == nil && p.MyLat != nil {
|
||
v := *p.MyLat
|
||
q.MyLat = &v
|
||
}
|
||
if q.MyLon == nil && p.MyLon != nil {
|
||
v := *p.MyLon
|
||
q.MyLon = &v
|
||
}
|
||
// Resolve any still-missing my zones / lat / lon via cty.dat using the
|
||
// profile's callsign — the fallback when the profile didn't store them.
|
||
if a.dxcc != nil && p.Callsign != "" {
|
||
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
|
||
if q.MyCQZone == nil && m.CQZone != 0 {
|
||
v := m.CQZone
|
||
q.MyCQZone = &v
|
||
}
|
||
if q.MyITUZone == nil && m.ITUZone != 0 {
|
||
v := m.ITUZone
|
||
q.MyITUZone = &v
|
||
}
|
||
if q.MyCountry == "" && m.Entity.Name != "" {
|
||
q.MyCountry = m.Entity.Name
|
||
}
|
||
if q.MyDXCC == nil {
|
||
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
|
||
q.MyDXCC = &n
|
||
}
|
||
}
|
||
// Lat/Lon: prefer the profile's grid (more precise than the
|
||
// DXCC entity centroid). Fall back to cty.dat coordinates.
|
||
if q.MyLat == nil || q.MyLon == nil {
|
||
if lat, lon, gOK := gridToLatLon(p.MyGrid); gOK {
|
||
if q.MyLat == nil {
|
||
v := lat
|
||
q.MyLat = &v
|
||
}
|
||
if q.MyLon == nil {
|
||
v := lon
|
||
q.MyLon = &v
|
||
}
|
||
} else {
|
||
if q.MyLat == nil && m.Lat != 0 {
|
||
v := m.Lat
|
||
q.MyLat = &v
|
||
}
|
||
if q.MyLon == nil && m.Lon != 0 {
|
||
v := m.Lon
|
||
q.MyLon = &v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *App) ListQSO(f qso.ListFilter) ([]qso.QSO, error) {
|
||
if a.qso == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.List(a.ctx, f)
|
||
}
|
||
|
||
func (a *App) CountQSO() (int64, error) {
|
||
if a.qso == nil {
|
||
return 0, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.Count(a.ctx)
|
||
}
|
||
|
||
// awardDefs returns the user's stored award definitions, seeding the built-in
|
||
// defaults on first use.
|
||
func (a *App) awardDefs() []award.Def {
|
||
if a.settings != nil {
|
||
if s, _ := a.settings.Get(a.ctx, keyAwardDefs); strings.TrimSpace(s) != "" {
|
||
var defs []award.Def
|
||
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
|
||
// Upgrade legacy defs (pre-rich-model) in memory on every load.
|
||
migrated, _ := award.Migrate(defs)
|
||
return migrated
|
||
}
|
||
}
|
||
}
|
||
return award.Defaults()
|
||
}
|
||
|
||
// GetAwardDefs returns the (editable) award definitions.
|
||
func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }
|
||
|
||
// AwardFields lists the scannable QSO fields for the award editor.
|
||
func (a *App) AwardFields() []string { return award.Fields() }
|
||
|
||
// migrateAwardDefs upgrades legacy award definitions in storage once, so the
|
||
// editor and persisted state reflect the new model (enabled awards + filled
|
||
// matching/confirmation fields). No-op when there is nothing to migrate.
|
||
func (a *App) migrateAwardDefs() {
|
||
if a.settings == nil {
|
||
return
|
||
}
|
||
s, _ := a.settings.Get(a.ctx, keyAwardDefs)
|
||
if strings.TrimSpace(s) == "" {
|
||
return // nothing saved yet → Defaults() (already on the new model)
|
||
}
|
||
var defs []award.Def
|
||
if json.Unmarshal([]byte(s), &defs) != nil || len(defs) == 0 {
|
||
return
|
||
}
|
||
migrated, changed := award.Migrate(defs)
|
||
// Version-gated correction of the built-in awards' Validate sources, which
|
||
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
|
||
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
|
||
// from Defaults to protected/built-in awards once.
|
||
const defsFixVersion = "2"
|
||
if v, _ := a.settings.Get(a.ctx, keyAwardDefsFixed); v != defsFixVersion {
|
||
byCode := map[string]award.Def{}
|
||
for _, d := range award.Defaults() {
|
||
byCode[strings.ToUpper(d.Code)] = d
|
||
}
|
||
for i := range migrated {
|
||
if d, ok := byCode[strings.ToUpper(migrated[i].Code)]; ok && (migrated[i].Builtin || migrated[i].Protected) {
|
||
migrated[i].Confirm = d.Confirm
|
||
migrated[i].Validate = d.Validate
|
||
changed = true
|
||
}
|
||
}
|
||
_ = a.settings.Set(a.ctx, keyAwardDefsFixed, defsFixVersion)
|
||
}
|
||
if !changed {
|
||
return
|
||
}
|
||
if b, err := json.Marshal(migrated); err == nil {
|
||
_ = a.settings.Set(a.ctx, keyAwardDefs, string(b))
|
||
applog.Printf("awards: migrated/fixed %d definitions", len(migrated))
|
||
}
|
||
}
|
||
|
||
// SaveAwardDefs persists edited award definitions.
|
||
func (a *App) SaveAwardDefs(defs []award.Def) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
b, err := json.Marshal(defs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return a.settings.Set(a.ctx, keyAwardDefs, string(b))
|
||
}
|
||
|
||
// ResetAwardDefs restores the built-in defaults.
|
||
func (a *App) ResetAwardDefs() ([]award.Def, error) {
|
||
d := award.Defaults()
|
||
if err := a.SaveAwardDefs(d); err != nil {
|
||
return nil, err
|
||
}
|
||
return d, nil
|
||
}
|
||
|
||
// GetAwards computes progress for EVERY award (whole-log scan). Kept for
|
||
// callers that need all results at once; the UI now computes one award at a
|
||
// time via GetAward to stay responsive on large logs.
|
||
func (a *App) GetAwards() ([]award.Result, error) {
|
||
return a.computeAwards(a.awardDefs())
|
||
}
|
||
|
||
// GetAward computes progress for a single award by code (one whole-log scan,
|
||
// matching only that award). This is what the awards UI calls when an award is
|
||
// selected, so opening the panel doesn't scan every award up front.
|
||
func (a *App) GetAward(code string) (award.Result, error) {
|
||
for _, d := range a.awardDefs() {
|
||
if strings.EqualFold(d.Code, code) {
|
||
results, err := a.computeAwards([]award.Def{d})
|
||
if err != nil {
|
||
return award.Result{}, err
|
||
}
|
||
if len(results) > 0 {
|
||
return results[0], nil
|
||
}
|
||
return award.Result{}, nil
|
||
}
|
||
}
|
||
return award.Result{}, fmt.Errorf("unknown award %q", code)
|
||
}
|
||
|
||
// computeAwards runs the engine for the given award definitions over the whole
|
||
// log and enriches dynamic awards (totals + worked-reference names).
|
||
func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
|
||
if a.qso == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
var all []qso.QSO
|
||
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||
a.enrichQSOForAwards(&q)
|
||
all = append(all, q)
|
||
return nil
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
nameOf := func(field, ref string) string {
|
||
switch field {
|
||
case "dxcc":
|
||
if n, err := strconv.Atoi(ref); err == nil {
|
||
return dxcc.NameForDXCC(n)
|
||
}
|
||
case "cont":
|
||
return continentName(ref)
|
||
}
|
||
return ""
|
||
}
|
||
refMetas := a.awardRefMetas(defs)
|
||
results := award.Compute(defs, all, refMetas, nameOf)
|
||
// Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their
|
||
// list can be huge. Enrich them after the fact: real Total from the stored
|
||
// count, and reference names for the worked references only.
|
||
if a.awardRefs != nil {
|
||
counts, _ := a.awardRefs.Counts(a.ctx)
|
||
for i := range results {
|
||
r := &results[i]
|
||
if _, predef := refMetas[strings.ToUpper(r.Code)]; predef {
|
||
continue // predefined awards are already complete (totals + names)
|
||
}
|
||
if total := counts[strings.ToUpper(r.Code)]; total > 0 {
|
||
r.Total = total
|
||
}
|
||
codes := make([]string, 0, len(r.Refs))
|
||
for _, rf := range r.Refs {
|
||
if rf.Name == "" {
|
||
codes = append(codes, rf.Ref)
|
||
}
|
||
}
|
||
if len(codes) == 0 {
|
||
continue
|
||
}
|
||
if names, err := a.awardRefs.NamesFor(a.ctx, r.Code, codes); err == nil {
|
||
for j := range r.Refs {
|
||
if r.Refs[j].Name == "" {
|
||
r.Refs[j].Name = names[strings.ToUpper(r.Refs[j].Ref)]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return results, nil
|
||
}
|
||
|
||
// AwardCellQSOs returns the QSOs that contribute to one award reference,
|
||
// optionally on a single band (band="" = all bands). Powers the award-grid
|
||
// cell drill-down ("show me every Canada contact on 20m").
|
||
func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) {
|
||
if a.qso == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
defs := a.awardDefs()
|
||
var def *award.Def
|
||
for i := range defs {
|
||
if strings.EqualFold(defs[i].Code, code) {
|
||
def = &defs[i]
|
||
break
|
||
}
|
||
}
|
||
if def == nil {
|
||
return nil, fmt.Errorf("unknown award %q", code)
|
||
}
|
||
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
|
||
wantRef := strings.ToUpper(strings.TrimSpace(ref))
|
||
wantBand := strings.ToLower(strings.TrimSpace(band))
|
||
|
||
var out []qso.QSO
|
||
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||
if wantBand != "" && strings.ToLower(strings.TrimSpace(q.Band)) != wantBand {
|
||
return nil
|
||
}
|
||
a.enrichQSOForAwards(&q)
|
||
for _, c := range award.MatchQSO(*def, metas, &q) {
|
||
if strings.ToUpper(c) == wantRef {
|
||
out = append(out, q)
|
||
break
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// AwardStatRow is one row of the award statistics matrix (e.g. "CONFIRMED CW"):
|
||
// distinct-reference counts per band, plus Total (distinct on any band) and
|
||
// GrandTotal (sum of the per-band band-slots).
|
||
type AwardStatRow struct {
|
||
Label string `json:"label"`
|
||
Cells []int `json:"cells"`
|
||
Total int `json:"total"`
|
||
GrandTotal int `json:"grand_total"`
|
||
}
|
||
|
||
// AwardStatsResult is the statistics matrix for one award (Log4OM "Statistics").
|
||
type AwardStatsResult struct {
|
||
Code string `json:"code"`
|
||
Bands []string `json:"bands"`
|
||
Rows []AwardStatRow `json:"rows"`
|
||
}
|
||
|
||
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"}
|
||
|
||
// GetAwardStats computes the worked/confirmed/validated reference counts of one
|
||
// award, broken down by band and by mode category (All/CW/Digital/Phone).
|
||
func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
|
||
if a.qso == nil {
|
||
return AwardStatsResult{}, fmt.Errorf("db not initialized")
|
||
}
|
||
defs := a.awardDefs()
|
||
var def *award.Def
|
||
for i := range defs {
|
||
if strings.EqualFold(defs[i].Code, code) {
|
||
def = &defs[i]
|
||
break
|
||
}
|
||
}
|
||
if def == nil {
|
||
return AwardStatsResult{}, fmt.Errorf("unknown award %q", code)
|
||
}
|
||
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
|
||
|
||
bandIdx := make(map[string]int, len(statsBands))
|
||
for i, b := range statsBands {
|
||
bandIdx[b] = i
|
||
}
|
||
cats := []string{"ALL", "CW", "DIGITAL", "PHONE"}
|
||
stats := []string{"WORKED", "CONFIRMED", "VALIDATED"}
|
||
|
||
// acc[cat][stat]: per-band ref sets + an overall (any-band) ref set.
|
||
type acc struct {
|
||
perBand []map[string]struct{}
|
||
overall map[string]struct{}
|
||
}
|
||
accs := map[string]map[string]*acc{}
|
||
for _, c := range cats {
|
||
accs[c] = map[string]*acc{}
|
||
for _, s := range stats {
|
||
pb := make([]map[string]struct{}, len(statsBands))
|
||
for i := range pb {
|
||
pb[i] = map[string]struct{}{}
|
||
}
|
||
accs[c][s] = &acc{perBand: pb, overall: map[string]struct{}{}}
|
||
}
|
||
}
|
||
|
||
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||
a.enrichQSOForAwards(&q)
|
||
refs := award.MatchQSO(*def, metas, &q)
|
||
if len(refs) == 0 {
|
||
return nil
|
||
}
|
||
bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))]
|
||
isConf := award.Confirmed(&q, def.Confirm)
|
||
isVal := award.Confirmed(&q, def.Validate)
|
||
cat := strings.ToUpper(award.EmissionOf(q.Mode))
|
||
|
||
record := func(c string) {
|
||
put := func(stat string) {
|
||
ac := accs[c][stat]
|
||
for _, r := range refs {
|
||
ac.overall[r] = struct{}{}
|
||
if hasBand {
|
||
ac.perBand[bi][r] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
put("WORKED")
|
||
if isConf {
|
||
put("CONFIRMED")
|
||
}
|
||
if isVal {
|
||
put("VALIDATED")
|
||
}
|
||
}
|
||
record("ALL")
|
||
if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" {
|
||
record(cat)
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return AwardStatsResult{}, err
|
||
}
|
||
|
||
res := AwardStatsResult{Code: def.Code, Bands: statsBands}
|
||
for _, c := range cats {
|
||
for _, s := range stats {
|
||
ac := accs[c][s]
|
||
cells := make([]int, len(statsBands))
|
||
grand := 0
|
||
for i := range ac.perBand {
|
||
cells[i] = len(ac.perBand[i])
|
||
grand += cells[i]
|
||
}
|
||
label := s
|
||
if c != "ALL" {
|
||
label = s + " " + c
|
||
}
|
||
res.Rows = append(res.Rows, AwardStatRow{Label: label, Cells: cells, Total: len(ac.overall), GrandTotal: grand})
|
||
}
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
|
||
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
|
||
// are large and not needed for matching; their names are filled afterwards.
|
||
func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta {
|
||
out := map[string][]award.RefMeta{}
|
||
if a.awardRefs == nil {
|
||
return out
|
||
}
|
||
for _, d := range defs {
|
||
if d.Dynamic {
|
||
continue
|
||
}
|
||
code := strings.ToUpper(d.Code)
|
||
refs, err := a.awardRefs.List(a.ctx, code)
|
||
if err != nil || len(refs) == 0 {
|
||
continue
|
||
}
|
||
metas := make([]award.RefMeta, 0, len(refs))
|
||
for _, rf := range refs {
|
||
dxccList := rf.DXCCList
|
||
if len(dxccList) == 0 && rf.DXCC > 0 {
|
||
dxccList = []int{rf.DXCC}
|
||
}
|
||
metas = append(metas, award.RefMeta{
|
||
Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp,
|
||
DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid,
|
||
})
|
||
}
|
||
out[code] = metas
|
||
}
|
||
return out
|
||
}
|
||
|
||
// QSOAwardRef is one award reference a single QSO contributes to. Pickable
|
||
// marks awards backed by a reference list (POTA, SOTA, …) — those are assigned
|
||
// manually; the rest (DXCC, WAZ, WPX, DDFM, …) are computed from QSO fields.
|
||
type QSOAwardRef struct {
|
||
Code string `json:"code"`
|
||
Ref string `json:"ref"`
|
||
Name string `json:"name,omitempty"`
|
||
Pickable bool `json:"pickable"`
|
||
}
|
||
|
||
// enrichQSOForAwards fills in CQ/ITU zone, continent and DXCC entity from
|
||
// cty.dat when the QSO doesn't carry them, so computed awards (WAZ/WITUZ/WAC/
|
||
// DXCC) work even on records that were never enriched. Non-destructive: it
|
||
// mutates the in-memory copy used for award matching only, never the database.
|
||
func (a *App) enrichQSOForAwards(q *qso.QSO) {
|
||
if a.dxcc == nil {
|
||
return
|
||
}
|
||
// Recover the band from the frequency when missing, so per-band award
|
||
// statistics aren't lost for QSOs that carry only a frequency.
|
||
if strings.TrimSpace(q.Band) == "" && q.FreqHz != nil && *q.FreqHz > 0 {
|
||
if b := bandForHz(*q.FreqHz); b != "" {
|
||
q.Band = b
|
||
}
|
||
}
|
||
needCQ := q.CQZ == nil || *q.CQZ == 0
|
||
needITU := q.ITUZ == nil || *q.ITUZ == 0
|
||
needCont := strings.TrimSpace(q.Continent) == ""
|
||
needDXCC := q.DXCC == nil || *q.DXCC == 0
|
||
if !needCQ && !needITU && !needCont && !needDXCC {
|
||
return
|
||
}
|
||
m, ok := a.dxcc.Lookup(q.Callsign)
|
||
if !ok {
|
||
return
|
||
}
|
||
if needCQ && m.CQZone > 0 {
|
||
z := m.CQZone
|
||
q.CQZ = &z
|
||
}
|
||
if needITU && m.ITUZone > 0 {
|
||
z := m.ITUZone
|
||
q.ITUZ = &z
|
||
}
|
||
if needCont && m.Continent != "" {
|
||
q.Continent = m.Continent
|
||
}
|
||
if needDXCC && m.Entity != nil {
|
||
if n := dxcc.EntityDXCC(m.Entity.Name); n > 0 {
|
||
q.DXCC = &n
|
||
}
|
||
}
|
||
// Zone-split countries (USA, Australia): the per-entity default zone is too
|
||
// coarse (W6 = CQ5 instead of 3). Apply the call-district rule so awards
|
||
// (WAZ / WITUZ) match Log4OM. This OVERRIDES a stored entity-default zone.
|
||
a.refineDistrictZones(q)
|
||
}
|
||
|
||
// awardBandPlan maps a frequency (Hz) to its ADIF band. Used to recover the
|
||
// band for award statistics when a QSO has a frequency but no band field.
|
||
var awardBandPlan = []struct {
|
||
name string
|
||
lo, hi int64
|
||
}{
|
||
{"2190m", 135700, 137800}, {"630m", 472000, 479000}, {"160m", 1800000, 2000000},
|
||
{"80m", 3500000, 4000000}, {"60m", 5060000, 5450000}, {"40m", 7000000, 7300000},
|
||
{"30m", 10100000, 10150000}, {"20m", 14000000, 14350000}, {"17m", 18068000, 18168000},
|
||
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
|
||
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
|
||
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
|
||
{"13cm", 2300000000, 2450000000},
|
||
}
|
||
|
||
func bandForHz(hz int64) string {
|
||
for _, b := range awardBandPlan {
|
||
if hz >= b.lo && hz <= b.hi {
|
||
return b.name
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// isComputedAwardField reports whether an award's field is auto-derived from
|
||
// structured QSO data (entity, zones, prefix, location) rather than a reference
|
||
// the operator assigns by hand. Such awards are read-only in the per-QSO editor.
|
||
func isComputedAwardField(field string) bool {
|
||
switch field {
|
||
case "dxcc", "cqz", "ituz", "prefix", "callsign", "state", "cont", "country", "grid":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ComputeQSOAwardRefs returns every award reference a single QSO contributes to
|
||
// — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for
|
||
// the per-QSO Award Refs editor. Reuses the same engine as GetAwards.
|
||
func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
||
nameOf := func(field, ref string) string {
|
||
switch field {
|
||
case "dxcc":
|
||
if n, err := strconv.Atoi(ref); err == nil {
|
||
return dxcc.NameForDXCC(n)
|
||
}
|
||
case "cont":
|
||
return continentName(ref)
|
||
}
|
||
return ""
|
||
}
|
||
a.enrichQSOForAwards(&q)
|
||
defs := a.awardDefs()
|
||
fieldByCode := map[string]string{}
|
||
for _, d := range defs {
|
||
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||
}
|
||
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
|
||
var out []QSOAwardRef
|
||
for i := range results {
|
||
r := &results[i]
|
||
// "Pickable" = the reference is manually assigned per QSO (POTA, notes…),
|
||
// NOT auto-derived from a structured field. DXCC/WAZ/WAS/WAC/WPX are
|
||
// computed and belong in the read-only panel even though they now have
|
||
// reference lists.
|
||
pickable := !isComputedAwardField(fieldByCode[strings.ToUpper(r.Code)])
|
||
for _, rf := range r.Refs {
|
||
if !rf.Worked {
|
||
continue // a single QSO only contributes worked references
|
||
}
|
||
out = append(out, QSOAwardRef{Code: r.Code, Ref: rf.Ref, Name: rf.Name, Pickable: pickable})
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// AwardRefMeta describes a reference list's state for the UI.
|
||
type AwardRefMeta struct {
|
||
Code string `json:"code"`
|
||
Count int `json:"count"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
CanUpdate bool `json:"can_update"`
|
||
}
|
||
|
||
// GetAwardReferenceMeta returns the reference-list status for every defined
|
||
// award (count + last update + whether an online updater exists).
|
||
func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
|
||
if a.awardRefs == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
counts, err := a.awardRefs.Counts(a.ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out []AwardRefMeta
|
||
for _, d := range a.awardDefs() {
|
||
code := strings.ToUpper(d.Code)
|
||
updated := ""
|
||
if a.settings != nil {
|
||
updated, _ = a.settings.Get(a.ctx, keyAwardRefsUpdated+code)
|
||
}
|
||
out = append(out, AwardRefMeta{
|
||
Code: d.Code,
|
||
Count: counts[code],
|
||
UpdatedAt: updated,
|
||
CanUpdate: awardref.CanUpdate(d.Code),
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// UpdateAwardReferenceList downloads the latest reference list for an award and
|
||
// replaces the stored set. Returns the new reference count.
|
||
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
|
||
if a.awardRefs == nil {
|
||
return AwardRefMeta{}, fmt.Errorf("db not initialized")
|
||
}
|
||
if !awardref.CanUpdate(code) {
|
||
return AwardRefMeta{}, fmt.Errorf("no online reference list for %q", code)
|
||
}
|
||
refs, err := awardref.Download(a.ctx, code)
|
||
if err != nil {
|
||
return AwardRefMeta{}, err
|
||
}
|
||
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
|
||
if err != nil {
|
||
return AwardRefMeta{}, err
|
||
}
|
||
now := time.Now().Format("2006-01-02 15:04")
|
||
if a.settings != nil {
|
||
_ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), now)
|
||
}
|
||
applog.Printf("award-refs: %s updated — %d references", strings.ToUpper(code), n)
|
||
return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil
|
||
}
|
||
|
||
// SearchAwardReferences finds references of an award by code/name (for the
|
||
// per-QSO reference picker). dxcc>0 restricts to one entity.
|
||
func (a *App) SearchAwardReferences(code, query string, dxcc, limit int) ([]awardref.Ref, error) {
|
||
if a.awardRefs == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.awardRefs.Search(a.ctx, code, query, dxcc, limit)
|
||
}
|
||
|
||
// ListAwardReferences returns every reference of an award (for the editor).
|
||
func (a *App) ListAwardReferences(code string) ([]awardref.Ref, error) {
|
||
if a.awardRefs == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.awardRefs.List(a.ctx, code)
|
||
}
|
||
|
||
// SaveAwardReference inserts or updates a single reference.
|
||
func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
|
||
if a.awardRefs == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
return a.awardRefs.Upsert(a.ctx, code, ref)
|
||
}
|
||
|
||
// DeleteAwardReference removes one reference from an award.
|
||
func (a *App) DeleteAwardReference(code, refCode string) error {
|
||
if a.awardRefs == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
return a.awardRefs.Delete(a.ctx, code, refCode)
|
||
}
|
||
|
||
// ReplaceAwardReferences atomically replaces an award's whole reference list
|
||
// (used by paste / CSV import and presets). Returns the new count.
|
||
func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, error) {
|
||
if a.awardRefs == nil {
|
||
return 0, fmt.Errorf("db not initialized")
|
||
}
|
||
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if a.settings != nil {
|
||
_ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// GetAwardPresets returns the catalogue of built-in reference lists.
|
||
func (a *App) GetAwardPresets() []awardref.Preset { return awardref.Presets() }
|
||
|
||
// ApplyAwardPreset replaces an award's reference list with a built-in preset.
|
||
// Returns the new reference count.
|
||
func (a *App) ApplyAwardPreset(code, presetKey string) (int, error) {
|
||
p, ok := awardref.PresetByKey(presetKey)
|
||
if !ok {
|
||
return 0, fmt.Errorf("unknown preset %q", presetKey)
|
||
}
|
||
return a.ReplaceAwardReferences(code, p.Refs)
|
||
}
|
||
|
||
// PopulateBuiltinReferences seeds an award's reference list from the built-in
|
||
// data (DXCC entities, CQ zones, continents, US states, French departments).
|
||
// Returns the new count; ok=false awards (online / custom) yield an error.
|
||
func (a *App) PopulateBuiltinReferences(code string) (int, error) {
|
||
refs, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
|
||
if !ok {
|
||
return 0, fmt.Errorf("no built-in reference list for %q", code)
|
||
}
|
||
return a.ReplaceAwardReferences(code, refs)
|
||
}
|
||
|
||
// HasBuiltinReferences reports whether an award code ships a built-in list.
|
||
func (a *App) HasBuiltinReferences(code string) bool {
|
||
_, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
|
||
return ok
|
||
}
|
||
|
||
// builtinRefsVersion is bumped whenever the built-in reference data changes
|
||
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
|
||
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
|
||
const builtinRefsVersion = "2"
|
||
|
||
// seedBuiltinReferences populates the reference lists of built-in awards.
|
||
// - First run (no version stored): seed only awards that have NO references
|
||
// yet, so an online-loaded list (POTA…) or a user list isn't clobbered.
|
||
// - Version bump (stored != current): RE-SEED the derived built-in lists
|
||
// (DXCC, WAZ, WAC, WAS, DDFM) to push data corrections to existing installs.
|
||
// These lists are canonical, not user-maintained, so overwriting is safe.
|
||
func (a *App) seedBuiltinReferences() {
|
||
if a.awardRefs == nil || a.settings == nil {
|
||
return
|
||
}
|
||
ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
|
||
if ver == builtinRefsVersion {
|
||
return
|
||
}
|
||
firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
|
||
if ver == "1" {
|
||
firstRun = false // already seeded once → treat as a version upgrade
|
||
}
|
||
counts, err := a.awardRefs.Counts(a.ctx)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, d := range a.awardDefs() {
|
||
code := strings.ToUpper(d.Code)
|
||
if firstRun && counts[code] > 0 {
|
||
continue // don't overwrite an existing list on a fresh install
|
||
}
|
||
if refs, ok := awardref.BuiltinRefs(code); ok {
|
||
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
|
||
applog.Printf("award-refs: seeded %s — %d references", code, n)
|
||
}
|
||
}
|
||
}
|
||
_ = a.settings.Set(a.ctx, keyAwardRefsSeeded, builtinRefsVersion)
|
||
}
|
||
|
||
// ImportAwardReferencesText parses pasted lines or CSV into references and
|
||
// replaces the award's list. Accepted per line (comma/semicolon/tab separated):
|
||
//
|
||
// CODE
|
||
// CODE,Description
|
||
// CODE,Description,Group
|
||
// CODE,Description,Group,Subgroup
|
||
// CODE,Description,Group,Subgroup,DXCC
|
||
//
|
||
// A leading header row (first field "code"/"ref"/"reference") is skipped.
|
||
func (a *App) ImportAwardReferencesText(code, text string) (int, error) {
|
||
refs := parseRefLines(text)
|
||
if len(refs) == 0 {
|
||
return 0, fmt.Errorf("no references found in input")
|
||
}
|
||
return a.ReplaceAwardReferences(code, refs)
|
||
}
|
||
|
||
// parseRefLines turns pasted/CSV text into references (best-effort, tolerant of
|
||
// comma, semicolon or tab delimiters).
|
||
func parseRefLines(text string) []awardref.Ref {
|
||
var out []awardref.Ref
|
||
for i, raw := range strings.Split(text, "\n") {
|
||
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
||
if line == "" {
|
||
continue
|
||
}
|
||
var fields []string
|
||
switch {
|
||
case strings.Contains(line, "\t"):
|
||
fields = strings.Split(line, "\t")
|
||
case strings.Contains(line, ";"):
|
||
fields = strings.Split(line, ";")
|
||
default:
|
||
fields = strings.Split(line, ",")
|
||
}
|
||
for j := range fields {
|
||
fields[j] = strings.TrimSpace(fields[j])
|
||
}
|
||
code := strings.ToUpper(fields[0])
|
||
if code == "" {
|
||
continue
|
||
}
|
||
// Skip a header row.
|
||
if i == 0 {
|
||
switch strings.ToLower(fields[0]) {
|
||
case "code", "ref", "reference", "ref_code":
|
||
continue
|
||
}
|
||
}
|
||
ref := awardref.Ref{Code: code, Valid: true}
|
||
if len(fields) > 1 {
|
||
ref.Name = fields[1]
|
||
}
|
||
if len(fields) > 2 {
|
||
ref.Group = fields[2]
|
||
}
|
||
if len(fields) > 3 {
|
||
ref.SubGrp = fields[3]
|
||
}
|
||
if len(fields) > 4 {
|
||
if n, err := strconv.Atoi(fields[4]); err == nil {
|
||
ref.DXCC = n
|
||
}
|
||
}
|
||
out = append(out, ref)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func continentName(code string) string {
|
||
switch strings.ToUpper(code) {
|
||
case "AF":
|
||
return "Africa"
|
||
case "AN":
|
||
return "Antarctica"
|
||
case "AS":
|
||
return "Asia"
|
||
case "EU":
|
||
return "Europe"
|
||
case "NA":
|
||
return "North America"
|
||
case "OC":
|
||
return "Oceania"
|
||
case "SA":
|
||
return "South America"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ListQSOFiltered returns QSOs matching the advanced filter builder.
|
||
func (a *App) ListQSOFiltered(f qso.QueryFilter) ([]qso.QSO, error) {
|
||
if a.qso == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.ListFiltered(a.ctx, f)
|
||
}
|
||
|
||
// CountQSOFiltered returns how many QSOs match the filter (ignoring the row
|
||
// limit) so the UI can show "showing 500 of 1,234 matches".
|
||
func (a *App) CountQSOFiltered(f qso.QueryFilter) (int64, error) {
|
||
if a.qso == nil {
|
||
return 0, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.CountFiltered(a.ctx, f)
|
||
}
|
||
|
||
// FilterFields exposes the whitelisted filterable columns to the frontend.
|
||
func (a *App) FilterFields() []string {
|
||
return qso.FilterableFields()
|
||
}
|
||
|
||
func (a *App) GetQSO(id int64) (qso.QSO, error) {
|
||
if a.qso == nil {
|
||
return qso.QSO{}, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.GetByID(a.ctx, id)
|
||
}
|
||
|
||
func (a *App) UpdateQSO(q qso.QSO) error {
|
||
if a.qso == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.Update(a.ctx, q)
|
||
}
|
||
|
||
func (a *App) DeleteQSO(id int64) error {
|
||
if a.qso == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.Delete(a.ctx, id)
|
||
}
|
||
|
||
// WorkedBefore returns prior contacts with the given callsign at both
|
||
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
||
// will infer it from past QSOs with the same call when possible.
|
||
func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, error) {
|
||
if a.qso == nil {
|
||
return qso.WorkedBefore{}, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
|
||
}
|
||
|
||
// SetCompactMode toggles a tiny always-on-top window that exposes just the
|
||
// QSO entry — useful when running on a single screen alongside WSJT-X,
|
||
// JT-Alert or the cluster.
|
||
//
|
||
// We can't easily spawn a real second OS window in Wails v2, but a resized
|
||
// always-on-top main window does the job from the user's perspective.
|
||
// Sizes tuned so the compact entry strip fits in a single row (no wrap).
|
||
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
|
||
// the previous (larger) min — and increased BEFORE resizing up.
|
||
const (
|
||
compactW, compactH = 1240, 158
|
||
normalW, normalH = 1400, 900
|
||
normalMinW, normalMinH = 1100, 700
|
||
// Large enough to never constrain a maximised window on big displays.
|
||
maxW, maxH = 8000, 6000
|
||
)
|
||
|
||
func (a *App) SetCompactMode(on bool) {
|
||
if a.ctx == nil {
|
||
return
|
||
}
|
||
if on {
|
||
// Lock the window to the compact size by pinning min == max. Without
|
||
// the max pin, dragging the frameless window (esp. across monitors /
|
||
// DPI boundaries) makes Windows snap it back to a large size.
|
||
wruntime.WindowSetMinSize(a.ctx, compactW, compactH)
|
||
wruntime.WindowSetMaxSize(a.ctx, compactW, compactH)
|
||
wruntime.WindowSetSize(a.ctx, compactW, compactH)
|
||
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
|
||
} else {
|
||
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
|
||
// Release the lock first (raise the max) before growing back.
|
||
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
|
||
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
||
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
||
}
|
||
}
|
||
|
||
// DeleteAllQSO wipes every QSO. Returns the number of rows removed.
|
||
// The frontend MUST gate this behind a strong confirmation prompt.
|
||
func (a *App) DeleteAllQSO() (int64, error) {
|
||
if a.qso == nil {
|
||
return 0, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.qso.DeleteAll(a.ctx)
|
||
}
|
||
|
||
// --- ADIF bindings ---
|
||
|
||
func (a *App) OpenADIFFile() (string, error) {
|
||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||
Title: "Import ADIF",
|
||
Filters: []wruntime.FileFilter{
|
||
{DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"},
|
||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||
},
|
||
})
|
||
}
|
||
|
||
// ImportADIF imports an ADIF file. dupMode controls how records matching an
|
||
// existing QSO (same call + UTC-minute + band + mode) are handled:
|
||
// - "skip" : leave the existing QSO untouched (default, safe)
|
||
// - "update" : merge the file's non-empty fields onto the existing QSO —
|
||
// refreshes QSL/confirmation statuses when re-syncing from
|
||
// Log4OM / LoTW without clobbering fields the file omits
|
||
// - "all" : insert every record, duplicates included
|
||
//
|
||
// applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from
|
||
// cty.dat for every record, overriding what the file carries — corrects the
|
||
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
|
||
// Russia). Everything else in the ADIF is still preserved verbatim.
|
||
func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.ImportResult, error) {
|
||
if a.qso == nil {
|
||
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
||
}
|
||
if path == "" {
|
||
return adif.ImportResult{}, fmt.Errorf("empty path")
|
||
}
|
||
// Import preserves the ADIF verbatim — NO station / confirmation defaults
|
||
// are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log);
|
||
// stamping them on a historical import would, e.g., flag old QSOs as
|
||
// "LoTW requested" and try to re-upload them.
|
||
im := &adif.Importer{Repo: a.qso}
|
||
switch dupMode {
|
||
case "update":
|
||
im.UpdateDuplicates = true
|
||
case "all":
|
||
// insert everything
|
||
default: // "skip"
|
||
im.SkipDuplicates = true
|
||
}
|
||
// When the user opts to fix countries on import, recompute from cty.dat and
|
||
// then apply ClubLog's date-ranged exceptions (which take precedence) if
|
||
// ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim.
|
||
clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded()
|
||
if applyCty {
|
||
im.Enrich = func(q *qso.QSO) {
|
||
a.enrichContactedFromCtyForce(q)
|
||
if clEnabled {
|
||
a.applyClublogException(q, false)
|
||
}
|
||
}
|
||
}
|
||
im.OnProgress = func(processed, total int) {
|
||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
||
}
|
||
return im.ImportFile(a.ctx, path)
|
||
}
|
||
|
||
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
|
||
// OpsLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled.
|
||
func (a *App) SaveADIFFile() (string, error) {
|
||
suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".adi"
|
||
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||
Title: "Export ADIF",
|
||
DefaultFilename: suggested,
|
||
Filters: []wruntime.FileFilter{
|
||
{DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"},
|
||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||
},
|
||
})
|
||
}
|
||
|
||
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
||
// Streams from DB so memory stays flat even with 100k+ records.
|
||
// includeAppFields=false → portable standard ADIF (for other loggers);
|
||
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
|
||
func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) {
|
||
if a.qso == nil {
|
||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||
}
|
||
if path == "" {
|
||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||
}
|
||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
||
return ex.ExportFile(a.ctx, path)
|
||
}
|
||
|
||
// ExportADIFFiltered writes the QSOs matching the current filter to path, with
|
||
// NO row limit (the on-screen list is capped by the threshold; this is not).
|
||
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter) (adif.ExportResult, error) {
|
||
if a.qso == nil {
|
||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||
}
|
||
if path == "" {
|
||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||
}
|
||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
||
return ex.ExportFileFiltered(a.ctx, path, f)
|
||
}
|
||
|
||
// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
|
||
// operator highlighted in the grid).
|
||
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64) (adif.ExportResult, error) {
|
||
if a.qso == nil {
|
||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||
}
|
||
if path == "" {
|
||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||
}
|
||
if len(ids) == 0 {
|
||
return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
|
||
}
|
||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
||
return ex.ExportFileByIDs(a.ctx, path, ids)
|
||
}
|
||
|
||
// --- Lookup bindings ---
|
||
|
||
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
||
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
|
||
// "callsign not found".
|
||
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||
if a.lookup == nil {
|
||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||
}
|
||
r, err := a.lookup.Lookup(a.ctx, callsign)
|
||
if errors.Is(err, lookup.ErrNotFound) {
|
||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||
}
|
||
// Respect the user's "Download profile images" setting: even if the
|
||
// cache holds the URL we hide it when the toggle is off so the
|
||
// frontend doesn't render the <img> (which would still fetch from
|
||
// QRZ). Cheap to check per call — settings is in-memory after init.
|
||
if err == nil && r.ImageURL != "" {
|
||
if s, _ := a.GetLookupSettings(); !s.DownloadImages {
|
||
r.ImageURL = ""
|
||
}
|
||
}
|
||
// ClubLog exception override (live entry → today's date): for an active
|
||
// DXpedition the entered call gets the right entity/zones immediately.
|
||
if a.clublogCtyEnabled() && a.clublog != nil {
|
||
if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok {
|
||
r.Country = titleEntity(e.Entity)
|
||
if e.Cont != "" {
|
||
r.Continent = e.Cont
|
||
}
|
||
if e.ADIF != 0 {
|
||
r.DXCC = e.ADIF
|
||
}
|
||
if e.CQZ != 0 {
|
||
r.CQZ = e.CQZ
|
||
}
|
||
if r.Callsign == "" {
|
||
r.Callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||
}
|
||
}
|
||
}
|
||
return r, err
|
||
}
|
||
|
||
// OpenExternalURL opens a URL in the user's default browser. Wails ships
|
||
// runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon
|
||
// next to the callsign field, the future Clublog/HamQTH shortcuts, etc.
|
||
func (a *App) OpenExternalURL(url string) error {
|
||
url = strings.TrimSpace(url)
|
||
if url == "" {
|
||
return fmt.Errorf("empty URL")
|
||
}
|
||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||
return fmt.Errorf("only http(s) URLs allowed, got %q", url)
|
||
}
|
||
wruntime.BrowserOpenURL(a.ctx, url)
|
||
return nil
|
||
}
|
||
|
||
// GetLookupSettings returns current credentials and cache TTL.
|
||
func (a *App) GetLookupSettings() (LookupSettings, error) {
|
||
if a.settings == nil {
|
||
return LookupSettings{}, fmt.Errorf("db not initialized")
|
||
}
|
||
m, err := a.settings.GetMany(a.ctx,
|
||
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
|
||
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages)
|
||
if err != nil {
|
||
return LookupSettings{}, err
|
||
}
|
||
ttl, _ := strconv.Atoi(m[keyCacheTTL])
|
||
if ttl <= 0 {
|
||
ttl = 30
|
||
}
|
||
return LookupSettings{
|
||
QRZUser: m[keyQRZUser],
|
||
QRZPassword: m[keyQRZPassword],
|
||
HamQTHUser: m[keyHQUser],
|
||
HamQTHPassword: m[keyHQPassword],
|
||
Primary: m[keyLookupPrimary],
|
||
Failsafe: m[keyLookupFailsafe],
|
||
DownloadImages: m[keyLookupImages] == "1",
|
||
CacheTTLDays: ttl,
|
||
}, nil
|
||
}
|
||
|
||
// SaveLookupSettings persists credentials and rebuilds the provider chain.
|
||
func (a *App) SaveLookupSettings(s LookupSettings) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
if s.CacheTTLDays <= 0 {
|
||
s.CacheTTLDays = 30
|
||
}
|
||
// Reject a primary == failsafe routing combo — would just hit the same
|
||
// provider twice. Frontend should prevent this but defend in depth.
|
||
if s.Primary != "" && s.Primary == s.Failsafe {
|
||
s.Failsafe = ""
|
||
}
|
||
for k, v := range map[string]string{
|
||
keyQRZUser: s.QRZUser,
|
||
keyQRZPassword: s.QRZPassword,
|
||
keyHQUser: s.HamQTHUser,
|
||
keyHQPassword: s.HamQTHPassword,
|
||
keyCacheTTL: strconv.Itoa(s.CacheTTLDays),
|
||
keyLookupPrimary: s.Primary,
|
||
keyLookupFailsafe: s.Failsafe,
|
||
keyLookupImages: boolStr(s.DownloadImages),
|
||
} {
|
||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
a.reloadLookupProviders()
|
||
return nil
|
||
}
|
||
|
||
// TestLookupProvider runs a one-shot lookup against a specific provider so
|
||
// the user can verify credentials before saving. callsign defaults to the
|
||
// active profile's callsign when empty (handy "test against my own call").
|
||
// Returns the result on success or a descriptive error.
|
||
func (a *App) TestLookupProvider(name, callsign, user, password string) (lookup.Result, error) {
|
||
if user == "" || password == "" {
|
||
return lookup.Result{}, fmt.Errorf("user and password required")
|
||
}
|
||
if callsign == "" {
|
||
if a.profiles != nil {
|
||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||
callsign = p.Callsign
|
||
}
|
||
}
|
||
if callsign == "" {
|
||
callsign = "W1AW" // ARRL HQ — always present in every database
|
||
}
|
||
}
|
||
var p lookup.Provider
|
||
switch name {
|
||
case "qrz":
|
||
p = lookup.NewQRZ(user, password)
|
||
case "hamqth":
|
||
p = lookup.NewHamQTH(user, password)
|
||
default:
|
||
return lookup.Result{}, fmt.Errorf("unknown provider %q", name)
|
||
}
|
||
r, err := p.Lookup(a.ctx, callsign)
|
||
if errors.Is(err, lookup.ErrNotFound) {
|
||
return lookup.Result{}, fmt.Errorf("%s reachable but %q not found (creds look OK)", name, callsign)
|
||
}
|
||
if err != nil {
|
||
return lookup.Result{}, err
|
||
}
|
||
r.Source = name
|
||
return r, nil
|
||
}
|
||
|
||
// --- CAT bindings ---
|
||
|
||
// GetCATSettings returns the stored CAT configuration (defaults applied).
|
||
func (a *App) GetCATSettings() (CATSettings, error) {
|
||
if a.settings == nil {
|
||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||
}
|
||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||
if err != nil {
|
||
return CATSettings{}, err
|
||
}
|
||
out := CATSettings{
|
||
Enabled: m[keyCATEnabled] == "1",
|
||
Backend: m[keyCATBackend],
|
||
OmniRigNum: 1,
|
||
PollMs: 250,
|
||
DelayMs: 0,
|
||
DigitalDefault: m[keyCATDigitalDefault],
|
||
}
|
||
if out.Backend == "" {
|
||
out.Backend = "omnirig"
|
||
}
|
||
if out.DigitalDefault == "" {
|
||
out.DigitalDefault = "FT8"
|
||
}
|
||
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
||
out.OmniRigNum = n
|
||
}
|
||
if n, _ := strconv.Atoi(m[keyCATPollMs]); n >= 50 && n <= 2000 {
|
||
out.PollMs = n
|
||
}
|
||
if n, _ := strconv.Atoi(m[keyCATDelayMs]); n >= 0 && n <= 500 {
|
||
out.DelayMs = n
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SaveCATSettings persists CAT config and restarts the manager accordingly.
|
||
func (a *App) SaveCATSettings(s CATSettings) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
if s.Backend == "" {
|
||
s.Backend = "omnirig"
|
||
}
|
||
if s.OmniRigNum != 1 && s.OmniRigNum != 2 {
|
||
s.OmniRigNum = 1
|
||
}
|
||
if s.PollMs < 50 || s.PollMs > 2000 {
|
||
s.PollMs = 250
|
||
}
|
||
if s.DelayMs < 0 || s.DelayMs > 500 {
|
||
s.DelayMs = 0
|
||
}
|
||
enabled := "0"
|
||
if s.Enabled {
|
||
enabled = "1"
|
||
}
|
||
if s.DigitalDefault == "" {
|
||
s.DigitalDefault = "FT8"
|
||
}
|
||
for k, v := range map[string]string{
|
||
keyCATEnabled: enabled,
|
||
keyCATBackend: s.Backend,
|
||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||
} {
|
||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
a.reloadCAT()
|
||
return nil
|
||
}
|
||
|
||
// ── Audio (Digital Voice Keyer + QSO recorder) ────────────────────────
|
||
|
||
// AudioSettings is the machine-local audio config for the voice keyer and
|
||
// the QSO recorder.
|
||
type AudioSettings struct {
|
||
FromRadio string `json:"from_radio"` // capture id: rig RX audio
|
||
ToRadio string `json:"to_radio"` // render id: into the rig
|
||
RecordingDevice string `json:"recording_device"` // capture id: your mic
|
||
ListeningDevice string `json:"listening_device"` // render id: preview
|
||
QSORecord bool `json:"qso_record"` // auto-record every QSO
|
||
QSODir string `json:"qso_dir"` // recordings folder
|
||
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
|
||
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
|
||
PTTPort string `json:"ptt_port"` // COM port for serial PTT
|
||
Format string `json:"format"` // "wav" | "mp3"
|
||
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
||
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
||
}
|
||
|
||
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
|
||
// for the device dropdowns.
|
||
func (a *App) ListAudioInputDevices() ([]audio.Device, error) { return audio.ListInputDevices() }
|
||
func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.ListOutputDevices() }
|
||
|
||
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
|
||
func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100}
|
||
if a.settings == nil {
|
||
return out, nil
|
||
}
|
||
m, err := a.settings.GetMany(a.ctx,
|
||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
||
keyAudioFromGain, keyAudioMicGain)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
if v := m[keyAudioFormat]; v == "mp3" || v == "wav" {
|
||
out.Format = v
|
||
}
|
||
if v := m[keyAudioPTTMethod]; v == "rts" || v == "dtr" || v == "cat" || v == "none" {
|
||
out.PTTMethod = v
|
||
}
|
||
out.PTTPort = m[keyAudioPTTPort]
|
||
out.FromRadio = m[keyAudioFromRadio]
|
||
out.ToRadio = m[keyAudioToRadio]
|
||
out.RecordingDevice = m[keyAudioRecDevice]
|
||
out.ListeningDevice = m[keyAudioListenDevice]
|
||
out.QSORecord = m[keyAudioQSORecord] == "1"
|
||
out.QSODir = m[keyAudioQSODir]
|
||
if n, _ := strconv.Atoi(m[keyAudioPreroll]); n >= 0 && n <= 60 {
|
||
if n > 0 {
|
||
out.PrerollSeconds = n
|
||
}
|
||
}
|
||
if n, _ := strconv.Atoi(m[keyAudioFromGain]); n > 0 && n <= 400 {
|
||
out.FromGain = n
|
||
}
|
||
if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 {
|
||
out.MicGain = n
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SaveAudioSettings persists the audio config.
|
||
func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
if s.PrerollSeconds < 0 || s.PrerollSeconds > 60 {
|
||
s.PrerollSeconds = 8
|
||
}
|
||
qr := "0"
|
||
if s.QSORecord {
|
||
qr = "1"
|
||
}
|
||
pttMethod := s.PTTMethod
|
||
if pttMethod != "rts" && pttMethod != "dtr" && pttMethod != "cat" {
|
||
pttMethod = "none"
|
||
}
|
||
format := s.Format
|
||
if format != "mp3" {
|
||
format = "wav"
|
||
}
|
||
if s.FromGain <= 0 || s.FromGain > 400 {
|
||
s.FromGain = 100
|
||
}
|
||
if s.MicGain <= 0 || s.MicGain > 400 {
|
||
s.MicGain = 100
|
||
}
|
||
for k, v := range map[string]string{
|
||
keyAudioFromRadio: s.FromRadio,
|
||
keyAudioToRadio: s.ToRadio,
|
||
keyAudioRecDevice: s.RecordingDevice,
|
||
keyAudioListenDevice: s.ListeningDevice,
|
||
keyAudioQSORecord: qr,
|
||
keyAudioQSODir: strings.TrimSpace(s.QSODir),
|
||
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
|
||
keyAudioPTTMethod: pttMethod,
|
||
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
|
||
keyAudioFormat: format,
|
||
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
||
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
||
} {
|
||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Apply device/preroll/enable changes to the running recorder.
|
||
a.startQSORecorderIfEnabled()
|
||
return nil
|
||
}
|
||
|
||
// PickAudioFolder opens a directory picker for the QSO-recordings folder.
|
||
func (a *App) PickAudioFolder() (string, error) {
|
||
if a.ctx == nil {
|
||
return "", fmt.Errorf("no app context")
|
||
}
|
||
cur, _ := a.GetAudioSettings()
|
||
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||
Title: "Pick a folder for QSO recordings",
|
||
DefaultDirectory: firstExistingAncestor(cur.QSODir),
|
||
})
|
||
}
|
||
|
||
// ── QSO recorder ──────────────────────────────────────────────────────
|
||
|
||
// startQSORecorderIfEnabled (re)starts the continuous recorder per the current
|
||
// settings. Safe to call repeatedly — it stops any running instance first.
|
||
func (a *App) startQSORecorderIfEnabled() {
|
||
if a.qsoRec == nil {
|
||
return
|
||
}
|
||
a.qsoRec.Stop()
|
||
cfg, _ := a.GetAudioSettings()
|
||
if !cfg.QSORecord {
|
||
return
|
||
}
|
||
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
|
||
applog.Printf("qso-rec: start failed: %v", err)
|
||
return
|
||
}
|
||
fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
|
||
if cfg.FromGain == 0 {
|
||
fromGain = 1
|
||
}
|
||
if cfg.MicGain == 0 {
|
||
micGain = 1
|
||
}
|
||
a.qsoRec.SetGains(fromGain, micGain)
|
||
applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain)
|
||
}
|
||
|
||
// qsoRecDir returns the configured recordings folder, defaulting to
|
||
// <dataDir>/Recordings, and ensures it exists.
|
||
func (a *App) qsoRecDir() string {
|
||
cfg, _ := a.GetAudioSettings()
|
||
d := strings.TrimSpace(cfg.QSODir)
|
||
if d == "" {
|
||
d = filepath.Join(a.dataDir, "Recordings")
|
||
}
|
||
_ = os.MkdirAll(d, 0o755)
|
||
return d
|
||
}
|
||
|
||
// saveQSORecording finalises the active recording (if any) into a file named
|
||
// CALL_BAND_MODE_YYYYMMDD_HHMMSS.ext, stores the filename on the QSO (so it can
|
||
// be e-mailed later), and auto-sends it to the contacted operator when enabled
|
||
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
|
||
// q must have its ID set.
|
||
// recordableMode reports whether a QSO mode is worth an audio recording —
|
||
// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no
|
||
// useful audio, so they are never recorded.
|
||
func recordableMode(mode string) bool {
|
||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (a *App) saveQSORecording(q *qso.QSO) {
|
||
if a.qsoRec == nil || !a.qsoRec.Active() {
|
||
return
|
||
}
|
||
if !recordableMode(q.Mode) {
|
||
a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
|
||
return
|
||
}
|
||
ext := "wav"
|
||
if cfg, _ := a.GetAudioSettings(); cfg.Format == "mp3" {
|
||
ext = "mp3"
|
||
}
|
||
parts := []string{sanitizeFilename(q.Callsign)}
|
||
if b := strings.TrimSpace(q.Band); b != "" {
|
||
parts = append(parts, sanitizeFilename(b))
|
||
}
|
||
if m := strings.TrimSpace(q.Mode); m != "" {
|
||
parts = append(parts, sanitizeFilename(m))
|
||
}
|
||
parts = append(parts, time.Now().UTC().Format("20060102_150405"))
|
||
name := strings.Join(parts, "_") + "." + ext
|
||
path := filepath.Join(a.qsoRecDir(), name)
|
||
if err := a.qsoRec.SaveQSO(path); err != nil {
|
||
applog.Printf("qso-rec: save failed: %v", err)
|
||
return
|
||
}
|
||
applog.Printf("qso-rec: saved %s", path)
|
||
|
||
// Remember the recording on the QSO so it can be e-mailed later.
|
||
if q.ID != 0 {
|
||
if q.Extras == nil {
|
||
q.Extras = map[string]string{}
|
||
}
|
||
q.Extras["APP_OPSLOG_RECORDING"] = name
|
||
if err := a.qso.Update(a.ctx, *q); err != nil {
|
||
applog.Printf("qso-rec: store recording path: %v", err)
|
||
}
|
||
}
|
||
|
||
// Auto-send to the correspondent when enabled and an e-mail is known.
|
||
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" {
|
||
qc := *q
|
||
go func() { _ = a.sendRecordingEmail(qc, path) }()
|
||
}
|
||
}
|
||
|
||
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
|
||
func sanitizeFilename(s string) string {
|
||
s = strings.ToUpper(strings.TrimSpace(s))
|
||
if s == "" {
|
||
s = "QSO"
|
||
}
|
||
repl := func(r rune) rune {
|
||
switch r {
|
||
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ':
|
||
return '_'
|
||
}
|
||
return r
|
||
}
|
||
return strings.Map(repl, s)
|
||
}
|
||
|
||
// QSOAudioBegin starts accumulating a QSO recording (seeded with the pre-roll).
|
||
// Called by the entry strip when a callsign is first entered.
|
||
// QSOAudioBegin starts accumulating a recording for the current QSO. It
|
||
// returns true when a recording is actually running (recorder enabled and
|
||
// capturing), so the UI can show a "REC" indicator.
|
||
func (a *App) QSOAudioBegin() bool {
|
||
if a.qsoRec == nil {
|
||
return false
|
||
}
|
||
a.qsoRec.BeginQSO()
|
||
return a.qsoRec.Active()
|
||
}
|
||
|
||
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
||
// abandoned without logging).
|
||
func (a *App) QSOAudioCancel() {
|
||
if a.qsoRec != nil {
|
||
a.qsoRec.DiscardQSO()
|
||
}
|
||
}
|
||
|
||
// RestartQSORecorder applies new audio settings to the running recorder.
|
||
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
|
||
|
||
// ── E-mail / SMTP (send QSO recordings) ───────────────────────────────
|
||
|
||
const (
|
||
defaultEmailSubject = "Our QSO recording — {CALL}"
|
||
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
||
)
|
||
|
||
// EmailSettings is the user's SMTP config + auto-send + message templates.
|
||
type EmailSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Host string `json:"smtp_host"`
|
||
Port int `json:"smtp_port"`
|
||
User string `json:"smtp_user"`
|
||
Password string `json:"smtp_password"`
|
||
From string `json:"from"`
|
||
Encryption string `json:"encryption"` // "ssl" | "starttls" | "none"
|
||
Auth bool `json:"auth"` // SMTP requires authorization
|
||
AutoSend bool `json:"auto_send"`
|
||
Subject string `json:"subject"`
|
||
Body string `json:"body"`
|
||
}
|
||
|
||
// GetEmailSettings returns the stored SMTP config (with sensible defaults).
|
||
func (a *App) GetEmailSettings() (EmailSettings, error) {
|
||
out := EmailSettings{Port: 587, Encryption: "starttls", Auth: true, Subject: defaultEmailSubject, Body: defaultEmailBody}
|
||
if a.settings == nil {
|
||
return out, nil
|
||
}
|
||
m, err := a.settings.GetMany(a.ctx,
|
||
keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword,
|
||
keyEmailFrom, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
out.Enabled = m[keyEmailEnabled] == "1"
|
||
out.Host = m[keyEmailHost]
|
||
if p, _ := strconv.Atoi(m[keyEmailPort]); p > 0 {
|
||
out.Port = p
|
||
}
|
||
out.User = m[keyEmailUser]
|
||
out.Password = m[keyEmailPassword]
|
||
out.From = m[keyEmailFrom]
|
||
if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" {
|
||
out.Encryption = e
|
||
}
|
||
out.Auth = m[keyEmailAuth] != "0" // default true (unset → required)
|
||
out.AutoSend = m[keyEmailAutoSend] == "1"
|
||
if s := m[keyEmailSubject]; s != "" {
|
||
out.Subject = s
|
||
}
|
||
if b := m[keyEmailBody]; b != "" {
|
||
out.Body = b
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SaveEmailSettings persists the SMTP config.
|
||
func (a *App) SaveEmailSettings(s EmailSettings) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
enc := s.Encryption
|
||
if enc != "ssl" && enc != "none" {
|
||
enc = "starttls"
|
||
}
|
||
if s.Port <= 0 {
|
||
s.Port = 587
|
||
}
|
||
b2s := func(b bool) string {
|
||
if b {
|
||
return "1"
|
||
}
|
||
return "0"
|
||
}
|
||
for k, v := range map[string]string{
|
||
keyEmailEnabled: b2s(s.Enabled),
|
||
keyEmailHost: strings.TrimSpace(s.Host),
|
||
keyEmailPort: strconv.Itoa(s.Port),
|
||
keyEmailUser: strings.TrimSpace(s.User),
|
||
keyEmailPassword: s.Password,
|
||
keyEmailFrom: strings.TrimSpace(s.From),
|
||
keyEmailEncryption: enc,
|
||
keyEmailAuth: b2s(s.Auth),
|
||
keyEmailAutoSend: b2s(s.AutoSend),
|
||
keyEmailSubject: s.Subject,
|
||
keyEmailBody: s.Body,
|
||
} {
|
||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) emailConfig(s EmailSettings) email.Config {
|
||
return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, Encryption: s.Encryption, Auth: s.Auth}
|
||
}
|
||
|
||
// TestEmail sends a test message to `to` (defaults to the From address) to
|
||
// validate the SMTP configuration.
|
||
func (a *App) TestEmail(to string) error {
|
||
s, _ := a.GetEmailSettings()
|
||
if to == "" {
|
||
to = s.From
|
||
}
|
||
if to == "" {
|
||
to = s.User
|
||
}
|
||
return email.Send(a.emailConfig(s), to,
|
||
"OpsLog SMTP test", "This is a test message from OpsLog — your SMTP settings work. 73", "")
|
||
}
|
||
|
||
// fillTemplate substitutes {CALL} {DATE} {BAND} {MODE} {MYCALL} in a string.
|
||
func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
|
||
myCall := ""
|
||
if a.profiles != nil {
|
||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||
myCall = p.Callsign
|
||
}
|
||
}
|
||
r := strings.NewReplacer(
|
||
"{CALL}", q.Callsign,
|
||
"{DATE}", q.QSODate.UTC().Format("2006-01-02 15:04 UTC"),
|
||
"{BAND}", q.Band,
|
||
"{MODE}", q.Mode,
|
||
"{MYCALL}", myCall,
|
||
)
|
||
return r.Replace(tmpl)
|
||
}
|
||
|
||
// sendRecordingEmail e-mails a QSO recording to the contacted operator.
|
||
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
|
||
s, _ := a.GetEmailSettings()
|
||
to := strings.TrimSpace(q.Email)
|
||
if to == "" {
|
||
return fmt.Errorf("no e-mail address for %s", q.Callsign)
|
||
}
|
||
subject := s.Subject
|
||
if subject == "" {
|
||
subject = defaultEmailSubject
|
||
}
|
||
body := s.Body
|
||
if body == "" {
|
||
body = defaultEmailBody
|
||
}
|
||
err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), attachPath)
|
||
if err != nil {
|
||
applog.Printf("email: send recording to %s failed: %v", to, err)
|
||
} else {
|
||
applog.Printf("email: recording sent to %s (%s)", to, q.Callsign)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// SendQSORecordingEmail e-mails the stored recording for a QSO id (right-click
|
||
// "Send recording by e-mail"). Errors if the QSO has no recording or e-mail.
|
||
func (a *App) SendQSORecordingEmail(id int64) error {
|
||
if a.qso == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
q, err := a.qso.GetByID(a.ctx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
name := ""
|
||
if q.Extras != nil {
|
||
name = q.Extras["APP_OPSLOG_RECORDING"]
|
||
}
|
||
if name == "" {
|
||
return fmt.Errorf("no recording stored for this QSO")
|
||
}
|
||
path := filepath.Join(a.qsoRecDir(), name)
|
||
if _, e := os.Stat(path); e != nil {
|
||
return fmt.Errorf("recording file missing: %s", name)
|
||
}
|
||
return a.sendRecordingEmail(q, path)
|
||
}
|
||
|
||
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
|
||
|
||
// ClublogCtyInfo is the UI status of the ClubLog exception data.
|
||
type ClublogCtyInfo struct {
|
||
Enabled bool `json:"enabled"`
|
||
Loaded bool `json:"loaded"`
|
||
Date string `json:"date"`
|
||
Count int `json:"count"`
|
||
}
|
||
|
||
func (a *App) clublogCtyEnabled() bool {
|
||
if a.settings == nil {
|
||
return false
|
||
}
|
||
v, _ := a.settings.Get(a.ctx, keyClublogCtyEnabled)
|
||
return v == "1"
|
||
}
|
||
|
||
// GetClublogCtyInfo returns the current ClubLog exception status.
|
||
func (a *App) GetClublogCtyInfo() ClublogCtyInfo {
|
||
info := ClublogCtyInfo{Enabled: a.clublogCtyEnabled()}
|
||
if a.clublog != nil {
|
||
info.Loaded = a.clublog.Loaded()
|
||
info.Date, info.Count = a.clublog.Info()
|
||
}
|
||
return info
|
||
}
|
||
|
||
// SetClublogCtyEnabled toggles ClubLog exception resolution, loading the cached
|
||
// file on first enable.
|
||
func (a *App) SetClublogCtyEnabled(on bool) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
v := "0"
|
||
if on {
|
||
v = "1"
|
||
}
|
||
if err := a.settings.Set(a.ctx, keyClublogCtyEnabled, v); err != nil {
|
||
return err
|
||
}
|
||
if on && a.clublog != nil && !a.clublog.Loaded() {
|
||
_ = a.clublog.EnsureLoaded() // ok if file not downloaded yet
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DownloadClublogCty fetches a fresh ClubLog country file.
|
||
func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
|
||
if a.clublog == nil {
|
||
return ClublogCtyInfo{}, fmt.Errorf("clublog not initialized")
|
||
}
|
||
if err := a.clublog.Download(a.ctx); err != nil {
|
||
return a.GetClublogCtyInfo(), err
|
||
}
|
||
return a.GetClublogCtyInfo(), nil
|
||
}
|
||
|
||
// applyClublogException overrides a QSO's entity fields from a ClubLog
|
||
// exception matching its callsign at its date. force=true ignores the
|
||
// enable toggle (used by the explicit "Update from ClubLog" action).
|
||
// Returns true if something changed.
|
||
func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
|
||
if a.clublog == nil || q.Callsign == "" {
|
||
return false
|
||
}
|
||
if !force && !a.clublogCtyEnabled() {
|
||
return false
|
||
}
|
||
date := q.QSODate
|
||
if date.IsZero() {
|
||
date = time.Now().UTC()
|
||
}
|
||
e, ok := a.clublog.Resolve(q.Callsign, date)
|
||
if !ok {
|
||
return false
|
||
}
|
||
q.Country = titleEntity(e.Entity)
|
||
if e.Cont != "" {
|
||
q.Continent = e.Cont
|
||
}
|
||
if e.ADIF != 0 {
|
||
n := e.ADIF
|
||
q.DXCC = &n
|
||
}
|
||
if e.CQZ != 0 {
|
||
v := e.CQZ
|
||
q.CQZ = &v
|
||
}
|
||
if e.Lat != 0 || e.Lon != 0 {
|
||
lat, lon := e.Lat, e.Lon
|
||
q.Lat, q.Lon = &lat, &lon
|
||
}
|
||
return true
|
||
}
|
||
|
||
// UpdateQSOsFromClublog re-resolves the selected QSOs against ClubLog
|
||
// exceptions (by their QSO date) and saves any that changed.
|
||
func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) {
|
||
if a.qso == nil {
|
||
return 0, fmt.Errorf("db not initialized")
|
||
}
|
||
if a.clublog == nil || !a.clublog.Loaded() {
|
||
return 0, fmt.Errorf("ClubLog data not loaded — download it first")
|
||
}
|
||
changed := 0
|
||
for _, id := range ids {
|
||
q, err := a.qso.GetByID(a.ctx, id)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if a.applyClublogException(&q, true) {
|
||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||
changed++
|
||
}
|
||
}
|
||
}
|
||
return changed, nil
|
||
}
|
||
|
||
// titleCaseIfUpper title-cases a string ONLY when it's entirely upper-case
|
||
// (e.g. Log4OM/contest ADIF sends "SANTO DOMINGO"); mixed-case values are
|
||
// left untouched. Codes like state "DN" stay as-is (no lower-case letters
|
||
// to gain, but they're short — callers pick which fields to pass).
|
||
func titleCaseIfUpper(s string) string {
|
||
t := strings.TrimSpace(s)
|
||
if t == "" || t != strings.ToUpper(t) {
|
||
return s
|
||
}
|
||
return titleEntity(t)
|
||
}
|
||
|
||
// titleEntity converts ClubLog's UPPERCASE entity names to title case
|
||
// ("LORD HOWE ISLAND" → "Lord Howe Island") for display consistency.
|
||
func titleEntity(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return s
|
||
}
|
||
words := strings.Fields(strings.ToLower(s))
|
||
for i, w := range words {
|
||
r := []rune(w)
|
||
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
|
||
words[i] = string(r)
|
||
}
|
||
return strings.Join(words, " ")
|
||
}
|
||
|
||
// ── Digital Voice Keyer (DVK) ─────────────────────────────────────────
|
||
//
|
||
// Six voice-message slots (F1–F6, like the WinKeyer macros). Each message is a
|
||
// WAV file in <dataDir>/dvk/dvk<N>.wav; its label lives in settings. Record via
|
||
// the configured "Recording mic", transmit via "To Radio", preview via
|
||
// "Listening".
|
||
|
||
const dvkSlots = 6
|
||
|
||
// DVKMessage is one voice-keyer slot for the UI.
|
||
type DVKMessage struct {
|
||
Slot int `json:"slot"`
|
||
Label string `json:"label"`
|
||
HasAudio bool `json:"has_audio"`
|
||
DurationSec float64 `json:"duration_sec"`
|
||
}
|
||
|
||
// DVKStatus reflects the live record/playback state for the operating panel.
|
||
type DVKStatus struct {
|
||
Recording bool `json:"recording"`
|
||
Playing bool `json:"playing"`
|
||
RecSlot int `json:"rec_slot"`
|
||
}
|
||
|
||
func (a *App) dvkDir() string {
|
||
d := filepath.Join(a.dataDir, "dvk")
|
||
_ = os.MkdirAll(d, 0o755)
|
||
return d
|
||
}
|
||
|
||
func (a *App) dvkPath(slot int) string {
|
||
return filepath.Join(a.dvkDir(), fmt.Sprintf("dvk%d.wav", slot))
|
||
}
|
||
|
||
func dvkLabelKey(slot int) string { return fmt.Sprintf("audio.dvk.label%d", slot) }
|
||
|
||
func (a *App) dvkStatus() DVKStatus {
|
||
st := DVKStatus{RecSlot: a.dvkRecSlot}
|
||
if a.audioMgr != nil {
|
||
st.Recording = a.audioMgr.IsRecording()
|
||
st.Playing = a.audioMgr.IsPlaying()
|
||
}
|
||
return st
|
||
}
|
||
|
||
// GetDVKStatus returns the current record/playback state.
|
||
func (a *App) GetDVKStatus() DVKStatus { return a.dvkStatus() }
|
||
|
||
// GetDVKMessages returns the six voice-keyer slots with their labels, whether
|
||
// a recording exists, and its duration.
|
||
func (a *App) GetDVKMessages() []DVKMessage {
|
||
out := make([]DVKMessage, 0, dvkSlots)
|
||
for s := 1; s <= dvkSlots; s++ {
|
||
m := DVKMessage{Slot: s}
|
||
if a.settings != nil {
|
||
if v, _ := a.settings.Get(a.ctx, dvkLabelKey(s)); v != "" {
|
||
m.Label = v
|
||
}
|
||
}
|
||
if fi, err := os.Stat(a.dvkPath(s)); err == nil && fi.Size() > 44 {
|
||
m.HasAudio = true
|
||
m.DurationSec = float64(fi.Size()-44) / 32000.0 // 16 kHz mono 16-bit
|
||
}
|
||
out = append(out, m)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// SetDVKLabel renames a voice-keyer slot.
|
||
func (a *App) SetDVKLabel(slot int, label string) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
if slot < 1 || slot > dvkSlots {
|
||
return fmt.Errorf("bad slot")
|
||
}
|
||
return a.settings.Set(a.ctx, dvkLabelKey(slot), strings.TrimSpace(label))
|
||
}
|
||
|
||
// DVKStartRecord begins recording a voice message into the given slot, using
|
||
// the configured Recording mic.
|
||
func (a *App) DVKStartRecord(slot int) error {
|
||
if a.audioMgr == nil {
|
||
return fmt.Errorf("audio not initialized")
|
||
}
|
||
if slot < 1 || slot > dvkSlots {
|
||
return fmt.Errorf("bad slot")
|
||
}
|
||
cfg, _ := a.GetAudioSettings()
|
||
a.dvkRecSlot = slot
|
||
return a.audioMgr.StartRecording(cfg.RecordingDevice)
|
||
}
|
||
|
||
// DVKStopRecord ends the recording and writes it to the slot's WAV file.
|
||
func (a *App) DVKStopRecord() error {
|
||
if a.audioMgr == nil {
|
||
return fmt.Errorf("audio not initialized")
|
||
}
|
||
return a.audioMgr.StopRecording(a.dvkPath(a.dvkRecSlot))
|
||
}
|
||
|
||
// DVKCancelRecord aborts a recording without saving.
|
||
func (a *App) DVKCancelRecord() { if a.audioMgr != nil { a.audioMgr.CancelRecording() } }
|
||
|
||
// DVKPlay transmits a slot's message to the rig ("To Radio"), asserting serial
|
||
// PTT (RTS/DTR) first unless the operator uses VOX. PTT is released
|
||
// automatically when playback ends (see the audio status callback).
|
||
func (a *App) DVKPlay(slot int) error {
|
||
if a.audioMgr == nil {
|
||
return fmt.Errorf("audio not initialized")
|
||
}
|
||
path := a.dvkPath(slot)
|
||
if fi, err := os.Stat(path); err != nil || fi.Size() <= 44 {
|
||
return fmt.Errorf("no recording in slot %d", slot)
|
||
}
|
||
cfg, _ := a.GetAudioSettings()
|
||
if err := a.pttKey(cfg); err != nil {
|
||
applog.Printf("dvk: PTT on failed: %v", err)
|
||
// Keep going — the audio still reaches the rig; the user may use VOX.
|
||
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
|
||
a.dvkPttKeyed = true
|
||
}
|
||
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
|
||
if a.dvkPttKeyed {
|
||
a.dvkPttKeyed = false
|
||
go a.dvkUnkeyPTT()
|
||
}
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip
|
||
// the end of the message.
|
||
func (a *App) dvkUnkeyPTT() {
|
||
time.Sleep(120 * time.Millisecond)
|
||
a.pttUnkey()
|
||
}
|
||
|
||
// pttKey keys the transmitter using the configured method:
|
||
// - "cat" → OmniRig (sets the Tx parameter to PM_TX)
|
||
// - "rts"/"dtr" → open the COM port and assert that line, held during TX
|
||
// - "none" → VOX, nothing to do
|
||
func (a *App) pttKey(cfg AudioSettings) error {
|
||
switch cfg.PTTMethod {
|
||
case "cat":
|
||
if a.cat == nil {
|
||
return fmt.Errorf("CAT not initialized")
|
||
}
|
||
if err := a.cat.SetPTT(true); err != nil {
|
||
return err
|
||
}
|
||
a.pttMu.Lock()
|
||
a.pttKeyedMethod = "cat"
|
||
a.pttMu.Unlock()
|
||
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
|
||
return nil
|
||
case "rts", "dtr":
|
||
if strings.TrimSpace(cfg.PTTPort) == "" {
|
||
return fmt.Errorf("no PTT COM port configured")
|
||
}
|
||
a.pttMu.Lock()
|
||
defer a.pttMu.Unlock()
|
||
if a.pttPort != nil {
|
||
return nil // already keyed
|
||
}
|
||
port, err := serial.Open(cfg.PTTPort, &serial.Mode{BaudRate: 9600})
|
||
if err != nil {
|
||
return fmt.Errorf("open %s: %w", cfg.PTTPort, err)
|
||
}
|
||
var lerr error
|
||
if cfg.PTTMethod == "rts" {
|
||
lerr = port.SetRTS(true)
|
||
_ = port.SetDTR(false)
|
||
} else {
|
||
lerr = port.SetDTR(true)
|
||
_ = port.SetRTS(false)
|
||
}
|
||
if lerr != nil {
|
||
_ = port.Close()
|
||
return fmt.Errorf("assert %s on %s: %w", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort, lerr)
|
||
}
|
||
a.pttPort = port
|
||
a.pttKeyedMethod = cfg.PTTMethod
|
||
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
|
||
return nil
|
||
}
|
||
return nil // none / VOX
|
||
}
|
||
|
||
// pttUnkey releases whichever PTT was keyed (CAT back to RX, or drop the
|
||
// serial line + close the port).
|
||
func (a *App) pttUnkey() {
|
||
a.pttMu.Lock()
|
||
method := a.pttKeyedMethod
|
||
a.pttKeyedMethod = ""
|
||
port := a.pttPort
|
||
a.pttPort = nil
|
||
a.pttMu.Unlock()
|
||
|
||
switch method {
|
||
case "cat":
|
||
if a.cat != nil {
|
||
if err := a.cat.SetPTT(false); err != nil {
|
||
applog.Printf("dvk: PTT off (CAT) failed: %v", err)
|
||
}
|
||
}
|
||
case "rts", "dtr":
|
||
if port != nil {
|
||
_ = port.SetRTS(false)
|
||
_ = port.SetDTR(false)
|
||
_ = port.Close()
|
||
}
|
||
default:
|
||
return
|
||
}
|
||
applog.Printf("dvk: PTT released")
|
||
}
|
||
|
||
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
|
||
func (a *App) TestPTT() error {
|
||
cfg, _ := a.GetAudioSettings()
|
||
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
|
||
return fmt.Errorf("PTT method is None (VOX) — nothing to test")
|
||
}
|
||
if err := a.pttKey(cfg); err != nil {
|
||
return err
|
||
}
|
||
go func() { time.Sleep(600 * time.Millisecond); a.pttUnkey() }()
|
||
return nil
|
||
}
|
||
|
||
// DVKPreview plays a slot's message locally on the "Listening" device.
|
||
func (a *App) DVKPreview(slot int) error {
|
||
if a.audioMgr == nil {
|
||
return fmt.Errorf("audio not initialized")
|
||
}
|
||
cfg, _ := a.GetAudioSettings()
|
||
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot))
|
||
}
|
||
|
||
// DVKStop halts any voice-keyer playback.
|
||
func (a *App) DVKStop() {
|
||
if a.audioMgr != nil {
|
||
a.audioMgr.StopPlayback()
|
||
}
|
||
}
|
||
|
||
// GetLogFilePath returns where the diagnostic log file lives so the user
|
||
// can open it from the Settings UI. Empty when applog hasn't initialised.
|
||
func (a *App) GetLogFilePath() string {
|
||
return applog.Path()
|
||
}
|
||
|
||
// ── QSL defaults ──────────────────────────────────────────────────────
|
||
|
||
// GetQSLDefaults returns the stored defaults — empty strings when the
|
||
// user hasn't configured anything (= leave QSO fields untouched).
|
||
func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||
out := QSLDefaults{}
|
||
if a.settings == nil {
|
||
return out, nil
|
||
}
|
||
prefix := ""
|
||
if a.profileHasGroup(markerQSL) {
|
||
prefix = a.profileScope()
|
||
}
|
||
m, err := a.getManyScoped(prefix,
|
||
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
||
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
||
)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
out.QSLSent = m[keyQSLDefaultQSLSent]
|
||
out.QSLRcvd = m[keyQSLDefaultQSLRcvd]
|
||
out.LOTWSent = m[keyQSLDefaultLOTWSent]
|
||
out.LOTWRcvd = m[keyQSLDefaultLOTWRcvd]
|
||
out.EQSLSent = m[keyQSLDefaultEQSLSent]
|
||
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
|
||
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
||
return out, nil
|
||
}
|
||
|
||
// SaveQSLDefaults persists the configured defaults. Future QSO inserts
|
||
// pick them up automatically — no app restart needed.
|
||
func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
scope := a.profileScope()
|
||
for k, v := range map[string]string{
|
||
keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)),
|
||
keyQSLDefaultQSLRcvd: strings.ToUpper(strings.TrimSpace(d.QSLRcvd)),
|
||
keyQSLDefaultLOTWSent: strings.ToUpper(strings.TrimSpace(d.LOTWSent)),
|
||
keyQSLDefaultLOTWRcvd: strings.ToUpper(strings.TrimSpace(d.LOTWRcvd)),
|
||
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
|
||
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
|
||
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
||
} {
|
||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := a.settings.Set(a.ctx, scope+markerQSL, "1"); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// applyQSLDefaults stamps the user-configured defaults onto a QSO when
|
||
// the corresponding fields are still empty. Called from every save path
|
||
// (manual entry via AddQSO, UDP auto-log via LogUDPLoggedADIF) so the
|
||
// confirmations columns always reflect the user's preferences.
|
||
func (a *App) applyQSLDefaults(q *qso.QSO) {
|
||
if a.settings == nil {
|
||
return
|
||
}
|
||
d, err := a.GetQSLDefaults()
|
||
if err != nil {
|
||
return
|
||
}
|
||
if q.QSLSent == "" { q.QSLSent = d.QSLSent }
|
||
if q.QSLRcvd == "" { q.QSLRcvd = d.QSLRcvd }
|
||
if q.LOTWSent == "" { q.LOTWSent = d.LOTWSent }
|
||
if q.LOTWRcvd == "" { q.LOTWRcvd = d.LOTWRcvd }
|
||
if q.EQSLSent == "" { q.EQSLSent = d.EQSLSent }
|
||
if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd }
|
||
if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus }
|
||
if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus }
|
||
if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus }
|
||
if q.QRZComDownloadStatus == "" { q.QRZComDownloadStatus = d.QRZComCfm }
|
||
}
|
||
|
||
// ── External services (logbook upload) ─────────────────────────────────
|
||
|
||
// loadExternalServices reads the configured external-service settings.
|
||
// ── Per-profile settings scoping ───────────────────────────────────────
|
||
//
|
||
// External Services and QSL Confirmations are scoped to the active profile
|
||
// so each operating identity (e.g. F4BPO vs TM2Q) uploads to its own
|
||
// accounts. They live under a "p<profileID>." key prefix. A per-group marker
|
||
// key records that a profile has saved its own copy; until then we
|
||
// transparently read the legacy un-prefixed (global) keys as the default —
|
||
// a lossless migration for logs created before profiles carried settings.
|
||
const (
|
||
markerExtsvc = "extsvc._set"
|
||
markerQSL = "qsl._set"
|
||
)
|
||
|
||
// profileScope returns the active profile's settings-key prefix ("p<id>.").
|
||
func (a *App) profileScope() string {
|
||
if a.profiles != nil {
|
||
if p, err := a.profiles.Active(a.ctx); err == nil && p.ID > 0 {
|
||
return fmt.Sprintf("p%d.", p.ID)
|
||
}
|
||
}
|
||
return "p0."
|
||
}
|
||
|
||
// profileHasGroup reports whether the active profile has saved its own copy
|
||
// of a settings group (identified by its marker key).
|
||
func (a *App) profileHasGroup(marker string) bool {
|
||
if a.settings == nil {
|
||
return false
|
||
}
|
||
v, _ := a.settings.Get(a.ctx, a.profileScope()+marker)
|
||
return v == "1"
|
||
}
|
||
|
||
// getManyScoped fetches base keys with the given prefix, returning a map
|
||
// keyed by the BASE key (so callers index with the plain constant).
|
||
func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, error) {
|
||
out := make(map[string]string, len(keys))
|
||
for _, k := range keys {
|
||
v, err := a.settings.Get(a.ctx, prefix+k)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out[k] = v
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||
var out extsvc.ExternalServices
|
||
if a.settings == nil {
|
||
return out
|
||
}
|
||
// Read the active profile's scoped keys once it has saved them; otherwise
|
||
// fall back to the legacy global keys as the shared default.
|
||
prefix := ""
|
||
if a.profileHasGroup(markerExtsvc) {
|
||
prefix = a.profileScope()
|
||
}
|
||
m, err := a.getManyScoped(prefix,
|
||
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
|
||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
||
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
||
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
||
if err != nil {
|
||
return out
|
||
}
|
||
out.QRZ = extsvc.ServiceConfig{
|
||
APIKey: m[keyExtQRZAPIKey],
|
||
ForceStationCallsign: m[keyExtQRZForceCall],
|
||
AutoUpload: m[keyExtQRZAutoUpload] == "1",
|
||
UploadMode: extsvc.UploadMode(m[keyExtQRZUploadMode]),
|
||
}
|
||
out.Clublog = extsvc.ServiceConfig{
|
||
Email: m[keyExtClublogEmail],
|
||
Password: m[keyExtClublogPassword],
|
||
Callsign: m[keyExtClublogCallsign],
|
||
APIKey: m[keyExtClublogAPIKey],
|
||
AutoUpload: m[keyExtClublogAutoUpload] == "1",
|
||
UploadMode: extsvc.UploadMode(m[keyExtClublogUploadMode]),
|
||
}
|
||
// Default the Club Log logbook callsign to the active profile's call
|
||
// when the user hasn't overridden it.
|
||
if out.Clublog.Callsign == "" && a.profiles != nil {
|
||
if p, perr := a.profiles.Active(a.ctx); perr == nil {
|
||
out.Clublog.Callsign = p.Callsign
|
||
}
|
||
}
|
||
out.LoTW = extsvc.ServiceConfig{
|
||
TQSLPath: m[keyExtLoTWTQSLPath],
|
||
StationLocation: m[keyExtLoTWStationLoc],
|
||
ForceStationCallsign: m[keyExtLoTWForceCall],
|
||
KeyPassword: m[keyExtLoTWKeyPassword],
|
||
UploadFlag: m[keyExtLoTWUploadFlag],
|
||
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
||
Username: m[keyExtLoTWUsername],
|
||
Password: m[keyExtLoTWWebPassword],
|
||
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
|
||
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
|
||
}
|
||
// Default the TQSL path to the standard install location when unset, so
|
||
// the field is pre-populated if TQSL is present.
|
||
if out.LoTW.TQSLPath == "" {
|
||
out.LoTW.TQSLPath = extsvc.DefaultTQSLPath()
|
||
}
|
||
return out
|
||
}
|
||
|
||
// GetExternalServices returns the saved external-service configuration.
|
||
func (a *App) GetExternalServices() (extsvc.ExternalServices, error) {
|
||
return a.loadExternalServices(), nil
|
||
}
|
||
|
||
// SaveExternalServices persists the config and reloads the live manager so
|
||
// the next logged QSO uses the new settings (no restart needed).
|
||
func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
mode := string(extsvc.ModeImmediate)
|
||
if cfg.QRZ.UploadMode == extsvc.ModeDelayed {
|
||
mode = string(extsvc.ModeDelayed)
|
||
}
|
||
auto := "0"
|
||
if cfg.QRZ.AutoUpload {
|
||
auto = "1"
|
||
}
|
||
clMode := string(extsvc.ModeImmediate)
|
||
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
|
||
clMode = string(extsvc.ModeDelayed)
|
||
}
|
||
clAuto := "0"
|
||
if cfg.Clublog.AutoUpload {
|
||
clAuto = "1"
|
||
}
|
||
ltMode := string(extsvc.ModeImmediate)
|
||
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
|
||
ltMode = string(extsvc.ModeDelayed)
|
||
}
|
||
ltAuto := "0"
|
||
if cfg.LoTW.AutoUpload {
|
||
ltAuto = "1"
|
||
}
|
||
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag))
|
||
if ltFlag != "N" && ltFlag != "R" {
|
||
ltFlag = "R"
|
||
}
|
||
ltWriteLog := "0"
|
||
if cfg.LoTW.WriteLog {
|
||
ltWriteLog = "1"
|
||
}
|
||
scope := a.profileScope() // write under the active profile's prefix
|
||
for k, v := range map[string]string{
|
||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
||
keyExtQRZAutoUpload: auto,
|
||
keyExtQRZUploadMode: mode,
|
||
|
||
keyExtClublogEmail: strings.TrimSpace(cfg.Clublog.Email),
|
||
keyExtClublogPassword: cfg.Clublog.Password,
|
||
keyExtClublogCallsign: strings.ToUpper(strings.TrimSpace(cfg.Clublog.Callsign)),
|
||
keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey),
|
||
keyExtClublogAutoUpload: clAuto,
|
||
keyExtClublogUploadMode: clMode,
|
||
|
||
keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath),
|
||
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
||
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
||
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
||
keyExtLoTWUploadFlag: ltFlag,
|
||
keyExtLoTWWriteLog: ltWriteLog,
|
||
keyExtLoTWAutoUpload: ltAuto,
|
||
keyExtLoTWUploadMode: ltMode,
|
||
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
|
||
keyExtLoTWWebPassword: cfg.LoTW.Password,
|
||
} {
|
||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Mark this profile as having its own External Services config (so future
|
||
// loads read the scoped keys instead of falling back to the global ones).
|
||
if err := a.settings.Set(a.ctx, scope+markerExtsvc, "1"); err != nil {
|
||
return err
|
||
}
|
||
if a.extsvc != nil {
|
||
a.extsvc.SetConfig(a.loadExternalServices())
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// TestQRZUpload validates the configured QRZ key by querying the logbook's
|
||
// status (ACTION=STATUS). Returns a human-readable message for the UI.
|
||
func (a *App) TestQRZUpload() (string, error) {
|
||
cfg := a.loadExternalServices().QRZ
|
||
return extsvc.TestQRZ(a.ctx, nil, cfg.APIKey)
|
||
}
|
||
|
||
// TestClublogUpload validates that the Club Log credentials are complete.
|
||
func (a *App) TestClublogUpload() (string, error) {
|
||
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
||
}
|
||
|
||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||
|
||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||
func uploadColumnFor(service string) string {
|
||
switch extsvc.Service(service) {
|
||
case extsvc.ServiceQRZ:
|
||
return "qrzcom_qso_upload_status"
|
||
case extsvc.ServiceClublog:
|
||
return "clublog_qso_upload_status"
|
||
case extsvc.ServiceLoTW:
|
||
return "lotw_sent"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// FindQSOsForUpload returns QSOs whose sent status for the given service
|
||
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
|
||
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) {
|
||
if a.qso == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
col := uploadColumnFor(service)
|
||
if col == "" {
|
||
return nil, fmt.Errorf("unknown service %q", service)
|
||
}
|
||
return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
|
||
}
|
||
|
||
// UploadQSOsManual uploads the given QSO ids to a service on demand
|
||
// (regardless of their current sent status — the user picked them). Runs in
|
||
// the background, emitting "qslmgr:log" lines and a final "qslmgr:done".
|
||
func (a *App) UploadQSOsManual(service string, ids []int64) error {
|
||
if a.qso == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
svc := extsvc.Service(service)
|
||
if uploadColumnFor(service) == "" {
|
||
return fmt.Errorf("unknown service %q", service)
|
||
}
|
||
cfg := a.loadExternalServices()
|
||
go a.runManualUpload(svc, ids, cfg)
|
||
return nil
|
||
}
|
||
|
||
func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
|
||
emit := func(line string) {
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||
}
|
||
}
|
||
ctx := context.Background()
|
||
uploaded := 0
|
||
|
||
if svc == extsvc.ServiceLoTW {
|
||
emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids)))
|
||
var recs []string
|
||
for _, id := range ids {
|
||
if rec, ok := a.buildUploadADIF(id, cfg.LoTW.ForceStationCallsign); ok {
|
||
recs = append(recs, rec)
|
||
}
|
||
}
|
||
res, err := extsvc.UploadLoTW(ctx, cfg.LoTW, "", strings.Join(recs, "\n"))
|
||
if err != nil || !res.OK {
|
||
msg := res.Message
|
||
if err != nil {
|
||
msg = err.Error()
|
||
}
|
||
emit("LoTW upload failed: " + msg)
|
||
} else {
|
||
for _, id := range ids {
|
||
a.markExtUploaded(svc, id, "")
|
||
uploaded++
|
||
}
|
||
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
|
||
}
|
||
} else {
|
||
for _, id := range ids {
|
||
q, gerr := a.qso.GetByID(ctx, id)
|
||
call := ""
|
||
if gerr == nil {
|
||
call = q.Callsign
|
||
}
|
||
force := ""
|
||
if svc == extsvc.ServiceQRZ {
|
||
force = cfg.QRZ.ForceStationCallsign
|
||
}
|
||
rec, ok := a.buildUploadADIF(id, force)
|
||
if !ok {
|
||
emit(call + " — skipped (no record)")
|
||
continue
|
||
}
|
||
var res extsvc.UploadResult
|
||
var err error
|
||
switch svc {
|
||
case extsvc.ServiceQRZ:
|
||
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||
case extsvc.ServiceClublog:
|
||
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
||
}
|
||
if err == nil && res.OK {
|
||
a.markExtUploaded(svc, id, "")
|
||
uploaded++
|
||
emit(call + " — OK")
|
||
} else {
|
||
msg := res.Message
|
||
if err != nil {
|
||
msg = err.Error()
|
||
}
|
||
emit(call + " — FAILED: " + msg)
|
||
}
|
||
}
|
||
}
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
|
||
}
|
||
}
|
||
|
||
// ConfirmationItem is one downloaded confirmation shown in the QSL Manager,
|
||
// with award-style NEW flags computed against the log's prior confirmations.
|
||
type ConfirmationItem struct {
|
||
Callsign string `json:"callsign"`
|
||
QSODate string `json:"qso_date"` // ISO UTC
|
||
Band string `json:"band"`
|
||
Mode string `json:"mode"`
|
||
Country string `json:"country"`
|
||
NewDXCC bool `json:"new_dxcc"`
|
||
NewBand bool `json:"new_band"`
|
||
NewSlot bool `json:"new_slot"`
|
||
}
|
||
|
||
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
|
||
// matching local QSOs' received status. LoTW only for now (the canonical
|
||
// confirmation system); runs in the background emitting the same
|
||
// "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window.
|
||
func (a *App) DownloadConfirmations(service string, addNotFound bool) error {
|
||
if a.qso == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
svc := extsvc.Service(service)
|
||
cfg := a.loadExternalServices()
|
||
go a.runDownloadConfirmations(svc, cfg, addNotFound)
|
||
return nil
|
||
}
|
||
|
||
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool) {
|
||
emit := func(line string) {
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||
}
|
||
}
|
||
done := func(matched, total int) {
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
|
||
}
|
||
}
|
||
ctx := context.Background()
|
||
matched, total, added := 0, 0, 0
|
||
|
||
switch svc {
|
||
case extsvc.ServiceLoTW:
|
||
since := ""
|
||
if a.settings != nil {
|
||
// Scoped to the active profile — each identity tracks its own
|
||
// LoTW account's last incremental-download date.
|
||
since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload)
|
||
}
|
||
if since != "" {
|
||
emit("Downloading LoTW confirmations received since " + since + "…")
|
||
} else {
|
||
emit("Downloading all LoTW confirmations…")
|
||
}
|
||
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, since)
|
||
if err != nil {
|
||
emit("Download failed: " + err.Error())
|
||
done(matched, total)
|
||
return
|
||
}
|
||
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
|
||
if kerr != nil {
|
||
emit("Error reading local log: " + kerr.Error())
|
||
done(matched, total)
|
||
return
|
||
}
|
||
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
|
||
// that count for ARRL awards) so each incoming one is flagged NEW.
|
||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
||
var items []ConfirmationItem
|
||
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
||
q, ok := adif.RecordToQSO(rec)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
total++
|
||
date := rec["qslrdate"]
|
||
if date == "" {
|
||
date = time.Now().UTC().Format("20060102")
|
||
}
|
||
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
|
||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||
if id, found := keyIDs[key]; found {
|
||
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
|
||
matched++
|
||
}
|
||
} else if addNotFound {
|
||
q.LOTWSent = "Y"
|
||
q.LOTWRcvd = "Y"
|
||
q.LOTWRcvdDate = date
|
||
if newID, e := a.qso.Add(ctx, q); e == nil {
|
||
keyIDs[key] = newID // guard against dup records in the report
|
||
added++
|
||
}
|
||
}
|
||
// Build the result row + NEW flags (vs the pre-download snapshot),
|
||
// then fold this slot into the sets so a repeat in the same batch
|
||
// isn't flagged twice.
|
||
dxccNum := 0
|
||
if q.DXCC != nil {
|
||
dxccNum = *q.DXCC
|
||
}
|
||
it := ConfirmationItem{
|
||
Callsign: q.Callsign,
|
||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||
Band: q.Band,
|
||
Mode: q.Mode,
|
||
Country: q.Country,
|
||
}
|
||
if dxccNum != 0 {
|
||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||
sets.DXCC[dxccNum] = true
|
||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||
}
|
||
items = append(items, it)
|
||
return nil
|
||
})
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||
}
|
||
if perr != nil {
|
||
emit("Parse error: " + perr.Error())
|
||
}
|
||
if addNotFound {
|
||
emit(fmt.Sprintf("Matched %d, added %d (of %d confirmed QSO(s))", matched, added, total))
|
||
} else {
|
||
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
|
||
}
|
||
// Remember today so the next pull is incremental (per active profile).
|
||
if a.settings != nil {
|
||
_ = a.settings.Set(ctx, a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||
}
|
||
|
||
case extsvc.ServiceQRZ:
|
||
emit("Fetching QRZ.com logbook…")
|
||
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
|
||
if err != nil {
|
||
emit("Fetch failed: " + err.Error())
|
||
done(matched, total)
|
||
return
|
||
}
|
||
adifText := fr.ADIF
|
||
emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
|
||
if snip := strings.TrimSpace(adifText); snip != "" {
|
||
if len(snip) > 300 {
|
||
snip = snip[:300]
|
||
}
|
||
emit("ADIF head: " + snip)
|
||
}
|
||
keyIDs, _ := a.qso.DedupeKeyIDs(ctx)
|
||
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
||
// judged only against other QRZ confirmations.
|
||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
||
// Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED",
|
||
// without a per-record DB read.
|
||
alreadyQrz := map[int64]bool{}
|
||
if rs, e := a.db.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
|
||
for rs.Next() {
|
||
var id int64
|
||
if rs.Scan(&id) == nil {
|
||
alreadyQrz[id] = true
|
||
}
|
||
}
|
||
rs.Close()
|
||
}
|
||
var items []ConfirmationItem
|
||
parsed := 0
|
||
allKeys := map[string]bool{} // union of field names seen, for diagnostics
|
||
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
|
||
// parser treats the stream as records.
|
||
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
|
||
parsed++
|
||
for k := range rec {
|
||
allKeys[k] = true
|
||
}
|
||
if !qrzRecordConfirmed(rec) {
|
||
return nil
|
||
}
|
||
q, ok := adif.RecordToQSO(rec)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
total++
|
||
date := rec["qrzcom_qso_download_date"]
|
||
if date == "" {
|
||
date = time.Now().UTC().Format("20060102")
|
||
}
|
||
a.enrichContactedFromCty(&q)
|
||
line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s",
|
||
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)
|
||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||
id, found := keyIDs[key]
|
||
switch {
|
||
case found:
|
||
if alreadyQrz[id] {
|
||
emit(line + " ### ALREADY CONFIRMED ###")
|
||
} else if e := a.qso.MarkQRZConfirmed(ctx, id, date); e == nil {
|
||
alreadyQrz[id] = true
|
||
matched++
|
||
emit(line + " ### UPDATED ###")
|
||
}
|
||
case addNotFound:
|
||
q.QRZComUploadStatus = "Y"
|
||
q.QRZComDownloadStatus = "Y"
|
||
q.QRZComDownloadDate = date
|
||
if newID, e := a.qso.Add(ctx, q); e == nil {
|
||
keyIDs[key] = newID
|
||
added++
|
||
emit(line + " ### ADDED ###")
|
||
}
|
||
default:
|
||
emit(line + " ### NOT IN LOG ###")
|
||
}
|
||
// Result row + NEW flags.
|
||
dxccNum := 0
|
||
if q.DXCC != nil {
|
||
dxccNum = *q.DXCC
|
||
}
|
||
it := ConfirmationItem{
|
||
Callsign: q.Callsign,
|
||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||
}
|
||
if dxccNum != 0 {
|
||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||
sets.DXCC[dxccNum] = true
|
||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||
}
|
||
items = append(items, it)
|
||
return nil
|
||
})
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||
}
|
||
if perr != nil {
|
||
emit("Parse error: " + perr.Error())
|
||
}
|
||
// Diagnostic: the union of every field name QRZ returned, so we can
|
||
// pin the confirmation marker against real data.
|
||
keys := make([]string, 0, len(allKeys))
|
||
for k := range allKeys {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
||
|
||
default:
|
||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||
}
|
||
done(matched+added, total)
|
||
}
|
||
|
||
// qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a
|
||
// confirmed QSO. QRZ's confirmation marker isn't clearly documented, so we
|
||
// accept the likely candidates; the download's one-time field dump lets us
|
||
// pin the exact field against real data and tighten this if needed.
|
||
func qrzRecordConfirmed(rec adif.Record) bool {
|
||
if strings.EqualFold(rec["qsl_rcvd"], "Y") {
|
||
return true
|
||
}
|
||
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
|
||
return true
|
||
}
|
||
switch strings.ToUpper(strings.TrimSpace(rec["app_qrzlog_status"])) {
|
||
case "C", "Y":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
|
||
// from cty.dat (offline) — used when adding a not-found confirmation that
|
||
// only carries call/band/mode/date.
|
||
func (a *App) enrichContactedFromCty(q *qso.QSO) {
|
||
if a.dxcc == nil || q.Callsign == "" {
|
||
return
|
||
}
|
||
m, ok := a.dxcc.Lookup(q.Callsign)
|
||
if !ok || m.Entity == nil {
|
||
return
|
||
}
|
||
if q.Country == "" {
|
||
q.Country = m.Entity.Name
|
||
}
|
||
if q.Continent == "" {
|
||
q.Continent = m.Continent
|
||
}
|
||
if q.DXCC == nil {
|
||
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
|
||
q.DXCC = &n
|
||
}
|
||
}
|
||
if q.CQZ == nil && m.CQZone != 0 {
|
||
v := m.CQZone
|
||
q.CQZ = &v
|
||
}
|
||
if q.ITUZ == nil && m.ITUZone != 0 {
|
||
v := m.ITUZone
|
||
q.ITUZ = &v
|
||
}
|
||
}
|
||
|
||
// enrichContactedFromCtyForce OVERWRITES the contacted-station country,
|
||
// continent, DXCC number and CQ/ITU zones from cty.dat. Unlike
|
||
// enrichContactedFromCty (which only fills blanks), this corrects values
|
||
// that are present-but-wrong — the case where contest software exports a
|
||
// bad COUNTRY/DXCC (e.g. RG2Y tagged "Asiatic Russia" instead of European).
|
||
// Returns true if cty.dat had a match.
|
||
func (a *App) enrichContactedFromCtyForce(q *qso.QSO) bool {
|
||
if a.dxcc == nil || q.Callsign == "" {
|
||
return false
|
||
}
|
||
m, ok := a.dxcc.Lookup(q.Callsign)
|
||
if !ok || m.Entity == nil {
|
||
return false
|
||
}
|
||
q.Country = m.Entity.Name
|
||
q.Continent = m.Continent
|
||
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
|
||
q.DXCC = &n
|
||
}
|
||
if m.CQZone != 0 {
|
||
v := m.CQZone
|
||
q.CQZ = &v
|
||
}
|
||
if m.ITUZone != 0 {
|
||
v := m.ITUZone
|
||
q.ITUZ = &v
|
||
}
|
||
// Zone-split countries (USA, Australia): refine the per-entity default zone
|
||
// to the call-district zone (W6 → CQ3/ITU6), matching Log4OM/DXKeeper.
|
||
a.refineDistrictZones(q)
|
||
return true
|
||
}
|
||
|
||
// UpdateQSOsFromCty recomputes country / continent / DXCC / CQ / ITU from
|
||
// cty.dat for the given QSO ids and saves them. Used by the grid's
|
||
// right-click "Update from cty.dat" on a multi-selection. Returns how many
|
||
// rows were actually changed.
|
||
func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
|
||
if a.qso == nil {
|
||
return 0, fmt.Errorf("db not initialized")
|
||
}
|
||
changed := 0
|
||
for _, id := range ids {
|
||
q, err := a.qso.GetByID(a.ctx, id)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
before := fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ)
|
||
if !a.enrichContactedFromCtyForce(&q) {
|
||
continue
|
||
}
|
||
if fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ) == before {
|
||
continue // no change
|
||
}
|
||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||
changed++
|
||
}
|
||
}
|
||
return changed, nil
|
||
}
|
||
|
||
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
|
||
// the configured providers) for each QSO id and overwrites the geographic
|
||
// + entity fields (country, continent, DXCC, zones, grid, state, county)
|
||
// plus name/QTH when the provider returns them. Used by the grid's
|
||
// right-click "Update from QRZ.com". Returns how many rows were saved.
|
||
func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
||
if a.qso == nil || a.lookup == nil {
|
||
return 0, fmt.Errorf("not initialized")
|
||
}
|
||
changed := 0
|
||
for _, id := range ids {
|
||
q, err := a.qso.GetByID(a.ctx, id)
|
||
if err != nil || q.Callsign == "" {
|
||
continue
|
||
}
|
||
r, err := a.lookup.Lookup(a.ctx, q.Callsign)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if r.Country != "" {
|
||
q.Country = r.Country
|
||
}
|
||
if r.Continent != "" {
|
||
q.Continent = r.Continent
|
||
}
|
||
if r.DXCC != 0 {
|
||
n := r.DXCC
|
||
q.DXCC = &n
|
||
}
|
||
if r.CQZ != 0 {
|
||
v := r.CQZ
|
||
q.CQZ = &v
|
||
}
|
||
if r.ITUZ != 0 {
|
||
v := r.ITUZ
|
||
q.ITUZ = &v
|
||
}
|
||
if r.Grid != "" {
|
||
q.Grid = strings.ToUpper(r.Grid)
|
||
}
|
||
if r.State != "" {
|
||
q.State = r.State
|
||
}
|
||
if r.County != "" {
|
||
q.County = r.County
|
||
}
|
||
if r.Name != "" {
|
||
q.Name = r.Name
|
||
}
|
||
if r.QTH != "" {
|
||
q.QTH = r.QTH
|
||
}
|
||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||
changed++
|
||
}
|
||
}
|
||
return changed, nil
|
||
}
|
||
|
||
// ListTQSLStationLocations returns the Station Locations defined in TQSL,
|
||
// for the LoTW settings dropdown.
|
||
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
|
||
return extsvc.ListStationLocations(extsvc.DefaultStationDataPath())
|
||
}
|
||
|
||
// TestLoTWUpload validates the LoTW config (TQSL present + station location
|
||
// exists).
|
||
func (a *App) TestLoTWUpload() (string, error) {
|
||
return extsvc.TestLoTW(a.loadExternalServices().LoTW, extsvc.DefaultStationDataPath())
|
||
}
|
||
|
||
// buildUploadADIF builds a single-record ADIF for QSO id, overriding the
|
||
// station callsign when forceCall is set (QRZ rejects QSOs whose station
|
||
// call differs from the logbook's registered call). ok=false → skip.
|
||
func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) {
|
||
if a.qso == nil {
|
||
return "", false
|
||
}
|
||
q, err := a.qso.GetByID(a.ctx, id)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
if forceCall != "" {
|
||
q.StationCallsign = forceCall
|
||
}
|
||
return adif.SingleRecordADIF(q), true
|
||
}
|
||
|
||
// stationCallOf returns the QSO's STATION_CALLSIGN (upper-cased), used by the
|
||
// uploader to verify a QSO belongs to the target logbook's callsign.
|
||
func (a *App) stationCallOf(id int64) string {
|
||
if a.qso == nil {
|
||
return ""
|
||
}
|
||
q, err := a.qso.GetByID(a.ctx, id)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
|
||
}
|
||
|
||
// extShouldUpload reports whether a QSO is eligible for upload to a service,
|
||
// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW
|
||
// uploads only QSOs whose lotw_sent matches the configured Upload flag
|
||
// ("N" or "R") — the Log4OM rule that must match the Confirmations default.
|
||
func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||
if a.qso == nil {
|
||
return false
|
||
}
|
||
q, err := a.qso.GetByID(a.ctx, id)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
switch svc {
|
||
case extsvc.ServiceQRZ:
|
||
if strings.EqualFold(q.QRZComUploadStatus, "Y") {
|
||
applog.Printf("extsvc: QSO %d not eligible for qrz — QRZComUploadStatus already %q (set Confirmations default to N to upload)", id, q.QRZComUploadStatus)
|
||
return false
|
||
}
|
||
return true
|
||
case extsvc.ServiceClublog:
|
||
if strings.EqualFold(q.ClublogUploadStatus, "Y") {
|
||
applog.Printf("extsvc: QSO %d not eligible for clublog — ClublogUploadStatus already %q (set Confirmations default to N to upload)", id, q.ClublogUploadStatus)
|
||
return false
|
||
}
|
||
return true
|
||
case extsvc.ServiceLoTW:
|
||
flag := "R"
|
||
if a.settings != nil {
|
||
if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil {
|
||
if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" {
|
||
flag = v
|
||
}
|
||
}
|
||
}
|
||
return strings.EqualFold(q.LOTWSent, flag)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// markExtUploaded stamps the per-service upload status on the QSO row and
|
||
// tells the frontend to refresh that row's confirmation columns.
|
||
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||
date := time.Now().UTC().Format("20060102")
|
||
switch svc {
|
||
case extsvc.ServiceQRZ:
|
||
if a.qso != nil {
|
||
if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil {
|
||
applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err)
|
||
}
|
||
}
|
||
case extsvc.ServiceClublog:
|
||
if a.qso != nil {
|
||
if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil {
|
||
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
|
||
}
|
||
}
|
||
case extsvc.ServiceLoTW:
|
||
if a.qso != nil {
|
||
if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil {
|
||
applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err)
|
||
}
|
||
}
|
||
}
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
||
"service": string(svc),
|
||
"qso_id": id,
|
||
"log_id": logID,
|
||
})
|
||
}
|
||
}
|
||
|
||
// notifyExtError surfaces a failed upload to the frontend.
|
||
func (a *App) notifyExtError(svc extsvc.Service, id int64, err error) {
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "extsvc:error", map[string]any{
|
||
"service": string(svc),
|
||
"qso_id": id,
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
}
|
||
|
||
// ── UDP integrations ───────────────────────────────────────────────────
|
||
|
||
// ListUDPIntegrations returns every saved UDP connection row.
|
||
func (a *App) ListUDPIntegrations() ([]udp.Config, error) {
|
||
if a.udpRepo == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
return a.udpRepo.List(a.ctx)
|
||
}
|
||
|
||
// SaveUDPIntegration upserts a UDP connection and reloads the manager so
|
||
// inbound listeners pick up the change without an app restart. Reload
|
||
// errors are surfaced — a "port already in use" failure should reach the
|
||
// user rather than be silently dropped.
|
||
func (a *App) SaveUDPIntegration(c udp.Config) (udp.Config, error) {
|
||
if a.udpRepo == nil {
|
||
return c, fmt.Errorf("db not initialized")
|
||
}
|
||
if err := a.udpRepo.Save(a.ctx, &c); err != nil {
|
||
return c, err
|
||
}
|
||
if a.udp != nil {
|
||
errs := a.udp.Reload(a.ctx)
|
||
if len(errs) > 0 {
|
||
return c, fmt.Errorf("listener errors: %s", strings.Join(errs, "; "))
|
||
}
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// DeleteUDPIntegration removes a row and reloads the manager.
|
||
func (a *App) DeleteUDPIntegration(id int64) error {
|
||
if a.udpRepo == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
if err := a.udpRepo.Delete(a.ctx, id); err != nil {
|
||
return err
|
||
}
|
||
if a.udp != nil {
|
||
a.udp.Reload(a.ctx)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ReloadUDPIntegrations is a no-arg way for the UI to force a restart
|
||
// (e.g. after toggling Enabled on a row).
|
||
func (a *App) ReloadUDPIntegrations() []string {
|
||
if a.udp == nil {
|
||
return nil
|
||
}
|
||
return a.udp.Reload(a.ctx)
|
||
}
|
||
|
||
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
|
||
// first record into the local logbook. Returns the ID of the inserted
|
||
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
|
||
// N1MM — the latter via a synthesised ADIF record from its XML datagram).
|
||
func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||
if a.qso == nil {
|
||
return 0, fmt.Errorf("db not initialized")
|
||
}
|
||
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
|
||
// always send a single QSO per UDP packet (no header) but we tolerate
|
||
// either form via adif.Parse.
|
||
// Pick the field decoder for this payload's encoding (UTF-8 as-is, else
|
||
// Windows-1252) so accented NAME/QTH from Log4OM/JTAlert aren't mangled.
|
||
// In UTF-8 mode the parser also repairs character-count field lengths.
|
||
decode := adif.ValueDecoderFor([]byte(adifText))
|
||
var record adif.Record
|
||
err := adif.ParseWithDecoder(strings.NewReader(adifText), decode, func(rec adif.Record) error {
|
||
if record == nil {
|
||
record = rec
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return 0, fmt.Errorf("parse adif: %w", err)
|
||
}
|
||
if record == nil {
|
||
// Some senders skip the <EOH> header; try treating the whole
|
||
// payload as a single record by prepending a fake header.
|
||
err := adif.ParseWithDecoder(strings.NewReader("<EOH>"+adifText), decode, func(rec adif.Record) error {
|
||
if record == nil {
|
||
record = rec
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil || record == nil {
|
||
return 0, fmt.Errorf("no valid QSO record in payload")
|
||
}
|
||
}
|
||
q, ok := adif.RecordToQSO(record)
|
||
if !ok {
|
||
return 0, fmt.Errorf("record missing required fields (call/band/mode/date)")
|
||
}
|
||
|
||
// ── Lookup-based enrichment ──
|
||
// WSJT sends only call/freq/mode/RST/date. Fill Name/QTH/Country/
|
||
// Grid/CQZ/ITUZ/DXCC/Continent via the lookup chain (QRZ/HamQTH/
|
||
// cty.dat). Best-effort: a network failure shouldn't block the log.
|
||
if a.lookup != nil {
|
||
if lr, lerr := a.lookup.Lookup(a.ctx, q.Callsign); lerr == nil {
|
||
if q.Name == "" { q.Name = lr.Name }
|
||
if q.QTH == "" { q.QTH = lr.QTH }
|
||
if q.Country == "" { q.Country = lr.Country }
|
||
if q.Grid == "" { q.Grid = lr.Grid }
|
||
if q.Continent == "" { q.Continent = lr.Continent }
|
||
if q.State == "" { q.State = lr.State }
|
||
if q.County == "" { q.County = lr.County }
|
||
if q.Address == "" { q.Address = lr.Address }
|
||
if q.Email == "" { q.Email = lr.Email }
|
||
if q.DXCC == nil && lr.DXCC != 0 { v := lr.DXCC; q.DXCC = &v }
|
||
if q.CQZ == nil && lr.CQZ != 0 { v := lr.CQZ; q.CQZ = &v }
|
||
if q.ITUZ == nil && lr.ITUZ != 0 { v := lr.ITUZ; q.ITUZ = &v }
|
||
if q.Lat == nil && lr.Lat != 0 { v := lr.Lat; q.Lat = &v }
|
||
if q.Lon == nil && lr.Lon != 0 { v := lr.Lon; q.Lon = &v }
|
||
}
|
||
}
|
||
|
||
// ── Name/city normalisation ──
|
||
// Log4OM / contest loggers often send NAME and QTH in ALL CAPS. Title-case
|
||
// them so UDP-logged QSOs match the manual + lookup paths ("SANTO DOMINGO"
|
||
// → "Santo Domingo"). Only all-caps values are touched.
|
||
q.Name = titleCaseIfUpper(q.Name)
|
||
q.QTH = titleCaseIfUpper(q.QTH)
|
||
q.Country = titleCaseIfUpper(q.Country)
|
||
q.MyCity = titleCaseIfUpper(q.MyCity)
|
||
|
||
// ── Operating-conditions stamp ──
|
||
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
|
||
// this band (if the user has configured Operating conditions).
|
||
if a.operating != nil && a.profiles != nil {
|
||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||
if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 {
|
||
if q.MyRig == "" { q.MyRig = d.StationName }
|
||
if q.MyAntenna == "" { q.MyAntenna = d.AntennaName }
|
||
if q.TXPower == nil && d.TXPower != nil { v := *d.TXPower; q.TXPower = &v }
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Active-profile station stamp ──
|
||
// Same as the manual AddQSO path: fill the operator's MY_* fields
|
||
// (station callsign, grid, country, zones, and the profile's default
|
||
// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP /
|
||
// WSJT-X auto-logged QSO carried none of the operator's own data.
|
||
a.applyStationDefaults(&q)
|
||
|
||
// ── DXCC# + QSL defaults ──
|
||
// applyDXCCNumber stamps the contacted-station DXCC# from the
|
||
// entity-name table; QSL defaults are applied last so explicit ADIF
|
||
// fields (or what the lookup gave us) always win.
|
||
a.applyDXCCNumber(&q)
|
||
a.applyClublogException(&q, false) // date-ranged DXpedition override
|
||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||
a.applyQSLDefaults(&q)
|
||
|
||
// ── Dedup ──
|
||
// Match by call + band + mode within a ±2-minute window: a QSO logged
|
||
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
|
||
// a minute (the two apps stamp their own time), so a minute-exact key
|
||
// missed it and the contact got duplicated.
|
||
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
||
if err == nil {
|
||
base := q.QSODate.UTC()
|
||
for d := -2; d <= 2; d++ {
|
||
min := base.Add(time.Duration(d) * time.Minute).Format("2006-01-02T15:04")
|
||
if _, dup := seen[qso.DedupeKey(q.Callsign, min, q.Band, q.Mode)]; dup {
|
||
return 0, fmt.Errorf("duplicate (already in log within ±2 min)")
|
||
}
|
||
}
|
||
}
|
||
|
||
id, err := a.qso.Add(a.ctx, q)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("insert qso: %w", err)
|
||
}
|
||
q.ID = id
|
||
a.saveQSORecording(&q)
|
||
if a.extsvc != nil {
|
||
a.extsvc.OnQSOLogged(id)
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
// consumeUDPEvents bridges parsed UDP events to the frontend over Wails'
|
||
// event bus. The frontend listens on:
|
||
// udp:dx_call → string callsign (also Grid/Mode/Freq when known)
|
||
// udp:logged_qso → ADIF text of a QSO that finished in WSJT-X/JTDX/MSHV
|
||
// udp:remote_call → string callsign from a remote-control source
|
||
func (a *App) consumeUDPEvents() {
|
||
if a.udp == nil {
|
||
return
|
||
}
|
||
for ev := range a.udp.Events() {
|
||
if a.ctx == nil {
|
||
continue
|
||
}
|
||
switch {
|
||
case ev.LoggedADIF != "":
|
||
applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF))
|
||
wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{
|
||
"config_id": ev.ConfigID,
|
||
"service": string(ev.Service),
|
||
"source": ev.Source,
|
||
"adif": ev.LoggedADIF,
|
||
})
|
||
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
|
||
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
|
||
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
||
case ev.DXCall != "":
|
||
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
|
||
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
|
||
"call": ev.DXCall,
|
||
"grid": ev.DXGrid,
|
||
"mode": ev.Mode,
|
||
"freq_hz": ev.FreqHz,
|
||
"service": string(ev.Service),
|
||
"source": ev.Source,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Operating conditions ───────────────────────────────────────────────
|
||
|
||
// ListOperatingTree returns the stations/antennas/bands tree for the
|
||
// active profile. The UI renders the Settings tree from this.
|
||
func (a *App) ListOperatingTree() ([]operating.Station, error) {
|
||
if a.operating == nil || a.profiles == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
p, err := a.profiles.Active(a.ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return a.operating.ListTree(a.ctx, p.ID)
|
||
}
|
||
|
||
// SaveOperatingStation upserts a station. profile_id is set from the
|
||
// active profile if zero so the frontend doesn't have to know about it.
|
||
func (a *App) SaveOperatingStation(s operating.Station) (operating.Station, error) {
|
||
if a.operating == nil || a.profiles == nil {
|
||
return s, fmt.Errorf("db not initialized")
|
||
}
|
||
if s.ProfileID == 0 {
|
||
p, err := a.profiles.Active(a.ctx)
|
||
if err != nil {
|
||
return s, err
|
||
}
|
||
s.ProfileID = p.ID
|
||
}
|
||
if err := a.operating.SaveStation(a.ctx, &s); err != nil {
|
||
return s, err
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// DeleteOperatingStation cascades to antennas + bands.
|
||
func (a *App) DeleteOperatingStation(id int64) error {
|
||
if a.operating == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
return a.operating.DeleteStation(a.ctx, id)
|
||
}
|
||
|
||
// SaveOperatingAntenna upserts an antenna and replaces its band list.
|
||
// Setting is_default on a band clears the flag from any other antenna
|
||
// on the same band within this profile.
|
||
func (a *App) SaveOperatingAntenna(ant operating.Antenna) (operating.Antenna, error) {
|
||
if a.operating == nil {
|
||
return ant, fmt.Errorf("db not initialized")
|
||
}
|
||
if err := a.operating.SaveAntenna(a.ctx, &ant); err != nil {
|
||
return ant, err
|
||
}
|
||
return ant, nil
|
||
}
|
||
|
||
// DeleteOperatingAntenna cascades to bands.
|
||
func (a *App) DeleteOperatingAntenna(id int64) error {
|
||
if a.operating == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
return a.operating.DeleteAntenna(a.ctx, id)
|
||
}
|
||
|
||
// OperatingDefaultForBand returns the (station, antenna) flagged default
|
||
// for `band` in the active profile. Used by the entry strip to auto-fill
|
||
// MY_RIG and MY_ANTENNA when the user picks a band.
|
||
func (a *App) OperatingDefaultForBand(band string) (operating.BandDefault, error) {
|
||
if a.operating == nil || a.profiles == nil {
|
||
return operating.BandDefault{}, fmt.Errorf("db not initialized")
|
||
}
|
||
p, err := a.profiles.Active(a.ctx)
|
||
if err != nil {
|
||
return operating.BandDefault{}, err
|
||
}
|
||
d, _, err := a.operating.BandDefault(a.ctx, p.ID, band)
|
||
return d, err
|
||
}
|
||
|
||
// ── Backup ──────────────────────────────────────────────────────────────
|
||
|
||
// BackupSettings is the user-tweakable database backup configuration.
|
||
type BackupSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Folder string `json:"folder"`
|
||
Rotation int `json:"rotation"`
|
||
Zip bool `json:"zip"`
|
||
LastBackupAt string `json:"last_backup_at"`
|
||
DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint
|
||
}
|
||
|
||
// GetBackupSettings returns stored backup config with safe defaults.
|
||
func (a *App) GetBackupSettings() (BackupSettings, error) {
|
||
out := BackupSettings{
|
||
Rotation: 5,
|
||
DefaultFolder: backup.DefaultFolder(filepath.Dir(a.dbPath)),
|
||
}
|
||
if a.settings == nil {
|
||
return out, nil
|
||
}
|
||
m, err := a.settings.GetMany(a.ctx,
|
||
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupLast)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
out.Enabled = m[keyBackupEnabled] == "1"
|
||
out.Folder = m[keyBackupFolder]
|
||
if n, _ := strconv.Atoi(m[keyBackupRotation]); n > 0 {
|
||
out.Rotation = n
|
||
}
|
||
out.Zip = m[keyBackupZip] == "1"
|
||
out.LastBackupAt = m[keyBackupLast]
|
||
return out, nil
|
||
}
|
||
|
||
// SaveBackupSettings persists backup config (no immediate backup —
|
||
// trigger it explicitly with RunBackupNow).
|
||
func (a *App) SaveBackupSettings(s BackupSettings) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
if s.Rotation <= 0 {
|
||
s.Rotation = 5
|
||
}
|
||
enabled := "0"
|
||
if s.Enabled {
|
||
enabled = "1"
|
||
}
|
||
doZip := "0"
|
||
if s.Zip {
|
||
doZip = "1"
|
||
}
|
||
for k, v := range map[string]string{
|
||
keyBackupEnabled: enabled,
|
||
keyBackupFolder: strings.TrimSpace(s.Folder),
|
||
keyBackupRotation: strconv.Itoa(s.Rotation),
|
||
keyBackupZip: doZip,
|
||
} {
|
||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RunBackupNow forces an immediate backup using the persisted settings.
|
||
// Returns the destination path of the file that was written.
|
||
func (a *App) RunBackupNow() (string, error) {
|
||
s, err := a.GetBackupSettings()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
folder := s.Folder
|
||
if folder == "" {
|
||
folder = s.DefaultFolder
|
||
}
|
||
path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip)
|
||
if err != nil {
|
||
return path, err
|
||
}
|
||
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||
return path, nil
|
||
}
|
||
|
||
// maybeShutdownBackup runs a backup at shutdown if the user enabled it
|
||
// and no backup for today already exists. Running at shutdown (not at
|
||
// startup) means the snapshot includes the QSOs the user just logged
|
||
// this session — exactly what we want to protect. Errors are printed
|
||
// but never block the close.
|
||
func (a *App) maybeShutdownBackup() {
|
||
if a.settings == nil || a.db == nil {
|
||
return
|
||
}
|
||
s, err := a.GetBackupSettings()
|
||
if err != nil || !s.Enabled {
|
||
return
|
||
}
|
||
folder := s.Folder
|
||
if folder == "" {
|
||
folder = s.DefaultFolder
|
||
}
|
||
if backup.HasBackupToday(folder) {
|
||
return
|
||
}
|
||
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
|
||
fmt.Println("OpsLog: shutdown backup failed:", err)
|
||
return
|
||
}
|
||
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||
}
|
||
|
||
// PickBackupFolder opens a native directory picker so the user can browse
|
||
// to a backup target rather than typing the path. Returns the absolute
|
||
// path (or empty string if the dialog was cancelled).
|
||
//
|
||
// Windows' shell dialog refuses to open when DefaultDirectory points at
|
||
// a path that doesn't exist yet (typical for our default backups folder
|
||
// on first launch). We walk up the path until we find an existing
|
||
// ancestor and use that as the dialog's starting point.
|
||
func (a *App) PickBackupFolder() (string, error) {
|
||
if a.ctx == nil {
|
||
return "", fmt.Errorf("no app context")
|
||
}
|
||
current, _ := a.GetBackupSettings()
|
||
defaultDir := current.Folder
|
||
if defaultDir == "" {
|
||
defaultDir = current.DefaultFolder
|
||
}
|
||
defaultDir = firstExistingAncestor(defaultDir)
|
||
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||
Title: "Pick a folder for OpsLog backups",
|
||
DefaultDirectory: defaultDir,
|
||
})
|
||
}
|
||
|
||
// firstExistingAncestor returns p if it exists, otherwise the closest
|
||
// parent directory that does. Returns "" if nothing valid is found (the
|
||
// dialog then opens at the OS default location).
|
||
func firstExistingAncestor(p string) string {
|
||
p = strings.TrimSpace(p)
|
||
for p != "" {
|
||
if st, err := os.Stat(p); err == nil && st.IsDir() {
|
||
return p
|
||
}
|
||
parent := filepath.Dir(p)
|
||
if parent == p {
|
||
break
|
||
}
|
||
p = parent
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// GetCATState returns the current snapshot from the CAT manager. Used by the
|
||
// frontend on mount before any cat:state event has been emitted.
|
||
func (a *App) GetCATState() cat.RigState {
|
||
if a.cat == nil {
|
||
return cat.RigState{}
|
||
}
|
||
return a.cat.State()
|
||
}
|
||
|
||
// SetCATFrequency lets the frontend push a freq to the rig (cluster click,
|
||
// memory recall, …). Returns an error if CAT isn't running or the backend
|
||
// refuses (out-of-range, etc.).
|
||
func (a *App) SetCATFrequency(hz int64) error {
|
||
if a.cat == nil {
|
||
return fmt.Errorf("cat not initialized")
|
||
}
|
||
err := a.cat.SetFrequency(hz)
|
||
if err != nil {
|
||
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
|
||
// translated to backend-specific values by the backend itself.
|
||
func (a *App) SetCATMode(mode string) error {
|
||
if a.cat == nil {
|
||
return fmt.Errorf("cat not initialized")
|
||
}
|
||
err := a.cat.SetMode(mode)
|
||
if err != nil {
|
||
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
||
// requiring a trip through the full Settings panel. Persists the choice
|
||
// so it survives restart.
|
||
func (a *App) SwitchCATRig(n int) error {
|
||
if n != 1 && n != 2 {
|
||
return fmt.Errorf("rig num must be 1 or 2, got %d", n)
|
||
}
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
|
||
return err
|
||
}
|
||
a.reloadCAT()
|
||
return nil
|
||
}
|
||
|
||
// reloadCAT (re)starts the CAT manager based on the current settings.
|
||
// Called at startup and after the user saves new CAT config.
|
||
func (a *App) reloadCAT() {
|
||
if a.cat == nil {
|
||
return
|
||
}
|
||
s, err := a.GetCATSettings()
|
||
if err != nil {
|
||
return
|
||
}
|
||
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
||
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
|
||
if !s.Enabled {
|
||
a.cat.Stop()
|
||
return
|
||
}
|
||
switch s.Backend {
|
||
case "omnirig":
|
||
// No explicit launch — COM auto-activates OmniRig.exe via its
|
||
// LocalServer32 registration when we CreateObject in Connect().
|
||
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
|
||
// reloadCAT raised the existing instance's window to the front,
|
||
// which is what Log4OM avoids by relying entirely on COM activation.
|
||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum))
|
||
default:
|
||
// Unknown backend → stop and emit a dummy state so the UI shows it.
|
||
a.cat.Stop()
|
||
}
|
||
}
|
||
|
||
// ClearLookupCache empties the local callsign cache.
|
||
func (a *App) ClearLookupCache() error {
|
||
if a.cache == nil {
|
||
return fmt.Errorf("cache not initialized")
|
||
}
|
||
return a.cache.Clear(a.ctx)
|
||
}
|
||
|
||
// CtyDatInfo describes the currently-loaded cty.dat file (or zero values
|
||
// if it hasn't been loaded yet). Exposed for the Maintenance menu so the
|
||
// user can see what they're working with before triggering a refresh.
|
||
type CtyDatInfo struct {
|
||
Path string `json:"path"`
|
||
Entities int `json:"entities"`
|
||
LoadedAt string `json:"loaded_at,omitempty"` // RFC3339, "" if not loaded
|
||
FileModTime string `json:"file_mod_time,omitempty"` // RFC3339, "" if missing
|
||
}
|
||
|
||
// GetCtyDatInfo returns metadata about the on-disk cty.dat.
|
||
func (a *App) GetCtyDatInfo() CtyDatInfo {
|
||
if a.dxcc == nil {
|
||
return CtyDatInfo{}
|
||
}
|
||
src := a.dxcc.Info()
|
||
out := CtyDatInfo{Path: src.Path, Entities: src.Entities}
|
||
if !src.LoadedAt.IsZero() {
|
||
out.LoadedAt = src.LoadedAt.UTC().Format(time.RFC3339)
|
||
}
|
||
if !src.FileModTime.IsZero() {
|
||
out.FileModTime = src.FileModTime.UTC().Format(time.RFC3339)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// RefreshCtyDat re-downloads cty.dat from country-files.com and reloads it
|
||
// into memory. Synchronous so the UI can show a spinner; ~1s typical.
|
||
func (a *App) RefreshCtyDat() (CtyDatInfo, error) {
|
||
if a.dxcc == nil {
|
||
return CtyDatInfo{}, fmt.Errorf("dxcc manager not initialized")
|
||
}
|
||
if err := a.dxcc.Refresh(a.ctx); err != nil {
|
||
return CtyDatInfo{}, err
|
||
}
|
||
return a.GetCtyDatInfo(), nil
|
||
}
|
||
|
||
// --- Station bindings ---
|
||
//
|
||
// GetStationSettings/SaveStationSettings now operate on the **currently
|
||
// active profile** rather than a flat settings key set. Kept for the
|
||
// existing topbar/quick-edit code paths; the full profile CRUD lives in
|
||
// the Profile bindings below.
|
||
|
||
func (a *App) GetStationSettings() (StationSettings, error) {
|
||
if a.profiles == nil {
|
||
return StationSettings{}, fmt.Errorf("profiles not initialized")
|
||
}
|
||
p, err := a.profiles.Active(a.ctx)
|
||
if err != nil {
|
||
return StationSettings{}, err
|
||
}
|
||
return StationSettings{
|
||
Callsign: p.Callsign,
|
||
Operator: p.Operator,
|
||
MyGrid: p.MyGrid,
|
||
MyCountry: p.MyCountry,
|
||
MySOTARef: p.MySOTARef,
|
||
MyPOTARef: p.MyPOTARef,
|
||
}, nil
|
||
}
|
||
|
||
// --- Lists bindings (bands + modes with default RST) ---
|
||
|
||
// GetListsSettings returns the user-customisable lists. Defaults are
|
||
// returned when the user has not customised anything.
|
||
func (a *App) GetListsSettings() (ListsSettings, error) {
|
||
if a.settings == nil {
|
||
return ListsSettings{Bands: defaultBands, Modes: defaultModes}, fmt.Errorf("db not initialized")
|
||
}
|
||
out := ListsSettings{}
|
||
if raw, _ := a.settings.Get(a.ctx, keyListsBands); raw != "" {
|
||
_ = json.Unmarshal([]byte(raw), &out.Bands)
|
||
}
|
||
if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" {
|
||
_ = json.Unmarshal([]byte(raw), &out.Modes)
|
||
}
|
||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTPhone); raw != "" {
|
||
_ = json.Unmarshal([]byte(raw), &out.RSTPhone)
|
||
}
|
||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTCW); raw != "" {
|
||
_ = json.Unmarshal([]byte(raw), &out.RSTCW)
|
||
}
|
||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTDigital); raw != "" {
|
||
_ = json.Unmarshal([]byte(raw), &out.RSTDigital)
|
||
}
|
||
if len(out.Bands) == 0 {
|
||
out.Bands = append([]string(nil), defaultBands...)
|
||
}
|
||
if len(out.Modes) == 0 {
|
||
out.Modes = append([]ModePreset(nil), defaultModes...)
|
||
}
|
||
if len(out.RSTPhone) == 0 {
|
||
out.RSTPhone = append([]string(nil), defaultRSTPhone...)
|
||
}
|
||
if len(out.RSTCW) == 0 {
|
||
out.RSTCW = append([]string(nil), defaultRSTCW...)
|
||
}
|
||
if len(out.RSTDigital) == 0 {
|
||
out.RSTDigital = append([]string(nil), defaultRSTDigital...)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SaveListsSettings persists the user-customised lists.
|
||
func (a *App) SaveListsSettings(l ListsSettings) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
b, err := json.Marshal(l.Bands)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := a.settings.Set(a.ctx, keyListsBands, string(b)); err != nil {
|
||
return err
|
||
}
|
||
m, err := json.Marshal(l.Modes)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := a.settings.Set(a.ctx, keyListsModes, string(m)); err != nil {
|
||
return err
|
||
}
|
||
for k, v := range map[string][]string{
|
||
keyListsRSTPhone: l.RSTPhone,
|
||
keyListsRSTCW: l.RSTCW,
|
||
keyListsRSTDigital: l.RSTDigital,
|
||
} {
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := a.settings.Set(a.ctx, k, string(b)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SaveStationSettings updates only the six "basic" fields on the active
|
||
// profile. Use the Profile bindings (ListProfiles / SaveProfile…) for
|
||
// full multi-profile management.
|
||
func (a *App) SaveStationSettings(s StationSettings) error {
|
||
if a.profiles == nil {
|
||
return fmt.Errorf("profiles not initialized")
|
||
}
|
||
p, err := a.profiles.Active(a.ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
p.Callsign = s.Callsign
|
||
p.Operator = s.Operator
|
||
p.MyGrid = s.MyGrid
|
||
p.MyCountry = s.MyCountry
|
||
p.MySOTARef = s.MySOTARef
|
||
p.MyPOTARef = s.MyPOTARef
|
||
return a.profiles.Save(a.ctx, &p)
|
||
}
|
||
|
||
// --- Profile bindings (multi-profile CRUD) ---
|
||
|
||
// ListProfiles returns every saved profile, active first.
|
||
func (a *App) ListProfiles() ([]profile.Profile, error) {
|
||
if a.profiles == nil {
|
||
return nil, fmt.Errorf("profiles not initialized")
|
||
}
|
||
return a.profiles.List(a.ctx)
|
||
}
|
||
|
||
// GetActiveProfile returns the currently-selected profile.
|
||
func (a *App) GetActiveProfile() (profile.Profile, error) {
|
||
if a.profiles == nil {
|
||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||
}
|
||
return a.profiles.Active(a.ctx)
|
||
}
|
||
|
||
// SaveProfile upserts a profile. Pass id=0 to create a new one.
|
||
func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
|
||
if a.profiles == nil {
|
||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||
}
|
||
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
||
return profile.Profile{}, err
|
||
}
|
||
a.refreshOperatorGrid()
|
||
return p, nil
|
||
}
|
||
|
||
// DeleteProfile removes a profile. Refuses to delete the last remaining
|
||
// profile; promotes another to active if the deleted one was selected.
|
||
func (a *App) DeleteProfile(id int64) error {
|
||
if a.profiles == nil {
|
||
return fmt.Errorf("profiles not initialized")
|
||
}
|
||
return a.profiles.Delete(a.ctx, id)
|
||
}
|
||
|
||
// ActivateProfile switches the selected profile. Subsequent QSOs stamp
|
||
// MY_* fields from this one.
|
||
func (a *App) ActivateProfile(id int64) error {
|
||
if a.profiles == nil {
|
||
return fmt.Errorf("profiles not initialized")
|
||
}
|
||
if err := a.profiles.SetActive(a.ctx, id); err != nil {
|
||
return err
|
||
}
|
||
a.refreshOperatorGrid()
|
||
// Per-profile config follows the active identity: reload the external-
|
||
// services manager so uploads now use this profile's accounts, and tell
|
||
// the frontend to refresh its settings panels.
|
||
if a.extsvc != nil {
|
||
a.extsvc.SetConfig(a.loadExternalServices())
|
||
}
|
||
if a.ctx != nil {
|
||
wruntime.EventsEmit(a.ctx, "profile:changed", id)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DuplicateProfile clones an existing profile under newName. Useful when
|
||
// the user has a "Home" profile and wants to derive "Portable" from it
|
||
// without retyping every field.
|
||
func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error) {
|
||
if a.profiles == nil {
|
||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||
}
|
||
return a.profiles.Duplicate(a.ctx, id, newName)
|
||
}
|
||
|
||
// --- Rotator bindings (PstRotator UDP v0) ---
|
||
|
||
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
||
type RotatorSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
Host string `json:"host"` // default 127.0.0.1
|
||
Port int `json:"port"` // default 12000
|
||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets
|
||
}
|
||
|
||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
||
out := RotatorSettings{Host: "127.0.0.1", Port: 12000}
|
||
if a.settings == nil {
|
||
return out, fmt.Errorf("db not initialized")
|
||
}
|
||
m, err := a.settings.GetMany(a.ctx,
|
||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
out.Enabled = m[keyRotatorEnabled] == "1"
|
||
if h := m[keyRotatorHost]; h != "" {
|
||
out.Host = h
|
||
}
|
||
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
|
||
out.Port = p
|
||
}
|
||
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
||
return out, nil
|
||
}
|
||
|
||
// SaveRotatorSettings persists the rotator config. Connection is per-call
|
||
// (UDP, no socket to (re)open) so no reload step is needed.
|
||
func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
if s.Host == "" {
|
||
s.Host = "127.0.0.1"
|
||
}
|
||
if s.Port <= 0 || s.Port > 65535 {
|
||
s.Port = 12000
|
||
}
|
||
for k, v := range map[string]string{
|
||
keyRotatorEnabled: boolStr(s.Enabled),
|
||
keyRotatorHost: s.Host,
|
||
keyRotatorPort: strconv.Itoa(s.Port),
|
||
keyRotatorHasElevation: boolStr(s.HasElevation),
|
||
} {
|
||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// rotatorClient returns a fresh PST UDP client built from current settings,
|
||
// or an error if the rotator is disabled / misconfigured.
|
||
func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
|
||
s, err := a.GetRotatorSettings()
|
||
if err != nil {
|
||
return nil, s, err
|
||
}
|
||
if !s.Enabled {
|
||
return nil, s, fmt.Errorf("rotator disabled in settings")
|
||
}
|
||
return pst.New(s.Host, s.Port), s, nil
|
||
}
|
||
|
||
// RotatorHeading is the live antenna heading for the status bar.
|
||
type RotatorHeading struct {
|
||
Enabled bool `json:"enabled"`
|
||
OK bool `json:"ok"`
|
||
Azimuth int `json:"azimuth"`
|
||
Raw string `json:"raw"`
|
||
}
|
||
|
||
// GetRotatorHeading queries PstRotator for the current azimuth. Returns
|
||
// Enabled=false when the rotator isn't configured. Polled by the status bar.
|
||
func (a *App) GetRotatorHeading() RotatorHeading {
|
||
s, err := a.GetRotatorSettings()
|
||
if err != nil || !s.Enabled {
|
||
return RotatorHeading{Enabled: false}
|
||
}
|
||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||
if herr != nil {
|
||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||
}
|
||
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
|
||
}
|
||
|
||
// RotatorGoTo points the antenna at the given azimuth (and optional
|
||
// elevation if the rotator is configured for it).
|
||
func (a *App) RotatorGoTo(az int, el int) error {
|
||
c, s, err := a.rotatorClient()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return c.GoTo(az, s.HasElevation, el)
|
||
}
|
||
|
||
// RotatorStop interrupts any in-progress rotation.
|
||
func (a *App) RotatorStop() error {
|
||
c, _, err := a.rotatorClient()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return c.Stop()
|
||
}
|
||
|
||
// RotatorPark moves the antenna to its parked position (configured in
|
||
// PstRotator itself).
|
||
func (a *App) RotatorPark() error {
|
||
c, _, err := a.rotatorClient()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return c.Park()
|
||
}
|
||
|
||
// TestRotator sends a no-op GoTo to the rotator's current heading to
|
||
// verify the UDP link without actually moving the antenna. We use 0° as
|
||
// the test target — pick a known direction the user expects to see.
|
||
// Returns nil on success or a descriptive error.
|
||
func (a *App) TestRotator(s RotatorSettings) error {
|
||
if s.Host == "" {
|
||
s.Host = "127.0.0.1"
|
||
}
|
||
if s.Port <= 0 || s.Port > 65535 {
|
||
s.Port = 12000
|
||
}
|
||
return pst.New(s.Host, s.Port).GoTo(0, false, -1)
|
||
}
|
||
|
||
func boolStr(b bool) string {
|
||
if b {
|
||
return "1"
|
||
}
|
||
return "0"
|
||
}
|
||
|
||
// --- WinKeyer (CW keyer) bindings ---
|
||
|
||
// WKMacro is one CW message slot (F1…): a short label + the macro text, which
|
||
// may contain <VARIABLE> tokens resolved by the frontend before sending.
|
||
type WKMacro struct {
|
||
Label string `json:"label"`
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
// WinkeyerSettings is the Hardware → CW Keyer panel shape. It embeds the
|
||
// engine Config (keying parameters) plus the enable flag and message macros.
|
||
type WinkeyerSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
winkeyer.Config
|
||
Engine string `json:"engine"` // keyer backend: "winkeyer" | "tci"
|
||
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
|
||
SendOnType bool `json:"send_on_type"` // key chars live as typed
|
||
Macros []WKMacro `json:"macros"`
|
||
}
|
||
|
||
// ListSerialPorts returns the available COM ports for the keyer dropdown.
|
||
func (a *App) ListSerialPorts() ([]string, error) {
|
||
return winkeyer.ListPorts()
|
||
}
|
||
|
||
// GetWinkeyerSettings returns the persisted keyer config (with sane defaults).
|
||
func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
|
||
out := WinkeyerSettings{
|
||
Config: winkeyer.Config{
|
||
Baud: 1200, WPM: 25, Weight: 50, LeadInMs: 10, TailMs: 50,
|
||
Ratio: 50, Sidetone: 600, Mode: winkeyer.ModeIambicB, AutoSpace: true,
|
||
SerialEcho: true, // so the panel shows text as it's transmitted
|
||
},
|
||
Engine: "winkeyer",
|
||
EscClearsCall: true,
|
||
Macros: defaultWKMacros(),
|
||
}
|
||
if a.settings == nil {
|
||
return out, nil
|
||
}
|
||
m, err := a.settings.GetMany(a.ctx,
|
||
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
|
||
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
|
||
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
|
||
keyWKEngine, keyWKEscClears, keyWKSendOnType)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
if v := m[keyWKEngine]; v != "" {
|
||
out.Engine = v
|
||
}
|
||
if v := m[keyWKEscClears]; v != "" {
|
||
out.EscClearsCall = v == "1"
|
||
}
|
||
out.SendOnType = m[keyWKSendOnType] == "1"
|
||
out.Enabled = m[keyWKEnabled] == "1"
|
||
if v := m[keyWKPort]; v != "" {
|
||
out.Port = v
|
||
}
|
||
atoiInto(m[keyWKBaud], &out.Baud)
|
||
atoiInto(m[keyWKWPM], &out.WPM)
|
||
atoiInto(m[keyWKWeight], &out.Weight)
|
||
atoiInto(m[keyWKLeadIn], &out.LeadInMs)
|
||
atoiInto(m[keyWKTail], &out.TailMs)
|
||
atoiInto(m[keyWKRatio], &out.Ratio)
|
||
atoiInto(m[keyWKFarnsworth], &out.Farnsworth)
|
||
atoiInto(m[keyWKSidetone], &out.Sidetone)
|
||
if v := m[keyWKMode]; v != "" {
|
||
out.Mode = winkeyer.Mode(v)
|
||
}
|
||
out.Swap = m[keyWKSwap] == "1"
|
||
if v := m[keyWKAutoSpace]; v != "" {
|
||
out.AutoSpace = v == "1"
|
||
}
|
||
out.UsePTT = m[keyWKUsePTT] == "1"
|
||
out.SerialEcho = m[keyWKSerialEcho] == "1"
|
||
if v := m[keyWKMacros]; v != "" {
|
||
var mac []WKMacro
|
||
if json.Unmarshal([]byte(v), &mac) == nil && len(mac) > 0 {
|
||
out.Macros = mac
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SaveWinkeyerSettings persists the keyer config; if a link is open and the
|
||
// keying params changed, the caller can reconnect to apply them.
|
||
func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
macJSON, _ := json.Marshal(s.Macros)
|
||
for k, v := range map[string]string{
|
||
keyWKEnabled: boolStr(s.Enabled),
|
||
keyWKPort: strings.TrimSpace(s.Port),
|
||
keyWKBaud: strconv.Itoa(s.Baud),
|
||
keyWKWPM: strconv.Itoa(s.WPM),
|
||
keyWKWeight: strconv.Itoa(s.Weight),
|
||
keyWKLeadIn: strconv.Itoa(s.LeadInMs),
|
||
keyWKTail: strconv.Itoa(s.TailMs),
|
||
keyWKRatio: strconv.Itoa(s.Ratio),
|
||
keyWKFarnsworth: strconv.Itoa(s.Farnsworth),
|
||
keyWKSidetone: strconv.Itoa(s.Sidetone),
|
||
keyWKMode: string(s.Mode),
|
||
keyWKSwap: boolStr(s.Swap),
|
||
keyWKAutoSpace: boolStr(s.AutoSpace),
|
||
keyWKUsePTT: boolStr(s.UsePTT),
|
||
keyWKSerialEcho: boolStr(s.SerialEcho),
|
||
keyWKMacros: string(macJSON),
|
||
keyWKEngine: strings.TrimSpace(s.Engine),
|
||
keyWKEscClears: boolStr(s.EscClearsCall),
|
||
keyWKSendOnType: boolStr(s.SendOnType),
|
||
} {
|
||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// WinkeyerConnect opens the serial link using the saved config.
|
||
func (a *App) WinkeyerConnect() error {
|
||
if a.winkeyer == nil {
|
||
return fmt.Errorf("winkeyer not initialized")
|
||
}
|
||
s, err := a.GetWinkeyerSettings()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return a.winkeyer.Connect(s.Config)
|
||
}
|
||
|
||
// WinkeyerDisconnect closes the serial link.
|
||
func (a *App) WinkeyerDisconnect() error {
|
||
if a.winkeyer != nil {
|
||
a.winkeyer.Disconnect()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// WinkeyerSend keys the (already variable-resolved) text as Morse.
|
||
func (a *App) WinkeyerSend(text string) error {
|
||
if a.winkeyer == nil {
|
||
return fmt.Errorf("winkeyer not initialized")
|
||
}
|
||
return a.winkeyer.Send(text)
|
||
}
|
||
|
||
// WinkeyerStop aborts the current message immediately.
|
||
func (a *App) WinkeyerStop() error {
|
||
if a.winkeyer == nil {
|
||
return fmt.Errorf("winkeyer not initialized")
|
||
}
|
||
return a.winkeyer.Stop()
|
||
}
|
||
|
||
// WinkeyerBackspace removes the last not-yet-keyed character (send-on-type).
|
||
func (a *App) WinkeyerBackspace() error {
|
||
if a.winkeyer == nil {
|
||
return fmt.Errorf("winkeyer not initialized")
|
||
}
|
||
return a.winkeyer.Backspace()
|
||
}
|
||
|
||
// WinkeyerSetSpeed changes the keying speed (WPM) live.
|
||
func (a *App) WinkeyerSetSpeed(wpm int) error {
|
||
if a.winkeyer == nil {
|
||
return fmt.Errorf("winkeyer not initialized")
|
||
}
|
||
return a.winkeyer.SetSpeed(wpm)
|
||
}
|
||
|
||
// GetWinkeyerStatus returns the current link status (used on mount).
|
||
func (a *App) GetWinkeyerStatus() winkeyer.Status {
|
||
if a.winkeyer == nil {
|
||
return winkeyer.Status{}
|
||
}
|
||
return a.winkeyer.Snapshot()
|
||
}
|
||
|
||
// defaultWKMacros mirrors the classic F-key set (CQ / answer / reports / 73).
|
||
func defaultWKMacros() []WKMacro {
|
||
return []WKMacro{
|
||
{Label: "CQ", Text: "CQ CQ DE <MY_CALL> <MY_CALL> K"},
|
||
{Label: "His call", Text: "<CALL> "},
|
||
{Label: "Report", Text: "<CALL> UR <STX> <STX> = "},
|
||
{Label: "Answer", Text: "<CALL> DE <MY_CALL> TU UR <RST_R> = "},
|
||
{Label: "Name/QTH", Text: "NAME <MY_NAME> QTH <MY_QTH> = "},
|
||
{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
|
||
{Label: "QRL?", Text: "QRL? "},
|
||
{Label: "AGN", Text: "AGN "},
|
||
}
|
||
}
|
||
|
||
func atoiInto(s string, dst *int) {
|
||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||
*dst = n
|
||
}
|
||
}
|
||
|
||
// --- DX Cluster bindings (multi-server) ---
|
||
|
||
// resolveClusterLogin returns the login callsign for a server: explicit
|
||
// override on the row, else the active profile's callsign.
|
||
func (a *App) resolveClusterLogin(override string) string {
|
||
if override != "" {
|
||
return strings.ToUpper(strings.TrimSpace(override))
|
||
}
|
||
if a.profiles != nil {
|
||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||
return strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// clusterAutoConnect reads the global "auto-connect on startup" toggle.
|
||
// Stored in settings (key/value) since it's a single bool, not per-row.
|
||
func (a *App) clusterAutoConnect() (bool, error) {
|
||
if a.settings == nil {
|
||
return false, fmt.Errorf("db not initialized")
|
||
}
|
||
v, err := a.settings.Get(a.ctx, keyClusterAutoConnect)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return v == "1", nil
|
||
}
|
||
|
||
// startAllEnabledClusters opens a session for every enabled server.
|
||
func (a *App) startAllEnabledClusters() {
|
||
servers, err := a.listClusterServers()
|
||
if err != nil {
|
||
fmt.Println("OpsLog: list cluster servers:", err)
|
||
return
|
||
}
|
||
for _, s := range servers {
|
||
if s.Enabled {
|
||
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
|
||
}
|
||
}
|
||
}
|
||
|
||
// listClusterServers reads the cluster_servers table ordered for display
|
||
// (sort_order asc, id asc). The first row with Enabled=true is the master.
|
||
func (a *App) listClusterServers() ([]cluster.ServerConfig, error) {
|
||
if a.db == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
rows, err := a.db.QueryContext(a.ctx, `
|
||
SELECT id, name, host, port, login_override, password, init_commands, enabled, sort_order
|
||
FROM cluster_servers
|
||
ORDER BY sort_order ASC, id ASC`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []cluster.ServerConfig
|
||
for rows.Next() {
|
||
var s cluster.ServerConfig
|
||
var enabled int
|
||
if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.Port, &s.LoginOverride,
|
||
&s.Password, &s.InitCommands, &enabled, &s.SortOrder); err != nil {
|
||
return nil, err
|
||
}
|
||
s.Enabled = enabled == 1
|
||
out = append(out, s)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ListClusterServers returns all saved cluster nodes.
|
||
func (a *App) ListClusterServers() ([]cluster.ServerConfig, error) {
|
||
return a.listClusterServers()
|
||
}
|
||
|
||
// SaveClusterServer upserts one row. id=0 inserts a new server. Restarts
|
||
// the session if the row was already running (so config edits take effect
|
||
// immediately).
|
||
func (a *App) SaveClusterServer(s cluster.ServerConfig) (cluster.ServerConfig, error) {
|
||
if a.db == nil {
|
||
return cluster.ServerConfig{}, fmt.Errorf("db not initialized")
|
||
}
|
||
if strings.TrimSpace(s.Name) == "" {
|
||
return cluster.ServerConfig{}, fmt.Errorf("server name required")
|
||
}
|
||
if s.Port <= 0 || s.Port > 65535 {
|
||
s.Port = 7300
|
||
}
|
||
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||
enabled := 0
|
||
if s.Enabled {
|
||
enabled = 1
|
||
}
|
||
if s.ID == 0 {
|
||
res, err := a.db.ExecContext(a.ctx, `
|
||
INSERT INTO cluster_servers
|
||
(name, host, port, login_override, password, init_commands, enabled, sort_order, created_at, updated_at)
|
||
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||
s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, now)
|
||
if err != nil {
|
||
return cluster.ServerConfig{}, err
|
||
}
|
||
id, _ := res.LastInsertId()
|
||
s.ID = id
|
||
} else {
|
||
_, err := a.db.ExecContext(a.ctx, `
|
||
UPDATE cluster_servers SET name=?, host=?, port=?, login_override=?, password=?,
|
||
init_commands=?, enabled=?, sort_order=?, updated_at=?
|
||
WHERE id=?`,
|
||
s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, s.ID)
|
||
if err != nil {
|
||
return cluster.ServerConfig{}, err
|
||
}
|
||
}
|
||
// Apply runtime change: stop and restart if enabled, else just stop.
|
||
a.cluster.StopServer(s.ID)
|
||
if s.Enabled {
|
||
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// DeleteClusterServer drops a row and closes its session.
|
||
func (a *App) DeleteClusterServer(id int64) error {
|
||
if a.db == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
a.cluster.StopServer(id)
|
||
_, err := a.db.ExecContext(a.ctx, `DELETE FROM cluster_servers WHERE id=?`, id)
|
||
return err
|
||
}
|
||
|
||
// SetClusterAutoConnect persists the global auto-connect toggle.
|
||
func (a *App) SetClusterAutoConnect(on bool) error {
|
||
if a.settings == nil {
|
||
return fmt.Errorf("db not initialized")
|
||
}
|
||
return a.settings.Set(a.ctx, keyClusterAutoConnect, boolStr(on))
|
||
}
|
||
|
||
// GetClusterAutoConnect reads the persisted toggle.
|
||
func (a *App) GetClusterAutoConnect() (bool, error) {
|
||
return a.clusterAutoConnect()
|
||
}
|
||
|
||
// ConnectClusterServer opens a session for one specific saved server.
|
||
func (a *App) ConnectClusterServer(id int64) error {
|
||
if a.cluster == nil {
|
||
return fmt.Errorf("cluster not initialized")
|
||
}
|
||
servers, err := a.listClusterServers()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, s := range servers {
|
||
if s.ID == id {
|
||
if !s.Enabled {
|
||
return fmt.Errorf("server %q is disabled — enable it first", s.Name)
|
||
}
|
||
a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
|
||
return nil
|
||
}
|
||
}
|
||
return fmt.Errorf("no saved server with id %d", id)
|
||
}
|
||
|
||
// DisconnectClusterServer closes the session for one server.
|
||
func (a *App) DisconnectClusterServer(id int64) error {
|
||
if a.cluster == nil {
|
||
return fmt.Errorf("cluster not initialized")
|
||
}
|
||
a.cluster.StopServer(id)
|
||
return nil
|
||
}
|
||
|
||
// ConnectAllClusters opens sessions for every enabled server.
|
||
func (a *App) ConnectAllClusters() error {
|
||
if a.cluster == nil {
|
||
return fmt.Errorf("cluster not initialized")
|
||
}
|
||
a.startAllEnabledClusters()
|
||
return nil
|
||
}
|
||
|
||
// DisconnectAllClusters closes every running session.
|
||
func (a *App) DisconnectAllClusters() error {
|
||
if a.cluster == nil {
|
||
return fmt.Errorf("cluster not initialized")
|
||
}
|
||
a.cluster.StopAll()
|
||
return nil
|
||
}
|
||
|
||
// SendClusterCommand writes `cmd` to the **master** cluster — the first
|
||
// enabled server by sort_order. Returns an error if the master is not
|
||
// currently connected (the UI should grey the input out in that case).
|
||
func (a *App) SendClusterCommand(cmd string) error {
|
||
if a.cluster == nil {
|
||
return fmt.Errorf("cluster not initialized")
|
||
}
|
||
cmd = strings.TrimSpace(cmd)
|
||
if cmd == "" {
|
||
return fmt.Errorf("empty command")
|
||
}
|
||
servers, err := a.listClusterServers()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, s := range servers {
|
||
if s.Enabled {
|
||
return a.cluster.SendCommand(s.ID, cmd)
|
||
}
|
||
}
|
||
return fmt.Errorf("no enabled cluster server to send to")
|
||
}
|
||
|
||
// SendClusterSpot announces a DX spot on the **master** cluster (first
|
||
// enabled server). Format is the universal DXSpider/AR-Cluster command
|
||
// `DX <freq_khz> <call> <comment>`. The frequency is taken in kHz; call is
|
||
// upper-cased; comment is optional (commonly the mode, e.g. "CW").
|
||
func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) error {
|
||
call = strings.ToUpper(strings.TrimSpace(call))
|
||
if call == "" {
|
||
return fmt.Errorf("callsign required")
|
||
}
|
||
if freqKHz <= 0 {
|
||
return fmt.Errorf("invalid frequency")
|
||
}
|
||
// Trim a trailing ".0" so integer kHz stay clean (14205 not 14205.0),
|
||
// but keep sub-kHz precision when present (e.g. 10138.7).
|
||
freqStr := strconv.FormatFloat(freqKHz, 'f', -1, 64)
|
||
cmd := fmt.Sprintf("DX %s %s", freqStr, call)
|
||
if c := strings.TrimSpace(comment); c != "" {
|
||
cmd += " " + c
|
||
}
|
||
applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
|
||
return a.SendClusterCommand(cmd)
|
||
}
|
||
|
||
// GetClusterStatus returns a snapshot of every active session. Used by
|
||
// the UI on mount and to hydrate after a `cluster:state` event.
|
||
func (a *App) GetClusterStatus() []cluster.ServerStatus {
|
||
if a.cluster == nil {
|
||
return nil
|
||
}
|
||
return a.cluster.Status()
|
||
}
|
||
|
||
// SpotQuery is one (call, band, mode) tuple sent for status colouring.
|
||
type SpotQuery struct {
|
||
Call string `json:"call"`
|
||
Band string `json:"band"`
|
||
Mode string `json:"mode"`
|
||
}
|
||
|
||
// SpotStatus is the per-tuple result. Status is one of:
|
||
//
|
||
// "new" — entity never worked
|
||
// "new-band" — entity worked but never on this band
|
||
// "new-slot" — entity worked on this band but not in this mode
|
||
// "worked" — exact band+mode already in the log
|
||
// "" — couldn't resolve the entity (no cty.dat match)
|
||
type SpotStatus struct {
|
||
Call string `json:"call"`
|
||
Band string `json:"band"`
|
||
Mode string `json:"mode"`
|
||
Country string `json:"country,omitempty"`
|
||
Continent string `json:"continent,omitempty"`
|
||
Status string `json:"status"`
|
||
// WorkedCall is true when this exact callsign exists in the log
|
||
// (any band, any mode). Drives the per-call text highlight, in
|
||
// addition to the entity-level Status (NEW / NEW BAND / …).
|
||
WorkedCall bool `json:"worked_call"`
|
||
}
|
||
|
||
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
||
// each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT
|
||
// / WORKED). One cty.dat lookup + one DB scan, regardless of batch size.
|
||
//
|
||
// Mode handling: when the caller passes an empty Mode (cluster comment
|
||
// was ambiguous and the frontend couldn't infer) we degrade gracefully
|
||
// to band-only — saying "worked" rather than wrongly flagging "new-slot"
|
||
// just because we don't know the mode.
|
||
func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||
out := make([]SpotStatus, len(spots))
|
||
if a.qso == nil {
|
||
return out
|
||
}
|
||
// Pass a cty.dat-backed resolver so the past-QSO map uses the SAME
|
||
// entity name we'll compare each spot against. Without it QRZ-stored
|
||
// "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW.
|
||
resolveEntity := func(callsign string) string {
|
||
if a.dxcc == nil {
|
||
return ""
|
||
}
|
||
m, ok := a.dxcc.Lookup(callsign)
|
||
if !ok || m.Entity == nil {
|
||
return ""
|
||
}
|
||
return m.Entity.Name
|
||
}
|
||
entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity)
|
||
if err != nil {
|
||
return out
|
||
}
|
||
// Per-call worked set — separate from the entity check so we can flag
|
||
// "I've already QSO'd this exact station" even when the band/mode
|
||
// makes the entity check say "new-band" or "new-slot".
|
||
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
|
||
for i, q := range spots {
|
||
out[i] = SpotStatus{
|
||
Call: q.Call,
|
||
Band: strings.ToLower(q.Band),
|
||
Mode: strings.ToUpper(q.Mode),
|
||
}
|
||
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
|
||
out[i].WorkedCall = true
|
||
}
|
||
if a.dxcc == nil {
|
||
continue
|
||
}
|
||
m, ok := a.dxcc.Lookup(q.Call)
|
||
if !ok || m.Entity == nil {
|
||
continue
|
||
}
|
||
country := strings.ToLower(m.Entity.Name)
|
||
out[i].Country = m.Entity.Name
|
||
out[i].Continent = m.Continent
|
||
e, worked := entities[country]
|
||
if !worked {
|
||
out[i].Status = "new"
|
||
continue
|
||
}
|
||
if _, b := e.Bands[out[i].Band]; !b {
|
||
out[i].Status = "new-band"
|
||
continue
|
||
}
|
||
// Without a mode we can't distinguish "new slot" from "worked";
|
||
// the safer default is "worked" so we never falsely claim "new".
|
||
if out[i].Mode == "" {
|
||
out[i].Status = "worked"
|
||
continue
|
||
}
|
||
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
|
||
out[i].Status = "new-slot"
|
||
continue
|
||
}
|
||
out[i].Status = "worked"
|
||
}
|
||
return out
|
||
}
|