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
+503 -133
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) 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 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 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 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 // shuttingDown gates beforeClose re-entry: the first user attempt to
// close fires shutdown tasks (backup, future LoTW upload, ...) while // 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) fmt.Println("OpsLog:", a.startupErr)
return 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 { if err := os.MkdirAll(dataDir, 0o755); err != nil {
a.startupErr = "cannot create data dir: " + err.Error() a.startupErr = "cannot create data dir: " + err.Error()
fmt.Println("OpsLog:", a.startupErr) 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 // 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). // to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf cat.LogSink = applog.Printf
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
applog.Printf("startup: data dir = %s", dataDir) applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings, // The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award // 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 = settings.NewStore(conn)
a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set
a.profiles = profile.NewRepo(conn) 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.awardRefs = awardref.NewRepo(conn)
a.qslTemplates = qslcard.NewRepo(conn) a.qslTemplates = qslcard.NewRepo(conn)
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields) 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.udpRepo = udp.NewRepo(conn)
a.udp = udp.NewManager(a.udpRepo) a.udp = udp.NewManager(a.udpRepo)
go a.consumeUDPEvents() 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.cache = lookup.NewCache(conn, 30*24*time.Hour)
a.lookup = lookup.NewManager(a.cache) a.lookup = lookup.NewManager(a.cache)
a.reloadLookupProviders() a.reloadLookupProviders()
// Now choose where the QSO logbook lives. On a MySQL failure we fall back to // The QSO logbook lives where the ACTIVE PROFILE points it: the local SQLite
// the local SQLite logbook so the operator can still log (and fix config). // file, or a per-profile shared MySQL database. Switching profiles switches
logbookConn := conn // the logbook (see switchLogbook). One-time: adopt any legacy config.json
if mb := readBootstrap(dataDir).MySQL; mb != nil && mb.Enabled { // MySQL config into the active profile so existing setups keep working.
applog.Printf("startup: logbook backend = MySQL (%s:%d/%s)", mb.Host, mb.Port, mb.Database) a.adoptBootstrapMySQL(&active)
mysqlConn, mErr := db.OpenMySQL(db.MySQLConfig{ logbookConn, backend, lerr := a.connectLogbook(active.DB)
Host: mb.Host, Port: mb.Port, User: mb.User, Password: mb.Password, Database: mb.Database, if lerr != nil {
}) applog.Printf("startup: logbook open failed (%v) — falling back to SQLite logbook", lerr)
if mErr != nil { a.dbBackendErr = strings.TrimPrefix(lerr.Error(), "")
applog.Printf("startup: MySQL open failed (%v) — falling back to SQLite logbook", mErr) logbookConn, backend = conn, "sqlite"
a.dbBackendErr = "MySQL: " + mErr.Error()
} else {
logbookConn = mysqlConn
a.dbBackend = "mysql"
}
}
if a.dbBackend == "" {
a.dbBackend = "sqlite"
} }
a.dbBackend = backend
// db.Dialect describes the LOGBOOK backend — the only place SQL actually // db.Dialect describes the LOGBOOK backend — the only place SQL actually
// varies (qso JSON extraction). Config repos always run on SQLite. // varies (qso JSON extraction). Config repos always run on SQLite.
db.SetDialect(a.dbBackend) db.SetDialect(backend)
applog.Printf("startup: logbook backend = %s", a.dbBackend) applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn) a.qso = qso.NewRepo(logbookConn)
@@ -751,6 +740,19 @@ func (a *App) startup(ctx context.Context) {
// Ultrabeam antenna: connect in the background if enabled. // Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam() 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) fmt.Println("OpsLog: db ready at", a.dbPath)
} }
@@ -760,7 +762,6 @@ type StartupStatus struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Err string `json:"err"` Err string `json:"err"`
DBPath string `json:"db_path"` DBPath string `json:"db_path"`
MigratedFromAppData bool `json:"migrated_from_app_data"`
} }
// GetStartupStatus exposes whatever happened during startup so the UI // GetStartupStatus exposes whatever happened during startup so the UI
@@ -770,7 +771,6 @@ func (a *App) GetStartupStatus() StartupStatus {
OK: a.startupErr == "", OK: a.startupErr == "",
Err: a.startupErr, Err: a.startupErr,
DBPath: a.dbPath, DBPath: a.dbPath,
MigratedFromAppData: a.migratedFromAppData,
} }
} }
@@ -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) { func (a *App) shutdown(ctx context.Context) {
// If the user managed to skip beforeClose (force kill, OS shutdown, // If the user managed to skip beforeClose (force kill, OS shutdown,
// crash recovery) we still try the backup here as a best-effort // 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 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. // fileExists reports whether path exists and is a regular file.
func fileExists(path string) bool { func fileExists(path string) bool {
@@ -1130,45 +1111,125 @@ type DBConnectionInfo struct {
// it shows the local database file path. // it shows the local database file path.
func (a *App) GetDBConnectionInfo() DBConnectionInfo { func (a *App) GetDBConnectionInfo() DBConnectionInfo {
if a.dbBackend == "mysql" { if a.dbBackend == "mysql" {
if mb := readBootstrap(a.dataDir).MySQL; mb != nil { if p, err := a.profiles.Active(a.ctx); err == nil && p.DB.Backend == "mysql" {
port := mb.Port port := p.DB.Port
if port == 0 { if port == 0 {
port = 3306 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: "mysql", Label: "MySQL"}
} }
return DBConnectionInfo{Backend: "sqlite", Label: a.dbPath} return DBConnectionInfo{Backend: "sqlite", Label: a.dbPath}
} }
// GetMySQLSettings returns the stored shared-database config from the bootstrap // connectLogbook opens the logbook connection for a profile's DB target: a
// file (config.json), with defaults applied. Read before the DB is open, so it // shared MySQL database, or the local SQLite file (which doubles as the logbook
// must not depend on the settings table. // 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) { func (a *App) GetMySQLSettings() (MySQLSettings, error) {
out := MySQLSettings{Port: 3306} out := MySQLSettings{Port: 3306}
if mb := readBootstrap(a.dataDir).MySQL; mb != nil { p, err := a.profiles.Active(a.ctx)
out = *mb if err != nil {
if out.Port <= 0 { return out, nil
out.Port = 3306
} }
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 return out, nil
} }
// SaveMySQLSettings persists the shared-database config to the bootstrap file. // SaveMySQLSettings stores the DB target on the ACTIVE profile and switches the
// Switching the active backend takes effect on the next launch (we read this // live logbook to it immediately (no restart). Enabled=false reverts to the
// file before opening any database). // local SQLite logbook.
func (a *App) SaveMySQLSettings(s MySQLSettings) error { 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 { if s.Port <= 0 {
s.Port = 3306 s.Port = 3306
} }
s.Host = strings.TrimSpace(s.Host) cfg := profile.ProfileDB{Database: strings.TrimSpace(s.Database)}
s.User = strings.TrimSpace(s.User) if s.Enabled {
s.Database = strings.TrimSpace(s.Database) cfg.Backend = "mysql"
c := readBootstrap(a.dataDir) cfg.Host = strings.TrimSpace(s.Host)
c.MySQL = &s cfg.Port = s.Port
return writeBootstrap(a.dataDir, c) 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 // 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) 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 // awardDefs returns the user's stored award definitions, seeding the built-in
// defaults on first use. // defaults on first use.
func (a *App) awardDefs() []award.Def { func (a *App) awardDefs() []award.Def {
if a.settings != nil { 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 var defs []award.Def
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 { if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
// Upgrade legacy defs (pre-rich-model) in memory on every load. // Upgrade legacy defs (pre-rich-model) in memory on every load.
@@ -1666,7 +1746,10 @@ func (a *App) migrateAwardDefs() {
if a.settings == nil { if a.settings == nil {
return 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) == "" { if strings.TrimSpace(s) == "" {
return // nothing saved yet → Defaults() (already on the new model) 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 // even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
// from Defaults to protected/built-in awards once. // from Defaults to protected/built-in awards once.
const defsFixVersion = "2" 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{} byCode := map[string]award.Def{}
for _, d := range award.Defaults() { for _, d := range award.Defaults() {
byCode[strings.ToUpper(d.Code)] = d byCode[strings.ToUpper(d.Code)] = d
@@ -1692,13 +1775,13 @@ func (a *App) migrateAwardDefs() {
changed = true changed = true
} }
} }
a.setSetting(keyAwardDefsFixed, defsFixVersion) a.setSettingGlobal(keyAwardDefsFixed, defsFixVersion)
} }
if !changed { if !changed {
return return
} }
if b, err := json.Marshal(migrated); err == nil { 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)) applog.Printf("awards: migrated/fixed %d definitions", len(migrated))
} }
} }
@@ -1712,7 +1795,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
if err != nil { if err != nil {
return err 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. // ResetAwardDefs restores the built-in defaults.
@@ -1756,12 +1839,8 @@ func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
if a.qso == nil { if a.qso == nil {
return nil, fmt.Errorf("db not initialized") return nil, fmt.Errorf("db not initialized")
} }
var all []qso.QSO all, err := a.awardSnapshot()
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { if err != nil {
a.enrichQSOForAwards(&q)
all = append(all, q)
return nil
}); err != nil {
return nil, err return nil, err
} }
nameOf := func(field, ref string) string { 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 return []qso.QSO{}, nil // not meaningful without a DXCC scope
} }
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)] 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 var out []qso.QSO
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { for i := range snapshot {
a.enrichQSOForAwards(&q) q := snapshot[i]
// In the award's scope, yet no reference extracted → a gap to fix. // 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 { if award.InScope(*def, &q) && len(award.MatchQSO(*def, metas, &q)) == 0 {
out = append(out, q) out = append(out, q)
} }
return nil }
}) return out, nil
return out, err
} }
// GetPOTAToken returns the stored pota.app session token (for the settings UI). // 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"} 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 // GetAwardStats computes the worked/confirmed/validated reference counts of one
// award, broken down by band and by mode category (All/CW/Digital/Phone). // award, broken down by band and by mode category (All/CW/Digital/Phone).
func (a *App) GetAwardStats(code string) (AwardStatsResult, error) { 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 { snapshot, err := a.awardSnapshot()
a.enrichQSOForAwards(&q) if err != nil {
refs := award.MatchQSO(*def, metas, &q) 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 { if len(refs) == 0 {
return nil continue
} }
bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))] bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))]
isConf := award.Confirmed(&q, def.Confirm) isConf := award.Confirmed(q, def.Confirm)
isVal := award.Confirmed(&q, def.Validate) isVal := award.Confirmed(q, def.Validate)
cat := strings.ToUpper(award.EmissionOf(q.Mode)) cat := strings.ToUpper(award.EmissionOf(q.Mode))
record := func(c string) { record := func(c string) {
@@ -2272,10 +2403,6 @@ func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" { if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" {
record(cat) record(cat)
} }
return nil
})
if err != nil {
return AwardStatsResult{}, err
} }
res := AwardStatsResult{Code: def.Code, Bands: statsBands} res := AwardStatsResult{Code: def.Code, Bands: statsBands}
@@ -2298,6 +2425,16 @@ func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
return res, nil 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) // awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists // into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
// are large and not needed for matching; their names are filled afterwards. // 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 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 // QSOAwardRef is one award reference a single QSO contributes to. Pickable
// marks awards backed by a reference list (POTA, SOTA, …) — those are assigned // marks awards backed by a reference list (POTA, SOTA, …) — those are assigned
// manually; the rest (DXCC, WAZ, WPX, DDFM, …) are computed from QSO fields. // 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 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 // SearchAwardReferences finds references of an award by code/name (for the
// per-QSO reference picker). dxcc>0 restricts to one entity. // per-QSO reference picker). dxcc>0 restricts to one entity.
func (a *App) SearchAwardReferences(code, query string, dxcc, limit int) ([]awardref.Ref, error) { 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 defs = migrated
} }
b, _ := json.Marshal(defs) 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) 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 { if a.qso == nil {
return fmt.Errorf("db not initialized") 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 { 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 return n, nil
} }
@@ -3579,13 +3803,23 @@ func (a *App) qsoRecDir() string {
// useful audio, so they are never recorded. // useful audio, so they are never recorded.
func recordableMode(mode string) bool { func recordableMode(mode string) bool {
switch strings.ToUpper(strings.TrimSpace(mode)) { 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 true
} }
return false return false
} }
func (a *App) saveQSORecording(q *qso.QSO) { 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() { if a.qsoRec == nil || !a.qsoRec.Active() {
return 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 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() { 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 { if err := audio.WritePCM(path, pcm); err != nil {
applog.Printf("qso-rec: save failed: %v", err) applog.Printf("qso-rec: save failed: %v", err)
return return
@@ -3992,6 +4243,9 @@ func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) {
} }
} }
} }
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil return changed, nil
} }
@@ -4327,15 +4581,30 @@ func (a *App) GetLogFilePath() string {
// GetQSLDefaults returns the stored defaults — empty strings when the // GetQSLDefaults returns the stored defaults — empty strings when the
// user hasn't configured anything (= leave QSO fields untouched). // 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) { func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out := QSLDefaults{} out := QSLDefaults{}
if a.settings == nil { if a.settings == nil {
return out, nil return out, nil
} }
prefix := "" // Fresh profile (never saved confirmations) → sensible defaults.
if a.profileHasGroup(markerQSL) { if !a.profileHasGroup(markerQSL) {
prefix = a.profileScope() return defaultQSLDefaults(), nil
} }
prefix := a.profileScope()
m, err := a.getManyScoped(prefix, m, err := a.getManyScoped(prefix,
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd, keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd, 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)) 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 { } 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 { for _, id := range ids {
q, gerr := a.qso.GetByID(ctx, id) q, gerr := a.qso.GetByID(ctx, id)
call := "" call := ""
@@ -4736,7 +5058,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
switch svc { switch svc {
case extsvc.ServiceQRZ: case extsvc.ServiceQRZ:
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec) res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
case extsvc.ServiceClublog: default:
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec) res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
} }
if err == nil && res.OK { if err == nil && res.OK {
@@ -5172,6 +5494,9 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
changed++ changed++
} }
} }
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil return changed, nil
} }
@@ -5231,6 +5556,9 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
changed++ changed++
} }
} }
if changed > 0 {
a.invalidateAwardStats()
}
return changed, nil return changed, nil
} }
@@ -6200,19 +6528,38 @@ func (a *App) ActivateProfile(id int64) error {
if err := a.profiles.SetActive(a.ctx, id); err != nil { if err := a.profiles.SetActive(a.ctx, id); err != nil {
return err 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() a.refreshOperatorGrid()
// Per-profile config follows the active identity: reload the external- // The logbook follows the active profile: reconnect to this profile's DB
// services manager so uploads now use this profile's accounts, and tell // target (local SQLite or its own MySQL) so QSOs go to the right logbook.
// the frontend to refresh its settings panels. if p, err := a.profiles.Get(a.ctx, id); err == nil {
if a.extsvc != nil { if err := a.switchLogbook(p); err != nil {
a.extsvc.SetConfig(a.loadExternalServices()) 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 { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "profile:changed", id) wruntime.EventsEmit(a.ctx, "profile:changed", id)
} }
return nil 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 // DuplicateProfile clones an existing profile under newName. Useful when
// the user has a "Home" profile and wants to derive "Portable" from it // the user has a "Home" profile and wants to derive "Portable" from it
// without retyping every field. // without retyping every field.
@@ -6220,7 +6567,30 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
if a.profiles == nil { if a.profiles == nil {
return profile.Profile{}, fmt.Errorf("profiles not initialized") 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) --- // --- Rotator bindings (PstRotator UDP v0) ---
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// keyAutostartPrograms holds the per-profile list of external programs OpsLog
// launches on startup (JSON array of AutostartProgram).
const keyAutostartPrograms = "autostart.programs"
// AutostartProgram is one external application OpsLog can launch when it starts
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
type AutostartProgram struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Args string `json:"args"`
Enabled bool `json:"enabled"`
}
// AutostartLaunchResult reports what happened for one program when launching.
type AutostartLaunchResult struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // launched | already_running | missing | disabled | error
Message string `json:"message"`
}
// GetAutostartPrograms returns the active profile's autostart list.
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
out := []AutostartProgram{}
if a.settings == nil {
return out, nil
}
s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
if strings.TrimSpace(s) == "" {
return out, nil
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []AutostartProgram{}, nil
}
return out, nil
}
// SaveAutostartPrograms persists the autostart list for the active profile.
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(progs)
if err != nil {
return err
}
return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
}
// BrowseExecutable opens a native file picker for choosing a program to launch.
func (a *App) BrowseExecutable() (string, error) {
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose a program to launch on startup",
Filters: []wruntime.FileFilter{
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
}
// LaunchAutostartPrograms starts every enabled program that isn't already
// running, returning a per-program result. Used at startup (best effort) and by
// the "Launch now" button in settings.
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
progs, _ := a.GetAutostartPrograms()
running := runningProcessNames()
out := make([]AutostartLaunchResult, 0, len(progs))
for _, p := range progs {
if !p.Enabled {
continue
}
out = append(out, launchProgram(p, running))
}
return out
}
// LaunchAutostartProgram launches a single program by id on demand (the per-row
// "launch now" action), regardless of its enabled flag.
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
progs, err := a.GetAutostartPrograms()
if err != nil {
return AutostartLaunchResult{}, err
}
for _, p := range progs {
if p.ID == id {
return launchProgram(p, runningProcessNames()), nil
}
}
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
}
// launchProgram starts one program unless its executable is already running.
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
path := strings.TrimSpace(p.Path)
if path == "" {
res.Status, res.Message = "error", "no path configured"
return res
}
if _, err := os.Stat(path); err != nil {
res.Status, res.Message = "missing", "executable not found: "+path
return res
}
// Skip if a process with the same executable name is already running, so we
// never spawn a second copy of WSJT-X / JTAlert / etc.
base := strings.ToLower(filepath.Base(path))
if running[base] {
res.Status, res.Message = "already_running", base+" already running"
return res
}
cmd := exec.Command(path, splitArgs(p.Args)...)
cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
if err := cmd.Start(); err != nil {
res.Status, res.Message = "error", err.Error()
return res
}
// Don't wait on the child — it runs independently of OpsLog. Release the
// handle so we don't accumulate zombies.
go func() { _ = cmd.Wait() }()
res.Status, res.Message = "launched", "started "+base
return res
}
// splitArgs does a minimal shell-like split of an argument string, honouring
// double quotes so a path with spaces stays one argument.
func splitArgs(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var args []string
var cur strings.Builder
inQuote := false
for _, r := range s {
switch {
case r == '"':
inQuote = !inQuote
case r == ' ' && !inQuote:
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
// runningProcessNames returns the set of lowercase executable names currently
// running, via the Windows `tasklist`. Best effort — on failure the set is
// empty (we then just attempt to launch, which is acceptable).
func runningProcessNames() map[string]bool {
out := map[string]bool{}
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
data, err := cmd.Output()
if err != nil {
applog.Printf("autostart: tasklist failed: %v", err)
return out
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// CSV row: "image.exe","PID",... — take the first quoted field.
field := line
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
field = line[1 : i+1]
}
field = strings.Trim(field, "\"")
if field != "" {
out[strings.ToLower(field)] = true
}
}
return out
}
+79 -17
View File
@@ -15,9 +15,9 @@ import {
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RefreshCtyDat, RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading,
GetDBConnectionInfo, GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection, GetUltrabeamStatus, SetUltrabeamDirection,
OpenExternalURL, OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
@@ -45,6 +45,7 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from '@/components/SettingsModal';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal'; import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap'; import { BandMap } from '@/components/BandMap';
import { MainMap } from '@/components/MainMap'; import { MainMap } from '@/components/MainMap';
@@ -435,7 +436,6 @@ export default function App() {
const [qsos, setQsos] = useState<QSO[]>([]); const [qsos, setQsos] = useState<QSO[]>([]);
const [total, setTotal] = useState<number>(0); const [total, setTotal] = useState<number>(0);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [migratedBanner, setMigratedBanner] = useState(false);
// Secret vault (encrypted passwords): prompt to unlock at launch when a // Secret vault (encrypted passwords): prompt to unlock at launch when a
// passphrase is configured but not yet entered this session. // passphrase is configured but not yet entered this session.
const [unlockOpen, setUnlockOpen] = useState(false); const [unlockOpen, setUnlockOpen] = useState(false);
@@ -667,6 +667,7 @@ export default function App() {
const [showDeleteAll, setShowDeleteAll] = useState(false); const [showDeleteAll, setShowDeleteAll] = useState(false);
const [deletingAll, setDeletingAll] = useState(false); const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false);
const [refsDownloading, setRefsDownloading] = useState(false);
// === ADIF === // === ADIF ===
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
@@ -732,6 +733,7 @@ export default function App() {
callsign: '', operator: '', callsign: '', operator: '',
my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '', my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '',
}); });
const [showFirstRun, setShowFirstRun] = useState(false);
myCallRef.current = (station.callsign || '').toUpperCase(); myCallRef.current = (station.callsign || '').toUpperCase();
// Bearing/distance from operator's grid to the DX — used by the entry-strip // Bearing/distance from operator's grid to the DX — used by the entry-strip
@@ -864,6 +866,36 @@ export default function App() {
// local SQLite file path). // local SQLite file path).
useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []); useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []);
// The logbook can switch at runtime when the active profile changes (each
// profile can target its own SQLite/MySQL database). Refresh the grid and the
// status-bar label when that happens.
useEffect(() => {
const off = EventsOn('logbook:changed', () => {
GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {});
refresh();
});
return () => { off(); };
}, [refresh]);
// Live sync for a SHARED MySQL logbook: poll a cheap revision fingerprint so
// QSOs another operator's instance adds/removes show up here within a few
// seconds, without a manual refresh. Pointless on local SQLite (single writer).
useEffect(() => {
if (dbConn?.backend !== 'mysql') return;
let alive = true;
let last = '';
const tick = async () => {
try {
const rev = await GetLogbookRevision();
if (alive && last && rev !== last) await refresh();
if (alive) last = rev;
} catch { /* logbook briefly unavailable — try again next tick */ }
};
tick();
const id = window.setInterval(tick, 2000);
return () => { alive = false; window.clearInterval(id); };
}, [dbConn, refresh]);
// RX freq mirrors TX freq on every TX change (unless the rig is in split, // RX freq mirrors TX freq on every TX change (unless the rig is in split,
// where the RX freq is genuinely different). It stays editable by hand: // where the RX freq is genuinely different). It stays editable by hand:
// a manual RX edit sticks until the next TX-freq change re-syncs it. // a manual RX edit sticks until the next TX-freq change re-syncs it.
@@ -986,8 +1018,12 @@ export default function App() {
// case its one-shot fetch ran during the startup race (before the // case its one-shot fetch ran during the startup race (before the
// backend was determined) and grabbed the wrong/stale value. // backend was determined) and grabbed the wrong/stale value.
GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {}); GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {});
} else if (!ok && alive && tries++ < 30) { } else if (!ok && alive && tries++ < 360) {
timer = window.setTimeout(attempt, 500); // Quick retries at first (normal startup connects in ~2 s); then keep
// trying for several minutes, because the very first migration against a
// slow remote MySQL can legitimately take that long before the logbook
// is ready. Stays silent so no "db not available" flashes meanwhile.
timer = window.setTimeout(attempt, tries < 20 ? 500 : 1000);
} else if (!ok && alive) { } else if (!ok && alive) {
refresh(); // give up quietly retrying; surface the error now refresh(); // give up quietly retrying; surface the error now
} }
@@ -1000,7 +1036,12 @@ export default function App() {
try { try {
const st = await GetStartupStatus(); const st = await GetStartupStatus();
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; } if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
if ((st as any).migrated_from_app_data) setMigratedBanner(true); // First launch (or a never-configured profile): collect the mandatory
// station identity before anything else.
try {
const ss = await GetStationSettings();
if (!ss.callsign?.trim()) setShowFirstRun(true);
} catch {}
} catch {} } catch {}
// Prompt to unlock encrypted passwords if a passphrase is configured. // Prompt to unlock encrypted passwords if a passphrase is configured.
try { try {
@@ -1186,6 +1227,16 @@ export default function App() {
setWkSendOnType(!!s.send_on_type); setWkSendOnType(!!s.send_on_type);
} catch { /* keyer not configured */ } } catch { /* keyer not configured */ }
}, []); }, []);
// Every setting is per-profile, so when the active profile changes the whole
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
// side reloads its managers; this keeps the React state in sync.
useEffect(() => {
const off = EventsOn('profile:changed', () => {
loadStation(); loadLists(); loadCATCfg(); reloadWk();
});
return () => { off(); };
}, [loadStation, loadLists, loadCATCfg, reloadWk]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await reloadWk(); await reloadWk();
@@ -1723,11 +1774,12 @@ export default function App() {
// Maintenance — bumped here while we only have one entry. Will move // Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land. // to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing }, { type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
]}, ]},
{ name: 'help', label: 'Help', items: [ { name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true }, { type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
]}, ]},
], [total, selectedId, ctyRefreshing, exporting, wkEnabled, dvkEnabled]); ], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
function handleMenu(action: string) { function handleMenu(action: string) {
switch (action) { switch (action) {
@@ -1744,6 +1796,21 @@ export default function App() {
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break;
}
}
async function downloadRefs() {
if (refsDownloading) return;
setRefsDownloading(true);
setError('');
try {
const summary = await DownloadAllReferenceLists();
showToast(`Reference lists updated — ${summary}`);
} catch (e: any) {
setError(`Reference download failed: ${String(e?.message ?? e)}`);
} finally {
setRefsDownloading(false);
} }
} }
@@ -2366,18 +2433,13 @@ export default function App() {
</div> </div>
)} )}
{/* Transient toasts (bottom-right). Errors stack on top of the green {/* First launch: mandatory station identity. Blocks until filled. */}
success toast; both auto-dismiss. */} {showFirstRun && (
{migratedBanner && ( <FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[110] flex items-start gap-3 rounded-lg border border-emerald-400 bg-emerald-50 text-emerald-900 px-4 py-3 text-sm shadow-xl max-w-lg animate-in fade-in slide-in-from-top-2">
<span className="flex-1">
<strong>Migration complete.</strong> Your data has been copied to the data folder next to OpsLog.exe.
Please <strong>restart OpsLog</strong> to use the new location.
</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setMigratedBanner(false)}>×</button>
</div>
)} )}
{/* Transient toasts (bottom-right). Errors stack on top of the green
success toast; both auto-dismiss. */}
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable: {/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
skipping leaves lookups/uploads without their passwords until unlocked. */} skipping leaves lookups/uploads without their passwords until unlocked. */}
{unlockOpen && ( {unlockOpen && (
+7 -4
View File
@@ -63,13 +63,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
// for a French call but not for others. // for a French call but not for others.
const awards = useMemo(() => { const awards = useMemo(() => {
return defs.filter((d) => { return defs.filter((d) => {
// Computed awards (field = dxcc/state/cqz/…) are derived automatically. // Computed awards (field = dxcc/cqz/…) are derived automatically.
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false; if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
const m = metas[String(d.code).toUpperCase()];
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
if (!hasRefs) return false;
const scope = d.dxcc_filter ?? []; const scope = d.dxcc_filter ?? [];
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false; if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
// Offer the award even when its reference list isn't loaded yet: a custom
// award (e.g. "Worked All Provinces of China") has no built-in list, so
// requiring one would make it impossible to assign references by hand. The
// operator can pick from a loaded list when present, or add an unlisted
// reference (the right-hand panel). Dynamic / list-backed awards behave as
// before — they just always pass here now.
return true; return true;
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() })) }).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
.sort((a, b) => a.code.localeCompare(b.code)); .sort((a, b) => a.code.localeCompare(b.code));
+164 -51
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react'; import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App'; import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor'; import { AwardEditor } from '@/components/AwardEditor';
@@ -57,7 +58,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
); );
} }
type AwardListItem = { code: string; name: string; valid?: boolean }; type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[] };
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) { export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const [awardList, setAwardList] = useState<AwardListItem[]>([]); const [awardList, setAwardList] = useState<AwardListItem[]>([]);
@@ -108,7 +109,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
try { try {
const defs = ((await GetAwardDefs()) ?? []) as any[]; const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs const list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid })) .map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [] }))
.sort((a, b) => a.code.localeCompare(b.code)); .sort((a, b) => a.code.localeCompare(b.code));
setAwardList(list); setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0]; const first = list.find((a) => a.code === selected) ?? list[0];
@@ -121,6 +122,17 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
const current = byCode[selected]; const current = byCode[selected];
// Band columns for the reference matrix: restrict to the award's own valid
// bands (e.g. WAPC = 40/20/15/10m) so the grid isn't padded with bands the
// award doesn't count. An award with no band restriction shows all bands.
const gridBands = useMemo(() => {
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
if (vb.length === 0) return GRID_BANDS;
const set = new Set(vb);
const filtered = GRID_BANDS.filter((b) => set.has(b));
return filtered.length ? filtered : GRID_BANDS;
}, [awardList, selected]);
const filteredRefs = useMemo(() => { const filteredRefs = useMemo(() => {
if (!current) return []; if (!current) return [];
const q = refSearch.trim().toUpperCase(); const q = refSearch.trim().toUpperCase();
@@ -228,10 +240,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{/* Band breakdown */} {/* Band breakdown */}
{(current.bands ?? []).length > 0 && ( {(current.bands ?? []).length > 0 && (
<div className="px-4 py-2 border-b border-border/60"> <div className="px-4 py-2 border-b border-border/60">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div> <div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(current.bands ?? []).map((b) => ( {(current.bands ?? []).map((b) => (
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs"> <div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
<span className="font-mono font-semibold">{b.band}</span>{' '} <span className="font-mono font-semibold">{b.band}</span>{' '}
<span className="text-emerald-600 font-mono">{b.confirmed}</span> <span className="text-emerald-600 font-mono">{b.confirmed}</span>
<span className="text-muted-foreground font-mono">/{b.worked}</span> <span className="text-muted-foreground font-mono">/{b.worked}</span>
@@ -245,9 +257,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="flex items-center gap-2 px-4 py-2 flex-wrap"> <div className="flex items-center gap-2 px-4 py-2 flex-wrap">
<div className="relative"> <div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" /> <Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} /> <Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div> </div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs"> <div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => ( {([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)} <button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}> className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
@@ -255,10 +267,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</button> </button>
))} ))}
</div> </div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span> <span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<button <button
onClick={() => setShowMissing(true)} onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-[11px] text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1" className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it" title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
> >
<AlertTriangle className="size-3" /> Missing refs <AlertTriangle className="size-3" /> Missing refs
@@ -283,13 +295,13 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{statsLoading || !stats ? ( {statsLoading || !stats ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div> <div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div>
) : ( ) : (
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}> <table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10"> <thead className="sticky top-0 z-10">
<tr className="bg-card"> <tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th> <th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)} {stats.bands.map((b) => <th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>)}
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th> <th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th> <th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -297,13 +309,13 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label); const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
return ( return (
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}> <tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap', <td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td> isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
{row.cells.map((c, j) => ( {row.cells.map((c, j) => (
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td> <td key={j} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
))} ))}
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td> <td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td> <td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
</tr> </tr>
); );
})} })}
@@ -313,30 +325,30 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div> </div>
) : view === 'grid' ? ( ) : view === 'grid' ? (
<div className="flex-1 overflow-auto px-4 pb-3"> <div className="flex-1 overflow-auto px-4 pb-3">
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}> <table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10"> <thead className="sticky top-0 z-10">
<tr className="bg-card"> <tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th> <th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th> <th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
{GRID_BANDS.map((b) => ( {gridBands.map((b) => (
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th> <th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredRefs.map((r) => ( {filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}> <tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td> <td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30"> <td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''} {r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td> </td>
{GRID_BANDS.map((b) => { {gridBands.map((b) => {
const s = cellStatus(r, b); const s = cellStatus(r, b);
return ( return (
<td key={b} className="border-b border-l border-border/30 p-0 text-center"> <td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-9 h-5" /> : ( {s === 'none' ? <span className="block w-11 h-7" /> : (
<button <button
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')} className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={`${r.ref} · ${b} — click to view QSOs`} title={`${r.ref} · ${b} — click to view QSOs`}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })} onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button> >{CELL_LABEL[s]}</button>
@@ -351,22 +363,22 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto px-4 pb-3"> <div className="flex-1 overflow-auto px-4 pb-3">
<table className="w-full text-xs"> <table className="w-full text-sm">
<thead className="sticky top-0 bg-card"> <thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border"> <tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1 pr-2 font-medium w-24">Ref</th> <th className="py-1.5 pr-2 font-medium w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</th> <th className="py-1.5 pr-2 font-medium">Name</th>
<th className="py-1 pr-2 font-medium w-40">Group</th> <th className="py-1.5 pr-2 font-medium w-40">Group</th>
<th className="py-1 pr-2 font-medium w-24">Status</th> <th className="py-1.5 pr-2 font-medium w-24">Status</th>
<th className="py-1 font-medium">Bands</th> <th className="py-1.5 font-medium">Bands</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredRefs.map((r) => ( {filteredRefs.map((r) => (
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}> <tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td> <td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td> <td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td> <td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
<td className="py-1 pr-2"> <td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70"> missing</span> {!r.worked ? <span className="text-muted-foreground/70"> missing</span>
: r.validated ? <span className="text-emerald-600">validated</span> : r.validated ? <span className="text-emerald-600">validated</span>
@@ -401,23 +413,91 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
// MissingQSOModal lists contacts within an award's scope that carry NO // MissingQSOModal lists contacts within an award's scope that carry NO
// reference — the silent gaps. Rows open the QSO editor so the operator can add // reference — the silent gaps. Rows open the QSO editor so the operator can add
// the missing reference (e.g. a department for DDFM). // the missing reference (e.g. a department for DDFM).
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) { function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
const [qsos, setQsos] = useState<any[]>([]); const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sel, setSel] = useState<Set<number>>(new Set());
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
const [assignRef, setAssignRef] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const load = () => { const load = () => {
setLoading(true); setLoading(true);
setSel(new Set());
AwardMissingQSOs(code) AwardMissingQSOs(code)
.then((r) => setQsos((r ?? []) as any)) .then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([])) .catch(() => setQsos([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
useEffect(() => { load(); }, [code]); useEffect(() => { load(); }, [code]);
// Distinct stations vs total contacts (a station may appear on several QSOs). // The award's reference list drives the "assign" dropdown (e.g. China provinces).
useEffect(() => {
ListAwardReferences(code)
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
.catch(() => setRefs([]));
}, [code]);
const qthOf = (q: any) => String(q.qth || q.notes || '');
const sorted = useMemo(() => {
const dir = sortDir === 'asc' ? 1 : -1;
const val = (q: any): string => {
switch (sortKey) {
case 'qso_date': return String(q.qso_date ?? '');
case 'callsign': return String(q.callsign ?? '').toUpperCase();
case 'band': return String(q.band ?? '');
case 'mode': return String(q.mode ?? '');
case 'country': return String(q.country ?? '').toUpperCase();
case 'qth': return qthOf(q).toUpperCase();
}
};
return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
}, [qsos, sortKey, sortDir]);
const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const setSort = (k: MissingSortKey) => {
if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortKey(k); setSortDir('asc'); }
};
async function applyAssign() {
if (!assignRef || sel.size === 0) return;
setBusy(true); setMsg('');
try {
const assignedIds = Array.from(sel);
const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
// Optimistic: the assigned contacts now carry a reference, so drop them
// from the list locally instead of re-running the slow whole-log scan.
const done = new Set(assignedIds);
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
setSel(new Set());
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
} catch (e: any) {
setMsg(String(e?.message ?? e));
} finally { setBusy(false); }
}
const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]); const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); }; const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
<th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
<span className="inline-flex items-center gap-0.5">{label}
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</span>
</th>
);
return ( return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}> <div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[760px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}> <div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b"> <div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-amber-600" /> <AlertTriangle className="size-4 text-amber-600" />
<span className="font-semibold text-sm">{code} contacts missing a reference</span> <span className="font-semibold text-sm">{code} contacts missing a reference</span>
@@ -432,8 +512,28 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
</div> </div>
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50"> <div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
{onEditQSO && ' Click a row to open the QSO and add the reference.'} Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
</div> </div>
{/* Bulk-assign toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
<span className="text-xs text-muted-foreground">{sel.size} selected </span>
<Select value={assignRef} onValueChange={setAssignRef}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
<SelectContent className="max-h-72">
{refs.map((r) => (
<SelectItem key={r.code} value={r.code}>
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
</Button>
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
</div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{loading ? ( {loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning</div> <div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning</div>
@@ -443,22 +543,35 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
</div> </div>
) : ( ) : (
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border"> <thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-2 font-medium">Country</th><th className="py-1 pr-3 font-medium">QTH / Note</th></tr> <tr>
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
<SortTh k="callsign" label="Callsign" />
<SortTh k="band" label="Band" />
<SortTh k="mode" label="Mode" />
<SortTh k="country" label="Country" />
<SortTh k="qth" label="QTH / Note" />
</tr>
</thead> </thead>
<tbody> <tbody>
{qsos.map((q, i) => ( {sorted.map((q, i) => {
<tr key={q.id ?? i} const id = q.id as number;
className={cn('border-b border-border/30', onEditQSO && 'cursor-pointer hover:bg-accent/40')} return (
onClick={() => onEditQSO && q.id && onEditQSO(q.id as number)}> <tr key={id ?? i}
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td> className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td> <td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
</td>
<td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
<td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
<td className="py-1 pr-2">{q.band}</td> <td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td> <td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[120px]">{q.country}</td> <td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[200px]">{q.qth || q.notes}</td> <td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
)} )}
+20 -11
View File
@@ -104,16 +104,25 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// "Newness" of the current band+mode entry, for the award/DX-chase badges. // "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was // Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list). // worked on — not just the operator's configured column list).
const curClass = CLASSES.find((c) => classMatchesMode(c, currentMode)); // Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
const statusKeys = useMemo(() => Array.from(statusMap.keys()), [statusMap]); // DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
const slotStatus = curClass ? statusMap.get(`${currentBand}|${curClass}`) : undefined; // lists every real (band, mode) the entity was worked on.
const bandWorked = statusKeys.some((k) => k.split('|')[0] === currentBand); const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
const modeWorked = !!curClass && statusKeys.some((k) => k.split('|')[1] === curClass); const curMode = (currentMode || '').toUpperCase().trim();
const newBand = hasDxcc && !newOne && !bandWorked; const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
const newMode = hasDxcc && !newOne && !!curClass && !modeWorked; const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
const newBandMode = hasDxcc && !newOne && !!curClass && !slotStatus; const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
// New slot for THIS call: worked the op before, but not on this band+mode. // Mutually-exclusive badges, shown only when the entity is worked but this
const newSlot = !newOne && callCount > 0 && !!curClass && slotStatus !== 'call_c' && slotStatus !== 'call_w'; // exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity.
// New Band = the band is new (the mode was worked on another band).
// New Mode = the mode is new (the band was worked in another mode).
// New Slot = both band and mode already worked — just not together.
const slotNew = hasDxcc && !newOne && !!curMode && !slotWorked;
const newBandMode = slotNew && !bandWorked && !modeWorked;
const newBand = slotNew && !bandWorked && modeWorked;
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
return ( return (
<section <section
@@ -152,9 +161,9 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</span> </span>
{(newBand || newMode || newBandMode || newSlot) && ( {(newBand || newMode || newBandMode || newSlot) && (
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band &amp; Mode</Badge>}
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>} {newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>} {newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
{!newBand && !newMode && newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band &amp; Mode</Badge>}
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>} {newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
</div> </div>
)} )}
+45
View File
@@ -113,6 +113,19 @@ function statusFor(p: any): SpotStatusEntry | undefined {
]; ];
} }
// statusBadge maps a resolved spot status to a short labelled badge for the
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
switch (s?.status) {
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
}
}
const COL_CATALOG: ColEntry[] = [ const COL_CATALOG: ColEntry[] = [
{ {
group: 'Spot', label: 'Time', colId: 'time', group: 'Spot', label: 'Time', colId: 'time',
@@ -136,6 +149,38 @@ const COL_CATALOG: ColEntry[] = [
return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined; return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined;
}, },
}, },
{
group: 'Spot', label: 'Status', colId: 'status',
headerName: 'Status', width: 96, sortable: true,
defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell.
valueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return 'NEW DXCC';
if (s?.status === 'new-band') return 'NEW BAND';
if (s?.status === 'new-slot') return 'NEW SLOT';
return s?.worked_call ? 'WKD CALL' : '';
},
cellRenderer: (p: any) => {
const b = statusBadge(statusFor(p));
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
return (
<span style={{
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{b.text}</span>
);
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`;
if (s?.status === 'new-band') return 'NEW BAND for this entity';
if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)';
if (s?.worked_call) return 'Already worked this call';
return undefined;
},
},
{ {
group: 'Spot', label: 'POTA', colId: 'pota', group: 'Spot', label: 'POTA', colId: 'pota',
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono', headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
+12 -2
View File
@@ -205,8 +205,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
<Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—" <Input inputMode="numeric" maxLength={2} className="font-mono" value={details.ituz ?? ''} placeholder="—"
onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} /> onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); onChange({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
</Field> </Field>
{/* DXCC #, Continent and Azimuth SP live in the main entry strip / {/* DXCC # closes the top row (next to the zones); Continent and
bandeau. F2 keeps CQ/ITU zones, the long-path bearing and distances. */} Azimuth SP live in the main entry strip / bandeau. The long-path
bearing and distances move to the row below. */}
<Field label="DXCC #">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={details.dxcc ?? ''}
placeholder="—"
/>
</Field>
<Field label="Azimuth LP"> <Field label="Azimuth LP">
<Input <Input
readOnly readOnly
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { Radio } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
type Profile = Omit<profileModels.Profile, 'convertValues'>;
// FirstRunModal collects the mandatory station identity on the very first launch
// (no callsign configured yet). It writes straight into the active profile, so
// OpsLog has a valid station before any QSO is logged. Not dismissable.
export function FirstRunModal({ onDone }: { onDone: () => void }) {
const [p, setP] = useState<Profile | null>(null);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
const [refsState, setRefsState] = useState<'idle' | 'loading' | 'done'>('idle');
const [refsMsg, setRefsMsg] = useState('');
async function downloadRefs() {
setRefsState('loading');
try {
const summary = await DownloadAllReferenceLists();
setRefsMsg(summary);
setRefsState('done');
} catch (e: any) {
setRefsMsg(String(e?.message ?? e));
setRefsState('idle');
}
}
useEffect(() => {
GetActiveProfile().then((x) => setP(x as Profile)).catch(() => setP({} as Profile));
}, []);
const set = (patch: Partial<Profile>) => setP((s: Profile | null) => ({ ...(s as Profile), ...patch }));
const callsign = (p?.callsign ?? '').trim().toUpperCase();
const grid = (p?.my_grid ?? '').trim().toUpperCase();
const operator = (p?.operator ?? '').trim().toUpperCase();
const canSave = callsign.length >= 3 && grid.length >= 4;
async function save() {
if (!p || !canSave) return;
setSaving(true);
setErr('');
try {
await SaveProfile({
...p,
callsign,
my_grid: grid,
operator: operator || callsign,
owner_callsign: (p.owner_callsign ?? '').trim().toUpperCase() || callsign,
op_name: (p.op_name ?? '').trim(),
} as any);
onDone();
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
<div className="flex items-center gap-2 mb-1">
<Radio className="size-5 text-primary" />
<h2 className="text-lg font-semibold">Welcome to OpsLog</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences Station Information (and per profile).
</p>
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
<Label className="text-sm">Callsign <span className="text-red-500">*</span></Label>
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
<Label className="text-sm">Locator <span className="text-red-500">*</span></Label>
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
<Label className="text-sm">Operator</Label>
<Input className="h-9 font-mono uppercase" placeholder="same as callsign" value={p?.operator ?? ''} onChange={(e) => set({ operator: e.target.value })} />
<Label className="text-sm">Owner</Label>
<Input className="h-9 font-mono uppercase" placeholder="station owner callsign" value={p?.owner_callsign ?? ''} onChange={(e) => set({ owner_callsign: e.target.value })} />
<Label className="text-sm">Name</Label>
<Input className="h-9" placeholder="your first name" value={p?.op_name ?? ''} onChange={(e) => set({ op_name: e.target.value })} />
</div>
{/* Optional: grab the award reference lists now (also in Tools later). */}
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs">
<div className="font-medium">Award reference lists</div>
<div className="text-muted-foreground">IOTA · POTA · WWFF · SOTA names &amp; totals for those awards (optional, can take a minute).</div>
</div>
<Button size="sm" variant="outline" disabled={refsState === 'loading'} onClick={downloadRefs}>
{refsState === 'loading' ? 'Downloading…' : refsState === 'done' ? 'Re-download' : 'Download'}
</Button>
</div>
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
</div>
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
<div className="mt-5 flex items-center justify-end gap-2">
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">Callsign and locator are required.</span>}
<Button disabled={!canSave || saving} onClick={save}>{saving ? 'Saving…' : 'Start logging'}</Button>
</div>
</div>
</div>
);
}
+19 -14
View File
@@ -121,13 +121,21 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ── // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (from && beamAzimuths && beamAzimuths.length) { if (from && beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2; const half = (beamWidth ?? 30) / 2;
const D = 5500; // lobe length (km) — short enough to rarely reach a pole const D = 5500; // lobe length (km)
const radial = (b: number): [number, number][] => // A great circle pointing poleward runs to lat ±90, where Mercator is
Array.from({ length: 14 }, (_, i) => { // infinite — the line then snaps across the top of the map. Generate the
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14); // radial with plenty of points (smooth curve) and STOP it just before the
return [d.lat, d.lon] as [number, number]; // pole, so a north/south beam draws a clean line toward the edge instead.
}); const radial = (b: number): [number, number][] => {
const edge = { color: '#dc2626', weight: 1.5, opacity: 0.6 }; const pts: [number, number][] = [];
const N = 64;
for (let i = 1; i <= N; i++) {
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
pts.push([d.lat, d.lon]);
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
}
return pts;
};
for (const az of beamAzimuths) { for (const az of beamAzimuths) {
const arc: [number, number][] = []; const arc: [number, number][] = [];
for (let b = az - half; b <= az + half + 0.001; b += 2) { for (let b = az - half; b <= az + half + 0.001; b += 2) {
@@ -140,13 +148,10 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
...arc, ...arc,
...radial(az + half).reverse(), ...radial(az + half).reverse(),
]); ]);
// A geodesic lobe that reaches near a pole can't be filled on a // Near a pole the lobe's two edges diverge wildly (one runs NW, the
// Mercator map without the polygon snapping across the whole world — // other NE) and look broken on a Mercator map — so for a poleward beam
// draw just the two edges in that case; otherwise the translucent lobe. // show ONLY the clean boresight (below). Otherwise draw the filled lobe.
if (ring.some(([la]) => Math.abs(la) > 82)) { if (!ring.some(([la]) => Math.abs(la) > 78)) {
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az - half)]) as L.LatLngExpression[], edge).addTo(wo);
L.polyline(unwrapLon([[from.lat, from.lon], ...radial(az + half)]) as L.LatLngExpression[], edge).addTo(wo);
} else {
L.polygon(ring as L.LatLngExpression[], { L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14, color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo); }).addTo(wo);
+15 -3
View File
@@ -94,6 +94,11 @@ function toLocalISO(d: any): string {
if (!d) return ''; if (!d) return '';
const date = new Date(d); const date = new Date(d);
if (isNaN(date.getTime())) return ''; if (isNaN(date.getTime())) return '';
// Go's zero time.Time serialises as "0001-01-01T00:00:00Z" (json omitempty
// doesn't apply to a time struct), so a QSO with no end time arrives as a
// year-1 date. Treat anything that old as unset — otherwise the datetime
// field shows a garbage value and fights the user's typing.
if (date.getUTCFullYear() <= 1) return '';
const p = (n: number) => String(n).padStart(2, '0'); const p = (n: number) => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`; return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
} }
@@ -159,8 +164,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz); const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
const [freqRxHz, setFreqRxHz] = useState(fr0.hz); const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date)); const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off)); const dateOffISO = toLocalISO(draft.qso_date_off); // '' when unset / Go zero time
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off); const [dateOff, setDateOff] = useState(dateOffISO);
const [endEnabled, setEndEnabled] = useState(!!dateOffISO);
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
const [localErr, setLocalErr] = useState(''); const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -428,7 +434,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div> <div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
<div> <div>
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC) <Checkbox checked={endEnabled} onCheckedChange={(c) => {
const on = !!c;
setEndEnabled(on);
// Prefill an empty end with the start time so the user
// only tweaks the minutes instead of typing a full date.
if (on && !dateOff) setDateOff(dateOn);
}} /> QSO End (UTC)
</Label> </Label>
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} /> <Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
</div> </div>
+189 -28
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play,
} from 'lucide-react'; } from 'lucide-react';
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -27,6 +27,8 @@ import {
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetDataDir, GetDataDir,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
@@ -125,6 +127,7 @@ const emptyProfile = (): Profile => ({
tx_pwr: undefined, tx_pwr: undefined,
is_active: false, is_active: false,
sort_order: 0, sort_order: 0,
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
created_at: '' as any, created_at: '' as any,
updated_at: '' as any, updated_at: '' as any,
}); });
@@ -157,6 +160,7 @@ type SectionId =
| 'cluster' | 'cluster'
| 'backup' | 'backup'
| 'database' | 'database'
| 'autostart'
| 'awards' | 'awards'
| 'cat' | 'cat'
| 'rotator' | 'rotator'
@@ -190,6 +194,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'DX Cluster', id: 'cluster' }, { kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations', id: 'udp' }, { kind: 'item', label: 'UDP integrations', id: 'udp' },
{ kind: 'item', label: 'Database', id: 'database' }, { kind: 'item', label: 'Database', id: 'database' },
{ kind: 'item', label: 'Autostart', id: 'autostart' },
], ],
}, },
{ {
@@ -216,6 +221,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
cluster: 'DX Cluster', cluster: 'DX Cluster',
backup: 'Database backup', backup: 'Database backup',
database: 'Database', database: 'Database',
autostart: 'Autostart',
udp: 'UDP integrations', udp: 'UDP integrations',
awards: 'Awards', awards: 'Awards',
cat: 'CAT interface', cat: 'CAT interface',
@@ -316,6 +322,129 @@ function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: s
); );
} }
// AutostartPanelComponent manages the per-profile list of external programs to
// launch when OpsLog starts. It's a self-contained component (its own state) so
// it can use hooks — rendered via the `() => <AutostartPanelComponent/>` wrapper
// in PANELS. Changes persist immediately (config is local SQLite, cheap writes).
type AutostartProg = { id: string; name: string; path: string; args: string; enabled: boolean };
function AutostartPanelComponent() {
const [progs, setProgs] = useState<AutostartProg[]>([]);
const [loaded, setLoaded] = useState(false);
const [err, setErr] = useState('');
const [launchMsg, setLaunchMsg] = useState<Record<string, string>>({});
async function load() {
try { setProgs(((await GetAutostartPrograms()) ?? []) as any); }
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setLoaded(true); }
}
useEffect(() => { load(); }, []);
useEffect(() => {
const off = EventsOn('profile:changed', () => load());
return () => { if (typeof off === 'function') off(); };
}, []);
async function commit(next: AutostartProg[]) {
setProgs(next);
try { await SaveAutostartPrograms(next as any); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
const patch = (id: string, p: Partial<AutostartProg>) =>
commit(progs.map((x) => (x.id === id ? { ...x, ...p } : x)));
const remove = (id: string) => commit(progs.filter((x) => x.id !== id));
async function addProgram() {
try {
const path = await BrowseExecutable();
if (!path) return;
const base = path.split(/[\\/]/).pop() || path;
const name = base.replace(/\.(exe|bat|cmd)$/i, '');
const id = (crypto as any)?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
commit([...progs, { id, name, path, args: '', enabled: true }]);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function rebrowse(id: string) {
try { const path = await BrowseExecutable(); if (path) patch(id, { path }); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function launchNow(id: string) {
try {
const r: any = await LaunchAutostartProgram(id);
const txt = r?.status === 'launched' ? '✓ launched'
: r?.status === 'already_running' ? 'already running — not started again'
: r?.status === 'missing' ? '✗ executable not found'
: (r?.message || r?.status || 'done');
setLaunchMsg((m) => ({ ...m, [id]: txt }));
} catch (e: any) { setLaunchMsg((m) => ({ ...m, [id]: String(e?.message ?? e) })); }
}
return (
<>
<SectionHeader
title="Autostart"
hint="Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile."
/>
<div className="space-y-2 max-w-3xl">
{loaded && progs.length === 0 && (
<p className="text-sm text-muted-foreground italic">No programs yet add one below.</p>
)}
{progs.map((p) => (
<div key={p.id} className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="flex items-center gap-2">
<Checkbox checked={p.enabled} onCheckedChange={(c) => patch(p.id, { enabled: !!c })} title="Launch at startup" />
<Input className="h-8 flex-1 font-medium" value={p.name} placeholder="Name"
onChange={(e) => patch(p.id, { name: e.target.value })} />
<Button size="sm" variant="outline" onClick={() => launchNow(p.id)} title="Launch now">
<Play className="size-3.5" /> Launch
</Button>
<Button size="sm" variant="ghost" onClick={() => remove(p.id)} title="Remove">
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
<div className="grid grid-cols-[78px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground">Program</Label>
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-mono text-xs" value={p.path} readOnly title={p.path} />
<Button size="sm" variant="outline" onClick={() => rebrowse(p.id)}>
<FolderOpen className="size-3.5" /> Browse
</Button>
</div>
<Label className="text-xs text-muted-foreground">Arguments</Label>
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
onChange={(e) => patch(p.id, { args: e.target.value })} />
</div>
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
</div>
))}
<Button variant="outline" onClick={addProgram}>
<Plus className="size-4" /> Add program
</Button>
{err && <div className="text-xs text-destructive">{err}</div>}
</div>
</>
);
}
// TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat
// (a random install ID + version + OS, sent once a day). Real component so it
// can own its state; embedded inside GeneralPanel.
function TelemetryToggle() {
const [on, setOn] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
}, []);
return (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
Send anonymous usage statistics
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day no callsign or QSO data)</span>
</label>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) { function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id; const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction; const IconCmp = Icon ?? Construction;
@@ -656,6 +785,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
})(); })();
}, []); }, []);
// Every setting is per-profile, so when the active profile changes WHILE this
// dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
// — otherwise they keep showing the previous profile's values until reopen.
useEffect(() => {
const off = EventsOn('profile:changed', () => {
(async () => {
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
try { setExtSvc(await GetExternalServices() as any); } catch {}
try { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
try {
const ls: any = await GetListsSettings();
setLists(ls);
setRstText({
phone: (ls.rst_phone ?? []).join(' '),
cw: (ls.rst_cw ?? []).join(' '),
digital: (ls.rst_digital ?? []).join(' '),
});
} catch {}
})();
});
return () => { off(); };
}, []);
// Auto-fill the active profile's MY_* DXCC metadata from the station // Auto-fill the active profile's MY_* DXCC metadata from the station
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These // callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
// are derived values, so they always recompute when the callsign or grid // are derived values, so they always recompute when the callsign or grid
@@ -2700,23 +2863,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<SectionHeader <SectionHeader
title="Database" title="Database"
/> />
{/* Backend selector top of the panel. SQLite (solo) vs MySQL (shared). {/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can
The choice is persisted immediately (it lives in config.json, read target its own database; choosing here and Save switches the live
before the DB opens) so switching to SQLite isn't lost when the MySQL logbook immediately (no restart). */}
panel below which holds its own Save button disappears. */} <div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-3">
<Label className="text-sm">Backend</Label> <Label className="text-sm">Backend</Label>
<Select <Select
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
onValueChange={(v) => { onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}
const next = { ...mysqlCfg, enabled: v === 'mysql' };
setMysqlCfg(next);
SaveMySQLSettings(next as any)
.then(() => setRestartMsg(next.enabled
? 'MySQL selected — fill in the connection below, Test, then restart.'
: 'Switched to local SQLite — restart OpsLog to apply.'))
.catch((e: any) => setErr(String(e?.message ?? e)));
}}
> >
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -2725,15 +2879,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">
This is the logbook for the <strong>active profile</strong>. Different profiles can point at different databases switching profile switches the logbook.
</p>
{/* Restart prompt shown after any backend change (works in both states, {/* Save (always visible) applies the active profile's DB target live. */}
unlike the MySQL panel's own Save which is hidden when SQLite). */} <div className="max-w-2xl mb-4 flex items-center gap-3">
{restartMsg && ( <Button size="sm" className="h-8"
<div className="max-w-2xl mb-4 text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3"> onClick={() => {
<span>{restartMsg}</span> SaveMySQLSettings(mysqlCfg as any)
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>Quit now</Button> .then(() => setRestartMsg(mysqlCfg.enabled
? 'Logbook switched to MySQL ✓'
: 'Logbook switched to local SQLite ✓'))
.catch((e: any) => setErr(String(e?.message ?? e)));
}}>
Save &amp; switch logbook
</Button>
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
</div> </div>
)}
{/* Active-backend status: confirms what OpsLog actually opened at launch. */} {/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && ( {backendStatus && (
@@ -2793,7 +2956,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
{mysqlCfg.enabled && ( {mysqlCfg.enabled && (
<div className="space-y-3 max-w-2xl"> <div className="space-y-3 max-w-2xl">
<div className="text-[11px] text-muted-foreground leading-relaxed"> <div className="text-[11px] text-muted-foreground leading-relaxed">
Several OpsLog instances pointed at one MySQL server see each other's QSOs live. Test the connection, then <strong>Save</strong> OpsLog switches to MySQL (and creates all tables) on the next launch. Several OpsLog instances pointed at one MySQL database see each other's QSOs live (refreshed every 2 s). <strong>Test &amp; create</strong> the database, then <strong>Save &amp; switch logbook</strong> above to start logging there.
</div> </div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center"> <div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Host</Label> <Label className="text-sm">Host</Label>
@@ -2812,10 +2975,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
onClick={() => { setMysqlMsg('Testing…'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected — database ready ✓')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}> onClick={() => { setMysqlMsg('Testing…'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected — database ready ✓')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}>
Test &amp; create database Test &amp; create database
</Button> </Button>
<Button size="sm" className="h-8"
onClick={() => { SaveMySQLSettings(mysqlCfg as any).then(() => setRestartMsg('Saved — restart OpsLog to connect to MySQL.')).catch((e: any) => setErr(String(e?.message ?? e))); }}>
Save
</Button>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span> <span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div> </div>
</div> </div>
@@ -3032,7 +3191,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return ( return (
<> <>
<SectionHeader title="General" hint="App behaviour (saved instantly)." /> <SectionHeader title="General" hint="App behaviour (saved instantly)." />
<div className="space-y-3 max-w-lg"> <div className="space-y-3 max-w-3xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} /> <Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
Auto-focus "Worked before" for known stations Auto-focus "Worked before" for known stations
@@ -3049,6 +3208,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} /> <Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span> Look up the callsign only after leaving the field <span className="text-xs text-muted-foreground">(not while typing)</span>
</label> </label>
<TelemetryToggle />
<div className="border-t border-border/60 pt-4 space-y-2"> <div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4> <h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
@@ -3221,6 +3381,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
udp: UDPIntegrationsPanelWrapper, udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel, backup: BackupPanel,
database: DatabasePanel, database: DatabasePanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />, awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel, cat: CATPanel,
rotator: RotatorPanel, rotator: RotatorPanel,
+1 -1
View File
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}
+1 -1
View File
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
return ( return (
<textarea <textarea
className={cn( className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50', 'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}
+25 -6
View File
@@ -33,12 +33,28 @@ function appendTokens(existing: string | undefined, refs: string): string {
return out; return out;
} }
// applyAwardRefs writes picked references onto a QSO payload using each award's // MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
// scanned field. fieldOf maps an award CODE (uppercase) to its field name. // the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
// The award engine honours these regardless of how the award matches, so a
// reference can be assigned even for awards that scan a free-text field by
// description/pattern (e.g. WAPC over ADDRESS) where writing a bare code into
// the field wouldn't match.
const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
// applyAwardRefs writes picked references onto a QSO payload. Every assignment
// is recorded under MANUAL_REFS_KEY (authoritative for the engine); in addition,
// awards backed by a standard ADIF column also get that column written so the
// data lives in its conventional place and exports correctly. Awards that match
// a free-text field by description/pattern (address/qth/name/custom) rely solely
// on the override — we don't pollute the text field with a code.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) { export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
const byCode = parseAwardRefs(awardRefs); const byCode = parseAwardRefs(awardRefs);
const extras: Record<string, string> = { ...(payload.extras ?? {}) }; const extras: Record<string, string> = { ...(payload.extras ?? {}) };
const overrides: string[] = [];
for (const [code, ref] of Object.entries(byCode)) { for (const [code, ref] of Object.entries(byCode)) {
for (const r of ref.split(',').map((s) => s.trim()).filter(Boolean)) {
overrides.push(`${code}@${r}`);
}
const field = fieldOf[code] || code.toLowerCase(); const field = fieldOf[code] || code.toLowerCase();
switch (field) { switch (field) {
case 'iota': payload.iota = ref; break; case 'iota': payload.iota = ref; break;
@@ -53,21 +69,24 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
extras['SIG'] = 'WWFF'; extras['SIG'] = 'WWFF';
extras['SIG_INFO'] = ref; extras['SIG_INFO'] = ref;
break; break;
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM // QSOFIELDS awards that scan a free-text field for a code (e.g. DDFM finds
// scans the note for "D06"). Picking such a reference appends its code(s) // "D06" in the note): append the code so the in-field matcher finds it too.
// to that field so the matcher finds it.
case 'note': case 'notes': case 'note': case 'notes':
payload.notes = appendTokens(payload.notes, ref); payload.notes = appendTokens(payload.notes, ref);
break; break;
case 'comment': case 'comment':
payload.comment = appendTokens(payload.comment, ref); payload.comment = appendTokens(payload.comment, ref);
break; break;
// address / qth / name / custom: the override above is authoritative; do
// not write a code into a free-text field the award matches by name.
default: default:
extras[field.toUpperCase()] = ref;
break; break;
} }
} }
if (overrides.length > 0) extras[MANUAL_REFS_KEY] = overrides.join(';');
else delete extras[MANUAL_REFS_KEY];
if (Object.keys(extras).length > 0) payload.extras = extras; if (Object.keys(extras).length > 0) payload.extras = extras;
else if (payload.extras) delete payload.extras;
} }
// awardRefValue reads a single award's stored reference from a QSO, inverse of // awardRefValue reads a single award's stored reference from a QSO, inverse of
+20
View File
@@ -25,12 +25,16 @@ export function AddQSO(arg1:qso.QSO):Promise<number>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>; export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>; export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>; export function AwardFields():Promise<Array<string>>;
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>; export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>; export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function ClearLookupCache():Promise<void>; export function ClearLookupCache():Promise<void>;
@@ -87,6 +91,8 @@ export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>; export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DownloadAllReferenceLists():Promise<string>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>; export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>; export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
@@ -109,6 +115,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAudioSettings():Promise<main.AudioSettings>; export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string):Promise<award.Result>; export function GetAward(arg1:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>; export function GetAwardDefs():Promise<Array<award.Def>>;
@@ -155,6 +163,8 @@ export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLogFilePath():Promise<string>; export function GetLogFilePath():Promise<string>;
export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>; export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>; export function GetMySQLSettings():Promise<main.MySQLSettings>;
@@ -175,6 +185,8 @@ export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationSettings():Promise<main.StationSettings>; export function GetStationSettings():Promise<main.StationSettings>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetUIPref(arg1:string):Promise<string>; export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>; export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
@@ -193,6 +205,10 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
export function ImportAwards():Promise<main.AwardImportResult>; export function ImportAwards():Promise<main.AwardImportResult>;
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>; export function ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():Promise<Array<audio.Device>>; export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
@@ -311,6 +327,8 @@ export function SaveADIFFile():Promise<string>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>; export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>; export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>; export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
@@ -375,6 +393,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>; export function SetUltrabeamDirection(arg1:number):Promise<void>;
+40
View File
@@ -22,6 +22,10 @@ export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2); return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
} }
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
}
export function AwardCellQSOs(arg1, arg2, arg3) { export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
} }
@@ -34,6 +38,10 @@ export function AwardMissingQSOs(arg1) {
return window['go']['main']['App']['AwardMissingQSOs'](arg1); return window['go']['main']['App']['AwardMissingQSOs'](arg1);
} }
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
export function BulkUpdateQSL(arg1, arg2) { export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
} }
@@ -146,6 +154,10 @@ export function DisconnectClusterServer(arg1) {
return window['go']['main']['App']['DisconnectClusterServer'](arg1); return window['go']['main']['App']['DisconnectClusterServer'](arg1);
} }
export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists']();
}
export function DownloadClublogCty() { export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty'](); return window['go']['main']['App']['DownloadClublogCty']();
} }
@@ -190,6 +202,10 @@ export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings'](); return window['go']['main']['App']['GetAudioSettings']();
} }
export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms']();
}
export function GetAward(arg1) { export function GetAward(arg1) {
return window['go']['main']['App']['GetAward'](arg1); return window['go']['main']['App']['GetAward'](arg1);
} }
@@ -282,6 +298,10 @@ export function GetLogFilePath() {
return window['go']['main']['App']['GetLogFilePath'](); return window['go']['main']['App']['GetLogFilePath']();
} }
export function GetLogbookRevision() {
return window['go']['main']['App']['GetLogbookRevision']();
}
export function GetLookupSettings() { export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings'](); return window['go']['main']['App']['GetLookupSettings']();
} }
@@ -322,6 +342,10 @@ export function GetStationSettings() {
return window['go']['main']['App']['GetStationSettings'](); return window['go']['main']['App']['GetStationSettings']();
} }
export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
export function GetUIPref(arg1) { export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1); return window['go']['main']['App']['GetUIPref'](arg1);
} }
@@ -358,6 +382,14 @@ export function ImportAwards() {
return window['go']['main']['App']['ImportAwards'](); return window['go']['main']['App']['ImportAwards']();
} }
export function LaunchAutostartProgram(arg1) {
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
}
export function LaunchAutostartPrograms() {
return window['go']['main']['App']['LaunchAutostartPrograms']();
}
export function ListAudioInputDevices() { export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices'](); return window['go']['main']['App']['ListAudioInputDevices']();
} }
@@ -594,6 +626,10 @@ export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1); return window['go']['main']['App']['SaveAudioSettings'](arg1);
} }
export function SaveAutostartPrograms(arg1) {
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
}
export function SaveAwardDefs(arg1) { export function SaveAwardDefs(arg1) {
return window['go']['main']['App']['SaveAwardDefs'](arg1); return window['go']['main']['App']['SaveAwardDefs'](arg1);
} }
@@ -722,6 +758,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
}
export function SetUIPref(arg1, arg2) { export function SetUIPref(arg1, arg2) {
return window['go']['main']['App']['SetUIPref'](arg1, arg2); return window['go']['main']['App']['SetUIPref'](arg1, arg2);
} }
+62 -2
View File
@@ -682,6 +682,44 @@ export namespace main {
this.mic_gain = source["mic_gain"]; this.mic_gain = source["mic_gain"];
} }
} }
export class AutostartLaunchResult {
id: string;
name: string;
status: string;
message: string;
static createFrom(source: any = {}) {
return new AutostartLaunchResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.status = source["status"];
this.message = source["message"];
}
}
export class AutostartProgram {
id: string;
name: string;
path: string;
args: string;
enabled: boolean;
static createFrom(source: any = {}) {
return new AutostartProgram(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.path = source["path"];
this.args = source["args"];
this.enabled = source["enabled"];
}
}
export class AwardImportResult { export class AwardImportResult {
awards: number; awards: number;
references: number; references: number;
@@ -1382,7 +1420,6 @@ export namespace main {
ok: boolean; ok: boolean;
err: string; err: string;
db_path: string; db_path: string;
migrated_from_app_data: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new StartupStatus(source); return new StartupStatus(source);
@@ -1393,7 +1430,6 @@ export namespace main {
this.ok = source["ok"]; this.ok = source["ok"];
this.err = source["err"]; this.err = source["err"];
this.db_path = source["db_path"]; this.db_path = source["db_path"];
this.migrated_from_app_data = source["migrated_from_app_data"];
} }
} }
export class StationInfoComputed { export class StationInfoComputed {
@@ -1685,6 +1721,28 @@ export namespace operating {
export namespace profile { export namespace profile {
export class ProfileDB {
backend: string;
host: string;
port: number;
user: string;
password: string;
database: string;
static createFrom(source: any = {}) {
return new ProfileDB(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.backend = source["backend"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.database = source["database"];
}
}
export class Profile { export class Profile {
id: number; id: number;
name: string; name: string;
@@ -1711,6 +1769,7 @@ export namespace profile {
tx_pwr?: number; tx_pwr?: number;
is_active: boolean; is_active: boolean;
sort_order: number; sort_order: number;
db: ProfileDB;
// Go type: time // Go type: time
created_at: any; created_at: any;
// Go type: time // Go type: time
@@ -1747,6 +1806,7 @@ export namespace profile {
this.tx_pwr = source["tx_pwr"]; this.tx_pwr = source["tx_pwr"];
this.is_active = source["is_active"]; this.is_active = source["is_active"];
this.sort_order = source["sort_order"]; this.sort_order = source["sort_order"];
this.db = this.convertValues(source["db"], ProfileDB);
this.created_at = this.convertValues(source["created_at"], null); this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null); this.updated_at = this.convertValues(source["updated_at"], null);
} }
+19
View File
@@ -120,6 +120,25 @@ func SingleRecordADIF(q qso.QSO) string {
return b.String() return b.String()
} }
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
func BatchRecordsADIF(records []string) string {
var b strings.Builder
now := time.Now().UTC().Format("20060102 150405")
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
len(adifVersion), adifVersion, now)
for _, r := range records {
r = strings.TrimRight(r, "\r\n")
if r == "" {
continue
}
b.WriteString(r)
b.WriteString("\n")
}
return b.String()
}
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>. // writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted" // Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical // mode (e.g. FT4 stored without a parent) is exported as the canonical
+18
View File
@@ -5,11 +5,26 @@ package audio
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"runtime/debug"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
// Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal
// — but it catches any Go-level panic in capture/mix.)
func recoverGoroutine(what string) {
if r := recover(); r != nil {
LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
}
}
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO // Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
// recording can begin a few seconds BEFORE the operator entered the callsign. // recording can begin a few seconds BEFORE the operator entered the callsign.
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a // It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
@@ -108,6 +123,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
defer recoverGoroutine("recorder capture (radio)")
_ = captureStream(fromDev, stop, func(chunk []byte) { _ = captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk) s := bytesToInt16(chunk)
r.srcMu.Lock() r.srcMu.Lock()
@@ -119,6 +135,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
defer recoverGoroutine("recorder capture (mic)")
_ = captureStream(micDev, stop, func(chunk []byte) { _ = captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk) s := bytesToInt16(chunk)
r.srcMu.Lock() r.srcMu.Lock()
@@ -132,6 +149,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
defer recoverGoroutine("recorder mixer")
t := time.NewTicker(40 * time.Millisecond) t := time.NewTicker(40 * time.Millisecond)
defer t.Stop() defer t.Stop()
for { for {
+67 -1
View File
@@ -414,7 +414,73 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
} }
} }
rl := NewRefList(metas) rl := NewRefList(metas)
return candidates(&d, re, q, rl, len(metas) > 0) found := candidates(&d, re, q, rl, len(metas) > 0)
// Merge operator-assigned references (manual override). These let the
// operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
// province NAME, so a contact whose address doesn't spell it out needs the
// province picked by hand. For a predefined award the override is still
// validated against its reference list.
if man := manualRefs(q, d.Code); len(man) > 0 {
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
}
return found
}
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
// comma list). Honoured by MatchQSO regardless of how the award matches.
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"
// manualRefs returns the reference codes the operator assigned to award `code`
// on this QSO (from the ManualRefsKey extra).
func manualRefs(q *qso.QSO, code string) []string {
if q == nil || q.Extras == nil {
return nil
}
raw := strings.TrimSpace(q.Extras[ManualRefsKey])
if raw == "" {
return nil
}
code = strings.ToUpper(strings.TrimSpace(code))
var out []string
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
at := strings.IndexByte(entry, '@')
if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
continue
}
for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
if r = strings.TrimSpace(r); r != "" {
out = append(out, r)
}
}
}
return out
}
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
// When the award is predefined, only references present and valid in its list
// are kept (so a typo can't invent a reference).
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
seen := map[string]struct{}{}
for _, c := range found {
seen[normalizeRef(c)] = struct{}{}
}
for _, c := range manual {
c = normalizeRef(c)
if _, dup := seen[c]; dup {
continue
}
if predefined {
if m, ok := rl.byCode[c]; !ok || !m.Valid {
continue
}
}
seen[c] = struct{}{}
found = append(found, c)
}
return found
} }
// Confirmed reports whether a QSO satisfies any of the given confirmation // Confirmed reports whether a QSO satisfies any of the given confirmation
+20 -5
View File
@@ -57,6 +57,7 @@ type RigState struct {
// Manager owns the active backend and runs the polling loop. // Manager owns the active backend and runs the polling loop.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
state RigState state RigState
emit func(RigState) emit func(RigState)
backend Backend backend Backend
@@ -115,7 +116,13 @@ func (m *Manager) State() RigState {
// state.Error rather than returned, so the UI can keep retrying via the // state.Error rather than returned, so the UI can keep retrying via the
// poll loop on next reconnect attempt. // poll loop on next reconnect attempt.
func (m *Manager) Start(b Backend) { func (m *Manager) Start(b Backend) {
m.Stop() // Serialize the whole stop-old-then-start-new sequence. Two concurrent
// Start (or Start+Stop) calls could otherwise interleave and leave the
// previous poll goroutine alive — two pollers then fight, e.g. flipping
// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock() m.mu.Lock()
m.stopCh = make(chan struct{}) m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{}) m.doneCh = make(chan struct{})
@@ -134,6 +141,18 @@ func (m *Manager) Start(b Backend) {
// Stop signals the CAT goroutine to disconnect and waits for it to exit. // Stop signals the CAT goroutine to disconnect and waits for it to exit.
func (m *Manager) Stop() { func (m *Manager) Stop() {
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// stopLocked tears down any running poller and blocks until it exits. The
// caller must hold startMu so it can't race a concurrent Start.
func (m *Manager) stopLocked() {
m.mu.Lock() m.mu.Lock()
stop := m.stopCh stop := m.stopCh
done := m.doneCh done := m.doneCh
@@ -148,10 +167,6 @@ func (m *Manager) Stop() {
if done != nil { if done != nil {
<-done <-done
} }
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
} }
// SetFrequency dispatches a SetFreq call to the CAT goroutine. // SetFrequency dispatches a SetFreq call to the CAT goroutine.
+94 -37
View File
@@ -3,11 +3,21 @@ package cat
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/go-ole/go-ole" "github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil" "github.com/go-ole/go-ole/oleutil"
) )
// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
// split ON = 0x8000, split OFF = 0x10000.
const (
pmSplitOn = 0x8000 // PM_SPLITON
pmSplitOff = 0x10000 // PM_SPLITOFF
)
// OmniRig talks to the user's installed OmniRig server over COM. // OmniRig talks to the user's installed OmniRig server over COM.
// //
// All methods MUST be called from the same OS thread (the one Manager.run // All methods MUST be called from the same OS thread (the one Manager.run
@@ -21,6 +31,15 @@ type OmniRig struct {
omnirig *ole.IDispatch omnirig *ole.IDispatch
rig *ole.IDispatch rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two
// after a QSY — that lag is why a clicked spot needed a second click to fix
// the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64
lastSetFreqAt time.Time
} }
// NewOmniRig creates a non-connected backend. Call Connect before use. // NewOmniRig creates a non-connected backend. Call Connect before use.
@@ -107,13 +126,19 @@ func (o *OmniRig) ReadState() (RigState, error) {
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil { if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
s.Mode = omniRigMode(modeVar.Val) s.Mode = omniRigMode(modeVar.Val)
} }
rawVfo := int64(0)
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil { if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
rawVfo = vfoVar.Val
s.Vfo = omniRigVfo(vfoVar.Val) s.Vfo = omniRigVfo(vfoVar.Val)
} }
// Read both VFO frequencies separately so we can expose split TX/RX. // Read the active/displayed frequency (generic Freq) AND both VFOs. The
// Fall back to generic Freq if the rig only exposes the merged property. // generic Freq is what the rig is operating on — the reliable source for the
freqA, freqB := int64(0), int64(0) // main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
freqMain, freqA, freqB := int64(0), int64(0), int64(0)
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freqMain = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil { if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
freqA = v.Val freqA = v.Val
} }
@@ -121,38 +146,58 @@ func (o *OmniRig) ReadState() (RigState, error) {
freqB = v.Val freqB = v.Val
} }
// Split detection: trust the explicit Split property when it's set, // Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
// BUT only call it a real split if both VFO frequencies are non-zero // compared to PM_SPLITON, not "!= 0".
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by splitRaw := int64(0)
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
// slice model doesn't map to VFO A/B — that would yield a useless splitRaw = v.Val
// permanent SPLIT badge.
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
s.Split = true
}
if s.Split && (freqB == 0 || freqA == freqB) {
s.Split = false
s.RxFreqHz = 0
} }
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
// normal operation stays quiet but toggling split on the radio is captured —
// needed to pin down this rig's PM_SPLITON value.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
return v.Val
}
return -1
}())
}
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
// distinct and non-zero, AND they're in the same band. The same-band test
// kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
if genuineSplit {
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B). // OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split). // ADIF: FreqHz = TX, RxFreqHz = RX.
s.Split = true
switch s.Vfo { switch s.Vfo {
case "AB":
s.FreqHz = freqB // TX
s.RxFreqHz = freqA // RX
case "BA": case "BA":
s.FreqHz = freqA // TX s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B
s.RxFreqHz = freqB // RX default: // "AB" and the usual "TX on the other VFO" case
case "B", "BB": s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A
}
} else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
s.Split = false
s.RxFreqHz = 0
s.FreqHz = freqMain
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB s.FreqHz = freqB
default: // "A", "AA", "" — single VFO on A or unknown } else {
s.FreqHz = freqA s.FreqHz = freqA
} }
if s.FreqHz == 0 {
// Last resort — some rigs only update generic Freq.
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
s.FreqHz = v.Val
} }
} }
return s, nil return s, nil
@@ -169,6 +214,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
return fmt.Errorf("frequency out of OmniRig int32 range") return fmt.Errorf("frequency out of OmniRig int32 range")
} }
hz32 := int32(hz) hz32 := int32(hz)
// Remember the commanded frequency so a mode change moments later (a clicked
// spot sets freq then mode) picks the sideband from the TARGET band, not the
// not-yet-updated OmniRig Freq property.
o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()
// Log the rig's writable-params, status and VFO state up front so a // Log the rig's writable-params, status and VFO state up front so a
// friend's session shows exactly what OmniRig reports for their rig. // friend's session shows exactly what OmniRig reports for their rig.
@@ -274,9 +323,15 @@ func (o *OmniRig) SetMode(mode string) error {
case "CW": case "CW":
bit, bitName = pmCWU, "PM_CW_U" bit, bitName = pmCWU, "PM_CW_U"
case "SSB": case "SSB":
// Read current freq to decide USB vs LSB. // Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
// property still reports the OLD band for a poll or two after a QSY, so
// reading it here picked the wrong sideband and the user had to click a
// second time. Fall back to the live read for a standalone mode change.
var freq int64 var freq int64
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil { if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
freq = o.lastSetFreq
} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freq = freqVar.Val freq = freqVar.Val
} }
if freq > 0 && freq < 10_000_000 { if freq > 0 && freq < 10_000_000 {
@@ -396,20 +451,22 @@ func omniRigMode(m int64) string {
return "" return ""
} }
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
func omniRigVfo(v int64) string { func omniRigVfo(v int64) string {
switch { switch {
case v&1024 != 0: case v&0x40 != 0: // PM_VFOAA
return "A"
case v&2048 != 0:
return "B"
case v&64 != 0:
return "AA" return "AA"
case v&128 != 0: case v&0x80 != 0: // PM_VFOAB
return "AB" return "AB"
case v&256 != 0: case v&0x100 != 0: // PM_VFOBA
return "BA" return "BA"
case v&512 != 0: case v&0x200 != 0: // PM_VFOBB
return "BB" return "BB"
case v&0x400 != 0: // PM_VFOA
return "A"
case v&0x800 != 0: // PM_VFOB
return "B"
} }
return "" return ""
} }
+48 -11
View File
@@ -29,8 +29,12 @@ func (c MySQLConfig) dsn() string {
port = 3306 port = 3306
} }
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full // parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
// Unicode (names, comments…). // Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4", // or wrong-port server fails fast with a real error instead of hanging
// startup (which would surface only as "db not initialized"); read/write
// timeouts cap a stuck statement (generous, so normal migrations/imports
// never trip them).
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
c.User, c.Password, c.Host, port, c.Database) c.User, c.Password, c.Host, port, c.Database)
} }
@@ -50,8 +54,12 @@ func validDBIdent(s string) bool {
// PingMySQL verifies a shared-database connection and creates the logbook // PingMySQL verifies a shared-database connection and creates the logbook
// database if it doesn't exist yet. It connects at server level first (no // database if it doesn't exist yet. It connects at server level first (no
// database selected) so a not-yet-created DB isn't an error, then runs // database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
// CREATE DATABASE IF NOT EXISTS. Backs the settings "Test connection" button. // database is actually usable. A restricted user (common on shared hosting)
// may lack the CREATE DATABASE privilege but still have full rights on a
// pre-created database — so a denied CREATE is not fatal as long as the
// database already exists and we can connect to it. Backs the "Test
// connection" button.
func PingMySQL(c MySQLConfig) error { func PingMySQL(c MySQLConfig) error {
if strings.TrimSpace(c.Host) == "" { if strings.TrimSpace(c.Host) == "" {
return fmt.Errorf("host is required") return fmt.Errorf("host is required")
@@ -63,17 +71,37 @@ func PingMySQL(c MySQLConfig) error {
return fmt.Errorf("open mysql: %w", err) return fmt.Errorf("open mysql: %w", err)
} }
defer conn.Close() defer conn.Close()
conn.SetConnMaxLifetime(5 * time.Second) conn.SetConnMaxLifetime(15 * time.Second)
if err := conn.Ping(); err != nil { if err := conn.Ping(); err != nil {
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err) return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
} }
if name := strings.TrimSpace(c.Database); name != "" { name := strings.TrimSpace(c.Database)
if name == "" {
return nil
}
if !validDBIdent(name) { if !validDBIdent(name) {
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name) return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
} }
createErr := error(nil)
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
if !isAccessDenied(err) {
return fmt.Errorf("create database %q: %w", name, err) return fmt.Errorf("create database %q: %w", name, err)
} }
createErr = err // remember it in case the DB also turns out to be unreachable
}
// Confirm the database is usable (it may have been pre-created by an admin
// even though this user can't CREATE one).
dbConn, err := sql.Open("mysql", c.dsn())
if err != nil {
return fmt.Errorf("open mysql db: %w", err)
}
defer dbConn.Close()
dbConn.SetConnMaxLifetime(15 * time.Second)
if err := dbConn.Ping(); err != nil {
if createErr != nil {
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
}
return fmt.Errorf("connect to database %q: %w", name, err)
} }
return nil return nil
} }
@@ -81,6 +109,13 @@ func PingMySQL(c MySQLConfig) error {
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsFS embed.FS var migrationsFS embed.FS
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
// dialect; run through the dialect translator for MySQL.
const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`
// Dialect names the backend opened at startup. Repos consult it for the few // Dialect names the backend opened at startup. Repos consult it for the few
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction). // places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
// Timestamps are generated in Go (NowISO) for both, so most queries are shared. // Timestamps are generated in Go (NowISO) for both, so most queries are shared.
@@ -138,13 +173,15 @@ func Open(path string) (*sql.DB, error) {
// (no external dependency). translate, when non-nil, rewrites each statement // (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is. // for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string) error { func migrate(conn *sql.DB, translate func(string) string) error {
// A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
mysqlPath := translate != nil
if translate == nil { if translate == nil {
translate = func(s string) string { return s } translate = func(s string) string { return s }
} }
if _, err := conn.Exec(translate(`CREATE TABLE IF NOT EXISTS schema_migrations ( if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err) return fmt.Errorf("create schema_migrations: %w", err)
} }
@@ -182,7 +219,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
// "duplicate column". Instead apply statement-by-statement and tolerate // "duplicate column". Instead apply statement-by-statement and tolerate
// "already exists" errors, making migrations idempotent (and self-healing // "already exists" errors, making migrations idempotent (and self-healing
// for a previously half-applied database). // for a previously half-applied database).
if IsMySQL() { if mysqlPath {
if err := applyMySQLMigration(conn, sqlText); err != nil { if err := applyMySQLMigration(conn, sqlText); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err) return fmt.Errorf("apply migration %s: %w", name, err)
} }
@@ -0,0 +1,6 @@
-- Per-profile logbook database. Each profile can target its own logbook:
-- the local SQLite file, or a specific shared MySQL database. Switching the
-- active profile switches the logbook accordingly. Stored as a small JSON
-- document {backend, host, port, user, password, database}; empty = inherit
-- the default (local SQLite).
ALTER TABLE station_profiles ADD COLUMN db_config TEXT NOT NULL DEFAULT '';
+223 -6
View File
@@ -37,6 +37,11 @@ var (
reBareInteger = regexp.MustCompile(`\bINTEGER\b`) reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`) reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`) reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
// uses backticks. Our schema has no double-quoted string literals, so it's
// safe to convert every "ident" to `ident`.
reDoubleQuoteIdent = regexp.MustCompile(`"([^"\n]*)"`)
) )
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB). // MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
@@ -71,6 +76,9 @@ var longTextColumns = map[string]string{
// file) into the MySQL dialect. See the package note above for the rules. // file) into the MySQL dialect. See the package note above for the rules.
func mysqlDDL(stmt string) string { func mysqlDDL(stmt string) string {
s := stmt s := stmt
// SQLite double-quoted identifiers → MySQL backticks (renamed tables in the
// baseline dump, etc.).
s = reDoubleQuoteIdent.ReplaceAllString(s, "`$1`")
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway). // MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX") s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare // Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
@@ -144,7 +152,11 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
conn.SetMaxOpenConns(50) conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10) conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second) conn.SetConnMaxIdleTime(90 * time.Second)
conn.SetConnMaxLifetime(5 * time.Minute) // No max lifetime: a slow server's first migration can run for minutes on a
// single connection, and reaping it mid-migration drops the selected database
// (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0)
if err := conn.Ping(); err != nil { if err := conn.Ping(); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err) return nil, fmt.Errorf("connect to %s: %w", name, err)
@@ -152,7 +164,22 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
// Set the dialect before migrating so the runner takes the MySQL path // Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path. // (per-statement, idempotent) rather than the SQLite transaction path.
Dialect = "mysql" Dialect = "mysql"
if err := migrate(conn, mysqlDDL); err != nil { fresh, err := isFreshMySQL(conn)
if err != nil {
_ = conn.Close()
Dialect = "sqlite"
return nil, fmt.Errorf("inspect database: %w", err)
}
if fresh {
// Empty database: build the whole final schema in one pass (one CREATE
// per table, no ALTERs) — far faster than replaying 21 migrations,
// especially on a server with slow DDL.
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL)
}
if err != nil {
_ = conn.Close() _ = conn.Close()
Dialect = "sqlite" // migration failed; we'll fall back to SQLite Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err return nil, err
@@ -160,6 +187,116 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
return conn, nil return conn, nil
} }
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator.
func isFreshMySQL(conn *sql.DB) (bool, error) {
var name string
err := conn.QueryRow("SHOW TABLES LIKE 'qso'").Scan(&name)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
return false, nil
}
// applyMySQLBaseline creates the entire current schema on a fresh MySQL database
// in a single pass, then records every migration as already applied. The schema
// is derived from the migrations themselves (replayed on a throwaway in-memory
// SQLite, whose sqlite_master holds each table's FINAL CREATE statement with all
// ALTER-added columns folded in) and translated to MySQL — so there's no second
// schema to maintain and the two backends can't drift. Future migrations apply
// incrementally on top.
func applyMySQLBaseline(conn *sql.DB) error {
mem, err := sql.Open("sqlite", "file:opslog_baseline?mode=memory&cache=shared")
if err != nil {
return fmt.Errorf("open baseline sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil); err != nil {
return fmt.Errorf("build baseline schema: %w", err)
}
var migNames []string
mrows, err := mem.Query("SELECT name FROM schema_migrations ORDER BY name")
if err != nil {
return fmt.Errorf("read baseline migrations: %w", err)
}
for mrows.Next() {
var n string
if err := mrows.Scan(&n); err != nil {
mrows.Close()
return err
}
migNames = append(migNames, n)
}
mrows.Close()
var tables, indexes []string
srows, err := mem.Query("SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'")
if err != nil {
return fmt.Errorf("read baseline schema: %w", err)
}
for srows.Next() {
var typ, s string
if err := srows.Scan(&typ, &s); err != nil {
srows.Close()
return err
}
switch typ {
case "table":
tables = append(tables, s)
case "index":
indexes = append(indexes, s)
}
}
srows.Close()
// Apply on one connection with FK checks off so table order doesn't matter.
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
if _, err := c.ExecContext(ctx, mysqlDDL(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, tb := range tables {
if _, err := c.ExecContext(ctx, mysqlDDL(tb)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline table: %w", err)
}
}
for _, ix := range indexes {
if _, err := c.ExecContext(ctx, mysqlDDL(ix)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline index: %w", err)
}
}
for _, n := range migNames {
if _, err := conn.Exec("INSERT INTO schema_migrations(name) VALUES(?)", n); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("record migration %s: %w", n, err)
}
}
return nil
}
// applyMySQLMigration executes a translated migration one statement at a time. // applyMySQLMigration executes a translated migration one statement at a time.
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL, // MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
// so running statements individually (and tolerating "already exists" errors) // so running statements individually (and tolerating "already exists" errors)
@@ -182,9 +319,19 @@ func applyMySQLMigration(conn *sql.DB, sqlText string) error {
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil { if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err) return fmt.Errorf("disable fk checks: %w", err)
} }
for _, stmt := range splitStatements(sqlText) { for _, stmt := range coalesceAddColumns(splitStatements(sqlText)) {
if _, err := c.ExecContext(ctx, stmt); err != nil { if _, err := c.ExecContext(ctx, stmt); err != nil {
if isIgnorableDDLError(err) { if isIgnorableDDLError(err) {
// A coalesced multi-column ALTER fails wholesale if even one
// column already exists (partial prior apply). Re-run it column
// by column so the missing ones still get added.
if cols := decomposeAddColumns(stmt); len(cols) > 1 {
for _, one := range cols {
if _, e := c.ExecContext(ctx, one); e != nil && !isIgnorableDDLError(e) {
return fmt.Errorf("statement %q: %w", firstLine(one), e)
}
}
}
continue continue
} }
return fmt.Errorf("statement %q: %w", firstLine(stmt), err) return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
@@ -193,13 +340,65 @@ func applyMySQLMigration(conn *sql.DB, sqlText string) error {
return nil return nil
} }
var reAddColumn = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+COLUMN\s+(.+)$`)
// coalesceAddColumns merges consecutive "ALTER TABLE t ADD COLUMN …" statements
// on the same table into a single multi-column ALTER. SQLite needs one ALTER per
// column, but each is a full table operation in MySQL — and on a slow server
// that's seconds apiece, turning a ~50-column migration into minutes (long
// enough to trip connection lifetimes and stall startup). One combined ALTER is
// a single table rebuild instead of fifty.
func coalesceAddColumns(stmts []string) []string {
out := make([]string, 0, len(stmts))
for i := 0; i < len(stmts); {
m := reAddColumn.FindStringSubmatch(stmts[i])
if m == nil {
out = append(out, stmts[i])
i++
continue
}
table := m[1]
cols := []string{strings.TrimSpace(m[2])}
j := i + 1
for j < len(stmts) {
m2 := reAddColumn.FindStringSubmatch(stmts[j])
if m2 == nil || m2[1] != table {
break
}
cols = append(cols, strings.TrimSpace(m2[2]))
j++
}
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.Join(cols, ", ADD COLUMN "))
i = j
}
return out
}
// decomposeAddColumns splits a (possibly coalesced) ADD COLUMN ALTER back into
// one ALTER per column, for the idempotent fallback path.
func decomposeAddColumns(stmt string) []string {
m := reAddColumn.FindStringSubmatch(stmt)
if m == nil {
return nil
}
table := m[1]
var out []string
for _, col := range strings.Split(m[2], ", ADD COLUMN ") {
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.TrimSpace(col))
}
return out
}
// splitStatements breaks a migration file into individual SQL statements, // splitStatements breaks a migration file into individual SQL statements,
// dropping full-line comments and blank fragments. Our migrations never embed // dropping comments (full-line and inline "-- …") and blank fragments. Our
// a ';' inside a string literal, so a simple split is safe. // migrations never embed a ';' or '--' inside a string literal, so this is safe.
func splitStatements(sqlText string) []string { func splitStatements(sqlText string) []string {
var b strings.Builder var b strings.Builder
for _, line := range strings.Split(sqlText, "\n") { for _, line := range strings.Split(sqlText, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "--") { if i := strings.Index(line, "--"); i >= 0 {
line = line[:i] // strip inline / full-line comment
}
if strings.TrimSpace(line) == "" {
continue continue
} }
b.WriteString(line) b.WriteString(line)
@@ -222,6 +421,24 @@ func firstLine(s string) string {
return s return s
} }
// isAccessDenied reports whether a MySQL error is a privilege denial — e.g. a
// restricted user trying to CREATE DATABASE. Such users can still operate on a
// pre-existing database they've been granted, so the caller treats this as
// non-fatal and verifies database access separately.
func isAccessDenied(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1044, // ER_DBACCESS_DENIED_ERROR — access denied to database
1045, // ER_ACCESS_DENIED_ERROR — access denied for user
1142: // ER_TABLEACCESS_DENIED_ERROR — command denied to user
return true
}
return false
}
// isIgnorableDDLError reports whether a MySQL error means the object the // isIgnorableDDLError reports whether a MySQL error means the object the
// statement creates/drops is already in the intended state — safe to skip when // statement creates/drops is already in the intended state — safe to skip when
// re-applying a forward-only migration. // re-applying a forward-only migration.
+76 -2
View File
@@ -1,19 +1,26 @@
package extsvc package extsvc
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
) )
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint. // clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.) // when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
const clublogRealtimeURL = "https://clublog.org/realtime.php" const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
// file in one multipart request and dedupes server-side, so a manual upload of
// N QSOs is one HTTP request instead of N realtime.php calls.
const clublogBatchURL = "https://clublog.org/putlogs.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log // clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the // requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in // user) — the same way Log4OM embeds its own key — so we ship it baked in
@@ -67,6 +74,73 @@ func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig,
return res, nil return res, nil
} }
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
//
// Multipart form fields: email, password, callsign, api, clientident, and the
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifDoc) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
}
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("email", email)
_ = mw.WriteField("password", cfg.Password)
_ = mw.WriteField("callsign", call)
_ = mw.WriteField("api", api)
_ = mw.WriteField("clientident", "OpsLog")
fw, err := mw.CreateFormFile("file", "opslog.adi")
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
}
if _, err := io.WriteString(fw, adifDoc); err != nil {
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
}
if err := mw.Close(); err != nil {
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode == http.StatusOK {
return UploadResult{OK: true, Message: msg}, nil
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
}
// TestClublog validates the configured credentials by attempting a no-op // TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the // style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload. // fields look complete; a real failure surfaces on the first upload.
+53
View File
@@ -58,6 +58,59 @@ type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// CopyProfile duplicates the entire stations→antennas→bands tree from one
// profile to another (used when a profile is duplicated). New ids are assigned;
// the band defaults and ordering are preserved.
func (r *Repo) CopyProfile(ctx context.Context, srcProfileID, dstProfileID int64) error {
tree, err := r.ListTree(ctx, srcProfileID)
if err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := db.NowISO()
for _, st := range tree {
var pwr any
if st.TXPower != nil {
pwr = *st.TXPower
}
res, err := tx.ExecContext(ctx,
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?)`, dstProfileID, st.Name, pwr, st.SortOrder, now, now)
if err != nil {
return fmt.Errorf("copy station: %w", err)
}
newStationID, _ := res.LastInsertId()
for _, ant := range st.Antennas {
ares, err := tx.ExecContext(ctx,
`INSERT INTO operating_antennas(station_id, name, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?)`, newStationID, ant.Name, ant.SortOrder, now, now)
if err != nil {
return fmt.Errorf("copy antenna: %w", err)
}
newAntID, _ := ares.LastInsertId()
for _, b := range ant.Bands {
if _, err := tx.ExecContext(ctx,
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default) VALUES(?,?,?)`,
newAntID, b.Band, boolToInt(b.IsDefault)); err != nil {
return fmt.Errorf("copy band: %w", err)
}
}
}
}
return tx.Commit()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// ListTree returns every station for the profile with its nested antennas // ListTree returns every station for the profile with its nested antennas
// and bands. One round-trip per level — three queries total regardless of // and bands. One round-trip per level — three queries total regardless of
// tree size, so the Settings panel stays snappy on big setups. // tree size, so the Settings panel stays snappy on big setups.
+41 -2
View File
@@ -11,10 +11,23 @@ package profile
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"time" "time"
) )
// ProfileDB is a profile's logbook database target. Backend "" or "sqlite" =
// the local SQLite file; "mysql" = the shared MySQL server described by the
// rest. Switching the active profile switches the live logbook to this.
type ProfileDB struct {
Backend string `json:"backend"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
// Profile is one operating configuration. A user typically keeps a few: // Profile is one operating configuration. A user typically keeps a few:
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"… // "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
type Profile struct { type Profile struct {
@@ -43,6 +56,7 @@ type Profile struct {
TxPower *float64 `json:"tx_pwr,omitempty"` TxPower *float64 `json:"tx_pwr,omitempty"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
DB ProfileDB `json:"db"` // per-profile logbook target (empty backend = local SQLite)
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -56,7 +70,7 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty, const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr, my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, created_at, updated_at` is_active, sort_order, db_config, created_at, updated_at`
// List returns every profile, active first then by sort_order/id. // List returns every profile, active first then by sort_order/id.
func (r *Repo) List(ctx context.Context) ([]Profile, error) { func (r *Repo) List(ctx context.Context) ([]Profile, error) {
@@ -136,6 +150,20 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
return err return err
} }
// SetDB updates only a profile's logbook database target, leaving the rest of
// the profile untouched (the general Save deliberately doesn't write db_config).
func (r *Repo) SetDB(ctx context.Context, id int64, cfg ProfileDB) error {
b, err := json.Marshal(cfg)
if err != nil {
return err
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
_, err = r.db.ExecContext(ctx,
`UPDATE station_profiles SET db_config = ?, updated_at = ? WHERE id = ?`,
string(b), now, id)
return err
}
// SetActive atomically switches the active profile. Clears the flag on all // SetActive atomically switches the active profile. Clears the flag on all
// rows first to keep the "only one active" invariant from the schema doc. // rows first to keep the "only one active" invariant from the schema doc.
func (r *Repo) SetActive(ctx context.Context, id int64) error { func (r *Repo) SetActive(ctx context.Context, id int64) error {
@@ -202,6 +230,7 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
if err != nil { if err != nil {
return Profile{}, err return Profile{}, err
} }
dbCfg := src.DB // Save deliberately doesn't write db_config — copy it after.
src.ID = 0 src.ID = 0
src.Name = newName src.Name = newName
src.IsActive = false src.IsActive = false
@@ -209,6 +238,12 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
if err := r.Save(ctx, &src); err != nil { if err := r.Save(ctx, &src); err != nil {
return Profile{}, err return Profile{}, err
} }
if dbCfg.Backend != "" {
if err := r.SetDB(ctx, src.ID, dbCfg); err != nil {
return Profile{}, err
}
src.DB = dbCfg
}
return src, nil return src, nil
} }
@@ -227,15 +262,19 @@ func scan(row scannable) (Profile, error) {
myDXCC, myCQZ, myITUZ sql.NullInt64 myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon, txPwr sql.NullFloat64 myLat, myLon, txPwr sql.NullFloat64
isActive, sortOrder int isActive, sortOrder int
dbConfig sql.NullString
createdAt, updatedAt string createdAt, updatedAt string
) )
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty, err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA, &myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr, &myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
&isActive, &sortOrder, &createdAt, &updatedAt) &isActive, &sortOrder, &dbConfig, &createdAt, &updatedAt)
if err != nil { if err != nil {
return p, err return p, err
} }
if s := dbConfig.String; s != "" {
_ = json.Unmarshal([]byte(s), &p.DB)
}
p.Callsign = callsign.String p.Callsign = callsign.String
p.Operator = operator.String p.Operator = operator.String
p.OpName = opName.String p.OpName = opName.String
+113
View File
@@ -454,6 +454,19 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
return inserted, nil return inserted, nil
} }
// Revision returns a cheap fingerprint of the logbook — row count and the
// highest id — so a client can poll for changes made by OTHER instances on a
// shared MySQL logbook and refresh when it differs. Inserts bump the max id;
// deletes change the count.
func (r *Repo) Revision(ctx context.Context) (string, error) {
var count, maxID int64
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(MAX(id), 0) FROM qso`).Scan(&count, &maxID); err != nil {
return "", err
}
return fmt.Sprintf("%d:%d", count, maxID), nil
}
// GetByID fetches a single QSO by primary key. // GetByID fetches a single QSO by primary key.
func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) { func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
row := r.db.QueryRowContext(ctx, row := r.db.QueryRowContext(ctx,
@@ -1308,6 +1321,106 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
return rows.Err() return rows.Err()
} }
// awardCols is the column set the award engine can read — every field reachable
// from refField/inScope/confirmed in internal/award plus enrichQSOForAwards:
// the reference fields (dxcc, zones, continent, country, state, grid/vucc, iota,
// sota, pota, wwff via extras_json, and the free-text fields name/qth/address/
// comment/notes a custom award may key on), band/mode/qso_date for scoping,
// freq_hz for band recovery, and the qsl/lotw/eqsl rcvd confirmation flags.
// This drops ~125 unused columns so award computation over a remote MySQL
// backend ships a fraction of each row instead of the whole record.
//
// IMPORTANT: when a new award references a QSO field not listed here, add the
// column to this list AND populate it in scanAwardQSO below, or that award will
// silently see an empty value during stats/computation.
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
`name, qth, address, comment, notes, ` +
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
// IterateForAwards streams a lightweight projection of every QSO — only the
// fields award computation reads (see awardCols). All other QSO fields are left
// zero. Use IterateAll when the full record is needed. Ordered by date/id like
// IterateAll for deterministic results.
func (r *Repo) IterateForAwards(ctx context.Context, fn func(QSO) error) error {
rows, err := r.db.QueryContext(ctx,
`SELECT `+awardCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
if err != nil {
return fmt.Errorf("query qso (awards): %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanAwardQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// scanAwardQSO reads one row produced by awardCols into a QSO, populating only
// the award-relevant fields. Column order MUST match awardCols.
func scanAwardQSO(s scanner) (QSO, error) {
var q QSO
var (
qsoDateStr string
freqHz sql.NullInt64
grid, vucc, country, state sql.NullString
cont, iotaRef, sota, pota sql.NullString
dxcc, cqz, ituz sql.NullInt64
name, qth, address sql.NullString
comment, notes sql.NullString
qslRcvd, lotwRcvd, eqslRcvd sql.NullString
extrasJSON sql.NullString
)
if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
&name, &qth, &address, &comment, &notes,
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
); err != nil {
return QSO{}, fmt.Errorf("scan qso (awards): %w", err)
}
q.QSODate = parseTimeLoose(qsoDateStr)
if freqHz.Valid {
v := freqHz.Int64
q.FreqHz = &v
}
q.Grid = grid.String
q.VUCCGrids = vucc.String
q.Country = country.String
q.State = state.String
q.Continent = cont.String
if cqz.Valid {
v := int(cqz.Int64)
q.CQZ = &v
}
if ituz.Valid {
v := int(ituz.Int64)
q.ITUZ = &v
}
if dxcc.Valid {
v := int(dxcc.Int64)
q.DXCC = &v
}
q.IOTA = iotaRef.String
q.SOTARef = sota.String
q.POTARef = pota.String
q.Name = name.String
q.QTH = qth.String
q.Address = address.String
q.Comment = comment.String
q.Notes = notes.String
q.QSLRcvd = qslRcvd.String
q.LOTWRcvd = lotwRcvd.String
q.EQSLRcvd = eqslRcvd.String
q.Extras = decodeExtras(extrasJSON.String)
return q, nil
}
// EntitySlot bundles every (band, mode) tuple ever worked for a given // EntitySlot bundles every (band, mode) tuple ever worked for a given
// DXCC entity name. Used by the cluster spot colouring code to decide // DXCC entity name. Used by the cluster spot colouring code to decide
// NEW / NEW SLOT / WORKED in constant time after one batched query. // NEW / NEW SLOT / WORKED in constant time after one batched query.
+87 -5
View File
@@ -11,6 +11,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"sync" "sync"
"hamlog/internal/db" "hamlog/internal/db"
@@ -29,6 +30,11 @@ type Store struct {
cipher Cipher // non-nil when secrets are unlocked cipher Cipher // non-nil when secrets are unlocked
sensitive func(key string) bool // which keys are encrypted at rest sensitive func(key string) bool // which keys are encrypted at rest
// prefix scopes every key to the active profile (e.g. "p3."), so each
// station profile keeps its own complete set of settings. Empty = unscoped
// (used briefly at startup before the active profile is known).
prefix string
// cache holds every setting's RAW (as-stored) value, loaded once. Reads are // cache holds every setting's RAW (as-stored) value, loaded once. Reads are
// served from memory so the Preferences dialog (dozens of keys) doesn't pay // served from memory so the Preferences dialog (dozens of keys) doesn't pay
// a network round-trip per key against a remote MySQL. Decryption still // a network round-trip per key against a remote MySQL. Decryption still
@@ -39,6 +45,50 @@ type Store struct {
func NewStore(db *sql.DB) *Store { return &Store{db: db} } func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// CopyProfile duplicates every setting from one profile to another (used when
// a profile is duplicated), preserving raw/encrypted values verbatim.
func (s *Store) CopyProfile(ctx context.Context, srcID, dstID int64) error {
if err := s.ensureCache(ctx); err != nil {
return err
}
srcPrefix := fmt.Sprintf("p%d.", srcID)
dstPrefix := fmt.Sprintf("p%d.", dstID)
s.mu.RLock()
pairs := make(map[string]string)
for k, v := range s.cache {
if strings.HasPrefix(k, srcPrefix) {
pairs[dstPrefix+strings.TrimPrefix(k, srcPrefix)] = v
}
}
s.mu.RUnlock()
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
for fullKey, val := range pairs {
if _, err := s.db.ExecContext(ctx, q, fullKey, val, db.NowISO()); err != nil {
return err
}
s.cachePut(fullKey, val)
}
return nil
}
// SetProfile scopes all subsequent reads/writes to the given profile id, so
// each profile has its own settings. Called at startup and whenever the active
// profile changes.
func (s *Store) SetProfile(id int64) {
s.mu.Lock()
s.prefix = fmt.Sprintf("p%d.", id)
s.mu.Unlock()
}
// profileKey returns the storage key for the active profile.
func (s *Store) profileKey(key string) string {
s.mu.RLock()
p := s.prefix
s.mu.RUnlock()
return p + key
}
// ensureCache lazily loads all settings into memory on first read. A concurrent // ensureCache lazily loads all settings into memory on first read. A concurrent
// double-load is harmless (the result is identical), so it's done without a // double-load is harmless (the result is identical), so it's done without a
// long-held lock. // long-held lock.
@@ -167,8 +217,9 @@ func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
if err := s.ensureCache(ctx); err != nil { if err := s.ensureCache(ctx); err != nil {
return "", err return "", err
} }
full := s.profileKey(key)
s.mu.RLock() s.mu.RLock()
v := s.cache[key] // "" when absent v := s.cache[full] // "" when absent
s.mu.RUnlock() s.mu.RUnlock()
return v, nil return v, nil
} }
@@ -178,12 +229,13 @@ func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
// per-operator, never on the shared MySQL logbook), so SQLite syntax is used // per-operator, never on the shared MySQL logbook), so SQLite syntax is used
// unconditionally. The backticks around `key` are accepted by SQLite too. // unconditionally. The backticks around `key` are accepted by SQLite too.
func (s *Store) SetRaw(ctx context.Context, key, value string) error { func (s *Store) SetRaw(ctx context.Context, key, value string) error {
full := s.profileKey(key)
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " + q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" "ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
if _, err := s.db.ExecContext(ctx, q, key, value, db.NowISO()); err != nil { if _, err := s.db.ExecContext(ctx, q, full, value, db.NowISO()); err != nil {
return fmt.Errorf("set %s: %w", key, err) return fmt.Errorf("set %s: %w", key, err)
} }
s.cachePut(key, value) s.cachePut(full, value)
return nil return nil
} }
@@ -192,15 +244,44 @@ func (s *Store) Set(ctx context.Context, key, value string) error {
return s.SetRaw(ctx, key, s.encodeWrite(key, value)) return s.SetRaw(ctx, key, s.encodeWrite(key, value))
} }
// GetGlobal reads a value stored WITHOUT the profile prefix — for settings that
// are shared across every profile (e.g. award definitions, which are the
// operator's own work and shouldn't be re-created per profile). Sensitive
// decryption still applies.
func (s *Store) GetGlobal(ctx context.Context, key string) (string, error) {
if err := s.ensureCache(ctx); err != nil {
return "", err
}
s.mu.RLock()
v := s.cache[key]
s.mu.RUnlock()
return s.decodeRead(key, v), nil
}
// SetGlobal upserts a value WITHOUT the profile prefix (shared across profiles).
func (s *Store) SetGlobal(ctx context.Context, key, value string) error {
value = s.encodeWrite(key, value)
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
if _, err := s.db.ExecContext(ctx, q, key, value, db.NowISO()); err != nil {
return fmt.Errorf("set global %s: %w", key, err)
}
s.cachePut(key, value)
return nil
}
// All returns every stored setting (sensitive values decrypted when unlocked). // All returns every stored setting (sensitive values decrypted when unlocked).
func (s *Store) All(ctx context.Context) (map[string]string, error) { func (s *Store) All(ctx context.Context) (map[string]string, error) {
if err := s.ensureCache(ctx); err != nil { if err := s.ensureCache(ctx); err != nil {
return nil, err return nil, err
} }
prefix := s.profileKey("")
s.mu.RLock() s.mu.RLock()
raw := make(map[string]string, len(s.cache)) raw := make(map[string]string, len(s.cache))
for k, v := range s.cache { for k, v := range s.cache {
raw[k] = v if strings.HasPrefix(k, prefix) {
raw[strings.TrimPrefix(k, prefix)] = v
}
} }
s.mu.RUnlock() s.mu.RUnlock()
out := make(map[string]string, len(raw)) out := make(map[string]string, len(raw))
@@ -221,10 +302,11 @@ func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string,
if err := s.ensureCache(ctx); err != nil { if err := s.ensureCache(ctx); err != nil {
return nil, err return nil, err
} }
prefix := s.profileKey("")
s.mu.RLock() s.mu.RLock()
raw := make([]string, len(keys)) raw := make([]string, len(keys))
for i, k := range keys { for i, k := range keys {
raw[i] = s.cache[k] raw[i] = s.cache[prefix+k]
} }
s.mu.RUnlock() s.mu.RUnlock()
for i, k := range keys { for i, k := range keys {
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"time"
"hamlog/internal/applog"
)
// Anonymous usage telemetry — a once-a-day "app_opened" heartbeat to PostHog so
// the OpsLog author can see how many people actively use it. Privacy by design:
// only a random install ID + app version + OS are sent (no callsign, no QSO
// data, no IP beyond what any HTTP request reveals). Users can disable it in
// Preferences → General. See [[user-analytics-posthog]] notes in MEMORY.
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
posthogHost = "https://eu.i.posthog.com"
// posthogAPIKey is the PostHog PROJECT API key ("phc_..."). It's a public,
// write-only ingestion key (safe to ship in the binary, like the ClubLog app
// key). Until it's filled in, telemetry is a no-op.
posthogAPIKey = "phc_vumvN7XTERNhmRzMZHNgY5DncZfFibTbomiE9epZvUJ4"
keyTelemetryEnabled = "telemetry.enabled" // "0" disables; default on
keyTelemetryInstallID = "telemetry.install_id" // random, stable per install
keyTelemetryLastSent = "telemetry.last_sent" // YYYY-MM-DD of last heartbeat
)
// GetTelemetryEnabled reports whether anonymous usage stats are on (default on).
func (a *App) GetTelemetryEnabled() bool {
if a.settings == nil {
return true
}
v, _ := a.settings.GetGlobal(a.ctx, keyTelemetryEnabled)
return strings.TrimSpace(v) != "0"
}
// SetTelemetryEnabled turns anonymous usage stats on or off.
func (a *App) SetTelemetryEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
val := "1"
if !on {
val = "0"
}
return a.settings.SetGlobal(a.ctx, keyTelemetryEnabled, val)
}
// telemetryInstallID returns this install's stable anonymous ID, generating and
// persisting one on first use.
func (a *App) telemetryInstallID() string {
id, _ := a.settings.GetGlobal(a.ctx, keyTelemetryInstallID)
if id = strings.TrimSpace(id); id != "" {
return id
}
id = randomUUID()
_ = a.settings.SetGlobal(a.ctx, keyTelemetryInstallID, id)
return id
}
// sendTelemetryHeartbeat sends at most one "app_opened" event per calendar day
// (UTC). Best effort: any failure is logged and ignored. Runs in a goroutine at
// startup.
func (a *App) sendTelemetryHeartbeat() {
if a.settings == nil || !a.GetTelemetryEnabled() {
return
}
if strings.Contains(posthogAPIKey, "REPLACE") || strings.TrimSpace(posthogAPIKey) == "" {
return // not configured yet
}
today := time.Now().UTC().Format("2006-01-02")
if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today {
return // already counted today
}
payload := map[string]any{
"api_key": posthogAPIKey,
"event": "app_opened",
"distinct_id": a.telemetryInstallID(),
"properties": map[string]any{
"version": appVersion,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"$lib": "opslog",
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
body, err := json.Marshal(payload)
if err != nil {
return
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Post(posthogHost+"/capture/", "application/json", bytes.NewReader(body))
if err != nil {
applog.Printf("telemetry: heartbeat failed: %v", err)
return
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
_ = a.settings.SetGlobal(a.ctx, keyTelemetryLastSent, today)
applog.Printf("telemetry: heartbeat ok (%s)", today)
} else {
applog.Printf("telemetry: heartbeat HTTP %d", resp.StatusCode)
}
}
// randomUUID returns a random RFC-4122 v4 UUID string.
func randomUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Extremely unlikely; fall back to a time-based id so we still get one.
return fmt.Sprintf("ts-%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}