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:
2026-07-27 13:48:11 +02:00
parent 2ad72b19fb
commit 139b4675e3
3 changed files with 162 additions and 4 deletions
+87 -2
View File
@@ -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
}