diff --git a/app.go b/app.go index ef7d1f2..2ec6927 100644 --- a/app.go +++ b/app.go @@ -372,8 +372,10 @@ type App struct { logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite + awardSnapMu sync.Mutex // guards the award QSO snapshot + awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations + awardSnapRev string // logbook revision the snapshot was built at ("" = none) dataDir string // /data — holds config.json, logs, cty.dat - migratedFromAppData bool // true when we auto-copied AppData on first portable launch // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while @@ -497,15 +499,6 @@ func (a *App) startup(ctx context.Context) { fmt.Println("OpsLog:", a.startupErr) return } - // First-launch migration: if the portable data dir has no database yet, - // copy whatever is in AppData/OpsLog (or AppData/HamLog) so the user - // keeps their log after the switch to fully-portable layout. - if migrated, migrErr := autoMigrateFromAppData(dataDir); migrated { - a.migratedFromAppData = true - if migrErr != nil { - fmt.Println("OpsLog: migration warning:", migrErr) - } - } if err := os.MkdirAll(dataDir, 0o755); err != nil { a.startupErr = "cannot create data dir: " + err.Error() fmt.Println("OpsLog:", a.startupErr) @@ -542,6 +535,7 @@ func (a *App) startup(ctx context.Context) { // Route CAT/OmniRig debug lines into the unified app log (they used to go // to a separate cat.log in the old HamLog folder, which users couldn't find). cat.LogSink = applog.Printf + audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log applog.Printf("startup: data dir = %s", dataDir) // The local SQLite file ALWAYS holds per-operator configuration — settings, // station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award @@ -563,6 +557,21 @@ func (a *App) startup(ctx context.Context) { a.settings = settings.NewStore(conn) a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set a.profiles = profile.NewRepo(conn) + // Determine the active profile and scope the settings store to it FIRST: + // every setting is per-profile, so all settings-dependent wiring below must + // read the active profile's values. + active, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{ + Callsign: keyStationCallsign, + Operator: keyStationOperator, + MyGrid: keyStationMyGrid, + Country: keyStationCountry, + SOTA: keyStationSOTA, + POTA: keyStationPOTA, + }) + if err != nil { + fmt.Println("OpsLog: EnsureDefault profile:", err) + } + a.settings.SetProfile(active.ID) a.awardRefs = awardref.NewRepo(conn) a.qslTemplates = qslcard.NewRepo(conn) a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields) @@ -571,46 +580,26 @@ func (a *App) startup(ctx context.Context) { a.udpRepo = udp.NewRepo(conn) a.udp = udp.NewManager(a.udpRepo) go a.consumeUDPEvents() - // On first run, copy the legacy single-station settings into a - // "Default" profile so the user's existing config carries over without - // any manual step. Subsequent runs just confirm an active profile. - if _, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{ - Callsign: keyStationCallsign, - Operator: keyStationOperator, - MyGrid: keyStationMyGrid, - Country: keyStationCountry, - SOTA: keyStationSOTA, - POTA: keyStationPOTA, - }); err != nil { - fmt.Println("OpsLog: EnsureDefault profile:", err) - } a.cache = lookup.NewCache(conn, 30*24*time.Hour) a.lookup = lookup.NewManager(a.cache) a.reloadLookupProviders() - // Now choose where the QSO logbook lives. On a MySQL failure we fall back to - // the local SQLite logbook so the operator can still log (and fix config). - logbookConn := conn - if mb := readBootstrap(dataDir).MySQL; mb != nil && mb.Enabled { - applog.Printf("startup: logbook backend = MySQL (%s:%d/%s)", mb.Host, mb.Port, mb.Database) - mysqlConn, mErr := db.OpenMySQL(db.MySQLConfig{ - Host: mb.Host, Port: mb.Port, User: mb.User, Password: mb.Password, Database: mb.Database, - }) - if mErr != nil { - applog.Printf("startup: MySQL open failed (%v) — falling back to SQLite logbook", mErr) - a.dbBackendErr = "MySQL: " + mErr.Error() - } else { - logbookConn = mysqlConn - a.dbBackend = "mysql" - } - } - if a.dbBackend == "" { - a.dbBackend = "sqlite" + // The QSO logbook lives where the ACTIVE PROFILE points it: the local SQLite + // file, or a per-profile shared MySQL database. Switching profiles switches + // the logbook (see switchLogbook). One-time: adopt any legacy config.json + // MySQL config into the active profile so existing setups keep working. + a.adoptBootstrapMySQL(&active) + logbookConn, backend, lerr := a.connectLogbook(active.DB) + if lerr != nil { + applog.Printf("startup: logbook open failed (%v) — falling back to SQLite logbook", lerr) + a.dbBackendErr = strings.TrimPrefix(lerr.Error(), "") + logbookConn, backend = conn, "sqlite" } + a.dbBackend = backend // db.Dialect describes the LOGBOOK backend — the only place SQL actually // varies (qso JSON extraction). Config repos always run on SQLite. - db.SetDialect(a.dbBackend) - applog.Printf("startup: logbook backend = %s", a.dbBackend) + db.SetDialect(backend) + applog.Printf("startup: logbook backend = %s", backend) a.logDb = logbookConn a.qso = qso.NewRepo(logbookConn) @@ -751,26 +740,37 @@ func (a *App) startup(ctx context.Context) { // Ultrabeam antenna: connect in the background if enabled. a.startUltrabeam() + // Autostart: launch the active profile's configured external programs that + // aren't already running (WSJT-X, JTAlert, rotator control, …). Background + // so a slow-launching app never delays the UI. + go func() { + for _, r := range a.LaunchAutostartPrograms() { + applog.Printf("autostart: %s — %s (%s)", r.Name, r.Status, r.Message) + } + }() + + // Anonymous usage heartbeat (once/day) so we can gauge active users. No-op + // when disabled in Preferences or until the PostHog key is configured. + go a.sendTelemetryHeartbeat() + fmt.Println("OpsLog: db ready at", a.dbPath) } // StartupStatus returns a diagnostic snapshot for the frontend. // dbPath is always populated; err is empty when the app is healthy. type StartupStatus struct { - OK bool `json:"ok"` - Err string `json:"err"` - DBPath string `json:"db_path"` - MigratedFromAppData bool `json:"migrated_from_app_data"` + OK bool `json:"ok"` + Err string `json:"err"` + DBPath string `json:"db_path"` } // GetStartupStatus exposes whatever happened during startup so the UI // can show a useful error instead of just "db not initialized". func (a *App) GetStartupStatus() StartupStatus { return StartupStatus{ - OK: a.startupErr == "", - Err: a.startupErr, - DBPath: a.dbPath, - MigratedFromAppData: a.migratedFromAppData, + OK: a.startupErr == "", + Err: a.startupErr, + DBPath: a.dbPath, } } @@ -914,6 +914,16 @@ func (a *App) setSetting(key, val string) { } } +// setSettingGlobal stores a value shared across all profiles (no profile prefix). +func (a *App) setSettingGlobal(key, val string) { + if a.settings == nil { + return + } + if err := a.settings.SetGlobal(a.ctx, key, val); err != nil { + applog.Printf("settings: set global %q failed: %v", key, err) + } +} + func (a *App) shutdown(ctx context.Context) { // If the user managed to skip beforeClose (force kill, OS shutdown, // crash recovery) we still try the backup here as a best-effort @@ -949,35 +959,6 @@ func userDataDir() (string, error) { return filepath.Join(filepath.Dir(exe), "data"), nil } -// autoMigrateFromAppData copies existing AppData/OpsLog (or AppData/HamLog) -// data into targetDir the first time the portable layout is used (i.e. when -// targetDir has no database yet). Returns true when a migration was performed. -func autoMigrateFromAppData(targetDir string) (bool, error) { - // Already have a database — nothing to migrate. - if fileExists(filepath.Join(targetDir, "opslog.db")) || - fileExists(filepath.Join(targetDir, "hamlog.db")) { - return false, nil - } - base, err := os.UserConfigDir() - if err != nil { - return false, nil - } - var srcDir string - for _, name := range []string{"OpsLog", "HamLog"} { - d := filepath.Join(base, name) - if _, err := os.Stat(d); err == nil { - srcDir = d - break - } - } - if srcDir == "" { - return false, nil // fresh install — no AppData to migrate - } - if err := os.MkdirAll(targetDir, 0o755); err != nil { - return false, err - } - return true, copyDirContents(srcDir, targetDir) -} // fileExists reports whether path exists and is a regular file. func fileExists(path string) bool { @@ -1130,45 +1111,125 @@ type DBConnectionInfo struct { // it shows the local database file path. func (a *App) GetDBConnectionInfo() DBConnectionInfo { if a.dbBackend == "mysql" { - if mb := readBootstrap(a.dataDir).MySQL; mb != nil { - port := mb.Port + if p, err := a.profiles.Active(a.ctx); err == nil && p.DB.Backend == "mysql" { + port := p.DB.Port if port == 0 { port = 3306 } - return DBConnectionInfo{Backend: "mysql", Label: fmt.Sprintf("%s:%d/%s", mb.Host, port, mb.Database)} + return DBConnectionInfo{Backend: "mysql", Label: fmt.Sprintf("%s:%d/%s", p.DB.Host, port, p.DB.Database)} } return DBConnectionInfo{Backend: "mysql", Label: "MySQL"} } return DBConnectionInfo{Backend: "sqlite", Label: a.dbPath} } -// GetMySQLSettings returns the stored shared-database config from the bootstrap -// file (config.json), with defaults applied. Read before the DB is open, so it -// must not depend on the settings table. +// connectLogbook opens the logbook connection for a profile's DB target: a +// shared MySQL database, or the local SQLite file (which doubles as the logbook +// when no MySQL is configured). Returns the connection and the backend name. +func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) { + if cfg.Backend == "mysql" { + c, err := db.OpenMySQL(db.MySQLConfig{ + Host: cfg.Host, Port: cfg.Port, User: cfg.User, Password: cfg.Password, Database: cfg.Database, + }) + if err != nil { + return nil, "", err + } + return c, "mysql", nil + } + return a.db, "sqlite", nil +} + +// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active +// profile (one-time), so users who set up MySQL before it became per-profile +// keep their logbook. The bootstrap entry is then cleared. +func (a *App) adoptBootstrapMySQL(active *profile.Profile) { + mb := readBootstrap(a.dataDir).MySQL + if mb == nil || !mb.Enabled || active.ID == 0 || active.DB.Backend != "" { + return + } + active.DB = profile.ProfileDB{ + Backend: "mysql", Host: mb.Host, Port: mb.Port, + User: mb.User, Password: mb.Password, Database: mb.Database, + } + if err := a.profiles.SetDB(a.ctx, active.ID, active.DB); err != nil { + applog.Printf("adopt bootstrap MySQL into profile: %v", err) + return + } + c := readBootstrap(a.dataDir) + c.MySQL = nil + _ = writeBootstrap(a.dataDir, c) +} + +// switchLogbook reconnects the live logbook to the given profile's DB target +// (called when the active profile changes or its DB config is saved), swaps the +// qso repo, and notifies the UI. The previous MySQL connection is closed. +func (a *App) switchLogbook(p profile.Profile) error { + newConn, backend, err := a.connectLogbook(p.DB) + if err != nil { + a.dbBackendErr = "MySQL: " + err.Error() + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "logbook:changed") + } + return err + } + old := a.logDb + a.qso = qso.NewRepo(newConn) + a.logDb = newConn + a.dbBackend = backend + a.dbBackendErr = "" + a.invalidateAwardStats() // different logbook → drop memoised award matrices + db.SetDialect(backend) + if old != nil && old != a.db && old != newConn { + _ = old.Close() + } + applog.Printf("logbook switched to %s for profile %q", backend, p.Name) + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "logbook:changed") + } + return nil +} + +// GetMySQLSettings returns the ACTIVE profile's logbook DB config (Enabled = +// MySQL). Each profile can target its own database. func (a *App) GetMySQLSettings() (MySQLSettings, error) { out := MySQLSettings{Port: 3306} - if mb := readBootstrap(a.dataDir).MySQL; mb != nil { - out = *mb - if out.Port <= 0 { - out.Port = 3306 - } + p, err := a.profiles.Active(a.ctx) + if err != nil { + return out, nil + } + d := p.DB + out.Enabled = d.Backend == "mysql" + out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database + if d.Port > 0 { + out.Port = d.Port } return out, nil } -// SaveMySQLSettings persists the shared-database config to the bootstrap file. -// Switching the active backend takes effect on the next launch (we read this -// file before opening any database). +// SaveMySQLSettings stores the DB target on the ACTIVE profile and switches the +// live logbook to it immediately (no restart). Enabled=false reverts to the +// local SQLite logbook. func (a *App) SaveMySQLSettings(s MySQLSettings) error { + p, err := a.profiles.Active(a.ctx) + if err != nil { + return fmt.Errorf("no active profile: %w", err) + } if s.Port <= 0 { s.Port = 3306 } - s.Host = strings.TrimSpace(s.Host) - s.User = strings.TrimSpace(s.User) - s.Database = strings.TrimSpace(s.Database) - c := readBootstrap(a.dataDir) - c.MySQL = &s - return writeBootstrap(a.dataDir, c) + cfg := profile.ProfileDB{Database: strings.TrimSpace(s.Database)} + if s.Enabled { + cfg.Backend = "mysql" + cfg.Host = strings.TrimSpace(s.Host) + cfg.Port = s.Port + cfg.User = strings.TrimSpace(s.User) + cfg.Password = s.Password + } + if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil { + return err + } + p.DB = cfg + return a.switchLogbook(p) } // TestMySQLConnection pings the shared MySQL database with the given settings @@ -1637,11 +1698,30 @@ func (a *App) CountQSO() (int64, error) { return a.qso.Count(a.ctx) } +// GetLogbookRevision returns a cheap fingerprint of the logbook (count + max id) +// so the UI can poll a shared MySQL logbook and refresh when another OpsLog +// instance has added or removed QSOs. +func (a *App) GetLogbookRevision() (string, error) { + if a.qso == nil { + return "", fmt.Errorf("db not initialized") + } + return a.qso.Revision(a.ctx) +} + // awardDefs returns the user's stored award definitions, seeding the built-in // defaults on first use. func (a *App) awardDefs() []award.Def { if a.settings != nil { - if s, _ := a.settings.Get(a.ctx, keyAwardDefs); strings.TrimSpace(s) != "" { + // Award definitions are GLOBAL (shared across every profile) — a custom + // award is the operator's own work, not station config. Read the global + // key first; fall back to a per-profile copy saved before awards became + // global so existing customisations aren't lost (the next save promotes + // them to global). + s, _ := a.settings.GetGlobal(a.ctx, keyAwardDefs) + if strings.TrimSpace(s) == "" { + s, _ = a.settings.Get(a.ctx, keyAwardDefs) + } + if strings.TrimSpace(s) != "" { var defs []award.Def if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 { // Upgrade legacy defs (pre-rich-model) in memory on every load. @@ -1666,7 +1746,10 @@ func (a *App) migrateAwardDefs() { if a.settings == nil { return } - s, _ := a.settings.Get(a.ctx, keyAwardDefs) + s, _ := a.settings.GetGlobal(a.ctx, keyAwardDefs) + if strings.TrimSpace(s) == "" { + s, _ = a.settings.Get(a.ctx, keyAwardDefs) // legacy per-profile copy + } if strings.TrimSpace(s) == "" { return // nothing saved yet → Defaults() (already on the new model) } @@ -1680,7 +1763,7 @@ func (a *App) migrateAwardDefs() { // even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate // from Defaults to protected/built-in awards once. const defsFixVersion = "2" - if v, _ := a.settings.Get(a.ctx, keyAwardDefsFixed); v != defsFixVersion { + if v, _ := a.settings.GetGlobal(a.ctx, keyAwardDefsFixed); v != defsFixVersion { byCode := map[string]award.Def{} for _, d := range award.Defaults() { byCode[strings.ToUpper(d.Code)] = d @@ -1692,13 +1775,13 @@ func (a *App) migrateAwardDefs() { changed = true } } - a.setSetting(keyAwardDefsFixed, defsFixVersion) + a.setSettingGlobal(keyAwardDefsFixed, defsFixVersion) } if !changed { return } if b, err := json.Marshal(migrated); err == nil { - a.setSetting(keyAwardDefs, string(b)) + a.setSettingGlobal(keyAwardDefs, string(b)) applog.Printf("awards: migrated/fixed %d definitions", len(migrated)) } } @@ -1712,7 +1795,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error { if err != nil { return err } - return a.settings.Set(a.ctx, keyAwardDefs, string(b)) + return a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)) } // ResetAwardDefs restores the built-in defaults. @@ -1756,12 +1839,8 @@ func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) { if a.qso == nil { return nil, fmt.Errorf("db not initialized") } - var all []qso.QSO - if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { - a.enrichQSOForAwards(&q) - all = append(all, q) - return nil - }); err != nil { + all, err := a.awardSnapshot() + if err != nil { return nil, err } nameOf := func(field, ref string) string { @@ -1877,16 +1956,22 @@ func (a *App) AwardMissingQSOs(code string) ([]qso.QSO, error) { return []qso.QSO{}, nil // not meaningful without a DXCC scope } metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)] + // Reuse the fast, already-enriched award snapshot (light column scan, cached + // against the logbook revision) instead of a full IterateAll — the latter + // pulled all ~150 columns and took ~20s over a remote MySQL. + snapshot, err := a.awardSnapshot() + if err != nil { + return nil, err + } var out []qso.QSO - err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { - a.enrichQSOForAwards(&q) + for i := range snapshot { + q := snapshot[i] // In the award's scope, yet no reference extracted → a gap to fix. if award.InScope(*def, &q) && len(award.MatchQSO(*def, metas, &q)) == 0 { out = append(out, q) } - return nil - }) - return out, err + } + return out, nil } // GetPOTAToken returns the stored pota.app session token (for the settings UI). @@ -2196,6 +2281,48 @@ type AwardStatsResult struct { var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"} +// awardSnapshot returns the logbook as a light-scanned, award-enriched slice, +// reused across award computations. Pulling the whole logbook is the dominant +// cost of every award (≈4s for 27k rows over a remote MySQL link), while the +// matching itself is a few milliseconds — so we pull once and keep the result +// in memory, rebuilding only when the logbook revision moves (a QSO added, +// removed, or edited). The returned slice is treated as read-only by callers. +func (a *App) awardSnapshot() ([]qso.QSO, error) { + if a.qso == nil { + return nil, fmt.Errorf("db not initialized") + } + rev, revErr := a.qso.Revision(a.ctx) + if revErr == nil { + a.awardSnapMu.Lock() + if a.awardSnap != nil && a.awardSnapRev == rev { + qs := a.awardSnap + a.awardSnapMu.Unlock() + return qs, nil + } + a.awardSnapMu.Unlock() + } + + t0 := time.Now() + var all []qso.QSO + if err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error { + a.enrichQSOForAwards(&q) + all = append(all, q) + return nil + }); err != nil { + return nil, err + } + applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s)", + len(all), time.Since(t0).Round(time.Millisecond), rev) + + if revErr == nil { + a.awardSnapMu.Lock() + a.awardSnap = all + a.awardSnapRev = rev + a.awardSnapMu.Unlock() + } + return all, nil +} + // GetAwardStats computes the worked/confirmed/validated reference counts of one // award, broken down by band and by mode category (All/CW/Digital/Phone). func (a *App) GetAwardStats(code string) (AwardStatsResult, error) { @@ -2239,15 +2366,19 @@ func (a *App) GetAwardStats(code string) (AwardStatsResult, error) { } } - err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { - a.enrichQSOForAwards(&q) - refs := award.MatchQSO(*def, metas, &q) + snapshot, err := a.awardSnapshot() + if err != nil { + return AwardStatsResult{}, err + } + for i := range snapshot { + q := &snapshot[i] // read-only; already award-enriched in the snapshot + refs := award.MatchQSO(*def, metas, q) if len(refs) == 0 { - return nil + continue } bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))] - isConf := award.Confirmed(&q, def.Confirm) - isVal := award.Confirmed(&q, def.Validate) + isConf := award.Confirmed(q, def.Confirm) + isVal := award.Confirmed(q, def.Validate) cat := strings.ToUpper(award.EmissionOf(q.Mode)) record := func(c string) { @@ -2272,10 +2403,6 @@ func (a *App) GetAwardStats(code string) (AwardStatsResult, error) { if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" { record(cat) } - return nil - }) - if err != nil { - return AwardStatsResult{}, err } res := AwardStatsResult{Code: def.Code, Bands: statsBands} @@ -2298,6 +2425,16 @@ func (a *App) GetAwardStats(code string) (AwardStatsResult, error) { return res, nil } +// invalidateAwardStats drops the in-memory award QSO snapshot. Called after QSO +// edits that don't change the logbook row count (e.g. QSL confirmation updates), +// since those aren't caught by the revision check in awardSnapshot. +func (a *App) invalidateAwardStats() { + a.awardSnapMu.Lock() + a.awardSnap = nil + a.awardSnapRev = "" + a.awardSnapMu.Unlock() +} + // awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false) // into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists // are large and not needed for matching; their names are filled afterwards. @@ -2331,6 +2468,59 @@ func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta { return out } +// AssignAwardRefToQSOs assigns one award reference to many QSOs at once — the +// bulk action in the award "missing references" view (e.g. tag every "Urumqi" +// contact as Xinjiang for WAPC). It writes the manual override the award engine +// honours, replacing any previous reference this award had on each QSO. Returns +// the number of QSOs updated. +func (a *App) AssignAwardRefToQSOs(code, ref string, ids []int64) (int, error) { + if a.qso == nil { + return 0, fmt.Errorf("db not initialized") + } + code = strings.ToUpper(strings.TrimSpace(code)) + ref = strings.ToUpper(strings.TrimSpace(ref)) + if code == "" || ref == "" { + return 0, fmt.Errorf("award code and reference are required") + } + n := 0 + for _, id := range ids { + q, err := a.qso.GetByID(a.ctx, id) + if err != nil { + continue + } + if q.Extras == nil { + q.Extras = map[string]string{} + } + q.Extras[award.ManualRefsKey] = setOverrideRef(q.Extras[award.ManualRefsKey], code, ref) + if err := a.qso.Update(a.ctx, q); err == nil { + n++ + } + } + if n > 0 { + a.invalidateAwardStats() + } + return n, nil +} + +// setOverrideRef replaces (or adds) the reference for one award code in a +// "CODE@REF;CODE@REF" override string, leaving other awards' entries intact. +func setOverrideRef(existing, code, ref string) string { + var out []string + for _, entry := range strings.Split(existing, ";") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + at := strings.IndexByte(entry, '@') + if at > 0 && strings.EqualFold(strings.TrimSpace(entry[:at]), code) { + continue // drop this award's previous reference + } + out = append(out, entry) + } + out = append(out, code+"@"+ref) + return strings.Join(out, ";") +} + // QSOAwardRef is one award reference a single QSO contributes to. Pickable // marks awards backed by a reference list (POTA, SOTA, …) — those are assigned // manually; the rest (DXCC, WAZ, WPX, DDFM, …) are computed from QSO fields. @@ -2529,6 +2719,33 @@ func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) { return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil } +// DownloadAllReferenceLists downloads every online award reference list +// (IOTA/POTA/WWFF/SOTA) in one go and returns a per-award summary. Used by the +// first-launch dialog and the Tools → Maintenance entry so the user doesn't +// have to open each award and update it individually. +func (a *App) DownloadAllReferenceLists() (string, error) { + if a.awardRefs == nil { + return "", fmt.Errorf("db not initialized") + } + var parts []string + var firstErr error + for _, code := range []string{"IOTA", "POTA", "WWFF", "SOTA"} { + if !awardref.CanUpdate(code) { + continue + } + meta, err := a.UpdateAwardReferenceList(code) + if err != nil { + parts = append(parts, fmt.Sprintf("%s ✗", code)) + if firstErr == nil { + firstErr = fmt.Errorf("%s: %w", code, err) + } + continue + } + parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count)) + } + return strings.Join(parts, " · "), firstErr +} + // SearchAwardReferences finds references of an award by code/name (for the // per-QSO reference picker). dxcc>0 restricts to one entity. func (a *App) SearchAwardReferences(code, query string, dxcc, limit int) ([]awardref.Ref, error) { @@ -2728,7 +2945,7 @@ func (a *App) ImportAwards() (AwardImportResult, error) { defs = migrated } b, _ := json.Marshal(defs) - if err := a.settings.Set(a.ctx, keyAwardDefs, string(b)); err != nil { + if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil { return res, fmt.Errorf("save award defs: %w", err) } @@ -2911,7 +3128,11 @@ func (a *App) UpdateQSO(q qso.QSO) error { if a.qso == nil { return fmt.Errorf("db not initialized") } - return a.qso.Update(a.ctx, q) + err := a.qso.Update(a.ctx, q) + if err == nil { + a.invalidateAwardStats() + } + return err } func (a *App) DeleteQSO(id int64) error { @@ -2968,6 +3189,9 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) { } } } + if n > 0 { + a.invalidateAwardStats() + } return n, nil } @@ -3579,13 +3803,23 @@ func (a *App) qsoRecDir() string { // useful audio, so they are never recorded. func recordableMode(mode string) bool { switch strings.ToUpper(strings.TrimSpace(mode)) { - case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW": + // CW is intentionally excluded: SmartSDR doesn't route CW audio through DAX, + // so the recording is empty/useless. Phone modes only. + case "SSB", "USB", "LSB", "AM", "FM", "DV": return true } return false } func (a *App) saveQSORecording(q *qso.QSO) { + // The logging path must never die because of the recorder. Recover any + // Go-level panic in the snapshot/stamp work below (the encode already runs + // in its own recovered goroutine). + defer func() { + if r := recover(); r != nil { + applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r) + } + }() if a.qsoRec == nil || !a.qsoRec.Active() { return } @@ -3630,8 +3864,25 @@ func (a *App) saveQSORecording(q *qso.QSO) { } } + // Clone the QSO for the goroutine with its OWN copy of the Extras map: a + // struct copy (qc := *q) would still share the underlying map, and a + // concurrent write from a post-log action (eQSL/upload status) racing this + // goroutine's read is a FATAL "concurrent map" error that no recover catches. qc := *q + if q.Extras != nil { + qc.Extras = make(map[string]string, len(q.Extras)) + for k, v := range q.Extras { + qc.Extras[k] = v + } + } go func() { + // A panic in the pure-Go MP3 encoder (or anywhere here) must NOT crash + // the whole app — recover, log it, and drop just this recording. + defer func() { + if r := recover(); r != nil { + applog.Printf("qso-rec: PANIC encoding %s: %v", path, r) + } + }() if err := audio.WritePCM(path, pcm); err != nil { applog.Printf("qso-rec: save failed: %v", err) return @@ -3992,6 +4243,9 @@ func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) { } } } + if changed > 0 { + a.invalidateAwardStats() + } return changed, nil } @@ -4327,15 +4581,30 @@ func (a *App) GetLogFilePath() string { // GetQSLDefaults returns the stored defaults — empty strings when the // user hasn't configured anything (= leave QSO fields untouched). +// defaultQSLDefaults is the out-of-the-box confirmation status for a profile +// that hasn't customised them: request the online confirmations (eQSL/LoTW/ +// Clublog/HRDLog/QRZ "sent"=R so OpsLog knows they still need uploading), and +// "N" for paper and everything received. +func defaultQSLDefaults() QSLDefaults { + return QSLDefaults{ + QSLSent: "N", QSLRcvd: "N", + EQSLSent: "R", EQSLRcvd: "N", + LOTWSent: "R", LOTWRcvd: "N", + ClublogStatus: "R", HRDLogStatus: "R", + QRZComStatus: "R", QRZComCfm: "N", + } +} + func (a *App) GetQSLDefaults() (QSLDefaults, error) { out := QSLDefaults{} if a.settings == nil { return out, nil } - prefix := "" - if a.profileHasGroup(markerQSL) { - prefix = a.profileScope() + // Fresh profile (never saved confirmations) → sensible defaults. + if !a.profileHasGroup(markerQSL) { + return defaultQSLDefaults(), nil } + prefix := a.profileScope() m, err := a.getManyScoped(prefix, keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd, keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd, @@ -4715,7 +4984,60 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern } emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded)) } + } else if svc == extsvc.ServiceClublog { + // Club Log accepts a whole ADIF file (putlogs.php) and dedupes + // server-side, so upload in chunks instead of one realtime.php request + // per QSO. Chunked so a single failure doesn't lose the whole run and + // the user sees progress. + const clublogChunk = 100 + type item struct { + id int64 + rec string + call string + } + var items []item + for _, id := range ids { + q, gerr := a.qso.GetByID(ctx, id) + call := "" + if gerr == nil { + call = q.Callsign + } + rec, ok := a.buildUploadADIF(id, "") + if !ok { + emit(call + " — skipped (no record)") + continue + } + items = append(items, item{id: id, rec: rec, call: call}) + } + emit(fmt.Sprintf("Club Log: uploading %d QSO(s) in batches of %d…", len(items), clublogChunk)) + for start := 0; start < len(items); start += clublogChunk { + end := start + clublogChunk + if end > len(items) { + end = len(items) + } + batch := items[start:end] + recs := make([]string, len(batch)) + for i, it := range batch { + recs[i] = it.rec + } + doc := adif.BatchRecordsADIF(recs) + res, err := extsvc.UploadClublogADIF(ctx, nil, cfg.Clublog, doc) + if err == nil && res.OK { + for _, it := range batch { + a.markExtUploaded(svc, it.id, "") + uploaded++ + } + emit(fmt.Sprintf("Club Log: %d/%d uploaded", end, len(items))) + } else { + msg := res.Message + if err != nil { + msg = err.Error() + } + emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg)) + } + } } else { + // QRZ.com: one record per request (its logbook API has no batch upload). for _, id := range ids { q, gerr := a.qso.GetByID(ctx, id) call := "" @@ -4736,7 +5058,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern switch svc { case extsvc.ServiceQRZ: res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec) - case extsvc.ServiceClublog: + default: res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec) } if err == nil && res.OK { @@ -5172,6 +5494,9 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) { changed++ } } + if changed > 0 { + a.invalidateAwardStats() + } return changed, nil } @@ -5231,6 +5556,9 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) { changed++ } } + if changed > 0 { + a.invalidateAwardStats() + } return changed, nil } @@ -6200,19 +6528,38 @@ func (a *App) ActivateProfile(id int64) error { if err := a.profiles.SetActive(a.ctx, id); err != nil { return err } + // EVERY setting is per-profile: re-scope the settings store first, so all + // the reloads below read this profile's values. + a.settings.SetProfile(id) a.refreshOperatorGrid() - // Per-profile config follows the active identity: reload the external- - // services manager so uploads now use this profile's accounts, and tell - // the frontend to refresh its settings panels. - if a.extsvc != nil { - a.extsvc.SetConfig(a.loadExternalServices()) + // The logbook follows the active profile: reconnect to this profile's DB + // target (local SQLite or its own MySQL) so QSOs go to the right logbook. + if p, err := a.profiles.Get(a.ctx, id); err == nil { + if err := a.switchLogbook(p); err != nil { + applog.Printf("activate profile %d: logbook switch failed: %v", id, err) + } } + // Re-apply every settings-dependent subsystem for the new profile. + a.reloadAfterProfileSwitch() if a.ctx != nil { wruntime.EventsEmit(a.ctx, "profile:changed", id) } return nil } +// reloadAfterProfileSwitch re-applies all settings-derived state for the newly +// active profile: lookup providers, upload-service accounts, CAT connection, +// and the QSO recorder (audio devices). The Winkeyer stays as-is (the operator +// connects it explicitly). The frontend reloads its panels via profile:changed. +func (a *App) reloadAfterProfileSwitch() { + a.reloadLookupProviders() + if a.extsvc != nil { + a.extsvc.SetConfig(a.loadExternalServices()) + } + a.reloadCAT() + a.startQSORecorderIfEnabled() +} + // DuplicateProfile clones an existing profile under newName. Useful when // the user has a "Home" profile and wants to derive "Portable" from it // without retyping every field. @@ -6220,7 +6567,30 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error if a.profiles == nil { return profile.Profile{}, fmt.Errorf("profiles not initialized") } - return a.profiles.Duplicate(a.ctx, id, newName) + p, err := a.profiles.Duplicate(a.ctx, id, newName) + if err != nil { + return profile.Profile{}, err + } + // A profile is the sum of its identity + DB target (copied by Duplicate) + + // its per-profile settings, its rig/antenna tree, and its QSL templates. + // Copy all of them so the clone is a true, independent duplicate. + if a.settings != nil { + if err := a.settings.CopyProfile(a.ctx, id, p.ID); err != nil { + applog.Printf("duplicate profile: copy settings: %v", err) + } + } + if a.operating != nil { + if err := a.operating.CopyProfile(a.ctx, id, p.ID); err != nil { + applog.Printf("duplicate profile: copy operating: %v", err) + } + } + if _, err := a.db.ExecContext(a.ctx, + `INSERT INTO qsl_templates (name, profile_id, json, is_default, created_at, updated_at) + SELECT name, ?, json, is_default, created_at, updated_at + FROM qsl_templates WHERE profile_id = ?`, p.ID, id); err != nil { + applog.Printf("duplicate profile: copy qsl templates: %v", err) + } + return p, nil } // --- Rotator bindings (PstRotator UDP v0) --- diff --git a/autostart.go b/autostart.go new file mode 100644 index 0000000..c201218 --- /dev/null +++ b/autostart.go @@ -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 +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83db057..e8e2baa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,9 +15,9 @@ import { SetCompactMode, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetSecretStatus, UnlockSecrets, - RefreshCtyDat, + RefreshCtyDat, DownloadAllReferenceLists, RotatorGoTo, RotatorStop, GetRotatorHeading, - GetDBConnectionInfo, + GetDBConnectionInfo, GetLogbookRevision, GetUltrabeamStatus, SetUltrabeamDirection, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, @@ -45,6 +45,7 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; +import { FirstRunModal } from '@/components/FirstRunModal'; import { QSOEditModal } from '@/components/QSOEditModal'; import { BandMap } from '@/components/BandMap'; import { MainMap } from '@/components/MainMap'; @@ -435,7 +436,6 @@ export default function App() { const [qsos, setQsos] = useState([]); const [total, setTotal] = useState(0); const [error, setError] = useState(''); - const [migratedBanner, setMigratedBanner] = useState(false); // Secret vault (encrypted passwords): prompt to unlock at launch when a // passphrase is configured but not yet entered this session. const [unlockOpen, setUnlockOpen] = useState(false); @@ -667,6 +667,7 @@ export default function App() { const [showDeleteAll, setShowDeleteAll] = useState(false); const [deletingAll, setDeletingAll] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false); + const [refsDownloading, setRefsDownloading] = useState(false); // === ADIF === const [importing, setImporting] = useState(false); @@ -732,6 +733,7 @@ export default function App() { callsign: '', operator: '', my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '', }); + const [showFirstRun, setShowFirstRun] = useState(false); myCallRef.current = (station.callsign || '').toUpperCase(); // 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). 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, // 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. @@ -986,8 +1018,12 @@ export default function App() { // case its one-shot fetch ran during the startup race (before the // backend was determined) and grabbed the wrong/stale value. GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {}); - } else if (!ok && alive && tries++ < 30) { - timer = window.setTimeout(attempt, 500); + } else if (!ok && alive && tries++ < 360) { + // 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) { refresh(); // give up quietly retrying; surface the error now } @@ -1000,7 +1036,12 @@ export default function App() { try { const st = await GetStartupStatus(); 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 {} // Prompt to unlock encrypted passwords if a passphrase is configured. try { @@ -1186,6 +1227,16 @@ export default function App() { setWkSendOnType(!!s.send_on_type); } 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(() => { (async () => { await reloadWk(); @@ -1723,11 +1774,12 @@ export default function App() { // Maintenance — bumped here while we only have one entry. Will move // 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: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading }, ]}, { name: 'help', label: 'Help', items: [ { 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) { switch (action) { @@ -1744,6 +1796,21 @@ export default function App() { case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); 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() { )} - {/* Transient toasts (bottom-right). Errors stack on top of the green - success toast; both auto-dismiss. */} - {migratedBanner && ( -
- - Migration complete. Your data has been copied to the data folder next to OpsLog.exe. - Please restart OpsLog to use the new location. - - -
+ {/* First launch: mandatory station identity. Blocks until filled. */} + {showFirstRun && ( + { setShowFirstRun(false); loadStation(); refresh(); }} /> )} + {/* Transient toasts (bottom-right). Errors stack on top of the green + success toast; both auto-dismiss. */} {/* Unlock encrypted passwords (set via Settings → Security). Dismissable: skipping leaves lookups/uploads without their passwords until unlocked. */} {unlockOpen && ( diff --git a/frontend/src/components/AwardRefSelector.tsx b/frontend/src/components/AwardRefSelector.tsx index 18f05e5..e4542a7 100644 --- a/frontend/src/components/AwardRefSelector.tsx +++ b/frontend/src/components/AwardRefSelector.tsx @@ -63,13 +63,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) // for a French call but not for others. const awards = useMemo(() => { 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; - 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 ?? []; 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; }).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() })) .sort((a, b) => a.code.localeCompare(b.code)); diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index cea7609..add0018 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; -import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle } from 'lucide-react'; -import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs } from '../../wailsjs/go/main/App'; +import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react'; +import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { cn } from '@/lib/utils'; 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 } = {}) { const [awardList, setAwardList] = useState([]); @@ -108,7 +109,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } try { const defs = ((await GetAwardDefs()) ?? []) as any[]; 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)); setAwardList(list); 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]; + // 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(() => { if (!current) return []; const q = refSearch.trim().toUpperCase(); @@ -228,10 +240,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } {/* Band breakdown */} {(current.bands ?? []).length > 0 && (
-
By band (confirmed / worked)
+
By band (confirmed / worked)
{(current.bands ?? []).map((b) => ( -
+
{b.band}{' '} {b.confirmed} /{b.worked} @@ -245,9 +257,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
- setRefSearch(e.target.value)} /> + setRefSearch(e.target.value)} />
-
+
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => ( ))}
- {filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''} + {filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}