feat(backup): option to back up on every exit, not just once a day

New BackupSettings.EveryExit (keyBackupEveryExit) with a Settings → Backup
checkbox. When on, the three shutdown-backup gates (plannedShutdownSteps,
runBackupForShutdown, maybeShutdownBackup) bypass the HasBackupToday /
HasADIFBackupToday "already done today" check and always run, so a second
session's QSOs are captured instead of skipped until the next day. The dated
backup file for today is refreshed (overwritten) each exit; prior days are still
kept by rotation.
This commit is contained in:
2026-08-07 09:46:23 +02:00
parent 970127cc16
commit 5338ad0b29
5 changed files with 62 additions and 44 deletions
+44 -39
View File
@@ -291,11 +291,12 @@ const (
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
keyBackupEnabled = "backup.enabled"
keyBackupFolder = "backup.folder"
keyBackupRotation = "backup.rotation"
keyBackupZip = "backup.zip"
keyBackupLast = "backup.last_at"
keyBackupEnabled = "backup.enabled"
keyBackupFolder = "backup.folder"
keyBackupRotation = "backup.rotation"
keyBackupZip = "backup.zip"
keyBackupEveryExit = "backup.every_exit" // "1" → back up on every quit, not just once/day
keyBackupLast = "backup.last_at"
keyQSLDefaultQSLSent = "qsl.qsl_sent"
keyQSLDefaultQSLRcvd = "qsl.qsl_rcvd"
@@ -569,8 +570,8 @@ type App struct {
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
// Loaded once, appended to on each log, rebuilt after bulk changes.
wcbm map[string]struct{}
wcbmMu sync.RWMutex
wcbm map[string]struct{}
wcbmMu sync.RWMutex
// clusterStatusIdx caches the whole-logbook maps ClusterSpotStatuses colours
// spots against (worked entities/calls/counties/POTA/prefixes). Building them
// per spot batch re-scanned the entire logbook ~20×/second under an RBN
@@ -580,30 +581,30 @@ type App struct {
clusterStatusIdx *clusterStatusCache
clusterStatusMu sync.Mutex
pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
ampsMu sync.Mutex // guards ampInsts
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
ampsMu sync.Mutex // guards ampInsts
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
// qsoRecManual marks a take the operator started by hand while automatic
// recording is OFF. Such a take opened the sound devices itself, so it must
// close them again when it ends — an operator who records one contact does
@@ -1450,7 +1451,8 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
if folder == "" {
folder = s.DefaultFolder
}
if !backup.HasBackupToday(folder) {
// "Back up on every exit" bypasses the once-a-day gate.
if s.EveryExit || !backup.HasBackupToday(folder) {
out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
}
}
@@ -1530,7 +1532,7 @@ func (a *App) runBackupForShutdown() error {
if mysql {
done = backup.HasADIFBackupToday(folder)
}
if done {
if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
return nil
}
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
@@ -11775,6 +11777,7 @@ type BackupSettings struct {
Folder string `json:"folder"`
Rotation int `json:"rotation"`
Zip bool `json:"zip"`
EveryExit bool `json:"every_exit"` // back up on every quit, not just the first of the day
LastBackupAt string `json:"last_backup_at"`
DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint
}
@@ -11789,7 +11792,7 @@ func (a *App) GetBackupSettings() (BackupSettings, error) {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupLast)
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupEveryExit, keyBackupLast)
if err != nil {
return out, err
}
@@ -11799,6 +11802,7 @@ func (a *App) GetBackupSettings() (BackupSettings, error) {
out.Rotation = n
}
out.Zip = m[keyBackupZip] == "1"
out.EveryExit = m[keyBackupEveryExit] == "1"
out.LastBackupAt = m[keyBackupLast]
return out, nil
}
@@ -11821,10 +11825,11 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
doZip = "1"
}
for k, v := range map[string]string{
keyBackupEnabled: enabled,
keyBackupFolder: strings.TrimSpace(s.Folder),
keyBackupRotation: strconv.Itoa(s.Rotation),
keyBackupZip: doZip,
keyBackupEnabled: enabled,
keyBackupFolder: strings.TrimSpace(s.Folder),
keyBackupRotation: strconv.Itoa(s.Rotation),
keyBackupZip: doZip,
keyBackupEveryExit: boolStr(s.EveryExit),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -11917,7 +11922,7 @@ func (a *App) maybeShutdownBackup() {
if mysql {
done = backup.HasADIFBackupToday(folder)
}
if done {
if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
return
}
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
+6 -2
View File
@@ -2,8 +2,12 @@
{
"version": "0.23.9",
"date": "",
"en": [],
"fr": []
"en": [
"Backup: new \"Back up on every exit\" option (Settings → Backup). With it on, OpsLog backs up the database each time you quit instead of only the first time of the day — so a second session's QSOs are always captured."
],
"fr": [
"Sauvegarde : nouvelle option \"Sauvegarder à chaque fermeture\" (Réglages → Sauvegarde). Activée, OpsLog sauvegarde la base à chaque fois que tu quittes au lieu d'une seule fois par jour — les QSO d'une seconde session sont ainsi toujours pris."
]
},
{
"version": "0.23.8",
+8 -1
View File
@@ -4361,13 +4361,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!backupCfg.zip}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
/>
<span>{t('bk.zip')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
<Checkbox
checked={!!(backupCfg as any).every_exit}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, every_exit: !!c } as any))}
/>
<span>{t('bk.everyExit')}</span>
</label>
</div>
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
+2 -2
View File
@@ -269,7 +269,7 @@ const en: Dict = {
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
'bk.auto': 'Automatic backup when closing OpsLog (max once per day)', 'bk.folder': 'Backup folder', 'bk.folderPh': 'leave empty for default', 'bk.browse': 'Browse…',
'bk.effective': 'Effective folder:', 'bk.defaultUse': 'If empty, OpsLog uses the default:', 'bk.rotation': 'Rotation (copies to keep)', 'bk.zip': 'ZIP backup (smaller file)',
'bk.effective': 'Effective folder:', 'bk.defaultUse': 'If empty, OpsLog uses the default:', 'bk.rotation': 'Rotation (copies to keep)', 'bk.zip': 'ZIP backup (smaller file)', 'bk.everyExit': 'Back up on every exit (not just once a day)',
'bk.lastRun': 'Last run:', 'bk.never': 'never', 'bk.backupNow': 'Back up now', 'bk.backingUp': 'Backing up…', 'bk.writtenTo': 'Backup written to',
// Section hints (hardware/software panel headers)
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
@@ -681,7 +681,7 @@ const fr: Dict = {
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
'bk.effective': 'Dossier effectif :', 'bk.defaultUse': 'Si vide, OpsLog utilise le défaut :', 'bk.rotation': 'Rotation (copies à garder)', 'bk.zip': 'Sauvegarde ZIP (fichier plus petit)',
'bk.effective': 'Dossier effectif :', 'bk.defaultUse': 'Si vide, OpsLog utilise le défaut :', 'bk.rotation': 'Rotation (copies à garder)', 'bk.zip': 'Sauvegarde ZIP (fichier plus petit)', 'bk.everyExit': 'Sauvegarder à chaque fermeture (pas une fois par jour)',
'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans',
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
+2
View File
@@ -1894,6 +1894,7 @@ export namespace main {
folder: string;
rotation: number;
zip: boolean;
every_exit: boolean;
last_backup_at: string;
default_folder: string;
@@ -1907,6 +1908,7 @@ export namespace main {
this.folder = source["folder"];
this.rotation = source["rotation"];
this.zip = source["zip"];
this.every_exit = source["every_exit"];
this.last_backup_at = source["last_backup_at"];
this.default_folder = source["default_folder"];
}