Compare commits

...
6 Commits
Author SHA1 Message Date
rouggy 3704dbdd99 chore: release v0.23.8 2026-08-07 00:16:56 +02:00
rouggy 3cdf16b501 perf(cluster): throttle the grid refreshCells to 200ms (coalesce RBN status bursts)
spotStatus updates ~20x/second under an RBN firehose; firing a full refreshCells
on each was pure churn on a slow PC. Coalesce into one refresh per 200ms. Keeps
the NEW/WORKED badge colours and the 'represents nothing' dimming — the dimming
itself is a trivial pure read now that the worked-index scan is cached, so it's
no longer a cost worth removing.
2026-08-07 00:13:23 +02:00
rouggy 9a72afd467 perf(cluster): cache the worked-index and bound the spot-status cache (RBN firehose)
A user on a slow PC with RBN saw OpsLog at 94% CPU and 8.5 GB RAM (Logger32: 3%
/ 34 MB on the same feeds). Two runaway costs under the spot firehose:

- ClusterSpotStatuses re-scanned the ENTIRE logbook (5-6 full-table maps) on
  every 50 ms spot batch — ~20×/second — its "one scan regardless of batch" doc
  was untrue. On a big log that's millions of row-scans/second → the pegged CPU.
  Now cached in clusterStatusCache (an immutable snapshot), rebuilt only when the
  logbook changes (noteWorked on a single log, invalidateAwardStats on bulk), so
  it's one scan per logged QSO instead of per batch.

- The frontend spotStatus map had no cap: one entry per call|band|mode ever seen,
  and RBN produces thousands of unique calls/hour → unbounded growth to GBs, plus
  a full {...prev} copy 20×/second. Now pruned back to the live (SPOTS_CAP=1000)
  spots once it drifts past 2×, with a cheap same-reference bail-out otherwise.
2026-08-07 00:08:24 +02:00
rouggy e2bfe73bdb fix(backup): back up the contacts (logbook), not just the settings db
Since the logbook was split into its own SQLite file, backup.Run was still
snapshotting a.db (the settings/config database) — so the scheduled and manual
backups silently stopped including the QSOs. The operator was backing up config
and thinking it was their log.

