diff --git a/app.go b/app.go index 82b08d4..c195868 100644 --- a/app.go +++ b/app.go @@ -9,6 +9,7 @@ import ( "io" "math" "os" + "os/exec" "path/filepath" "runtime/debug" "sort" @@ -620,17 +621,15 @@ func (a *App) startup(ctx context.Context) { // Windows reinstall. It lives OUTSIDE the DB since we must know the path // before opening it. if custom := readDBPointer(dataDir); custom != "" { - // Portability guard: a pointer that is merely ANOTHER folder's default DB - // location ("…//data/opslog.db") means the portable folder was - // renamed or copied — its config.json still points at the original. Ignore - // it and use THIS folder's own data (and clear the stale pointer so it - // stops happening). A genuine custom location — another drive, a different - // filename — is NOT default-style, so it's still honoured. - stale := strings.EqualFold(filepath.Base(custom), "opslog.db") && - strings.EqualFold(filepath.Base(filepath.Dir(custom)), "data") && - !strings.EqualFold(filepath.Clean(filepath.Dir(custom)), filepath.Clean(dataDir)) - if stale { - fmt.Printf("OpsLog: ignoring stale DB pointer %q (folder moved) — using %s\n", custom, a.dbPath) + // Honour any chosen database that still exists on disk. Only fall back to + // this folder's own default when the pointed-to file is genuinely GONE — + // the portable folder was copied to a machine without the original drive, + // or the path was deleted — and clear the now-dangling pointer so it stops + // trying. (The old heuristic guessed "folder moved" from the file name and + // wrongly discarded valid logbooks named …/data/opslog.db, sending the user + // back to the default every launch — the save appeared not to stick.) + if _, err := os.Stat(custom); err != nil { + fmt.Printf("OpsLog: chosen database %q not found (%v) — using %s\n", custom, err, a.dbPath) _ = writeDBPointer(dataDir, "") } else { a.dbPath = custom @@ -1527,6 +1526,26 @@ func (a *App) QuitApp() { } } +// RestartApp relaunches OpsLog and closes this instance, so a database change +// (the DB pointer is read once at startup) applies without the user manually +// reopening the app. The new process opens the SQLite files while this one is +// closing; SQLite's busy_timeout(5000) covers that brief overlap. +func (a *App) RestartApp() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locate executable: %w", err) + } + cmd := exec.Command(exe) + cmd.Dir = filepath.Dir(exe) + if err := cmd.Start(); err != nil { + return fmt.Errorf("relaunch OpsLog: %w", err) + } + if a.ctx != nil { + wruntime.Quit(a.ctx) + } + return nil +} + // reloadLookupProviders rebuilds the lookup chain from current settings. // Called at startup and after the user saves new credentials. // diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e64ed92..4c098d1 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3,7 +3,7 @@ import { ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ChevronDown, ChevronRight, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, - Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, + Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, @@ -26,9 +26,8 @@ import { ConnectClusterServer, DisconnectClusterServer, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, - GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase, + GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, - GetDataDir, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetTelemetryEnabled, SetTelemetryEnabled, GetLiveStatusEnabled, SetLiveStatusEnabled, @@ -988,7 +987,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [mysqlMsg, setMysqlMsg] = useState(''); const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply" const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null); - const [dataDir, setDataDir] = useState(''); const [clusterServers, setClusterServers] = useState([]); const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); @@ -1081,7 +1079,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan try { setDbSettings(await GetDatabaseSettings() as any); } catch {} try { setMysqlCfg(await GetMySQLSettings() as any); } catch {} try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} - try { setDataDir(await GetDataDir()); } catch {} try { const locs: any = await ListTQSLStationLocations(); setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); @@ -3506,13 +3503,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan function DatabasePanel() { async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} } + async function refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} } + // The chosen file is persisted (config.json) the moment it's picked; dbMsg + // holds the pending path so the banner can offer a one-click restart to load + // it (the DB pointer is only read at startup). async function openExisting() { try { const p = await PickOpenDatabase(); if (!p) return; await OpenDatabase(p); await refreshDb(); - setDbMsg(`Database set to:\n${p}\nRestart OpsLog to apply.`); + setDbMsg(p); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function saveCopy() { @@ -3521,7 +3522,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan if (!p) return; await MoveDatabase(p); await refreshDb(); - setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`); + setDbMsg(p); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function createNew() { @@ -3530,29 +3531,45 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan if (!p) return; await CreateDatabase(p); await refreshDb(); - setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`); + setDbMsg(p); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function resetDefault() { try { await ResetDatabaseToDefault(); await refreshDb(); - setDbMsg('Database reset to the default location. Restart OpsLog to apply.'); + setDbMsg(dbSettings.default_path || ''); } 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() { + SaveMySQLSettings({ ...mysqlCfg, enabled: false } as any) + .then(async () => { setMysqlField({ enabled: false }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); }) + .catch((e: any) => setErr(String(e?.message ?? e))); + } + function connectMysql() { + SaveMySQLSettings(mysqlCfg as any) + .then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); }) + .catch((e: any) => setErr(String(e?.message ?? e))); + } return ( <> - - {/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can - target its own database; choosing here and Save switches the live - logbook immediately (no restart). */} + + + {/* 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). */}
- +
-

- {t('db.profileHint')} -

+

{t('db.profileHint')}

- {/* Save (always visible) applies the active profile's DB target live. */} -
- - {restartMsg && {restartMsg}} -
- - {/* Active-backend status: confirms what OpsLog actually opened at launch. */} + {/* Compact active-backend confirmation / MySQL-fallback warning. */} {backendStatus && ( -
- {backendStatus.fallback ? ( -
- {t('db.fallback')} -
{backendStatus.error}
-
- ) : backendStatus.active === 'mysql' ? ( -
- {t('db.logbook')} {t('db.mysqlConnected')} - · - {t('db.config')} {t('db.localSqlite')} -
- {t('db.mysqlNote')} -
-
- ) : ( -
- {t('db.activeBackend')} {backendStatus.active} -
- )} -
+ backendStatus.fallback ? ( +
+ {t('db.fallback')} +
{backendStatus.error}
+
+ ) : ( +
+ {t('db.activeBackend')} {backendStatus.active} + {backendStatus.active === 'mysql' && · {t('db.configLocal')}} +
+ ) )} + {/* SQLite: local logbook file management */} {!mysqlCfg.enabled && (
@@ -3620,26 +3611,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
- - + + {dbSettings.is_custom && }
{dbMsg && ( -
- {dbMsg} - +
+
+ +
+
{t('db.savedRestart')}
+
{dbMsg}
+
+
+
+ + {t('db.restartHint')} +
)}
)} - {/* Shared MySQL database (multi-operator) */} + {/* MySQL: shared logbook connection (multi-operator) */} {mysqlCfg.enabled && (
-
- {t('db.mysqlHint')} -
+
{t('db.mysqlHint')}
setMysqlField({ host: e.target.value })} /> @@ -3652,28 +3652,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setMysqlField({ password: e.target.value })} />
-
+
+ {mysqlMsg}
)} - {/* Data location */} -
-
-
{t('db.dataLocation')}
-
-
- -
- {dataDir || '—'} -
-
-
+ {restartMsg &&
{restartMsg}
} {/* Backup settings, merged into this Database section. */}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index c9e98c2..c41f45c 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -149,6 +149,8 @@ const en: Dict = { // Database panel 'db.optSqlite': 'SQLite — local file (solo)', '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.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)', 'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:', 'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now', 'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ', @@ -334,6 +336,8 @@ const fr: Dict = { '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 (solo)', '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.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)', 'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :', 'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant', 'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 65a6e7a..d036d34 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -552,6 +552,8 @@ export function ResetAwardDefs():Promise>; export function ResetDatabaseToDefault():Promise; +export function RestartApp():Promise; + export function RestartQSORecorder():Promise; export function RotatorGoTo(arg1:number,arg2:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 41c22a1..c209bbf 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1066,6 +1066,10 @@ export function ResetDatabaseToDefault() { return window['go']['main']['App']['ResetDatabaseToDefault'](); } +export function RestartApp() { + return window['go']['main']['App']['RestartApp'](); +} + export function RestartQSORecorder() { return window['go']['main']['App']['RestartQSORecorder'](); }