diff --git a/app.go b/app.go index c74c84a..408d5b4 100644 --- a/app.go +++ b/app.go @@ -699,6 +699,20 @@ func (a *App) startup(ctx context.Context) { usingDefault = false } } + // A rename in a previous session left the OLD file to delete now that it's no + // longer open. Only ever delete a file that ISN'T the one we're about to use. + if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" { + old := strings.TrimSpace(boot.DeletePending) + if old != a.dbPath { + for _, p := range []string{old, old + "-wal", old + "-shm"} { + if err := os.Remove(p); err == nil { + fmt.Printf("OpsLog: removed old database file %s\n", p) + } + } + } + boot.DeletePending = "" + _ = writeBootstrap(dataDir, boot) + } if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil { a.startupErr = "cannot create db folder: " + err.Error() fmt.Println("OpsLog:", a.startupErr) @@ -1340,6 +1354,11 @@ func copyFileData(src, dst string) error { type dbPointer struct { DBPath string `json:"db_path"` MySQL *MySQLSettings `json:"mysql,omitempty"` + // DeletePending is the previous database file to remove on the NEXT launch — + // set by a rename, which can't delete the still-open old file in-process. The + // startup path deletes it (with its -wal/-shm sidecars) once the new DB is the + // one in use, then clears this. + DeletePending string `json:"delete_pending,omitempty"` } func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") } @@ -1653,9 +1672,10 @@ func (a *App) PickSaveDatabase() (string, error) { return "", fmt.Errorf("no app context") } return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ - Title: "Save the OpsLog database to…", - DefaultFilename: "opslog.db", - Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, + Title: "Save the OpsLog database to…", + DefaultDirectory: filepath.Dir(a.dbPath), + DefaultFilename: "opslog.db", + Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, }) } @@ -1697,6 +1717,44 @@ func (a *App) MoveDatabase(dest string) error { return writeDBPointer(a.dataDir, dest) } +// RenameDatabase renames the current database file to dest — keeping ALL config +// (it is the same database under a new name), unlike "New database" which starts +// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the +// ORIGINAL file is scheduled for deletion on the next launch (it is open now and +// can't be removed in-process on Windows). dest must not already exist. +func (a *App) RenameDatabase(dest string) error { + dest = strings.TrimSpace(dest) + if dest == "" { + return fmt.Errorf("no destination given") + } + if a.db == nil { + return fmt.Errorf("database not open") + } + old := a.dbPath + if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) { + return fmt.Errorf("that is already the current database 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.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil { + return fmt.Errorf("copy database: %w", err) + } + boot := readBootstrap(a.dataDir) + boot.DBPath = dest + // Only schedule the old file for deletion when it's a real, on-disk file (a + // custom path or the default opslog.db) — never something we somehow share + // with the destination. + if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) { + boot.DeletePending = old + } + return writeBootstrap(a.dataDir, boot) +} + // CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and // points OpsLog at it for the next launch. dest must not already exist. func (a *App) CreateDatabase(dest string) error { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index fb56074..03fc63e 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, Check, Eye, EyeOff, + Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, @@ -28,7 +28,7 @@ import { ConnectClusterServer, DisconnectClusterServer, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, - GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, + GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetTelemetryEnabled, SetTelemetryEnabled, @@ -3978,6 +3978,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setDbMsg(p); } catch (e: any) { setErr(String(e?.message ?? e)); } } + // Rename the CURRENT database (keeps all config), unlike New database which + // starts empty. The old file is removed on the next launch. + async function renameDb() { + try { + const p = await PickSaveDatabase(); + if (!p) return; + await RenameDatabase(p); + await refreshDb(); + setDbMsg(p); + } catch (e: any) { setErr(String(e?.message ?? e)); } + } async function resetDefault() { try { await ResetDatabaseToDefault(); @@ -4056,6 +4067,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
+ {dbSettings.is_custom && }
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index dc57e46..b01425b 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -244,7 +244,7 @@ const en: Dict = { '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.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.rename': 'Rename…', 'db.renameTip': 'Rename this database (keeps all your config). The old file is removed on the next launch.', '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: ', 'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.', 'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory', @@ -544,7 +544,7 @@ const fr: Dict = { '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.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.rename': 'Renommer…', 'db.renameTip': 'Renomme cette base (garde toute ta config). L’ancien fichier est supprimé au prochain lancement.', '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 : ', 'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.", 'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index c687633..c8ea0bb 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -678,6 +678,8 @@ export function ReloadUDPIntegrations():Promise>; export function RemovePassphrase(arg1:string):Promise; +export function RenameDatabase(arg1:string):Promise; + export function RenderEQSL(arg1:number,arg2:number):Promise; export function ReplaceAwardReferences(arg1:string,arg2:Array):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 3a3ab76..a5ddbe7 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1314,6 +1314,10 @@ export function RemovePassphrase(arg1) { return window['go']['main']['App']['RemovePassphrase'](arg1); } +export function RenameDatabase(arg1) { + return window['go']['main']['App']['RenameDatabase'](arg1); +} + export function RenderEQSL(arg1, arg2) { return window['go']['main']['App']['RenderEQSL'](arg1, arg2); }