Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb |
@@ -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
|
||||
}
|
||||
|
||||
+6
-2
@@ -4,11 +4,15 @@
|
||||
"date": "2026-07-27",
|
||||
"en": [
|
||||
"Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.",
|
||||
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier)."
|
||||
"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).",
|
||||
"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 amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur)."
|
||||
"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).",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4200,6 +4200,11 @@ export default function App() {
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<RecentQSOsGrid
|
||||
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||
// Its OWN layout, separate from the full-width Recent QSOs tab.
|
||||
// This pane is roughly half as wide, so it wants fewer columns and
|
||||
// narrower ones; sharing one key meant whichever was opened last
|
||||
// rewrote the other's widths.
|
||||
storageKey="mainpane"
|
||||
rows={qsosWithAwards as any}
|
||||
myGrid={station.my_grid}
|
||||
total={total}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.21.4';
|
||||
export const APP_VERSION = '0.21.5';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.21.4"
|
||||
appVersion = "0.21.5"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user