up
This commit is contained in:
@@ -372,8 +372,10 @@ type App struct {
|
|||||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
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
@@ -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
@@ -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 && (
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 & 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 & 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & 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 & create</strong> the database, then <strong>Save & 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 & create database
|
Test & 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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Vendored
+20
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, ¬es,
|
||||||
|
&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.
|
||||||
|
|||||||
@@ -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
@@ -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])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user