up
This commit is contained in:
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user