package main import ( "context" "fmt" "time" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" "hamlog/internal/applog" "hamlog/internal/db" "hamlog/internal/offlineq" "hamlog/internal/qso" ) // ── Offline safety net ──────────────────────────────────────────────── // // With the shared MySQL logbook, the network sits on the critical path of every // write: lose it and you can't log at all. Rather than a fragile "fall back to // SQLite" (which would leave you with a partial log and needs a restart), we // keep the architecture exactly as it is and add a NET: // // DB unreachable → the QSO is parked in a local ADIF outbox (never lost) // DB back → replayed into the logbook, idempotently, file archived // // It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what // keeps it small. Accepted trade-off: while offline you don't see other ops' // QSOs, and worked-before doesn't know about your own pending ones (they're // listed separately in the UI instead). // offlineReplayEvery is how often we re-probe the database while QSOs are // waiting. Short enough to feel automatic, long enough not to hammer a dead host. const offlineReplayEvery = 15 * time.Second // OfflineStatus is what the UI shows: are we parked, and how much is waiting. type OfflineStatus struct { Offline bool `json:"offline"` // last write failed because the DB was unreachable Pending int `json:"pending"` // QSOs sitting in the outbox Path string `json:"path"` // where the outbox physically is } // queueOffline parks a QSO that couldn't reach the database. Returns false when // queueing isn't applicable (local SQLite can't "go offline") or the write to the // outbox itself failed — in which case the caller MUST surface the original error // rather than pretend the QSO was saved. func (a *App) queueOffline(q qso.QSO, cause error) bool { if a.offlineQ == nil || !db.IsMySQL() { return false } qid, err := a.offlineQ.Append(q) if err != nil { // The net itself tore: do NOT claim success. applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause) return false } applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid) a.offlineMode = true a.emitOfflineStatus() return true } // emitOfflineStatus pushes the banner state to the UI. func (a *App) emitOfflineStatus() { if a.ctx == nil || a.offlineQ == nil { return } wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus()) } // GetOfflineStatus reports whether we're parked and how many QSOs are waiting. func (a *App) GetOfflineStatus() OfflineStatus { if a.offlineQ == nil { return OfflineStatus{} } n := a.offlineQ.Count() return OfflineStatus{ Offline: a.offlineMode && n > 0, Pending: n, Path: a.offlineQ.Path(), } } // GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE // what they logged while the database was down instead of flying blind. func (a *App) GetPendingQSOs() ([]qso.QSO, error) { if a.offlineQ == nil { return nil, nil } return a.offlineQ.Pending() } // RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of // waiting for the next tick. func (a *App) RetryOfflineSync() (int, error) { return a.replayOfflineQueue() } // offlineReplayLoop re-probes the database while QSOs are waiting and replays // them the moment it answers. func (a *App) offlineReplayLoop() { t := time.NewTicker(offlineReplayEvery) defer t.Stop() for range t.C { if a.offlineQ == nil || a.offlineQ.Count() == 0 { continue } if a.logDb == nil { continue } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) err := a.logDb.PingContext(ctx) cancel() if err != nil { continue // still down — keep waiting, the QSOs are safe on disk } if n, rerr := a.replayOfflineQueue(); rerr != nil { applog.Printf("offline: replay failed: %v", rerr) } else if n > 0 { applog.Printf("offline: replayed %d QSO(s) into the logbook", n) } } } // replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each // parked QSO carries a queue id, and one already present in the log is skipped — // so a crash between "inserted" and "removed from the file" can never duplicate a // contact. The file is archived BEFORE being cleared. func (a *App) replayOfflineQueue() (int, error) { if a.offlineQ == nil || a.qso == nil { return 0, nil } pending, err := a.offlineQ.Pending() if err != nil { return 0, err } if len(pending) == 0 { a.offlineMode = false a.emitOfflineStatus() return 0, nil } imported := 0 var failed []qso.QSO for _, q := range pending { qid := "" if q.Extras != nil { qid = q.Extras[qso.OfflineQueueKey] } // Idempotency guard: already replayed on an earlier (interrupted) pass? if qid != "" { if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists { imported++ // it IS in the log — treat as done, drop from the outbox continue } else if e != nil && db.IsConnLost(e) { failed = append(failed, q) // DB went away again mid-replay continue } } if _, e := a.qso.Add(a.ctx, q); e != nil { applog.Printf("offline: replay of %s failed: %v", q.Callsign, e) failed = append(failed, q) continue } imported++ } // Archive the outbox as it was, then keep only what still failed. if imported > 0 { if dst, e := a.offlineQ.Archive(); e != nil { applog.Printf("offline: archive failed: %v", e) } else if dst != "" { applog.Printf("offline: outbox archived to %s", dst) } } if e := a.offlineQ.Rewrite(failed); e != nil { return imported, fmt.Errorf("offline: rewrite outbox: %w", e) } a.offlineMode = len(failed) > 0 a.emitOfflineStatus() if imported > 0 && a.ctx != nil { wruntime.EventsEmit(a.ctx, "logbook:changed") wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported)) } return imported, nil } // newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT // in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the // very thing this design avoids. func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) }