feat(backup): "keep every backup" option — timestamped files per exit

Sub-option of "back up on every exit": when on, backup.Run/RunADIF stamp the
filename with the time (opslog-YYYY-MM-DD-HHMMSS.db) so each run is a distinct
file instead of overwriting the day's snapshot. Rotation still trims to the
newest N (raise Rotation for a longer history). Threaded as a `unique` flag
through runConfiguredBackup/backupLogADIF from BackupSettings.KeepAll
(keyBackupKeepAll); UI sub-checkbox shown only when EveryExit is on.
This commit is contained in:
2026-08-07 12:28:52 +02:00
parent e9feeffdc3
commit 949cc17ed1
5 changed files with 50 additions and 22 deletions
+14 -10
View File
@@ -296,6 +296,7 @@ const (
keyBackupRotation = "backup.rotation" keyBackupRotation = "backup.rotation"
keyBackupZip = "backup.zip" keyBackupZip = "backup.zip"
keyBackupEveryExit = "backup.every_exit" // "1" → back up on every quit, not just once/day keyBackupEveryExit = "backup.every_exit" // "1" → back up on every quit, not just once/day
keyBackupKeepAll = "backup.keep_all" // "1" → each backup is a distinct time-stamped file (no same-day overwrite)
keyBackupLast = "backup.last_at" keyBackupLast = "backup.last_at"
keyQSLDefaultQSLSent = "qsl.qsl_sent" keyQSLDefaultQSLSent = "qsl.qsl_sent"
@@ -1535,7 +1536,7 @@ func (a *App) runBackupForShutdown() error {
if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
return nil return nil
} }
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil { if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll); err != nil {
return err return err
} }
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
@@ -11784,6 +11785,7 @@ type BackupSettings struct {
Rotation int `json:"rotation"` Rotation int `json:"rotation"`
Zip bool `json:"zip"` Zip bool `json:"zip"`
EveryExit bool `json:"every_exit"` // back up on every quit, not just the first of the day EveryExit bool `json:"every_exit"` // back up on every quit, not just the first of the day
KeepAll bool `json:"keep_all"` // time-stamped file per backup instead of one-per-day (kept up to Rotation)
LastBackupAt string `json:"last_backup_at"` LastBackupAt string `json:"last_backup_at"`
DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint
} }
@@ -11798,7 +11800,7 @@ func (a *App) GetBackupSettings() (BackupSettings, error) {
return out, nil return out, nil
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupEveryExit, keyBackupLast) keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupEveryExit, keyBackupKeepAll, keyBackupLast)
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -11809,6 +11811,7 @@ func (a *App) GetBackupSettings() (BackupSettings, error) {
} }
out.Zip = m[keyBackupZip] == "1" out.Zip = m[keyBackupZip] == "1"
out.EveryExit = m[keyBackupEveryExit] == "1" out.EveryExit = m[keyBackupEveryExit] == "1"
out.KeepAll = m[keyBackupKeepAll] == "1"
out.LastBackupAt = m[keyBackupLast] out.LastBackupAt = m[keyBackupLast]
return out, nil return out, nil
} }
@@ -11836,6 +11839,7 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
keyBackupRotation: strconv.Itoa(s.Rotation), keyBackupRotation: strconv.Itoa(s.Rotation),
keyBackupZip: doZip, keyBackupZip: doZip,
keyBackupEveryExit: boolStr(s.EveryExit), keyBackupEveryExit: boolStr(s.EveryExit),
keyBackupKeepAll: boolStr(s.KeepAll),
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -11852,15 +11856,15 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
// backing up the QSOs once the logbook was split out. On MySQL the contacts // backing up the QSOs once the logbook was split out. On MySQL the contacts
// aren't in a local file, so they're exported to ADIF instead. Returns the path // aren't in a local file, so they're exported to ADIF instead. Returns the path
// of the contacts backup. // of the contacts backup.
func (a *App) runConfiguredBackup(folder string, rotation int, zip bool) (string, error) { func (a *App) runConfiguredBackup(folder string, rotation int, zip, unique bool) (string, error) {
// Config snapshot (profiles, hardware, awards). Best-effort — a config-backup // Config snapshot (profiles, hardware, awards). Best-effort — a config-backup
// failure must never stop the contacts from being protected. // failure must never stop the contacts from being protected.
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, rotation, zip, "opslogcfg"); err != nil { if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, rotation, zip, "opslogcfg", unique); err != nil {
applog.Printf("backup: config snapshot failed: %v", err) applog.Printf("backup: config snapshot failed: %v", err)
} }
if a.dbBackend == "mysql" { if a.dbBackend == "mysql" {
// The live log is on MySQL; VACUUM INTO can't reach it — export to ADIF. // The live log is on MySQL; VACUUM INTO can't reach it — export to ADIF.
return a.backupLogADIF(folder, rotation, zip) return a.backupLogADIF(folder, rotation, zip, unique)
} }
// SQLite: snapshot the logbook file that holds the contacts. Fall back to the // SQLite: snapshot the logbook file that holds the contacts. Fall back to the
// settings db only when it doubles as the logbook (rare split-failure case). // settings db only when it doubles as the logbook (rare split-failure case).
@@ -11868,7 +11872,7 @@ func (a *App) runConfiguredBackup(folder string, rotation int, zip bool) (string
if conn == nil || path == "" { if conn == nil || path == "" {
conn, path = a.db, a.dbPath conn, path = a.db, a.dbPath
} }
return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog") return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog", unique)
} }
// RunBackupNow forces an immediate backup using the persisted settings. // RunBackupNow forces an immediate backup using the persisted settings.
@@ -11882,7 +11886,7 @@ func (a *App) RunBackupNow() (string, error) {
if folder == "" { if folder == "" {
folder = s.DefaultFolder folder = s.DefaultFolder
} }
path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip) path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll)
if err != nil { if err != nil {
return path, err return path, err
} }
@@ -11893,11 +11897,11 @@ func (a *App) RunBackupNow() (string, error) {
// backupLogADIF writes a rotating ADIF export of the (MySQL) logbook into the // backupLogADIF writes a rotating ADIF export of the (MySQL) logbook into the
// backup folder. The full set of ADIF + app fields is included so the backup is // backup folder. The full set of ADIF + app fields is included so the backup is
// a complete, re-importable copy of the log. // a complete, re-importable copy of the log.
func (a *App) backupLogADIF(folder string, rotation int, zip bool) (string, error) { func (a *App) backupLogADIF(folder string, rotation int, zip, unique bool) (string, error) {
if a.qso == nil { if a.qso == nil {
return "", fmt.Errorf("logbook not initialized") return "", fmt.Errorf("logbook not initialized")
} }
return backup.RunADIF(folder, rotation, zip, func(p string) error { return backup.RunADIF(folder, rotation, zip, unique, func(p string) error {
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: true} ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: true}
_, e := ex.ExportFile(a.ctx, p) _, e := ex.ExportFile(a.ctx, p)
return e return e
@@ -11931,7 +11935,7 @@ func (a *App) maybeShutdownBackup() {
if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
return return
} }
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil { if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll); err != nil {
fmt.Println("OpsLog: shutdown backup failed:", err) fmt.Println("OpsLog: shutdown backup failed:", err)
return return
} }
+4 -2
View File
@@ -7,14 +7,16 @@
"Cluster details (My station): removed the example placeholders in My rig / My antenna (and the satellite name/mode) — they looked like real values, so an empty field read as already filled.", "Cluster details (My station): removed the example placeholders in My rig / My antenna (and the satellite name/mode) — they looked like real values, so an empty field read as already filled.",
"QSL: new \"OpsLog QSL received\" marker on the QSO edit window (next to QSL Message), with a live PSE QSL / TNX indicator. When a QSL was received the card prints TNX (thanks), otherwise PSE QSL (please send one) — automatic. The stamp uses the {qso.pse_tnx} token (in the default card footer; placeable anywhere in the designer). A \"QSL received\" column was added to Recent QSOs.", "QSL: new \"OpsLog QSL received\" marker on the QSO edit window (next to QSL Message), with a live PSE QSL / TNX indicator. When a QSL was received the card prints TNX (thanks), otherwise PSE QSL (please send one) — automatic. The stamp uses the {qso.pse_tnx} token (in the default card footer; placeable anywhere in the designer). A \"QSL received\" column was added to Recent QSOs.",
"QSL: a default QSL message (Settings → E-mail, under the QSL card e-mail) now prints on the card when a QSO has no QSL Message of its own — a QSO's own QSL Message always takes precedence.", "QSL: a default QSL message (Settings → E-mail, under the QSL card e-mail) now prints on the card when a QSO has no QSL Message of its own — a QSO's own QSL Message always takes precedence.",
"UDP outbound: a QSO logged from WSJT-X / JTDX / MSHV (via the inbound UDP listener) is now forwarded to the outbound ADIF integrations too — previously only QSOs typed directly into OpsLog were re-emitted." "UDP outbound: a QSO logged from WSJT-X / JTDX / MSHV (via the inbound UDP listener) is now forwarded to the outbound ADIF integrations too — previously only QSOs typed directly into OpsLog were re-emitted.",
"Backup: with \"back up on every exit\", a new \"Keep every backup\" sub-option writes a distinct time-stamped file per exit (opslog-YYYY-MM-DD-HHMMSS.db) instead of overwriting the day's file. Rotation still keeps the newest N — raise it for more history."
], ],
"fr": [ "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.", "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.",
"Détails cluster (Ma station) : suppression des exemples en filigrane dans Mon rig / Mon antenne (et nom/mode satellite) — ils ressemblaient à de vraies valeurs, faisant croire qu'un champ vide était déjà rempli.", "Détails cluster (Ma station) : suppression des exemples en filigrane dans Mon rig / Mon antenne (et nom/mode satellite) — ils ressemblaient à de vraies valeurs, faisant croire qu'un champ vide était déjà rempli.",
"QSL : nouveau marqueur \"QSL OpsLog reçue\" dans la fenêtre d'édition du QSO (à côté du Message QSL), avec un indicateur PSE QSL / TNX en direct. Si une QSL a été reçue, la carte imprime TNX (merci), sinon PSE QSL (envoie-moi une carte) — automatique. Le tampon utilise le token {qso.pse_tnx} (dans le pied de carte par défaut ; plaçable où tu veux dans le designer). Une colonne \"QSL reçue\" a été ajoutée aux QSO récents.", "QSL : nouveau marqueur \"QSL OpsLog reçue\" dans la fenêtre d'édition du QSO (à côté du Message QSL), avec un indicateur PSE QSL / TNX en direct. Si une QSL a été reçue, la carte imprime TNX (merci), sinon PSE QSL (envoie-moi une carte) — automatique. Le tampon utilise le token {qso.pse_tnx} (dans le pied de carte par défaut ; plaçable où tu veux dans le designer). Une colonne \"QSL reçue\" a été ajoutée aux QSO récents.",
"QSL : un message QSL par défaut (Réglages → E-mail, sous l'e-mail de carte QSL) s'imprime désormais sur la carte quand un QSO n'a pas son propre Message QSL — le Message QSL du QSO l'emporte toujours.", "QSL : un message QSL par défaut (Réglages → E-mail, sous l'e-mail de carte QSL) s'imprime désormais sur la carte quand un QSO n'a pas son propre Message QSL — le Message QSL du QSO l'emporte toujours.",
"UDP sortant : un QSO enregistré depuis WSJT-X / JTDX / MSHV (via l'écouteur UDP entrant) est désormais aussi transmis aux intégrations ADIF sortantes — avant, seuls les QSO saisis directement dans OpsLog étaient réémis." "UDP sortant : un QSO enregistré depuis WSJT-X / JTDX / MSHV (via l'écouteur UDP entrant) est désormais aussi transmis aux intégrations ADIF sortantes — avant, seuls les QSO saisis directement dans OpsLog étaient réémis.",
"Sauvegarde : avec \"sauvegarder à chaque fermeture\", une nouvelle sous-option \"Garder chaque sauvegarde\" écrit un fichier horodaté distinct par fermeture (opslog-AAAA-MM-JJ-HHMMSS.db) au lieu d'écraser le fichier du jour. La rotation garde les N plus récents — augmente-la pour plus d'historique."
] ]
}, },
{ {
+14 -1
View File
@@ -4368,13 +4368,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
/> />
<span>{t('bk.zip')}</span> <span>{t('bk.zip')}</span>
</label> </label>
<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 <Checkbox
checked={!!(backupCfg as any).every_exit} checked={!!(backupCfg as any).every_exit}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, every_exit: !!c } as any))} onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, every_exit: !!c } as any))}
/> />
<span>{t('bk.everyExit')}</span> <span>{t('bk.everyExit')}</span>
</label> </label>
{!!(backupCfg as any).every_exit && (
<label className="flex items-start gap-2 text-sm cursor-pointer pl-6 pb-2">
<Checkbox
className="mt-0.5"
checked={!!(backupCfg as any).keep_all}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, keep_all: !!c } as any))}
/>
<span>
{t('bk.keepAll')}
<span className="block text-xs text-muted-foreground mt-0.5">{t('bk.keepAllHint')}</span>
</span>
</label>
)}
</div> </div>
<div className="border-t border-border/60 pt-3 flex items-center gap-3"> <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.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.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.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.everyExit': 'Back up on every exit (not just once a day)', '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.keepAll': 'Keep every backup (timestamped files)', 'bk.keepAllHint': 'Each exit writes a separate opslog-YYYY-MM-DD-HHMMSS.db instead of overwriting the days file. Rotation still keeps the newest N — raise it for more history.',
'bk.lastRun': 'Last run:', 'bk.never': 'never', 'bk.backupNow': 'Back up now', 'bk.backingUp': 'Backing up…', 'bk.writtenTo': 'Backup written to', '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) // 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.', '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.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.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.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.everyExit': 'Sauvegarder à chaque fermeture (pas une fois par jour)', '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.keepAll': 'Garder chaque sauvegarde (fichiers horodatés)', 'bk.keepAllHint': 'Chaque fermeture écrit un opslog-AAAA-MM-JJ-HHMMSS.db distinct au lieu d’écraser le fichier du jour. La rotation garde les N plus récents — augmente-la pour plus dhistorique.',
'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans', '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.", '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).", '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).",
+12 -3
View File
@@ -44,7 +44,7 @@ func DefaultFolder(dataDir string) string {
// statement (no torn-copy window while the app keeps writing), and compacts // statement (no torn-copy window while the app keeps writing), and compacts
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy", // the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
// which could capture a half-written page during a concurrent write. // which could capture a half-written page during a concurrent write.
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string) (string, error) { func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string, unique bool) (string, error) {
if dbConn == nil { if dbConn == nil {
return "", fmt.Errorf("nil db connection") return "", fmt.Errorf("nil db connection")
} }
@@ -61,7 +61,12 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
return "", fmt.Errorf("create backup folder: %w", err) return "", fmt.Errorf("create backup folder: %w", err)
} }
// One file per DAY by default (same-day runs overwrite). With `unique`, add the
// time so every run is a distinct file — rotation still keeps the newest N.
stamp := time.Now().Format("2006-01-02") stamp := time.Now().Format("2006-01-02")
if unique {
stamp = time.Now().Format("2006-01-02-150405")
}
base := fmt.Sprintf("%s-%s", prefix, stamp) base := fmt.Sprintf("%s-%s", prefix, stamp)
// VACUUM INTO requires a non-existent target → use a temp file, then // VACUUM INTO requires a non-existent target → use a temp file, then
@@ -106,7 +111,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an // log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
// ADIF export is a portable, re-importable backup of the actual contacts. // ADIF export is a portable, re-importable backup of the actual contacts.
// writeADIF must write the full ADIF to the path it's handed. // writeADIF must write the full ADIF to the path it's handed.
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) { func RunADIF(folder string, rotation int, doZip bool, unique bool, writeADIF func(path string) error) (string, error) {
if rotation <= 0 { if rotation <= 0 {
rotation = 5 rotation = 5
} }
@@ -116,7 +121,11 @@ func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string
if err := os.MkdirAll(folder, 0o755); err != nil { if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err) return "", fmt.Errorf("create backup folder: %w", err)
} }
base := "opslog-log-" + time.Now().Format("2006-01-02") stamp := time.Now().Format("2006-01-02")
if unique {
stamp = time.Now().Format("2006-01-02-150405")
}
base := "opslog-log-" + stamp
tmp := filepath.Join(folder, base+".adi.tmp") tmp := filepath.Join(folder, base+".adi.tmp")
_ = os.Remove(tmp) _ = os.Remove(tmp)
if err := writeADIF(tmp); err != nil { if err := writeADIF(tmp); err != nil {