fix: portable folder — stop storing absolute database paths
Moving the folder (C:\OpsLog → D:\OpsLog, or onto a stick) broke everything: config.json and each profile's logbook path were absolute, so on the new machine the pointer named a drive that no longer applied. The logbook case was the dangerous one. db.Open CREATES what is missing, so if the stale C: path happened to be creatable, the operator silently got a NEW EMPTY logbook instead of an error — with their QSOs sitting untouched in the folder they had just copied. A path inside the application folder is now stored relative to it and resolved against the current location at read time. A path OUTSIDE it (a synced folder, a chosen drive) stays absolute and untouched — that is a deliberate choice; it is only re-rooted when it has gone missing AND a file of the same name exists in this install's data folder, i.e. the copied-folder case. With no such twin the path is left alone so the failure is reported rather than papered over with an unrelated database. Both rules are pinned by tests.
This commit is contained in:
@@ -1517,6 +1517,74 @@ type dbPointer struct {
|
||||
|
||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||
|
||||
// ── Portable paths ─────────────────────────────────────────────────────
|
||||
//
|
||||
// OpsLog is meant to be carried on a stick or copied between machines: the exe,
|
||||
// its data folder and the databases travel together. Storing "C:\OpsLog\data\
|
||||
// logbook.db" broke that — dropped into D:\OpsLog on another PC, the pointer
|
||||
// still named C:, and the app either lost the database or (worse) silently
|
||||
// created a NEW empty one at a C: path that happened to be creatable.
|
||||
//
|
||||
// So a path INSIDE the application folder is stored relative to it, and any
|
||||
// stored path is resolved against the CURRENT location at read time. Absolute
|
||||
// paths outside the folder (a deliberate choice — a synced folder, another
|
||||
// drive) keep working exactly as before: they are only re-rooted if they have
|
||||
// gone missing AND the same file exists in this install's data folder.
|
||||
|
||||
// appDir is the folder the running exe lives in — the anchor for relative paths.
|
||||
func appDir() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
// portablePath prepares a path for STORAGE: relative to the app folder when it
|
||||
// sits inside it, unchanged otherwise. Forward slashes so the value survives a
|
||||
// round trip through a folder copied between machines.
|
||||
func portablePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
base := appDir()
|
||||
if p == "" || base == "" || !filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
rel, err := filepath.Rel(base, p)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return p // outside the app folder — the user meant that exact place
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
// resolvePath turns a stored path back into an absolute one for THIS install.
|
||||
// dataDir is where the app's own data lives, used for the re-rooting rescue.
|
||||
func resolvePath(dataDir, p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
if base := appDir(); base != "" {
|
||||
return filepath.Join(base, filepath.FromSlash(p))
|
||||
}
|
||||
return filepath.FromSlash(p)
|
||||
}
|
||||
if fileExists(p) {
|
||||
return p
|
||||
}
|
||||
// An absolute path from another machine. Rescue it ONLY if a file of the same
|
||||
// name is sitting in this install's data folder — that is the copied-folder
|
||||
// case. Anything else is left alone so a genuinely-missing database is
|
||||
// reported rather than silently replaced by an unrelated file.
|
||||
if dataDir != "" {
|
||||
if cand := filepath.Join(dataDir, filepath.Base(p)); fileExists(cand) {
|
||||
applog.Printf("path: %q not found — using %q from this install (folder moved?)", p, cand)
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ── Window geometry (window.json) ──────────────────────────────────────
|
||||
//
|
||||
// Remembered across restarts so the window reopens where and how you left it.
|
||||
@@ -1636,12 +1704,16 @@ func readBootstrap(dataDir string) dbPointer {
|
||||
return c
|
||||
}
|
||||
_ = json.Unmarshal(b, &c)
|
||||
c.DBPath = strings.TrimSpace(c.DBPath)
|
||||
// Stored relative when it lives inside the app folder, so the pointer follows
|
||||
// the folder from C:OpsLog to D:OpsLog or to a stick.
|
||||
c.DBPath = resolvePath(dataDir, c.DBPath)
|
||||
c.DeletePending = resolvePath(dataDir, c.DeletePending)
|
||||
return c
|
||||
}
|
||||
|
||||
func writeBootstrap(dataDir string, c dbPointer) error {
|
||||
c.DBPath = strings.TrimSpace(c.DBPath)
|
||||
c.DBPath = portablePath(c.DBPath)
|
||||
c.DeletePending = portablePath(c.DeletePending)
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
||||
}
|
||||
@@ -1779,6 +1851,15 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
||||
if lp == "" {
|
||||
return a.db, "sqlite", nil
|
||||
}
|
||||
// Resolve against THIS install before opening. Without it, a profile carried
|
||||
// from C:\OpsLog to D:\OpsLog kept naming the C: path — and since db.Open
|
||||
// CREATES what is missing, the operator silently got a brand-new empty
|
||||
// logbook instead of an error. Their QSOs were still on disk, in the folder
|
||||
// they had just copied.
|
||||
if r := resolvePath(a.dataDir, lp); r != lp {
|
||||
applog.Printf("logbook: profile path %q resolved to %q", lp, r)
|
||||
lp = r
|
||||
}
|
||||
c, err := db.Open(lp)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
|
||||
@@ -11960,6 +12041,10 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
|
||||
if a.profiles == nil {
|
||||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
// Store a logbook that lives inside the app folder RELATIVE to it, so the
|
||||
// profile keeps working when the folder is copied to another machine or
|
||||
// another drive letter.
|
||||
p.DB.Path = portablePath(p.DB.Path)
|
||||
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
||||
return profile.Profile{}, err
|
||||
}
|
||||
|
||||
+4
-2
@@ -5,12 +5,14 @@
|
||||
"en": [
|
||||
"Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.",
|
||||
"Recent QSOs keeps a separate column layout in the Main tab and in its own tab — the pane is half as wide there.",
|
||||
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier)."
|
||||
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier).",
|
||||
"Portable folder: moving OpsLog to another drive or PC (C:OpsLog → D:OpsLog) no longer loses the databases — paths inside the folder are now stored relative to it."
|
||||
],
|
||||
"fr": [
|
||||
"Les dispositions de colonnes (largeurs, ordre, colonnes masquées) tiennent enfin — cinq défauts les défaisaient.",
|
||||
"Les QSO récents gardent une disposition de colonnes distincte dans l'onglet Principal et dans leur propre onglet — le volet y est deux fois moins large.",
|
||||
"Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur)."
|
||||
"Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur).",
|
||||
"Dossier portable : déplacer OpsLog sur un autre disque ou un autre PC (C:OpsLog → D:OpsLog) ne fait plus perdre les bases — les chemins internes au dossier sont désormais enregistrés relativement à celui-ci."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The portable-folder contract: a database that lives inside the application
|
||||
// folder must be stored relative to it, so copying C:\OpsLog to D:\OpsLog (or to
|
||||
// a USB stick) keeps working. A path the operator deliberately put elsewhere
|
||||
// must be left exactly as it is.
|
||||
func TestPortablePath(t *testing.T) {
|
||||
base := appDir()
|
||||
if base == "" {
|
||||
t.Skip("no executable dir available")
|
||||
}
|
||||
|
||||
inside := filepath.Join(base, "data", "logbook.db")
|
||||
if got := portablePath(inside); got != "data/logbook.db" {
|
||||
t.Errorf("inside the app folder: got %q, want %q", got, "data/logbook.db")
|
||||
}
|
||||
|
||||
// Outside: an absolute path on another drive / a synced folder is a
|
||||
// deliberate choice and must survive untouched.
|
||||
outside := filepath.FromSlash("Z:/Sync/ham/logbook.db")
|
||||
if got := portablePath(outside); got != outside {
|
||||
t.Errorf("outside the app folder: got %q, want it unchanged", got)
|
||||
}
|
||||
|
||||
if got := portablePath(""); got != "" {
|
||||
t.Errorf("empty path: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePath(t *testing.T) {
|
||||
base := appDir()
|
||||
if base == "" {
|
||||
t.Skip("no executable dir available")
|
||||
}
|
||||
|
||||
// A relative path is anchored to the CURRENT install, whatever drive it is on.
|
||||
want := filepath.Join(base, "data", "logbook.db")
|
||||
if got := resolvePath("", "data/logbook.db"); got != want {
|
||||
t.Errorf("relative: got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// An absolute path that still exists is honoured as-is.
|
||||
dir := t.TempDir()
|
||||
real := filepath.Join(dir, "logbook.db")
|
||||
if err := os.WriteFile(real, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolvePath(dir, real); got != real {
|
||||
t.Errorf("existing absolute: got %q, want %q", got, real)
|
||||
}
|
||||
|
||||
// The rescue: a path from ANOTHER machine, with the same file present in this
|
||||
// install's data folder — the copied-folder case.
|
||||
stale := filepath.FromSlash("C:/OldPC/OpsLog/data/logbook.db")
|
||||
if got := resolvePath(dir, stale); got != real {
|
||||
t.Errorf("stale absolute with a local twin: got %q, want %q", got, real)
|
||||
}
|
||||
|
||||
// No local twin: leave it alone so the failure is REPORTED rather than
|
||||
// silently replaced by an unrelated database.
|
||||
missing := filepath.FromSlash("C:/OldPC/OpsLog/data/other.db")
|
||||
if got := resolvePath(dir, missing); got != missing {
|
||||
t.Errorf("stale absolute with no twin: got %q, want it unchanged", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user