Track the resolved logbook file path (a.logDbPath, set in connectLogbook) and
route the backup through a new runConfiguredBackup: the CONTACTS become the
primary "opslog-*" backup (the logbook file on SQLite, an ADIF export on MySQL),
and the settings/config db is snapshotted separately as "opslogcfg-*" so nothing
is lost. backup.Run takes a name prefix; the two sets rotate independently.
2026-08-06 23:22:04 +02:00
rouggy 6e2e2cc3aa changelog: move the recording-email editor to 0.23.8 (0.23.7 is released) 2026-08-06 19:11:10 +02:00
rouggy 75d11a4069 feat(email): editable subject/body for the QSO recording e-mail
The recording e-mail's subject and body round-tripped through EmailSettings and
the backend (keyEmailSubject/keyEmailBody) but had no UI editor — only the QSL
card e-mail did, so the recording mail was stuck on the default text. Add the
same subject/body editor to the E-mail panel, above the QSL block, sharing the
{CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} template variables.
2026-08-06 18:14:24 +02:00
9 changed files with 195 additions and 78 deletions
+130 -58
View File
@@ -571,7 +571,15 @@ type App struct {
// Loaded once, appended to on each log, rebuilt after bulk changes.
wcbm map[string]struct{}
wcbmMu sync.RWMutex
pota *pota.Cache
// 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
// firehose — the dominant CPU cost on a large log. Built lazily, treated as an
// immutable snapshot, and dropped on any logbook change (invalidateAwardStats)
// or when a setting that shapes the maps flips.
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
@@ -644,6 +652,7 @@ type App struct {
settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme
dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
logbookPath string // default SQLite logbook file (QSOs), next to the settings db — used when a profile doesn't point elsewhere
logDbPath string // resolved SQLite file actually backing logDb ("" on MySQL, or when the settings db serves as the logbook) — the file the backup snapshots
logDb *sql.DB // QSO logbook connection — MySQL, a per-profile SQLite file, or the default logbook.db (never the settings db, except on fallback)
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
@@ -1066,6 +1075,7 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook open failed (%v) — falling back to SQLite logbook", lerr)
a.dbBackendErr = strings.TrimPrefix(lerr.Error(), "")
logbookConn, backend = conn, "sqlite"
a.logDbPath = "" // fell back to the settings db as the logbook — backup snapshots a.db
}
a.dbBackend = backend
// db.Dialect describes the LOGBOOK backend — the only place SQL actually
@@ -1523,14 +1533,9 @@ func (a *App) runBackupForShutdown() error {
if done {
return nil
}
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
return err
}
if mysql {
if _, err := a.backupLogADIF(folder, s.Rotation, s.Zip); err != nil {
return err
}
}
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}
@@ -2053,6 +2058,7 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
if err != nil {
return nil, "", err
}
a.logDbPath = "" // MySQL: no local file to snapshot (the log is exported to ADIF instead)
return c, "mysql", nil
}
// SQLite logbook FILE, separate from the settings/config database. A profile
@@ -2066,6 +2072,7 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
lp = a.logbookPath
}
if lp == "" {
a.logDbPath = "" // settings db serves as the logbook (split failed) — backup snapshots a.db
return a.db, "sqlite", nil
}
// Resolve against THIS install before opening. Without it, a profile carried
@@ -2081,6 +2088,7 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
if err != nil {
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
}
a.logDbPath = lp // the SQLite file the backup snapshots for the contacts
return c, "sqlite", nil
}
@@ -4254,6 +4262,11 @@ func (a *App) invalidateAwardStats() {
a.awardSnap = nil
a.awardSnapRev = ""
a.awardSnapMu.Unlock()
// Drop the cluster worked-index snapshot too, so the Cluster tab's NEW/WORKED
// colouring reflects the change on the next spot batch (it rebuilds lazily).
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
// Bulk QSO changes (import, delete, bulk edit) also land here — refresh the
// worked-index so alert "needed" checks stay accurate. Async: never block the
// mutation, and it's a single lightweight query.
@@ -7775,6 +7788,11 @@ func (a *App) noteWorked(call, band, mode string) {
}
a.wcbm[wcbmKey(call, band, mode)] = struct{}{}
a.wcbmMu.Unlock()
// The cluster worked-index snapshot is now stale (this call/slot just became
// worked) — drop it so the next spot batch recolours with the new QSO.
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
}
// isWorkedBandMode reports whether this exact call+band+mode is in the log,
@@ -11815,6 +11833,33 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
return nil
}
// runConfiguredBackup writes the backup set to folder: the CONTACTS as the
// primary "opslog-*" backup, plus a separate "opslogcfg-*" snapshot of the
// settings/config db, so neither is lost. The contacts are what the user means
// by "the log": on SQLite they live in the split-out logbook file (a.logDbPath),
// NOT the settings db — the old code snapshotted a.db and so silently stopped
// 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
// of the contacts backup.
func (a *App) runConfiguredBackup(folder string, rotation int, zip bool) (string, error) {
// Config snapshot (profiles, hardware, awards). Best-effort — a config-backup
// 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 {
applog.Printf("backup: config snapshot failed: %v", err)
}
if a.dbBackend == "mysql" {
// The live log is on MySQL; VACUUM INTO can't reach it — export to ADIF.
return a.backupLogADIF(folder, rotation, zip)
}
// 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).
conn, path := a.logDb, a.logDbPath
if conn == nil || path == "" {
conn, path = a.db, a.dbPath
}
return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog")
}
// RunBackupNow forces an immediate backup using the persisted settings.
// Returns the destination path of the file that was written.
func (a *App) RunBackupNow() (string, error) {
@@ -11826,20 +11871,10 @@ func (a *App) RunBackupNow() (string, error) {
if folder == "" {
folder = s.DefaultFolder
}
// Always snapshot the local SQLite (config + any pre-MySQL local QSOs).
path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip)
path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip)
if err != nil {
return path, err
}
// On MySQL the live QSO log isn't in the local DB — export it to ADIF so the
// contacts are actually protected. The ADIF path is the one we surface.
if a.dbBackend == "mysql" {
adiPath, aerr := a.backupLogADIF(folder, s.Rotation, s.Zip)
if aerr != nil {
return adiPath, aerr
}
path = adiPath
}
a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
return path, nil
}
@@ -11885,16 +11920,10 @@ func (a *App) maybeShutdownBackup() {
if done {
return
}
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
fmt.Println("OpsLog: shutdown backup failed:", err)
return
}
if mysql {
if _, err := a.backupLogADIF(folder, s.Rotation, s.Zip); err != nil {
fmt.Println("OpsLog: shutdown ADIF log backup failed:", err)
return
}
}
a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}
@@ -16075,18 +16104,46 @@ type SpotStatus struct {
Pfx string `json:"pfx,omitempty"`
}
// ClusterSpotStatuses takes a batch of spots and returns slot status for
// each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT
// / WORKED). One cty.dat lookup + one DB scan, regardless of batch size.
//
// Mode handling: when the caller passes an empty Mode (cluster comment
// was ambiguous and the frontend couldn't infer) we degrade gracefully
// to band-only — saying "worked" rather than wrongly flagging "new-slot"
// just because we don't know the mode.
func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out := make([]SpotStatus, len(spots))
// clusterStatusCache holds the whole-logbook maps ClusterSpotStatuses colours
// spots against. Rebuilding them per spot batch re-scanned the entire logbook
// ~20×/second under an RBN firehose — the dominant CPU cost on a large log. This
// is an immutable snapshot: once built its maps are never mutated, so a batch
// that already holds the pointer keeps reading valid (stale-by-one-log) data
// while a newer snapshot is being built. See clusterStatusMaps.
type clusterStatusCache struct {
entities map[int]*qso.EntitySlot
workedCalls map[string]struct{}
workedCallSlots map[string]struct{} // nil unless the "same slot" option is on
workedCounties map[string]struct{}
workedPOTA map[string]struct{}
workedPfx map[string]struct{}
normMode func(string) string // nil unless digital-mode grouping is on
groupDigital bool // settings the maps were built under —
sameSlot bool // a change rebuilds the snapshot
}
// clusterStatusMaps returns the cached worked-index snapshot, building it once
// per logbook change (invalidated by invalidateAwardStats) or when a setting
// that shapes the maps flips. This turns the per-batch full-logbook scans into
// one scan per logged QSO — the fix for the RBN-firehose CPU pegging.
func (a *App) clusterStatusMaps() *clusterStatusCache {
groupDigital := a.groupDigitalSlots()
sameSlot := a.clusterWorkedSameSlot()
a.clusterStatusMu.Lock()
defer a.clusterStatusMu.Unlock()
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot {
return c
}
c := &clusterStatusCache{groupDigital: groupDigital, sameSlot: sameSlot}
if a.qso == nil {
return out
a.clusterStatusIdx = c
return c
}
// Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/
// RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was
// worked shows "worked", not "new-slot" — DXCC-style mode classes.
if groupDigital {
c.normMode = qso.GroupDigitalMode
}
// Compare by DXCC entity NUMBER, not name. For each logged QSO the key is
// its stored DXCC if present (the authoritative value set at log time, incl.
@@ -16109,43 +16166,58 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
}
return 0
}
// Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/
// RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was
// worked shows "worked", not "new-slot" — DXCC-style mode classes.
var normMode func(string) string
if a.groupDigitalSlots() {
normMode = qso.GroupDigitalMode
}
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor, normMode)
if err != nil {
return out
}
c.entities, _ = a.qso.EntitySlotMap(a.ctx, keyFor, c.normMode)
// Per-call worked set — separate from the entity check so we can flag
// "I've already QSO'd this exact station" even when the band/mode
// makes the entity check say "new-band" or "new-slot".
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
c.workedCalls, _ = a.qso.WorkedCallsigns(a.ctx)
// "Already worked only on the same slot" option (Settings → DX Cluster): the
// WORKED-call flag then needs the SAME band and mode (digital-grouped through
// the same normMode when that option is on) rather than the call anywhere.
sameSlot := a.clusterWorkedSameSlot()
var workedCallSlots map[string]struct{}
if sameSlot {
workedCallSlots, _ = a.qso.WorkedCallSlotKeys(a.ctx, normMode)
c.workedCallSlots, _ = a.qso.WorkedCallSlotKeys(a.ctx, c.normMode)
}
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks. Both built once per batch.
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
// lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
// Worked WPX prefixes, derived from the callsigns we already loaded — no
// extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this
// in step with the WPX award, which does the same thing.
workedPfx := make(map[string]struct{}, len(workedCalls))
for c := range workedCalls {
if p := award.WPXPrefix(c); p != "" {
workedPfx[p] = struct{}{}
c.workedPfx = make(map[string]struct{}, len(c.workedCalls))
for call := range c.workedCalls {
if p := award.WPXPrefix(call); p != "" {
c.workedPfx[p] = struct{}{}
}
}
a.clusterStatusIdx = c
return c
}
// ClusterSpotStatuses takes a batch of spots and returns slot status for
// each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT
// / WORKED). Reads the cached worked-index snapshot (clusterStatusMaps) so a
// spot batch never re-scans the logbook — critical under an RBN firehose.
//
// Mode handling: when the caller passes an empty Mode (cluster comment
// was ambiguous and the frontend couldn't infer) we degrade gracefully
// to band-only — saying "worked" rather than wrongly flagging "new-slot"
// just because we don't know the mode.
func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out := make([]SpotStatus, len(spots))
if a.qso == nil {
return out
}
idx := a.clusterStatusMaps()
entities := idx.entities
workedCalls := idx.workedCalls
workedCallSlots := idx.workedCallSlots
workedCounties := idx.workedCounties
workedPOTA := idx.workedPOTA
workedPfx := idx.workedPfx
normMode := idx.normMode
sameSlot := idx.sameSlot
for i, q := range spots {
out[i] = SpotStatus{
Call: q.Call,
+14
View File
@@ -1,4 +1,18 @@
[
{
"version": "0.23.8",
"date": "",
"en": [
"E-mail: the QSO recording e-mail (subject and body) is now editable in Settings → E-mail, like the QSL card e-mail — with the {CALL} {DATE} {BAND} {MODE} {MYCALL} variables.",
"Backup fix: the backup now saves your CONTACTS (the logbook), not just the settings. Once the logbook was split into its own file, the backup kept snapshotting the settings database and silently missed the QSOs. It now writes the log to opslog-*.db and the configuration separately to opslogcfg-*.db (MySQL logs still export to ADIF).",
"Performance: much lower CPU and memory on a busy cluster (RBN and other high-volume feeds). The Cluster tab was re-scanning the entire logbook for every batch of spots (~20×/second) and its spot-status cache grew without limit — on a firehose that could climb to gigabytes of RAM and peg a CPU. The worked-index is now cached (rebuilt only when you log a QSO) and the status cache is bounded to the spots actually shown."
],
"fr": [
"E-mail : le texte de l'e-mail d'enregistrement QSO (objet et corps) est désormais modifiable dans Réglages → E-mail, comme l'e-mail de carte QSL — avec les variables {CALL} {DATE} {BAND} {MODE} {MYCALL}.",
"Correction sauvegarde : la sauvegarde enregistre désormais tes CONTACTS (le journal), et plus seulement les réglages. Depuis que le journal a été séparé dans son propre fichier, la sauvegarde continuait à copier la base des réglages et oubliait les QSO. Elle écrit maintenant le log dans opslog-*.db et la configuration à part dans opslogcfg-*.db (les logs MySQL restent exportés en ADIF).",
"Performances : CPU et mémoire nettement réduits sur un cluster chargé (RBN et autres flux à fort volume). L'onglet Cluster rescannait tout le journal à chaque lot de spots (~20×/seconde) et son cache de statuts grossissait sans limite — sur un flux intense cela pouvait atteindre des gigaoctets de RAM et saturer un cœur. L'index des contacts est désormais mis en cache (reconstruit seulement quand tu logues un QSO) et le cache de statuts est borné aux spots réellement affichés."
]
},
{
"version": "0.23.7",
"date": "",
+16
View File
@@ -1504,6 +1504,22 @@ export default function App() {
// a stale closure.
const spotsRef = useRef(spots);
useEffect(() => { spotsRef.current = spots; }, [spots]);
// Bound the status cache. Keyed per call|band|mode, it otherwise kept an entry
// for every station ever seen — under an RBN firehose (thousands of unique
// calls/hour) that grew without limit to gigabytes. Prune it back to the live
// (SPOTS_CAP-limited) spots once it drifts well past them. The size check bails
// cheaply the rest of the time (returning the same reference, so no dependent
// memo re-runs); an evicted spot is just re-resolved if it reappears.
useEffect(() => {
setSpotStatus((prev) => {
const keys = Object.keys(prev);
if (keys.length <= SPOTS_CAP * 2) return prev;
const live = new Set(spots.map((x) => spotStatusKey(x.dx_call, x.band ?? '', x.comment ?? '', x.freq_hz)));
const pruned: typeof prev = {};
for (const k of keys) if (live.has(k)) pruned[k] = prev[k];
return pruned;
});
}, [spots]);
// Re-fetch the status of every SHOWN spot and OVERWRITE the cache (merge, never
// clear). Overwriting keeps the other NEW badges on screen until their fresh
// value lands, instead of blanking the whole grid and letting the badges pop
+14 -7
View File
@@ -424,15 +424,22 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
// won't re-render those cells on its own — force a refresh so e.g. a worked call
// turns blue once its status loads.
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
// refresh so e.g. a worked call turns blue once its status loads.
//
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
// into one refresh every 200 ms (still imperceptible) instead of one per update.
const refreshPending = useRef<number | undefined>(undefined);
useEffect(() => {
// Light refresh so status-dependent styling — the Call/Band/Mode colours AND
// the dimmed "represents nothing" class (cellClassRules above) — re-applies
// once a status lands, WITHOUT redrawing whole rows (which pegged the CPU).
gridRef.current?.api?.refreshCells({ force: true });
if (refreshPending.current !== undefined) return; // a refresh is already queued
refreshPending.current = window.setTimeout(() => {
refreshPending.current = undefined;
gridRef.current?.api?.refreshCells({ force: true });
}, 200);
}, [spotStatus]);
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
// Restore AFTER the profile scope is known — this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint.
+11
View File
@@ -5802,6 +5802,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</div>
<div className="pt-2 mt-2 border-t border-border space-y-2">
<Label className="text-sm font-semibold">{t('em.recEmail')}</Label>
<div className="text-[11px] text-muted-foreground">
{t('em.recVarsHint')} {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
</div>
<Input className="h-8" placeholder={t('em.subject')} value={emailCfg.subject}
onChange={(e) => setEmailField({ subject: e.target.value })} />
<Textarea rows={3} className="text-sm" placeholder={t('em.body')} value={emailCfg.body}
onChange={(e) => setEmailField({ body: e.target.value })} />
</div>
<div className="pt-2 mt-2 border-t border-border space-y-2">
<Label className="text-sm font-semibold">{t('em.qslCardEmail')}</Label>
<div className="text-[11px] text-muted-foreground">
+2 -2
View File
@@ -328,7 +328,7 @@ const en: Dict = {
// Email panel
'em.none': 'None', 'em.smtpAuth': 'SMTP requires authorization', 'em.username': 'Username', 'em.fromAddr': 'From address', 'em.replyTo': 'Reply-To address', 'em.replyToPh': '(optional — where replies go)', 'em.replyToHint': 'Leave blank to use the From address. Set it so correspondents reply to e.g. your personal inbox.',
'em.sendTest': 'Send test e-mail', 'em.sendingTest': 'Sending test…', 'em.testSent': 'Test e-mail sent ✓', 'em.testFailed': 'Test failed: ',
'em.qslCardEmail': 'OpsLog QSL card e-mail', 'em.qslVarsHint': 'Message sent with the QSL card. Variables:', 'em.subject': 'Subject', 'em.body': 'Body', 'em.autoSend': 'Auto-send OpsLog QSL when a QSO is logged', 'em.autoSendHint': 'Sends automatically only when the contact has an e-mail address and a default QSL template exists.',
'em.recEmail': 'QSO recording e-mail', 'em.recVarsHint': 'Message sent with the QSO audio recording. Variables:', 'em.qslCardEmail': 'OpsLog QSL card e-mail', 'em.qslVarsHint': 'Message sent with the QSL card. Variables:', 'em.subject': 'Subject', 'em.body': 'Body', 'em.autoSend': 'Auto-send OpsLog QSL when a QSO is logged', 'em.autoSendHint': 'Sends automatically only when the contact has an e-mail address and a default QSL template exists.',
'settings.title': 'Preferences',
'btn.cancel': 'Cancel', 'btn.save': 'Save', 'btn.saveClose': 'Save and close', 'btn.savingLong': 'Saving…',
// Component keys (chat / call history / band map / first-run / contest / adif extras)
@@ -734,7 +734,7 @@ const fr: Dict = {
'db.mysqlNote': "Seuls les QSO vont dans MySQL ; tes réglages, profils, stations et cluster restent locaux (et rapides). Les QSO locaux existants ne sont pas copiés — importe-les dans le journal partagé si tu veux ton historique là-bas.",
'em.none': 'Aucun', 'em.smtpAuth': 'Le SMTP requiert une authentification', 'em.username': 'Utilisateur', 'em.fromAddr': 'Adresse expéditeur', 'em.replyTo': 'Adresse de réponse', 'em.replyToPh': '(optionnel — où vont les réponses)', 'em.replyToHint': "Vide = utilise l'adresse expéditeur. Renseigne-la pour que les correspondants répondent p. ex. sur ta boîte perso.",
'em.sendTest': 'Envoyer un e-mail test', 'em.sendingTest': 'Envoi du test…', 'em.testSent': 'E-mail test envoyé ✓', 'em.testFailed': 'Échec du test : ',
'em.qslCardEmail': 'E-mail de carte QSL OpsLog', 'em.qslVarsHint': 'Message envoyé avec la carte QSL. Variables :', 'em.subject': 'Objet', 'em.body': 'Corps', 'em.autoSend': "Envoyer auto la QSL OpsLog à l'enregistrement d'un QSO", 'em.autoSendHint': "Envoi automatique uniquement si le contact a une adresse e-mail et qu'un modèle QSL par défaut existe.",
'em.recEmail': 'E-mail denregistrement QSO', 'em.recVarsHint': 'Message envoyé avec lenregistrement audio du QSO. Variables :', 'em.qslCardEmail': 'E-mail de carte QSL OpsLog', 'em.qslVarsHint': 'Message envoyé avec la carte QSL. Variables :', 'em.subject': 'Objet', 'em.body': 'Corps', 'em.autoSend': "Envoyer auto la QSL OpsLog à l'enregistrement d'un QSO", 'em.autoSendHint': "Envoi automatique uniquement si le contact a une adresse e-mail et qu'un modèle QSL par défaut existe.",
'settings.title': 'Préférences',
'btn.cancel': 'Annuler', 'btn.save': 'Enregistrer', 'btn.saveClose': 'Enregistrer et fermer', 'btn.savingLong': 'Enregistrement…',
'chatp.chat': 'Chat', 'chatp.online': 'En ligne', 'chatp.close': 'Fermer', 'chatp.noMessages': 'Aucun message pour le moment.', 'chatp.messagePh': 'Message…',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.23.7';
export const APP_VERSION = '0.23.8';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+6 -9
View File
@@ -44,7 +44,7 @@ func DefaultFolder(dataDir string) string {
// 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",
// 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) (string, error) {
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string) (string, error) {
if dbConn == nil {
return "", fmt.Errorf("nil db connection")
}
@@ -54,12 +54,15 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
if folder == "" {
return "", fmt.Errorf("backup folder not set")
}
if prefix == "" {
prefix = "opslog"
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
stamp := time.Now().Format("2006-01-02")
base := fmt.Sprintf("opslog-%s", stamp)
base := fmt.Sprintf("%s-%s", prefix, stamp)
// VACUUM INTO requires a non-existent target → use a temp file, then
// move/zip it into place.
@@ -92,7 +95,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
}
}
if err := rotate(folder, rotation); err != nil {
if err := rotateMatch(folder, rotation, prefix+"-", ".db", ".db.zip"); err != nil {
// Rotation errors are non-fatal — the backup itself succeeded.
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
}
@@ -203,12 +206,6 @@ func copyZipped(src, dst, innerName string) error {
return out.Close()
}
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
// opslog-*.db.zip) and deletes the rest.
func rotate(folder string, keep int) error {
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
}
// rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.23.7"
appVersion = "0.23.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.