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:
@@ -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) */}
|
||||
|
||||
@@ -286,9 +286,10 @@ const en: Dict = {
|
||||
'prof.hint': 'Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile.', 'prof.active': 'ACTIVE', 'prof.duplicate': 'Duplicate', 'prof.delete': 'Delete', 'prof.profileName': 'Profile name',
|
||||
'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 — shared local database (default)', 'db.optSqliteFile': 'SQLite — separate file (this profile only)', '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.logbookFile': "This profile's logbook file", 'db.logbookFileHint': "Only this profile's QSOs live here. Your settings and profiles stay in the shared app database, so switching this never affects your other profiles — ideal for a visiting operator. A new name is created automatically.", 'db.chooseFile': 'Choose file…', 'db.switchedSqliteFile': 'Logbook now uses a separate SQLite file.',
|
||||
'db.appDb': 'Shared application database', 'db.appDbHint': 'Holds your settings, profiles and (for this profile) the QSOs. Changing its location moves the WHOLE install — settings and profiles included.',
|
||||
'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.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 ✓',
|
||||
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
||||
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
||||
@@ -643,9 +644,10 @@ const fr: Dict = {
|
||||
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
||||
'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 — base locale partagée (défaut)', 'db.optSqliteFile': 'SQLite — fichier séparé (ce profil seulement)', '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.logbookFile': 'Fichier journal de ce profil', 'db.logbookFileHint': "Seuls les QSO de ce profil y sont stockés. Tes réglages et profils restent dans la base applicative partagée, donc changer ceci n'affecte jamais tes autres profils — idéal pour un opérateur de passage. Un nouveau nom est créé automatiquement.", 'db.chooseFile': 'Choisir le fichier…', 'db.switchedSqliteFile': 'Le journal utilise désormais un fichier SQLite séparé.',
|
||||
'db.appDb': 'Base applicative partagée', 'db.appDbHint': "Contient tes réglages, tes profils et (pour ce profil) les QSO. Changer son emplacement déplace TOUTE l'installation — réglages et profils compris.",
|
||||
'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.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 ✓',
|
||||
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
||||
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
||||
|
||||
Vendored
+2
@@ -764,6 +764,8 @@ export function RestartQSORecorder():Promise<void>;
|
||||
|
||||
export function RetryOfflineSync():Promise<number>;
|
||||
|
||||
export function RevealDataFolder():Promise<void>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
|
||||
@@ -1482,6 +1482,10 @@ export function RetryOfflineSync() {
|
||||
return window['go']['main']['App']['RetryOfflineSync']();
|
||||
}
|
||||
|
||||
export function RevealDataFolder() {
|
||||
return window['go']['main']['App']['RevealDataFolder']();
|
||||
}
|
||||
|
||||
export function RotatorGoTo(arg1, arg2) {
|
||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -2109,6 +2109,7 @@ export namespace main {
|
||||
path: string;
|
||||
default_path: string;
|
||||
is_custom: boolean;
|
||||
logbook_default_path: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DatabaseSettings(source);
|
||||
@@ -2119,6 +2120,7 @@ export namespace main {
|
||||
this.path = source["path"];
|
||||
this.default_path = source["default_path"];
|
||||
this.is_custom = source["is_custom"];
|
||||
this.logbook_default_path = source["logbook_default_path"];
|
||||
}
|
||||
}
|
||||
export class DuplicateGroup {
|
||||
|
||||
Reference in New Issue
Block a user