This commit is contained in:
2026-06-15 23:45:14 +02:00
parent 29fd832bcd
commit 22e3bb4a18
32 changed files with 2531 additions and 362 deletions
+510 -140
View File
@@ -372,8 +372,10 @@ type App struct {
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
migratedFromAppData bool // true when we auto-copied AppData on first portable launch
// shuttingDown gates beforeClose re-entry: the first user attempt to
// close fires shutdown tasks (backup, future LoTW upload, ...) while
@@ -497,15 +499,6 @@ func (a *App) startup(ctx context.Context) {
fmt.Println("OpsLog:", a.startupErr)
return
}
// First-launch migration: if the portable data dir has no database yet,
// copy whatever is in AppData/OpsLog (or AppData/HamLog) so the user
// keeps their log after the switch to fully-portable layout.
if migrated, migrErr := autoMigrateFromAppData(dataDir); migrated {
a.migratedFromAppData = true
if migrErr != nil {
fmt.Println("OpsLog: migration warning:", migrErr)
}
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
a.startupErr = "cannot create data dir: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
@@ -542,6 +535,7 @@ func (a *App) startup(ctx context.Context) {
// 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
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
@@ -563,6 +557,21 @@ func (a *App) startup(ctx context.Context) {
a.settings = settings.NewStore(conn)
a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set
a.profiles = profile.NewRepo(conn)
// Determine the active profile and scope the settings store to it FIRST:
// every setting is per-profile, so all settings-dependent wiring below must
// read the active profile's values.
active, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{
Callsign: keyStationCallsign,
Operator: keyStationOperator,
MyGrid: keyStationMyGrid,
Country: keyStationCountry,
SOTA: keyStationSOTA,
POTA: keyStationPOTA,
})
if err != nil {
fmt.Println("OpsLog: EnsureDefault profile:", err)
}
a.settings.SetProfile(active.ID)
a.awardRefs = awardref.NewRepo(conn)
a.qslTemplates = qslcard.NewRepo(conn)
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
@@ -571,46 +580,26 @@ func (a *App) startup(ctx context.Context) {
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()
// Now choose where the QSO logbook lives. On a MySQL failure we fall back to
// the local SQLite logbook so the operator can still log (and fix config).
logbookConn := conn
if mb := readBootstrap(dataDir).MySQL; mb != nil && mb.Enabled {
applog.Printf("startup: logbook backend = MySQL (%s:%d/%s)", mb.Host, mb.Port, mb.Database)
mysqlConn, mErr := db.OpenMySQL(db.MySQLConfig{
Host: mb.Host, Port: mb.Port, User: mb.User, Password: mb.Password, Database: mb.Database,
})
if mErr != nil {
applog.Printf("startup: MySQL open failed (%v) — falling back to SQLite logbook", mErr)
a.dbBackendErr = "MySQL: " + mErr.Error()
} else {
logbookConn = mysqlConn
a.dbBackend = "mysql"
}
}
if a.dbBackend == "" {
a.dbBackend = "sqlite"
// The QSO logbook lives where the ACTIVE PROFILE points it: the local SQLite
// file, or a per-profile shared MySQL database. Switching profiles switches
// the logbook (see switchLogbook). One-time: adopt any legacy config.json
// MySQL config into the active profile so existing setups keep working.
a.adoptBootstrapMySQL(&active)
logbookConn, backend, lerr := a.connectLogbook(active.DB)
if lerr != nil {
applog.Printf("startup: logbook open failed (%v) — falling back to SQLite logbook", lerr)
a.dbBackendErr = strings.TrimPrefix(lerr.Error(), "")
logbookConn, backend = conn, "sqlite"
}
a.dbBackend = backend
// db.Dialect describes the LOGBOOK backend — the only place SQL actually
// varies (qso JSON extraction). Config repos always run on SQLite.
db.SetDialect(a.dbBackend)
applog.Printf("startup: logbook backend = %s", a.dbBackend)
db.SetDialect(backend)
applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn)
@@ -751,26 +740,37 @@ func (a *App) startup(ctx context.Context) {
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// Autostart: launch the active profile's configured external programs that
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
// so a slow-launching app never delays the UI.
go func() {
for _, r := range a.LaunchAutostartPrograms() {
applog.Printf("autostart: %s — %s (%s)", r.Name, r.Status, r.Message)
}
}()
// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
// when disabled in Preferences or until the PostHog key is configured.
go a.sendTelemetryHeartbeat()
fmt.Println("OpsLog: db ready at", a.dbPath)
}
// StartupStatus returns a diagnostic snapshot for the frontend.
// dbPath is always populated; err is empty when the app is healthy.
type StartupStatus struct {
OK bool `json:"ok"`
Err string `json:"err"`
DBPath string `json:"db_path"`
MigratedFromAppData bool `json:"migrated_from_app_data"`
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,
MigratedFromAppData: a.migratedFromAppData,
OK: a.startupErr == "",
Err: a.startupErr,
DBPath: a.dbPath,
}
}
@@ -914,6 +914,16 @@ func (a *App) setSetting(key, val string) {
}
}
// setSettingGlobal stores a value shared across all profiles (no profile prefix).
func (a *App) setSettingGlobal(key, val string) {
if a.settings == nil {
return
}
if err := a.settings.SetGlobal(a.ctx, key, val); err != nil {
applog.Printf("settings: set global %q failed: %v", key, err)
}
}
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
@@ -949,35 +959,6 @@ func userDataDir() (string, error) {
return filepath.Join(filepath.Dir(exe), "data"), nil
}
// autoMigrateFromAppData copies existing AppData/OpsLog (or AppData/HamLog)
// data into targetDir the first time the portable layout is used (i.e. when
// targetDir has no database yet). Returns true when a migration was performed.
func autoMigrateFromAppData(targetDir string) (bool, error) {
// Already have a database — nothing to migrate.
if fileExists(filepath.Join(targetDir, "opslog.db")) ||
fileExists(filepath.Join(targetDir, "hamlog.db")) {
return false, nil
}
base, err := os.UserConfigDir()
if err != nil {
return false, nil
}
var srcDir string
for _, name := range []string{"OpsLog", "HamLog"} {
d := filepath.Join(base, name)
if _, err := os.Stat(d); err == nil {
srcDir = d
break
}
}
if srcDir == "" {
return false, nil // fresh install — no AppData to migrate
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return false, err
}
return true, copyDirContents(srcDir, targetDir)
}
// fileExists reports whether path exists and is a regular file.
func fileExists(path string) bool {
@@ -1130,45 +1111,125 @@ type DBConnectionInfo struct {
// it shows the local database file path.
func (a *App) GetDBConnectionInfo() DBConnectionInfo {
if a.dbBackend == "mysql" {
if mb := readBootstrap(a.dataDir).MySQL; mb != nil {
port := mb.Port
if p, err := a.profiles.Active(a.ctx); err == nil && p.DB.Backend == "mysql" {
port := p.DB.Port
if port == 0 {
port = 3306
}
return DBConnectionInfo{Backend: "mysql", Label: fmt.Sprintf("%s:%d/%s", mb.Host, port, mb.Database)}
return DBConnectionInfo{Backend: "mysql", Label: fmt.Sprintf("%s:%d/%s", p.DB.Host, port, p.DB.Database)}
}
return DBConnectionInfo{Backend: "mysql", Label: "MySQL"}
}
return DBConnectionInfo{Backend: "sqlite", Label: a.dbPath}
}
// GetMySQLSettings returns the stored shared-database config from the bootstrap
// file (config.json), with defaults applied. Read before the DB is open, so it
// must not depend on the settings table.
// connectLogbook opens the logbook connection for a profile's DB target: a
// shared MySQL database, or the local SQLite file (which doubles as the logbook
// when no MySQL is configured). Returns the connection and the backend name.
func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
if cfg.Backend == "mysql" {
c, err := db.OpenMySQL(db.MySQLConfig{
Host: cfg.Host, Port: cfg.Port, User: cfg.User, Password: cfg.Password, Database: cfg.Database,
})
if err != nil {
return nil, "", err
}
return c, "mysql", nil
}
return a.db, "sqlite", nil
}
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
// profile (one-time), so users who set up MySQL before it became per-profile
// keep their logbook. The bootstrap entry is then cleared.
func (a *App) adoptBootstrapMySQL(active *profile.Profile) {
mb := readBootstrap(a.dataDir).MySQL
if mb == nil || !mb.Enabled || active.ID == 0 || active.DB.Backend != "" {
return
}
active.DB = profile.ProfileDB{
Backend: "mysql", Host: mb.Host, Port: mb.Port,
User: mb.User, Password: mb.Password, Database: mb.Database,
}
if err := a.profiles.SetDB(a.ctx, active.ID, active.DB); err != nil {
applog.Printf("adopt bootstrap MySQL into profile: %v", err)
return
}
c := readBootstrap(a.dataDir)
c.MySQL = nil
_ = writeBootstrap(a.dataDir, c)
}
// switchLogbook reconnects the live logbook to the given profile's DB target
// (called when the active profile changes or its DB config is saved), swaps the
// qso repo, and notifies the UI. The previous MySQL connection is closed.
func (a *App) switchLogbook(p profile.Profile) error {
newConn, backend, err := a.connectLogbook(p.DB)
if err != nil {
a.dbBackendErr = "MySQL: " + err.Error()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "logbook:changed")
}
return err
}
old := a.logDb
a.qso = qso.NewRepo(newConn)
a.logDb = newConn
a.dbBackend = backend
a.dbBackendErr = ""
a.invalidateAwardStats() // different logbook → drop memoised award matrices
db.SetDialect(backend)
if old != nil && old != a.db && old != newConn {
_ = old.Close()
}
applog.Printf("logbook switched to %s for profile %q", backend, p.Name)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "logbook:changed")
}
return nil
}
// GetMySQLSettings returns the ACTIVE profile's logbook DB config (Enabled =
// MySQL). Each profile can target its own database.
func (a *App) GetMySQLSettings() (MySQLSettings, error) {
out := MySQLSettings{Port: 3306}
if mb := readBootstrap(a.dataDir).MySQL; mb != nil {
out = *mb
if out.Port <= 0 {
out.Port = 3306
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return out, nil
}
d := p.DB
out.Enabled = d.Backend == "mysql"
out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
if d.Port > 0 {
out.Port = d.Port
}
return out, nil
}
// SaveMySQLSettings persists the shared-database config to the bootstrap file.
// Switching the active backend takes effect on the next launch (we read this
// file before opening any database).
// SaveMySQLSettings stores the DB target on the ACTIVE profile and switches the
// live logbook to it immediately (no restart). Enabled=false reverts to the
// local SQLite logbook.
func (a *App) SaveMySQLSettings(s MySQLSettings) error {
p, err := a.profiles.Active(a.ctx)
if err != nil {
return fmt.Errorf("no active profile: %w", err)
}
if s.Port <= 0 {
s.Port = 3306
}
s.Host = strings.TrimSpace(s.Host)
s.User = strings.TrimSpace(s.User)
s.Database = strings.TrimSpace(s.Database)
c := readBootstrap(a.dataDir)
c.MySQL = &s
return writeBootstrap(a.dataDir, c)
cfg := profile.ProfileDB{Database: strings.TrimSpace(s.Database)}
if s.Enabled {
cfg.Backend = "mysql"
cfg.Host = strings.TrimSpace(s.Host)
cfg.Port = s.Port
cfg.User = strings.TrimSpace(s.User)
cfg.Password = s.Password
}
if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil {
return err
}
p.DB = cfg
return a.switchLogbook(p)
}
// TestMySQLConnection pings the shared MySQL database with the given settings
@@ -1637,11 +1698,30 @@ func (a *App) CountQSO() (int64, error) {
return a.qso.Count(a.ctx)
}
// GetLogbookRevision returns a cheap fingerprint of the logbook (count + max id)
// so the UI can poll a shared MySQL logbook and refresh when another OpsLog
// instance has added or removed QSOs.
func (a *App) GetLogbookRevision() (string, error) {
if a.qso == nil {
return "", fmt.Errorf("db not initialized")
}
return a.qso.Revision(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) != "" {
// Award definitions are GLOBAL (shared across every profile) — a custom
// award is the operator's own work, not station config. Read the global
// key first; fall back to a per-profile copy saved before awards became
// global so existing customisations aren't lost (the next save promotes
// them to global).
s, _ := a.settings.GetGlobal(a.ctx, keyAwardDefs)
if strings.TrimSpace(s) == "" {
s, _ = a.settings.Get(a.ctx, keyAwardDefs)
}
if 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.
@@ -1666,7 +1746,10 @@ func (a *App) migrateAwardDefs() {
if a.settings == nil {
return
}
s, _ := a.settings.Get(a.ctx, keyAwardDefs)
s, _ := a.settings.GetGlobal(a.ctx, keyAwardDefs)
if strings.TrimSpace(s) == "" {
s, _ = a.settings.Get(a.ctx, keyAwardDefs) // legacy per-profile copy
}
if strings.TrimSpace(s) == "" {
return // nothing saved yet → Defaults() (already on the new model)
}
@@ -1680,7 +1763,7 @@ func (a *App) migrateAwardDefs() {
// 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 {
if v, _ := a.settings.GetGlobal(a.ctx, keyAwardDefsFixed); v != defsFixVersion {
byCode := map[string]award.Def{}
for _, d := range award.Defaults() {
byCode[strings.ToUpper(d.Code)] = d
@@ -1692,13 +1775,13 @@ func (a *App) migrateAwardDefs() {
changed = true
}
}
a.setSetting(keyAwardDefsFixed, defsFixVersion)
a.setSettingGlobal(keyAwardDefsFixed, defsFixVersion)
}
if !changed {
return
}
if b, err := json.Marshal(migrated); err == nil {
a.setSetting(keyAwardDefs, string(b))
a.setSettingGlobal(keyAwardDefs, string(b))
applog.Printf("awards: migrated/fixed %d definitions", len(migrated))
}
}
@@ -1712,7 +1795,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
if err != nil {
return err
}
return a.settings.Set(a.ctx, keyAwardDefs, string(b))
return a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b))
}
// ResetAwardDefs restores the built-in defaults.
@@ -1756,12 +1839,8 @@ 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 {
all, err := a.awardSnapshot()
if err != nil {
return nil, err
}
nameOf := func(field, ref string) string {
@@ -1877,16 +1956,22 @@ func (a *App) AwardMissingQSOs(code string) ([]qso.QSO, error) {
return []qso.QSO{}, nil // not meaningful without a DXCC scope
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
// Reuse the fast, already-enriched award snapshot (light column scan, cached
// against the logbook revision) instead of a full IterateAll — the latter
// pulled all ~150 columns and took ~20s over a remote MySQL.
snapshot, err := a.awardSnapshot()
if err != nil {
return nil, err
}
var out []qso.QSO
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
for i := range snapshot {
q := snapshot[i]
// In the award's scope, yet no reference extracted → a gap to fix.
if award.InScope(*def, &q) && len(award.MatchQSO(*def, metas, &q)) == 0 {
out = append(out, q)
}
return nil
})
return out, err
}
return out, nil
}
// GetPOTAToken returns the stored pota.app session token (for the settings UI).
@@ -2196,6 +2281,48 @@ type AwardStatsResult struct {
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"}
// awardSnapshot returns the logbook as a light-scanned, award-enriched slice,
// reused across award computations. Pulling the whole logbook is the dominant
// cost of every award (≈4s for 27k rows over a remote MySQL link), while the
// matching itself is a few milliseconds — so we pull once and keep the result
// in memory, rebuilding only when the logbook revision moves (a QSO added,
// removed, or edited). The returned slice is treated as read-only by callers.
func (a *App) awardSnapshot() ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
rev, revErr := a.qso.Revision(a.ctx)
if revErr == nil {
a.awardSnapMu.Lock()
if a.awardSnap != nil && a.awardSnapRev == rev {
qs := a.awardSnap
a.awardSnapMu.Unlock()
return qs, nil
}
a.awardSnapMu.Unlock()
}
t0 := time.Now()
var all []qso.QSO
if err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
all = append(all, q)
return nil
}); err != nil {
return nil, err
}
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s)",
len(all), time.Since(t0).Round(time.Millisecond), rev)
if revErr == nil {
a.awardSnapMu.Lock()
a.awardSnap = all
a.awardSnapRev = rev
a.awardSnapMu.Unlock()
}
return all, nil
}
// 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) {
@@ -2239,15 +2366,19 @@ func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
}
}
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
refs := award.MatchQSO(*def, metas, &q)
snapshot, err := a.awardSnapshot()
if err != nil {
return AwardStatsResult{}, err
}
for i := range snapshot {
q := &snapshot[i] // read-only; already award-enriched in the snapshot
refs := award.MatchQSO(*def, metas, q)
if len(refs) == 0 {
return nil
continue
}
bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))]
isConf := award.Confirmed(&q, def.Confirm)
isVal := award.Confirmed(&q, def.Validate)
isConf := award.Confirmed(q, def.Confirm)
isVal := award.Confirmed(q, def.Validate)
cat := strings.ToUpper(award.EmissionOf(q.Mode))
record := func(c string) {
@@ -2272,10 +2403,6 @@ func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" {
record(cat)
}
return nil
})
if err != nil {
return AwardStatsResult{}, err
}
res := AwardStatsResult{Code: def.Code, Bands: statsBands}
@@ -2298,6 +2425,16 @@ func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
return res, nil
}
// invalidateAwardStats drops the in-memory award QSO snapshot. Called after QSO
// edits that don't change the logbook row count (e.g. QSL confirmation updates),
// since those aren't caught by the revision check in awardSnapshot.
func (a *App) invalidateAwardStats() {
a.awardSnapMu.Lock()
a.awardSnap = nil
a.awardSnapRev = ""
a.awardSnapMu.Unlock()
}
// 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.
@@ -2331,6 +2468,59 @@ func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta {
return out
}
// AssignAwardRefToQSOs assigns one award reference to many QSOs at once — the
// bulk action in the award "missing references" view (e.g. tag every "Urumqi"
// contact as Xinjiang for WAPC). It writes the manual override the award engine
// honours, replacing any previous reference this award had on each QSO. Returns
// the number of QSOs updated.
func (a *App) AssignAwardRefToQSOs(code, ref string, ids []int64) (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
code = strings.ToUpper(strings.TrimSpace(code))
ref = strings.ToUpper(strings.TrimSpace(ref))
if code == "" || ref == "" {
return 0, fmt.Errorf("award code and reference are required")
}
n := 0
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
continue
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras[award.ManualRefsKey] = setOverrideRef(q.Extras[award.ManualRefsKey], code, ref)
if err := a.qso.Update(a.ctx, q); err == nil {
n++
}
}
if n > 0 {
a.invalidateAwardStats()
}
return n, nil
}
// setOverrideRef replaces (or adds) the reference for one award code in a
// "CODE@REF;CODE@REF" override string, leaving other awards' entries intact.
func setOverrideRef(existing, code, ref string) string {
var out []string
for _, entry := range strings.Split(existing, ";") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
at := strings.IndexByte(entry, '@')
if at > 0 && strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
continue // drop this award's previous reference
}
out = append(out, entry)
}
out = append(out, code+"@"+ref)
return strings.Join(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.
@@ -2529,6 +2719,33 @@ func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil
}
// DownloadAllReferenceLists downloads every online award reference list
// (IOTA/POTA/WWFF/SOTA) in one go and returns a per-award summary. Used by the
// first-launch dialog and the Tools → Maintenance entry so the user doesn't
// have to open each award and update it individually.
func (a *App) DownloadAllReferenceLists() (string, error) {
if a.awardRefs == nil {
return "", fmt.Errorf("db not initialized")
}
var parts []string
var firstErr error
for _, code := range []string{"IOTA", "POTA", "WWFF", "SOTA"} {
if !awardref.CanUpdate(code) {
continue
}
meta, err := a.UpdateAwardReferenceList(code)
if err != nil {
parts = append(parts, fmt.Sprintf("%s ✗", code))
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", code, err)
}
continue
}
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
}
return strings.Join(parts, " · "), firstErr
}
// 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) {
@@ -2728,7 +2945,7 @@ func (a *App) ImportAwards() (AwardImportResult, error) {
defs = migrated
}
b, _ := json.Marshal(defs)
if err := a.settings.Set(a.ctx, keyAwardDefs, string(b)); err != nil {
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
return res, fmt.Errorf("save award defs: %w", err)
}
@@ -2911,7 +3128,11 @@ 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)
err := a.qso.Update(a.ctx, q)
if err == nil {
a.invalidateAwardStats()
}
return err
}
func (a *App) DeleteQSO(id int64) error {
@@ -2968,6 +3189,9 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
}
}
}
if n > 0 {
a.invalidateAwardStats()
}
return n, nil
}
@@ -3579,13 +3803,23 @@ func (a *App) qsoRecDir() string {
// 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":
// CW is intentionally excluded: SmartSDR doesn't route CW audio through DAX,
// so the recording is empty/useless. Phone modes only.
case "SSB", "USB", "LSB", "AM", "FM", "DV":
return true
}
return false
}
func (a *App) saveQSORecording(q *qso.QSO) {
// The logging path must never die because of the recorder. Recover any
// Go-level panic in the snapshot/stamp work below (the encode already runs
// in its own recovered goroutine).
defer func() {
if r := recover(); r != nil {
applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r)
}
}()
if a.qsoRec == nil || !a.qsoRec.Active() {
return
}
@@ -3630,8 +3864,25 @@ func (a *App) saveQSORecording(q *qso.QSO) {
}
}
// Clone the QSO for the goroutine with its OWN copy of the Extras map: a
// struct copy (qc := *q) would still share the underlying map, and a
// concurrent write from a post-log action (eQSL/upload status) racing this
// goroutine's read is a FATAL "concurrent map" error that no recover catches.
qc := *q
if q.Extras != nil {
qc.Extras = make(map[string]string, len(q.Extras))
for k, v := range q.Extras {
qc.Extras[k] = v
}
}
go func() {
// A panic in the pure-Go MP3 encoder (or anywhere here) must NOT crash
// the whole app — recover, log it, and drop just this recording.
defer func() {
if r := recover(); r != nil {
applog.Printf("qso-rec: PANIC encoding %s: %v", path, r)
}
}()
if err := audio.WritePCM(path, pcm); err != nil {
applog.Printf("qso-rec: save failed: %v", err)
return
@@ -3992,6 +4243,9 @@ func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) {
}
}
}
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil
}
@@ -4327,15 +4581,30 @@ func (a *App) GetLogFilePath() string {
// GetQSLDefaults returns the stored defaults — empty strings when the
// user hasn't configured anything (= leave QSO fields untouched).
// defaultQSLDefaults is the out-of-the-box confirmation status for a profile
// that hasn't customised them: request the online confirmations (eQSL/LoTW/
// Clublog/HRDLog/QRZ "sent"=R so OpsLog knows they still need uploading), and
// "N" for paper and everything received.
func defaultQSLDefaults() QSLDefaults {
return QSLDefaults{
QSLSent: "N", QSLRcvd: "N",
EQSLSent: "R", EQSLRcvd: "N",
LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", HRDLogStatus: "R",
QRZComStatus: "R", QRZComCfm: "N",
}
}
func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out := QSLDefaults{}
if a.settings == nil {
return out, nil
}
prefix := ""
if a.profileHasGroup(markerQSL) {
prefix = a.profileScope()
// Fresh profile (never saved confirmations) → sensible defaults.
if !a.profileHasGroup(markerQSL) {
return defaultQSLDefaults(), nil
}
prefix := a.profileScope()
m, err := a.getManyScoped(prefix,
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
@@ -4715,7 +4984,60 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
}
} else if svc == extsvc.ServiceClublog {
// Club Log accepts a whole ADIF file (putlogs.php) and dedupes
// server-side, so upload in chunks instead of one realtime.php request
// per QSO. Chunked so a single failure doesn't lose the whole run and
// the user sees progress.
const clublogChunk = 100
type item struct {
id int64
rec string
call string
}
var items []item
for _, id := range ids {
q, gerr := a.qso.GetByID(ctx, id)
call := ""
if gerr == nil {
call = q.Callsign
}
rec, ok := a.buildUploadADIF(id, "")
if !ok {
emit(call + " — skipped (no record)")
continue
}
items = append(items, item{id: id, rec: rec, call: call})
}
emit(fmt.Sprintf("Club Log: uploading %d QSO(s) in batches of %d…", len(items), clublogChunk))
for start := 0; start < len(items); start += clublogChunk {
end := start + clublogChunk
if end > len(items) {
end = len(items)
}
batch := items[start:end]
recs := make([]string, len(batch))
for i, it := range batch {
recs[i] = it.rec
}
doc := adif.BatchRecordsADIF(recs)
res, err := extsvc.UploadClublogADIF(ctx, nil, cfg.Clublog, doc)
if err == nil && res.OK {
for _, it := range batch {
a.markExtUploaded(svc, it.id, "")
uploaded++
}
emit(fmt.Sprintf("Club Log: %d/%d uploaded", end, len(items)))
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
}
}
} else {
// QRZ.com: one record per request (its logbook API has no batch upload).
for _, id := range ids {
q, gerr := a.qso.GetByID(ctx, id)
call := ""
@@ -4736,7 +5058,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
switch svc {
case extsvc.ServiceQRZ:
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
case extsvc.ServiceClublog:
default:
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
}
if err == nil && res.OK {
@@ -5172,6 +5494,9 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
changed++
}
}
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil
}
@@ -5231,6 +5556,9 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
changed++
}
}
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil
}
@@ -6200,19 +6528,38 @@ func (a *App) ActivateProfile(id int64) error {
if err := a.profiles.SetActive(a.ctx, id); err != nil {
return err
}
// EVERY setting is per-profile: re-scope the settings store first, so all
// the reloads below read this profile's values.
a.settings.SetProfile(id)
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())
// The logbook follows the active profile: reconnect to this profile's DB
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
if p, err := a.profiles.Get(a.ctx, id); err == nil {
if err := a.switchLogbook(p); err != nil {
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
}
}
// Re-apply every settings-dependent subsystem for the new profile.
a.reloadAfterProfileSwitch()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "profile:changed", id)
}
return nil
}
// reloadAfterProfileSwitch re-applies all settings-derived state for the newly
// active profile: lookup providers, upload-service accounts, CAT connection,
// and the QSO recorder (audio devices). The Winkeyer stays as-is (the operator
// connects it explicitly). The frontend reloads its panels via profile:changed.
func (a *App) reloadAfterProfileSwitch() {
a.reloadLookupProviders()
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
}
a.reloadCAT()
a.startQSORecorderIfEnabled()
}
// 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.
@@ -6220,7 +6567,30 @@ 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)
p, err := a.profiles.Duplicate(a.ctx, id, newName)
if err != nil {
return profile.Profile{}, err
}
// A profile is the sum of its identity + DB target (copied by Duplicate) +
// its per-profile settings, its rig/antenna tree, and its QSL templates.
// Copy all of them so the clone is a true, independent duplicate.
if a.settings != nil {
if err := a.settings.CopyProfile(a.ctx, id, p.ID); err != nil {
applog.Printf("duplicate profile: copy settings: %v", err)
}
}
if a.operating != nil {
if err := a.operating.CopyProfile(a.ctx, id, p.ID); err != nil {
applog.Printf("duplicate profile: copy operating: %v", err)
}
}
if _, err := a.db.ExecContext(a.ctx,
`INSERT INTO qsl_templates (name, profile_id, json, is_default, created_at, updated_at)
SELECT name, ?, json, is_default, created_at, updated_at
FROM qsl_templates WHERE profile_id = ?`, p.ID, id); err != nil {
applog.Printf("duplicate profile: copy qsl templates: %v", err)
}
return p, nil
}
// --- Rotator bindings (PstRotator UDP v0) ---