feat: rename/relocate a profile's SQLite logbook (QSOs carried across)

"Choose a dedicated file" points at a fresh/empty file; there was no way to
rename or move an existing logbook WITH its data. RenameLogbook VACUUM INTOs the
active profile's SQLite logbook to a new path, repoints the profile and switches
the live logbook (no restart). Deletes the old file only when it was a dedicated
per-profile file; the shared default logbook.db is left for other profiles.
Errors on a MySQL logbook. UI: "Rename / relocate…" button in the logbook section.
This commit is contained in:
2026-07-24 18:22:17 +02:00
parent 2b3d118d84
commit 77a2350240
6 changed files with 78 additions and 5 deletions
+55
View File
@@ -1806,6 +1806,61 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
return a.switchLogbook(p)
}
// RenameLogbook copies the ACTIVE profile's SQLite logbook to dest (with its
// QSOs), repoints the profile at it and switches the live logbook — no restart.
// Unlike "choose a dedicated file" (which points at a fresh/empty file), this
// carries the data across. The old file is deleted only when it was this
// profile's OWN dedicated file; the shared default logbook.db is left in place
// (other profiles may use it). Errors if the logbook is MySQL.
func (a *App) RenameLogbook(dest string) error {
dest = strings.TrimSpace(dest)
if dest == "" {
return fmt.Errorf("no destination given")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return fmt.Errorf("no active profile: %w", err)
}
if p.DB.Backend == "mysql" {
return fmt.Errorf("this profile's logbook is MySQL — no file to rename")
}
old := strings.TrimSpace(p.DB.Path)
wasDedicated := old != ""
if old == "" {
old = a.logbookPath
}
if old == "" || a.logDb == nil {
return fmt.Errorf("no logbook file to rename")
}
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
return fmt.Errorf("that is already the current logbook name")
}
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
safe := strings.ReplaceAll(dest, "'", "''")
if _, err := a.logDb.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
return fmt.Errorf("copy logbook: %w", err)
}
p.DB.Backend = "sqlite"
p.DB.Path = dest
if err := a.profiles.SetDB(a.ctx, p.ID, p.DB); err != nil {
return err
}
if err := a.switchLogbook(p); err != nil { // opens dest, closes the old conn
return err
}
if wasDedicated {
for _, f := range []string{old, old + "-wal", old + "-shm"} {
_ = os.Remove(f)
}
}
return nil
}
// TestMySQLConnection pings the shared MySQL database with the given settings
// (no migrations) so the user can validate connectivity from the UI.
func (a *App) TestMySQLConnection(s MySQLSettings) error {
+2 -2
View File
@@ -3,7 +3,7 @@
"version": "0.20.12",
"date": "2026-07-24",
"en": [
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut.",
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut, and a logbook file can be renamed/relocated (its QSOs move with it).",
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (Interlock is preventing transmission). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the follow the rig loop re-sent a tune command on almost every poll, and each command re-armed the block TX while the antenna moves window — so the interlock never released. The follow loop now keys its deadband off the rigs frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an interlock set reason= command that SmartSDR rejects (its read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR Tunable range setting (Settings → Hardware → Antenna, default 1354 MHz = 20 m6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m6 m SteppIR no longer tries to move it or touches the interlock.",
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA), matched from the QSO's address or QTH on HF. Each canton also recognises its main cities (Genève, Lausanne, Zürich, Bellinzona…), since operators rarely write the canton itself.",
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
@@ -17,7 +17,7 @@
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
],
"fr": [
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier ».",
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier », et un fichier journal peut être renommé/déplacé (ses QSO le suivent).",
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA), reconnus depuis l'adresse ou le QTH du QSO en HF. Chaque canton reconnaît aussi ses principales villes (Genève, Lausanne, Zürich, Bellinzone…), car les opérateurs écrivent rarement le canton lui-même.",
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
+13 -1
View File
@@ -29,7 +29,7 @@ import {
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, RevealDataFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, RevealDataFolder, RenameLogbook,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
@@ -4308,6 +4308,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
function revealFolder() { RevealDataFolder().catch((e: any) => setErr(String(e?.message ?? e))); }
// Rename/relocate THIS profile's logbook, carrying the QSOs across.
async function renameLogbook() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await RenameLogbook(p);
setMysqlField({ enabled: false, sqlite_path: p });
setRestartMsg(t('db.logbookRenamed'));
await refreshBackend();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Switching the logbook backend applies immediately (no restart): the local
// SQLite file always stays the config store; only the QSO logbook moves.
function useLocalLogbook() {
@@ -4423,6 +4434,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={pickSeparateSqlite}><FolderOpen className="size-3.5" /> {t('db.chooseFile')}</Button>
<Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
{mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
</div>
{restartMsg && <div className="text-xs text-success-muted-foreground">{restartMsg}</div>}
+2 -2
View File
@@ -287,7 +287,7 @@ const en: Dict = {
'prof.configId': 'Configuration ID', 'prof.description': 'Description', 'prof.new': 'New', 'prof.newTitle': 'Create a new empty profile', 'prof.dupTitle': 'Clone the selected profile (keeps all its fields)', 'prof.setActive': 'Set active', 'prof.setActiveTitle': 'Activate the selected profile — new QSOs will use its MY_* fields', 'prof.deleteTitle': 'Delete the selected profile', 'prof.cantDeleteLast': 'Cannot delete the last profile', 'prof.activeSuffix': ' (active)', 'prof.viewingNote': "You're viewing {name}. The active profile is {active} — its values are stamped on new QSOs. Click Set active to switch.",
// Database panel
'db.optSqlite': 'SQLite — local file', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
'db.logbookLabel': 'Logbook', 'db.openFolder': 'Open folder', 'db.dedicatedFile': 'dedicated file', 'db.useDefaultLogbook': 'Use the default logbook',
'db.logbookLabel': 'Logbook', 'db.openFolder': 'Open folder', 'db.dedicatedFile': 'dedicated file', 'db.useDefaultLogbook': 'Use the default logbook', 'db.renameLogbook': 'Rename / relocate…', 'db.renameLogbookTip': 'Rename or move this logbook file, carrying its QSOs across', 'db.logbookRenamed': 'Logbook renamed.',
'db.logbookFile': "This profile's logbook file", 'db.logbookFileHint': "Your QSOs live here — separate from the settings database. By default it's logbook.db next to your settings; choose a dedicated file to keep a visiting operator's contacts apart. A new file is created automatically.", 'db.chooseFile': 'Choose a dedicated file…', 'db.switchedSqliteFile': 'Logbook now uses a dedicated SQLite file.',
'db.appDb': 'Settings database (settings + profiles)', 'db.appDbHint': 'Holds your settings and profiles — NOT your QSOs (those are in the logbook below). Changing its location moves the whole install.',
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
@@ -645,7 +645,7 @@ const fr: Dict = {
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
'db.optSqlite': 'SQLite — fichier local', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
'db.logbookLabel': 'Journal', 'db.openFolder': 'Ouvrir le dossier', 'db.dedicatedFile': 'fichier dédié', 'db.useDefaultLogbook': 'Utiliser le journal par défaut',
'db.logbookLabel': 'Journal', 'db.openFolder': 'Ouvrir le dossier', 'db.dedicatedFile': 'fichier dédié', 'db.useDefaultLogbook': 'Utiliser le journal par défaut', 'db.renameLogbook': 'Renommer / déplacer…', 'db.renameLogbookTip': 'Renommer ou déplacer ce fichier journal, en emmenant ses QSO', 'db.logbookRenamed': 'Journal renommé.',
'db.logbookFile': 'Fichier journal de ce profil', 'db.logbookFileHint': "Tes QSO sont ici — séparés de la base de réglages. Par défaut c'est logbook.db à côté de tes réglages ; choisis un fichier dédié pour isoler les contacts d'un opérateur de passage. Un nouveau fichier est créé automatiquement.", 'db.chooseFile': 'Choisir un fichier dédié…', 'db.switchedSqliteFile': 'Le journal utilise désormais un fichier SQLite dédié.',
'db.appDb': 'Base de réglages (réglages + profils)', 'db.appDbHint': "Contient tes réglages et tes profils — PAS tes QSO (ceux-ci sont dans le journal ci-dessous). Changer son emplacement déplace toute l'installation.",
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
+2
View File
@@ -746,6 +746,8 @@ export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>;
export function RenameLogbook(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
+4
View File
@@ -1446,6 +1446,10 @@ export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1);
}
export function RenameLogbook(arg1) {
return window['go']['main']['App']['RenameLogbook'](arg1);
}
export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
}