feat: split settings DB from the QSO logbook (own file per profile)

Design flaw: opslog.db held BOTH the config (settings + profiles) AND the SQLite
logbook, so the only way to a separate SQLite logbook — the whole-app "change
database location" pointer — swapped the config too, wiping the operator's setup
(reported: creating a new SQLite for a visiting op lost all profiles/settings).

Now the two are separate files:
- Settings database: settings + profiles. Fresh installs name it settings.db;
  existing installs keep opslog.db.
- Logbook (QSOs): a dedicated logbook.db next to the settings db, or a
  per-profile file (profile.ProfileDB.Path), or MySQL. connectLogbook opens the
  logbook file (db.Open creates+migrates on first use); the settings db is used
  as the logbook only if the split ever fails.

One-time, non-destructive migration at startup: if there's no logbook file yet
but the settings db already holds QSOs (legacy combined opslog.db), seed
logbook.db with a clean copy via VACUUM INTO — contacts move to the logbook, the
originals stay in the settings db as an untouched backup.

UI: the Database panel now shows the settings database and this profile's logbook
as two clearly labelled sections (+ "Open folder"), and the logbook selector is
SQLite (default logbook.db, or a dedicated file via "Choose a dedicated file…")
vs MySQL — no more confusing shared/separate choice. New binding RevealDataFolder.
This commit is contained in:
2026-07-24 18:13:08 +02:00
parent 638ffcb326
commit 2b3d118d84
8 changed files with 205 additions and 131 deletions
+69 -76
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,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, RevealDataFolder,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
@@ -1297,7 +1297,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [backupRunning, setBackupRunning] = useState(false);
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean; logbook_default_path?: string }>({ path: '', default_path: '', is_custom: false });
const [dbMsg, setDbMsg] = useState('');
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string; sqlite_path?: string };
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
@@ -4307,6 +4307,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setDbMsg(dbSettings.default_path || '');
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
function revealFolder() { RevealDataFolder().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() {
@@ -4335,87 +4336,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<>
<SectionHeader title={t('sec.database')} />
{/* Logbook backend: local SQLite file (solo) or shared MySQL (multi-op).
Switching is instant — no restart. Only changing the local SQLite
FILE needs a restart (it also stores this operator's settings). */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<Label className="text-sm">{t('db.backend')}</Label>
<Select
value={mysqlCfg.enabled ? 'mysql' : (mysqlCfg.sqlite_path ? 'sqlite_file' : 'sqlite')}
onValueChange={(v) => {
setRestartMsg('');
if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
if (v === 'sqlite') { useLocalLogbook(); return; } // shared app db
// sqlite_file: keep any existing path; if none, prompt to pick one.
setMysqlField({ enabled: false });
if (!mysqlCfg.sqlite_path) pickSeparateSqlite();
}}
>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
<SelectItem value="sqlite_file">{t('db.optSqliteFile')}</SelectItem>
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
{backendStatus && (
backendStatus.fallback ? (
<div className="max-w-2xl mb-4 text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
{t('db.fallback')}
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
</div>
) : (
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
{backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
</div>
)
)}
{/* SQLite: local logbook file management */}
{/* Separate per-profile SQLite logbook FILE (config stays in opslog.db) */}
{!mysqlCfg.enabled && mysqlCfg.sqlite_path && (
<div className="space-y-3 max-w-2xl">
<div className="space-y-1">
<Label>{t('db.logbookFile')}</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">{mysqlCfg.sqlite_path}</div>
<p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
{/* Settings / application database (settings + profiles) — always shown,
distinct from the QSO logbook so the two are never confused. */}
<div className="space-y-2 max-w-2xl mb-5 border border-border/60 rounded-md p-3">
<Label>{t('db.appDb')}</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dbSettings.path || '—'}
{dbSettings.is_custom
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
</div>
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={pickSeparateSqlite}><FolderOpen className="size-3.5" /> {t('db.chooseFile')}</Button>
</div>
{restartMsg && <div className="text-xs text-success-muted-foreground">{restartMsg}</div>}
</div>
)}
{/* Shared application/config database (opslog.db) = settings + profiles +
this profile's QSOs when it uses the default logbook. */}
{!mysqlCfg.enabled && !mysqlCfg.sqlite_path && (
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
<Label>{t('db.appDb')}</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dbSettings.path || '—'}
{dbSettings.is_custom
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
</div>
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</span></div>
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
<Button variant="outline" size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</div>
{dbMsg && (
<div className="text-xs bg-success-muted border border-success-border text-success-muted-foreground rounded-md px-3 py-3 space-y-2">
<div className="flex items-start gap-2">
@@ -4434,6 +4373,60 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
</div>
{/* Logbook (QSOs) — this profile: default SQLite file, a dedicated file, or MySQL. */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<Label className="text-sm">{t('db.logbookLabel')}</Label>
<Select
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
onValueChange={(v) => {
setRestartMsg('');
if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
}}
>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
{backendStatus && (
backendStatus.fallback ? (
<div className="max-w-2xl mb-4 text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
{t('db.fallback')}
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
</div>
) : (
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
</div>
)
)}
{/* SQLite logbook file: default logbook.db, or a dedicated file for this profile. */}
{!mysqlCfg.enabled && (
<div className="space-y-3 max-w-2xl">
<div className="space-y-1">
<Label>{t('db.logbookFile')}</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{mysqlCfg.sqlite_path || dbSettings.logbook_default_path || '—'}
{mysqlCfg.sqlite_path
? <span className="ml-2 text-[10px] text-success">{t('db.dedicatedFile')}</span>
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
</div>
<p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={pickSeparateSqlite}><FolderOpen className="size-3.5" /> {t('db.chooseFile')}</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>}
</div>
)}
{/* MySQL: shared logbook connection (multi-operator) */}