Compare commits

...
3 Commits
Author SHA1 Message Date
rouggy 2b5c195ab4 chore: release v0.21.5 2026-07-27 13:49:19 +02:00
rouggy 139b4675e3 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.
2026-07-27 13:48:11 +02:00
rouggy 2ad72b19fb feat: separate column layout for Recent QSOs in the Main tab
The Main-tab pane and the full Recent QSOs tab are the same component sharing
one storage key, but the pane is about half as wide — so it wants fewer and
narrower columns. Whichever was opened last rewrote the other's widths.

The pane now stores under "mainpane", which also scopes its award-column
visibility and widths. The full tab keeps the historical key, so its layout is
untouched; the pane starts from defaults once.
2026-07-27 11:58:24 +02:00
6 changed files with 171 additions and 6 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
}
+6 -2
View File
@@ -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."
]
},
{
+5
View File
@@ -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 -1
View File
@@ -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';
+71
View File
@@ -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
View File
@@ -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.