diff --git a/app.go b/app.go index 8b46517..f5d46ae 100644 --- a/app.go +++ b/app.go @@ -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 } diff --git a/changelog.json b/changelog.json index 5af26d4..67f5fc1 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/portable_path_test.go b/portable_path_test.go new file mode 100644 index 0000000..060b7f8 --- /dev/null +++ b/portable_path_test.go @@ -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) + } +}