Compare commits

...
16 Commits
Author SHA1 Message Date
rouggy 18b69ee8b4 ui: drop the "← pick a reference" placeholder in the award ref selector
Empty-state dashed box added clutter; show nothing until a reference is picked.
2026-07-25 02:48:06 +02:00
rouggy 8c1b7af5b3 ui: compact the "will count for" detected-awards list in QSO details
In the QSO details Awards tab, the detected-refs line grew tall (DXCC/WAS/WAZ/
WAC/WPX/USA-CA… each with its full name), squishing the AwardRefSelector above so
you couldn't see the selected awards. Show just CODE@REF chips (full name on
hover), cap the block height with overflow scroll, and add a separator. Changelog 0.21.1.
2026-07-25 02:32:42 +02:00
rouggy 6e953ab1f4 feat: Ctrl+Up/Down hops spot-to-spot on the Main-view band map
Add a keyNav prop to BandMap: when set (only the docked Main-view map, not the
multi-band Band Map tab), Ctrl+ArrowUp / Ctrl+ArrowDown selects the next in-band
spot above / below the rig frequency and tunes to it via the existing spot-click
handler. Higher freq is up on the map (freqToY), so Up = next higher spot.
Ignored while typing in an input. Changelog 0.21.1.
2026-07-25 02:05:51 +02:00
rouggy 88202efddb ui: Station Control masonry layout — pack cards into balanced columns
flex-wrap with fixed-width cards started each new row below the TALLEST card of
the previous row, leaving big gaps under short panels (e.g. the amplifier alone
on a second line). Switch the dashboard to balanced CSS columns (column-width
430px, break-inside-avoid): cards flow top-to-bottom and pack tightly by height.
"Auto" fits as many columns as the window allows; 1-4 cap the column count via
the container max-width. Grip-drag reorder unchanged. Changelog 0.21.1.
2026-07-25 01:49:18 +02:00
rouggy 3f15608c59 fix: ADIF export field picker All/None buttons dead — hoist GroupCard
GroupCard was defined inside ExportFieldsDialog, so it got a new component
identity every render and React remounted the whole grid on each sel change,
making the per-group All/None buttons (and checkboxes) feel unresponsive. Hoist
GroupCard to module scope with sel + handlers passed as props. Changelog 0.21.1.
2026-07-25 00:51:32 +02:00
rouggy e6a6f04ccf docs: move theme fix to a new 0.21.1 (0.21.0 released) + shorten the entry 2026-07-24 19:21:32 +02:00
rouggy 3b1a8ef01a fix: theme sometimes reverts on reopen — gate per-profile UI prefs on scope
The theme (and other per-profile UI prefs) are read via a.settings.Get, which is
scoped to the active profile. GetUIPref only guarded on a.settings==nil, so in
the startup window AFTER NewStore but BEFORE SetProfile(active) it resolved with
the wrong (empty) scope. The frontend theme self-heal treats a resolved ""  as
"answered, unset → keep default, stop retrying", so a race landed on the light
default and stayed. Add a settingsScoped atomic flag set right after
SetProfile; GetUIPref/SetUIPref return "not ready" until then, so the frontend
keeps retrying and restores the real theme. Also prevents seeding prefs into the
wrong profile scope. Changelog entry added to 0.21.0 (EN+FR).
2026-07-24 19:14:26 +02:00
rouggy fd097a647f docs: un-mix 0.21.0 and 0.20.12 — 0.21.0 holds only the architecture change
0.20.12 was already released; restore its 11 entries under 0.20.12 and keep only
the two settings/logbook-split entries under 0.21.0.
2026-07-24 18:40:39 +02:00
rouggy b2bd818ac4 chore: release v0.21.0 2026-07-24 18:32:27 +02:00
rouggy d71d09cbb6 docs: bump upcoming release to 0.21.0 + prominent architecture-change warning
Big architecture change (settings/logbook DB split) warrants a minor bump. Rename
the unreleased 0.20.12 changelog block to 0.21.0 and prepend a clear ⚠️ warning
(EN+FR) explaining the automatic non-destructive split, that nothing is lost, and
advising a data-folder backup before updating.
2026-07-24 18:31:59 +02:00
rouggy 557fb162c3 ui: tidy Database panel — settings shows only Open-folder; DB actions on logbook
Per feedback: New/Open/Rename/Save-copy/Reset made no sense under the settings
database — moved the meaningful DB actions to the logbook (New database / Open
existing / Rename-relocate). The settings section now shows just its path + Open
folder. Also fixed the "Logbook switched…" confirmation rendering twice (dropped
the duplicate render in the SQLite block; kept the shared one). The unused
settings-db handlers/bindings stay for a later re-add.
2026-07-24 18:30:05 +02:00
rouggy 77a2350240 feat: rename/relocate a profile's SQLite logbook (QSOs carried across)
"Choose a dedicated file" points at a fresh/empty file; there was no way to
rename or move an existing logbook WITH its data. RenameLogbook VACUUM INTOs the
active profile's SQLite logbook to a new path, repoints the profile and switches
the live logbook (no restart). Deletes the old file only when it was a dedicated
per-profile file; the shared default logbook.db is left for other profiles.
Errors on a MySQL logbook. UI: "Rename / relocate…" button in the logbook section.
2026-07-24 18:22:17 +02:00
rouggy 2b3d118d84 feat: split settings DB from the QSO logbook (own file per profile)
Design flaw: opslog.db held BOTH the config (settings + profiles) AND the SQLite
logbook, so the only way to a separate SQLite logbook — the whole-app "change
database location" pointer — swapped the config too, wiping the operator's setup
(reported: creating a new SQLite for a visiting op lost all profiles/settings).

Now the two are separate files:
- Settings database: settings + profiles. Fresh installs name it settings.db;
  existing installs keep opslog.db.
- Logbook (QSOs): a dedicated logbook.db next to the settings db, or a
  per-profile file (profile.ProfileDB.Path), or MySQL. connectLogbook opens the
  logbook file (db.Open creates+migrates on first use); the settings db is used
  as the logbook only if the split ever fails.

One-time, non-destructive migration at startup: if there's no logbook file yet
but the settings db already holds QSOs (legacy combined opslog.db), seed
logbook.db with a clean copy via VACUUM INTO — contacts move to the logbook, the
originals stay in the settings db as an untouched backup.

UI: the Database panel now shows the settings database and this profile's logbook
as two clearly labelled sections (+ "Open folder"), and the logbook selector is
SQLite (default logbook.db, or a dedicated file via "Choose a dedicated file…")
vs MySQL — no more confusing shared/separate choice. New binding RevealDataFolder.
2026-07-24 18:13:08 +02:00
rouggy 638ffcb326 feat: per-profile separate SQLite logbook file (config never swapped)
Design flaw: the SQLite backend conflated the app/config database (opslog.db —
settings + profiles) with the QSO logbook. connectLogbook returned a.db for any
non-MySQL profile, so the ONLY way to get a separate SQLite logbook was the
whole-app "change database location" pointer — which swapped config + profiles
too, wiping the operator's setup (reported: creating Jerem.db lost all configs).

profile.ProfileDB gains a Path field: a non-empty path routes that profile's
logbook to its own SQLite file (db.Open creates + migrates it on first use),
while opslog.db keeps settings/profiles. connectLogbook opens the file; empty
path = shared db (unchanged default, backward compatible). MySQLSettings carries
sqlite_path; Get/Save wire it through.

UI: the Database panel gains a third backend option "SQLite — separate file"
with a file picker, and now clearly labels the shared application database
(settings + profiles) vs this profile's logbook, so the two are never confused.
2026-07-24 17:12:31 +02:00
rouggy bf4fba484a feat: H26 award recognises each canton by its main cities (regex per reference)
Operators rarely write the canton in their address/QTH but do write the city.
Add a per-reference city regex to every one of the 26 cantons (same mechanism as
WAPC): the description match now fires on the canton NAME or any of its main
cities (Genève→GE, Lausanne→VD, Zürich→ZH, Bellinzona→TI, …), with accented and
ASCII/localised spellings. Scoped to DXCC 287 so cross-border city-name clashes
can't misfire. Catalog version bumped 1→2 so the update reaches anyone already
running H26 v1 (unless they've edited it).
2026-07-24 16:29:59 +02:00
rouggy 3ea7f44fd1 telemetry: use station callsign as the PostHog distinct_id (dedupe users)
The random per-install UUID lives in the LOCAL SQLite, so one operator running
OpsLog on several machines (laptop / desktop / Maestro) or after a reinstall
counted as several distinct "users" — making the user total unreliable. Use the
station callsign as distinct_id when set (public in ham radio, stable across all
of an op's machines); the install UUID stays as a fallback and rides along as an
install_id property so per-machine detail isn't lost.
2026-07-24 16:20:23 +02:00
17 changed files with 434 additions and 106 deletions
+162 -13
View File
@@ -515,8 +515,10 @@ type App struct {
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location) settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
logbookPath string // default SQLite logbook file (QSOs), next to the settings db — used when a profile doesn't point elsewhere
logDb *sql.DB // QSO logbook connection — MySQL, a per-profile SQLite file, or the default logbook.db (never the settings db, except on fallback)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
@@ -711,7 +713,16 @@ func (a *App) startup(ctx context.Context) {
return return
} }
a.dataDir = dataDir a.dataDir = dataDir
a.dbPath = filepath.Join(dataDir, "opslog.db") // Settings/config database (settings + profiles). Fresh installs use
// settings.db; existing installs keep their opslog.db (which also held the
// QSOs before they were split into a dedicated logbook file — see below).
settingsDefault := filepath.Join(dataDir, "settings.db")
legacyOpslog := filepath.Join(dataDir, "opslog.db")
if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
a.dbPath = legacyOpslog
} else {
a.dbPath = settingsDefault
}
usingDefault := true usingDefault := true
// config.json (in the data dir) may point the database to a user-chosen // config.json (in the data dir) may point the database to a user-chosen
// location — e.g. another drive or a synced folder, so it survives a // location — e.g. another drive or a synced folder, so it survives a
@@ -783,6 +794,23 @@ func (a *App) startup(ctx context.Context) {
} }
a.db = conn a.db = conn
// The QSO logbook lives in its OWN file (logbook.db) next to the settings db,
// so QSOs never share the settings/profiles database. One-time split for
// existing installs: if there's no logbook file yet but the settings db
// already holds QSOs (the legacy combined opslog.db), seed logbook.db with a
// clean copy (VACUUM INTO) — the contacts move to the logbook while the
// originals stay in the settings db as an untouched backup. Non-destructive.
a.logbookPath = filepath.Join(filepath.Dir(a.dbPath), "logbook.db")
if !fileExists(a.logbookPath) && sqliteHasQSOs(a.db) {
esc := strings.ReplaceAll(a.logbookPath, "'", "''")
if _, verr := a.db.Exec("VACUUM INTO '" + esc + "'"); verr != nil {
applog.Printf("logbook split: VACUUM INTO %s failed (%v) — the settings db will serve as the logbook", a.logbookPath, verr)
a.logbookPath = "" // fall back to using the settings db as the logbook
} else {
fmt.Printf("OpsLog: split logbook — seeded %s from the existing database (originals kept as backup)\n", a.logbookPath)
}
}
// Wire the LOCAL config repos first — they're backed by the already-open // Wire the LOCAL config repos first — they're backed by the already-open
// SQLite file, so the station/profiles/settings are ready instantly. Doing // SQLite file, so the station/profiles/settings are ready instantly. Doing
// this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI // this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
@@ -821,6 +849,7 @@ func (a *App) startup(ctx context.Context) {
} }
} }
a.settings.SetProfile(active.ID) a.settings.SetProfile(active.ID)
a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
// US county resolver — its own local SQLite (data/uls.db), populated on demand // US county resolver — its own local SQLite (data/uls.db), populated on demand
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never // by DownloadULSCounties. Opening (creating an empty store) is cheap and never
// fatal: county resolution simply stays inert until the operator downloads it. // fatal: county resolution simply stays inert until the operator downloads it.
@@ -1542,15 +1571,39 @@ func writeDBPointer(dataDir, path string) error {
// DatabaseSettings describes the active database file for the Settings UI. // DatabaseSettings describes the active database file for the Settings UI.
type DatabaseSettings struct { type DatabaseSettings struct {
Path string `json:"path"` Path string `json:"path"` // settings/config database (settings + profiles)
DefaultPath string `json:"default_path"` DefaultPath string `json:"default_path"` // where the settings db lives by default
IsCustom bool `json:"is_custom"` IsCustom bool `json:"is_custom"` // config.json points it elsewhere
LogbookDefaultPath string `json:"logbook_default_path"` // default SQLite logbook file (QSOs), when a profile doesn't point elsewhere
} }
// GetDatabaseSettings returns where the active database lives. // GetDatabaseSettings returns where the active database lives.
func (a *App) GetDatabaseSettings() DatabaseSettings { func (a *App) GetDatabaseSettings() DatabaseSettings {
def := filepath.Join(a.dataDir, "opslog.db") // Default settings-db location: settings.db on fresh installs, opslog.db when
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def} // an existing one is present (mirrors the startup resolution).
settingsDefault := filepath.Join(a.dataDir, "settings.db")
legacyOpslog := filepath.Join(a.dataDir, "opslog.db")
def := settingsDefault
if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
def = legacyOpslog
}
lp := a.logbookPath
if lp == "" {
lp = a.dbPath // split disabled → the settings db doubles as the logbook
}
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def, LogbookDefaultPath: lp}
}
// RevealDataFolder opens the folder that holds the settings database (and the
// default logbook) in the OS file manager — the "where is my data" shortcut.
func (a *App) RevealDataFolder() error {
dir := filepath.Dir(a.dbPath)
return openInFileManager(dir)
}
// openInFileManager opens a folder in Windows Explorer (matches OpenAwardsFolder).
func openInFileManager(dir string) error {
return exec.Command("explorer", dir).Start()
} }
// MySQLSettings is the shared-database (multi-operator) connection config. When // MySQLSettings is the shared-database (multi-operator) connection config. When
@@ -1563,6 +1616,9 @@ type MySQLSettings struct {
User string `json:"user"` User string `json:"user"`
Password string `json:"password"` Password string `json:"password"`
Database string `json:"database"` Database string `json:"database"`
// SqlitePath, when set (and Enabled=false), routes this profile's logbook to
// its OWN SQLite file instead of the shared app database. Empty = shared.
SqlitePath string `json:"sqlite_path,omitempty"`
} }
// DBBackendStatus reports which backend OpsLog actually opened at startup so // DBBackendStatus reports which backend OpsLog actually opened at startup so
@@ -1620,7 +1676,38 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
} }
return c, "mysql", nil return c, "mysql", nil
} }
// SQLite logbook FILE, separate from the settings/config database. A profile
// may point at its own file (cfg.Path, e.g. a visiting operator's log); with
// no path it uses the default logbook.db beside the settings db. db.Open
// creates + migrates the file if it doesn't exist yet. Only when there is no
// default logbook path at all (VACUUM-INTO split failed) do we fall back to the
// settings db itself as the logbook.
lp := strings.TrimSpace(cfg.Path)
if lp == "" {
lp = a.logbookPath
}
if lp == "" {
return a.db, "sqlite", nil return a.db, "sqlite", nil
}
c, err := db.Open(lp)
if err != nil {
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
}
return c, "sqlite", nil
}
// sqliteHasQSOs reports whether the given (SQLite) database has at least one QSO
// row — used once at startup to decide whether to seed the split-out logbook
// file from a legacy combined database. Missing table / any error → false.
func sqliteHasQSOs(conn *sql.DB) bool {
if conn == nil {
return false
}
var n int
if err := conn.QueryRow("SELECT EXISTS(SELECT 1 FROM qso)").Scan(&n); err != nil {
return false
}
return n > 0
} }
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active // adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
@@ -1684,6 +1771,7 @@ func (a *App) GetMySQLSettings() (MySQLSettings, error) {
d := p.DB d := p.DB
out.Enabled = d.Backend == "mysql" out.Enabled = d.Backend == "mysql"
out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
out.SqlitePath = d.Path
if d.Port > 0 { if d.Port > 0 {
out.Port = d.Port out.Port = d.Port
} }
@@ -1708,6 +1796,10 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
cfg.Port = s.Port cfg.Port = s.Port
cfg.User = strings.TrimSpace(s.User) cfg.User = strings.TrimSpace(s.User)
cfg.Password = s.Password cfg.Password = s.Password
} else if sp := strings.TrimSpace(s.SqlitePath); sp != "" {
// Separate per-profile SQLite logbook file (config stays in opslog.db).
cfg.Backend = "sqlite"
cfg.Path = sp
} }
if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil { if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil {
return err return err
@@ -1716,6 +1808,61 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
return a.switchLogbook(p) return a.switchLogbook(p)
} }
// RenameLogbook copies the ACTIVE profile's SQLite logbook to dest (with its
// QSOs), repoints the profile at it and switches the live logbook — no restart.
// Unlike "choose a dedicated file" (which points at a fresh/empty file), this
// carries the data across. The old file is deleted only when it was this
// profile's OWN dedicated file; the shared default logbook.db is left in place
// (other profiles may use it). Errors if the logbook is MySQL.
func (a *App) RenameLogbook(dest string) error {
dest = strings.TrimSpace(dest)
if dest == "" {
return fmt.Errorf("no destination given")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return fmt.Errorf("no active profile: %w", err)
}
if p.DB.Backend == "mysql" {
return fmt.Errorf("this profile's logbook is MySQL — no file to rename")
}
old := strings.TrimSpace(p.DB.Path)
wasDedicated := old != ""
if old == "" {
old = a.logbookPath
}
if old == "" || a.logDb == nil {
return fmt.Errorf("no logbook file to rename")
}
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
return fmt.Errorf("that is already the current logbook name")
}
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
safe := strings.ReplaceAll(dest, "'", "''")
if _, err := a.logDb.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
return fmt.Errorf("copy logbook: %w", err)
}
p.DB.Backend = "sqlite"
p.DB.Path = dest
if err := a.profiles.SetDB(a.ctx, p.ID, p.DB); err != nil {
return err
}
if err := a.switchLogbook(p); err != nil { // opens dest, closes the old conn
return err
}
if wasDedicated {
for _, f := range []string{old, old + "-wal", old + "-shm"} {
_ = os.Remove(f)
}
}
return nil
}
// TestMySQLConnection pings the shared MySQL database with the given settings // TestMySQLConnection pings the shared MySQL database with the given settings
// (no migrations) so the user can validate connectivity from the UI. // (no migrations) so the user can validate connectivity from the UI.
func (a *App) TestMySQLConnection(s MySQLSettings) error { func (a *App) TestMySQLConnection(s MySQLSettings) error {
@@ -1868,10 +2015,12 @@ func (a *App) groupDigitalSlots() bool {
} }
func (a *App) GetUIPref(key string) (string, error) { func (a *App) GetUIPref(key string) (string, error) {
if a.settings == nil { if a.settings == nil || !a.settingsScoped.Load() {
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store // Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
// isn't wired yet. There's a brief window at launch where the frontend can // isn't wired AND scoped to the active profile yet. There's a brief window at
// call this before OnStartup has opened the DB and built the store. The UI // launch where the frontend can call this before it's ready — reading then
// returns the wrong profile's (empty) value, which stopped the theme
// self-heal early ("dark theme reverts to light on reopen"). The UI
// uses the error to keep RETRYING rather than treat it as "unset" and fall // uses the error to keep RETRYING rather than treat it as "unset" and fall
// back to a default — the "dark theme reverts to light after an update" bug // back to a default — the "dark theme reverts to light after an update" bug
// (the update cleared localStorage, and the DB read gave up too early while // (the update cleared localStorage, and the DB read gave up too early while
@@ -1882,8 +2031,8 @@ func (a *App) GetUIPref(key string) (string, error) {
} }
func (a *App) SetUIPref(key, value string) error { func (a *App) SetUIPref(key, value string) error {
if a.settings == nil { if a.settings == nil || !a.settingsScoped.Load() {
return fmt.Errorf("db not initialized") return fmt.Errorf("settings store not ready") // avoid seeding the wrong profile scope
} }
return a.settings.Set(a.ctx, "ui."+key, value) return a.settings.Set(a.ctx, "ui."+key, value)
} }
+32 -2
View File
@@ -1,10 +1,40 @@
[ [
{
"version": "0.21.1",
"date": "2026-07-24",
"en": [
"Fixed the colour theme sometimes reverting to the default when reopening OpsLog — it's now restored reliably.",
"ADIF export field picker: the per-group All / None buttons work again.",
"Station Control: cards now pack tightly into balanced columns instead of leaving big gaps under shorter panels — a cleaner, more even dashboard.",
"Main-view band map: Ctrl+↑ / Ctrl+↓ jumps to the next spot above / below the current frequency and tunes to it.",
"QSO details → Awards: the 'this contact will count for' list is now compact (CODE@REF chips, full name on hover) with a capped height, so it no longer hides the awards you've selected."
],
"fr": [
"Correction du thème qui revenait parfois au défaut à la réouverture d'OpsLog — il est maintenant restauré de façon fiable.",
"Sélecteur de champs à l'export ADIF : les boutons Tout / Aucun par groupe refonctionnent.",
"Station Control : les cartes se rangent en colonnes équilibrées et se tassent au lieu de laisser de gros trous sous les panneaux plus courts — tableau de bord plus propre et régulier.",
"Band map de l'écran principal : Ctrl+↑ / Ctrl+↓ saute au spot suivant au-dessus / en dessous de la fréquence courante et s'y accorde.",
"Détails du QSO → Diplômes : la liste « ce contact comptera pour » est maintenant compacte (pastilles CODE@REF, nom complet au survol) et de hauteur limitée, elle ne masque plus les diplômes sélectionnés."
]
},
{
"version": "0.21.0",
"date": "2026-07-24",
"en": [
"⚠️ IMPORTANT — ARCHITECTURE CHANGE. OpsLog now keeps your settings/profiles and your QSO logbook in SEPARATE database files (before, everything was in one file). On the first launch after this update, your existing database is split AUTOMATICALLY and non-destructively: your contacts are copied into a new logbook file (logbook.db) while the originals stay untouched in the settings database as a backup — nothing is deleted, no QSO is lost. Existing installs keep their opslog.db as the settings database; fresh installs name it settings.db. As a precaution, back up your OpsLog data folder before updating. Afterwards, Settings → Database shows the settings database and this profile's logbook as two separate sections.",
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut, and a logbook file can be renamed/relocated (its QSOs move with it)."
],
"fr": [
"⚠️ IMPORTANT — CHANGEMENT D'ARCHITECTURE. OpsLog stocke désormais tes réglages/profils et ton journal de QSO dans des fichiers de base de données SÉPARÉS (avant, tout était dans un seul fichier). Au premier lancement après cette mise à jour, ta base existante est scindée AUTOMATIQUEMENT et sans destruction : tes contacts sont copiés dans un nouveau fichier journal (logbook.db) tandis que les originaux restent intacts dans la base de réglages en sauvegarde — rien n'est supprimé, aucun QSO n'est perdu. Les installs existantes gardent leur opslog.db comme base de réglages ; les nouvelles installs la nomment settings.db. Par précaution, sauvegarde ton dossier de données OpsLog avant de mettre à jour. Ensuite, Réglages → Base de données affiche la base de réglages et le journal de ce profil en deux sections distinctes.",
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier », et un fichier journal peut être renommé/déplacé (ses QSO le suivent)."
]
},
{ {
"version": "0.20.12", "version": "0.20.12",
"date": "2026-07-24", "date": "2026-07-24",
"en": [ "en": [
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (Interlock is preventing transmission). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the follow the rig loop re-sent a tune command on almost every poll, and each command re-armed the block TX while the antenna moves window — so the interlock never released. The follow loop now keys its deadband off the rigs frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an interlock set reason= command that SmartSDR rejects (its read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR Tunable range setting (Settings → Hardware → Antenna, default 1354 MHz = 20 m6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m6 m SteppIR no longer tries to move it or touches the interlock.", "Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (Interlock is preventing transmission). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the follow the rig loop re-sent a tune command on almost every poll, and each command re-armed the block TX while the antenna moves window — so the interlock never released. The follow loop now keys its deadband off the rigs frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an interlock set reason= command that SmartSDR rejects (its read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR Tunable range setting (Settings → Hardware → Antenna, default 1354 MHz = 20 m6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m6 m SteppIR no longer tries to move it or touches the interlock.",
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA). Ships with the full canton reference list; matches the canton from the QSO's address or QTH, on HF, for Swiss (HB) contacts. Enable it in Awards and rescan to see your standings.", "New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA), matched from the QSO's address or QTH on HF. Each canton also recognises its main cities (Genève, Lausanne, Zürich, Bellinzona…), since operators rarely write the canton itself.",
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.", "FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.", "ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.", "Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
@@ -17,7 +47,7 @@
], ],
"fr": [ "fr": [
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.", "Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA). Livré avec la liste complète des cantons ; il reconnaît le canton depuis l'adresse ou le QTH du QSO, en HF, pour les contacts suisses (HB). Active-le dans les Diplômes et relance un scan pour voir ton avancement.", "Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA), reconnus depuis l'adresse ou le QTH du QSO en HF. Chaque canton reconnaît aussi ses principales villes (Genève, Lausanne, Zürich, Bellinzone…), car les opérateurs écrivent rarement le canton lui-même.",
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.", "Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.", "L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.", "Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
+1
View File
@@ -5320,6 +5320,7 @@ export default function App() {
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick} onSpotClick={handleSpotClick}
onClose={() => setBandMapShown(false)} onClose={() => setBandMapShown(false)}
keyNav
/> />
</div> </div>
)} )}
+2 -6
View File
@@ -216,8 +216,8 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span> <span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
</div> </div>
{/* Selected ref chip */} {/* Selected ref chip (nothing shown until one is picked) */}
{selectedRef ? ( {selectedRef && (
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0"> <div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span> <span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span> <span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
@@ -225,10 +225,6 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
<X className="size-3" /> <X className="size-3" />
</button> </button>
</div> </div>
) : (
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
{t('awrs.pickReference')}
</div>
)} )}
{/* Add — references are always scoped to the contacted DXCC */} {/* Add — references are always scoped to the contacted DXCC */}
+37 -1
View File
@@ -41,6 +41,10 @@ interface Props {
// globally from the band-map tab toolbar. // globally from the band-map tab toolbar.
hideDigital?: boolean; hideDigital?: boolean;
fitToBand?: boolean; fitToBand?: boolean;
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
// frequency (and tune to it). Only the docked Main-view band map sets this, so
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
keyNav?: boolean;
} }
const BAND_RANGES: Record<string, [number, number]> = { const BAND_RANGES: Record<string, [number, number]> = {
@@ -153,7 +157,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// last; ties broken by closeness to the rig freq). // last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30; const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) { export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const range = BAND_RANGES[band]; const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? []; const segments = SEGMENT_COLORS[band] ?? [];
@@ -362,6 +366,38 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2); scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
} }
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
// Only active on the docked Main-view map (keyNav) and ignored while typing.
useEffect(() => {
if (!keyNav) return;
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const ae = document.activeElement as HTMLElement | null;
const tag = (ae?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
const list = spots
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
.slice()
.sort((a, b) => a.freq_hz - b.freq_hz);
if (!list.length) return;
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
let target: Spot | undefined;
if (e.key === 'ArrowUp') {
target = list.find((s) => s.freq_hz > cur + EPS);
} else {
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
}
if (!target) return;
e.preventDefault();
onSpotClick(target);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0; const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
const showRigPointer = currentKHz >= lo && currentKHz <= hi; const showRigPointer = currentKHz >= lo && currentKHz <= hi;
const rigY = freqToY(currentKHz); const rigY = freqToY(currentKHz);
+3 -3
View File
@@ -306,11 +306,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
heightClass="flex-1 min-h-0" heightClass="flex-1 min-h-0"
/> />
{detected.length > 0 && ( {detected.length > 0 && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0"> <div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '} <span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
{detected.map((r) => ( {detected.map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono"> <span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null} <span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
</span> </span>
))} ))}
</div> </div>
+38 -19
View File
@@ -12,6 +12,34 @@ import { adif } from '@/../wailsjs/go/models';
type FieldDef = adif.FieldDef; type FieldDef = adif.FieldDef;
const PREF_KEY = 'opslog.exportFields'; const PREF_KEY = 'opslog.exportFields';
// One category card with its checkboxes + All/None. Defined at MODULE scope (not
// inside ExportFieldsDialog) so its component identity is stable across renders —
// an inner component is re-created every render, remounting the whole subtree and
// making the All/None buttons and checkboxes feel dead.
function GroupCard({ title, tags, warn, sel, allLabel, noneLabel, onAll, onNone, onToggle }: {
title: string; tags: string[]; warn?: boolean; sel: Set<string>;
allLabel: string; noneLabel: string;
onAll: (tags: string[]) => void; onNone: (tags: string[]) => void; onToggle: (name: string, on: boolean) => void;
}) {
return (
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
<div className="flex items-center justify-between mb-1 gap-2">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
<span className="flex gap-1.5 shrink-0">
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => onAll(tags)}>{allLabel}</button>
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => onNone(tags)}>{noneLabel}</button>
</span>
</div>
{tags.map((name) => (
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => onToggle(name, !!c)} />
<span className="font-mono break-all">{name}</span>
</label>
))}
</div>
);
}
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export // ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category) // writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
// and the OpsLog / non-standard tags actually present in the log's extras. The // and the OpsLog / non-standard tags actually present in the log's extras. The
@@ -69,23 +97,14 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
onExport(fields); onExport(fields);
}; };
const GroupCard = ({ title, tags, warn }: { title: string; tags: string[]; warn?: boolean }) => ( const allLabel = t('exf.all');
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}> const noneLabel = t('exf.none');
<div className="flex items-center justify-between mb-1 gap-2"> const cardProps = {
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span> sel, allLabel, noneLabel,
<span className="flex gap-1.5 shrink-0"> onAll: (tags: string[]) => setMany(tags, true),
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => setMany(tags, true)}>{t('exf.all')}</button> onNone: (tags: string[]) => setMany(tags, false),
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => setMany(tags, false)}>{t('exf.none')}</button> onToggle: toggle,
</span> };
</div>
{tags.map((name) => (
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => toggle(name, !!c)} />
<span className="font-mono break-all">{name}</span>
</label>
))}
</div>
);
return ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
@@ -104,9 +123,9 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1"> <div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
{/* OpsLog / non-standard group first (most relevant to keep or drop). */} {/* OpsLog / non-standard group first (most relevant to keep or drop). */}
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn />} {extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn {...cardProps} />}
{groups.map(([g, list]) => ( {groups.map(([g, list]) => (
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} /> <GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
))} ))}
</div> </div>
+71 -44
View File
@@ -29,7 +29,7 @@ import {
ConnectClusterServer, DisconnectClusterServer, ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, RevealDataFolder, RenameLogbook,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled, GetTelemetryEnabled, SetTelemetryEnabled,
@@ -1297,9 +1297,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [backupRunning, setBackupRunning] = useState(false); const [backupRunning, setBackupRunning] = useState(false);
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null); const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false }); const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean; logbook_default_path?: string }>({ path: '', default_path: '', is_custom: false });
const [dbMsg, setDbMsg] = useState(''); const [dbMsg, setDbMsg] = useState('');
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string }; type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string; sqlite_path?: string };
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' }); const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch })); const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
const [mysqlMsg, setMysqlMsg] = useState(''); const [mysqlMsg, setMysqlMsg] = useState('');
@@ -4307,13 +4307,48 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setDbMsg(dbSettings.default_path || ''); setDbMsg(dbSettings.default_path || '');
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
function revealFolder() { RevealDataFolder().catch((e: any) => setErr(String(e?.message ?? e))); }
// Rename/relocate THIS profile's logbook, carrying the QSOs across.
async function renameLogbook() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await RenameLogbook(p);
setMysqlField({ enabled: false, sqlite_path: p });
setRestartMsg(t('db.logbookRenamed'));
await refreshBackend();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Switching the logbook backend applies immediately (no restart): the local // Switching the logbook backend applies immediately (no restart): the local
// SQLite file always stays the config store; only the QSO logbook moves. // SQLite file always stays the config store; only the QSO logbook moves.
function useLocalLogbook() { function useLocalLogbook() {
SaveMySQLSettings({ ...mysqlCfg, enabled: false } as any) SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: '' } as any)
.then(async () => { setMysqlField({ enabled: false }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); }) .then(async () => { setMysqlField({ enabled: false, sqlite_path: '' }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
.catch((e: any) => setErr(String(e?.message ?? e))); .catch((e: any) => setErr(String(e?.message ?? e)));
} }
// Point THIS profile's logbook at a SQLite file (settings stay in the
// settings db). A NEW name is created + migrated empty; an existing file is
// opened with its QSOs.
async function newLogbook() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
setMysqlField({ enabled: false, sqlite_path: p });
setRestartMsg(t('db.switchedSqliteFile'));
await refreshBackend();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function openLogbook() {
try {
const p = await PickOpenDatabase();
if (!p) return;
await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
setMysqlField({ enabled: false, sqlite_path: p });
setRestartMsg(t('db.switchedSqliteFile'));
await refreshBackend();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
function connectMysql() { function connectMysql() {
SaveMySQLSettings(mysqlCfg as any) SaveMySQLSettings(mysqlCfg as any)
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); }) .then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
@@ -4323,18 +4358,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<> <>
<SectionHeader title={t('sec.database')} /> <SectionHeader title={t('sec.database')} />
{/* Logbook backend: local SQLite file (solo) or shared MySQL (multi-op). {/* Settings / application database (settings + profiles) — always shown,
Switching is instant — no restart. Only changing the local SQLite distinct from the QSO logbook so the two are never confused. */}
FILE needs a restart (it also stores this operator's settings). */} <div className="space-y-2 max-w-2xl mb-5 border border-border/60 rounded-md p-3">
<Label>{t('db.appDb')}</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dbSettings.path || '—'}
{dbSettings.is_custom
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
</div>
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
</div>
</div>
{/* Logbook (QSOs) — this profile: default SQLite file, a dedicated file, or MySQL. */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1"> <div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<Label className="text-sm">{t('db.backend')}</Label> <Label className="text-sm">{t('db.logbookLabel')}</Label>
<Select <Select
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
onValueChange={(v) => { onValueChange={(v) => {
const enabled = v === 'mysql';
setMysqlField({ enabled });
setRestartMsg(''); setRestartMsg('');
if (!enabled) useLocalLogbook(); // switching to local applies at once if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
}} }}
> >
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
@@ -4356,50 +4404,29 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
) : ( ) : (
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground"> <div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong> {t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
{backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
</div> </div>
) )
)} )}
{/* SQLite: local logbook file management */} {/* SQLite logbook file: default logbook.db, or a dedicated file for this profile. */}
{!mysqlCfg.enabled && ( {!mysqlCfg.enabled && (
<div className="space-y-4 max-w-2xl"> <div className="space-y-3 max-w-2xl">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('db.current')}</Label> <Label>{t('db.logbookFile')}</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all"> <div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dbSettings.path || '—'} {mysqlCfg.sqlite_path || dbSettings.logbook_default_path || '—'}
{dbSettings.is_custom {mysqlCfg.sqlite_path
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span> ? <span className="ml-2 text-[10px] text-success">{t('db.dedicatedFile')}</span>
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>} : <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
</div> </div>
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</span></div> <p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button> <Button variant="outline" size="sm" onClick={newLogbook}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button> <Button variant="outline" size="sm" onClick={openLogbook}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button> <Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button> {mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</div> </div>
{dbMsg && (
<div className="text-xs bg-success-muted border border-success-border text-success-muted-foreground rounded-md px-3 py-3 space-y-2">
<div className="flex items-start gap-2">
<Check className="size-4 mt-0.5 shrink-0" />
<div>
<div className="font-medium">{t('db.savedRestart')}</div>
<div className="font-mono text-[10px] mt-1 break-all opacity-90">{dbMsg}</div>
</div>
</div>
<div className="flex items-center gap-2 pl-6">
<Button size="sm" onClick={() => { RestartApp().catch((e: any) => setErr(String(e?.message ?? e))); }}>
<Power className="size-3.5" /> {t('db.restartNow')}
</Button>
<span className="text-[10px] opacity-80">{t('db.restartHint')}</span>
</div>
</div>
)}
</div> </div>
)} )}
@@ -442,14 +442,15 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
)} )}
{/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed {/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
column count caps the container width so cards wrap onto more lines. Each they pack tightly by height (no ragged gaps under short cards). "Auto"
card has a grip handle (left rail) as the drag initiator (the card body is fits as many ~430px columns as the window allows; a fixed count caps the
full of buttons, so dragging the whole card was unreliable). */} container width to that many columns. Each card has a grip handle (left
<div className="flex flex-wrap gap-4 items-start" rail) as the drag initiator (the body is full of buttons). */}
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}> <div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
{ordered.map((w) => ( {ordered.map((w) => (
<div key={w.id} className="flex items-stretch w-[430px]" <div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}> onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable <div draggable
+8 -2
View File
@@ -286,7 +286,10 @@ const en: Dict = {
'prof.hint': 'Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile.', 'prof.active': 'ACTIVE', 'prof.duplicate': 'Duplicate', 'prof.delete': 'Delete', 'prof.profileName': 'Profile name', 'prof.hint': 'Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile.', 'prof.active': 'ACTIVE', 'prof.duplicate': 'Duplicate', 'prof.delete': 'Delete', 'prof.profileName': 'Profile name',
'prof.configId': 'Configuration ID', 'prof.description': 'Description', 'prof.new': 'New', 'prof.newTitle': 'Create a new empty profile', 'prof.dupTitle': 'Clone the selected profile (keeps all its fields)', 'prof.setActive': 'Set active', 'prof.setActiveTitle': 'Activate the selected profile — new QSOs will use its MY_* fields', 'prof.deleteTitle': 'Delete the selected profile', 'prof.cantDeleteLast': 'Cannot delete the last profile', 'prof.activeSuffix': ' (active)', 'prof.viewingNote': "You're viewing {name}. The active profile is {active} — its values are stamped on new QSOs. Click Set active to switch.", 'prof.configId': 'Configuration ID', 'prof.description': 'Description', 'prof.new': 'New', 'prof.newTitle': 'Create a new empty profile', 'prof.dupTitle': 'Clone the selected profile (keeps all its fields)', 'prof.setActive': 'Set active', 'prof.setActiveTitle': 'Activate the selected profile — new QSOs will use its MY_* fields', 'prof.deleteTitle': 'Delete the selected profile', 'prof.cantDeleteLast': 'Cannot delete the last profile', 'prof.activeSuffix': ' (active)', 'prof.viewingNote': "You're viewing {name}. The active profile is {active} — its values are stamped on new QSOs. Click Set active to switch.",
// Database panel // Database panel
'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.', 'db.optSqlite': 'SQLite — local file', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
'db.logbookLabel': 'Logbook', 'db.openFolder': 'Open folder', 'db.dedicatedFile': 'dedicated file', 'db.useDefaultLogbook': 'Use the default logbook', 'db.renameLogbook': 'Rename / relocate…', 'db.renameLogbookTip': 'Rename or move this logbook file, carrying its QSOs across', 'db.logbookRenamed': 'Logbook renamed.',
'db.logbookFile': "This profile's logbook file", 'db.logbookFileHint': "Your QSOs live here — separate from the settings database. By default it's logbook.db next to your settings; choose a dedicated file to keep a visiting operator's contacts apart. A new file is created automatically.", 'db.chooseFile': 'Choose a dedicated file…', 'db.switchedSqliteFile': 'Logbook now uses a dedicated SQLite file.',
'db.appDb': 'Settings database (settings + profiles)', 'db.appDbHint': 'Holds your settings and profiles — NOT your QSOs (those are in the logbook below). Changing its location moves the whole install.',
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓', 'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use', 'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)', 'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
@@ -641,7 +644,10 @@ const fr: Dict = {
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil', 'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil', 'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.', 'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.', 'db.optSqlite': 'SQLite — fichier local', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
'db.logbookLabel': 'Journal', 'db.openFolder': 'Ouvrir le dossier', 'db.dedicatedFile': 'fichier dédié', 'db.useDefaultLogbook': 'Utiliser le journal par défaut', 'db.renameLogbook': 'Renommer / déplacer…', 'db.renameLogbookTip': 'Renommer ou déplacer ce fichier journal, en emmenant ses QSO', 'db.logbookRenamed': 'Journal renommé.',
'db.logbookFile': 'Fichier journal de ce profil', 'db.logbookFileHint': "Tes QSO sont ici — séparés de la base de réglages. Par défaut c'est logbook.db à côté de tes réglages ; choisis un fichier dédié pour isoler les contacts d'un opérateur de passage. Un nouveau fichier est créé automatiquement.", 'db.chooseFile': 'Choisir un fichier dédié…', 'db.switchedSqliteFile': 'Le journal utilise désormais un fichier SQLite dédié.',
'db.appDb': 'Base de réglages (réglages + profils)', 'db.appDbHint': "Contient tes réglages et tes profils — PAS tes QSO (ceux-ci sont dans le journal ci-dessous). Changer son emplacement déplace toute l'installation.",
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓', 'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser', 'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)', 'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.12'; export const APP_VERSION = '0.21.0';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+4
View File
@@ -746,6 +746,8 @@ export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>; export function RenameDatabase(arg1:string):Promise<void>;
export function RenameLogbook(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>; export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>; export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
@@ -764,6 +766,8 @@ export function RestartQSORecorder():Promise<void>;
export function RetryOfflineSync():Promise<number>; export function RetryOfflineSync():Promise<number>;
export function RevealDataFolder():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>; export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>; export function RotatorPark():Promise<void>;
+8
View File
@@ -1446,6 +1446,10 @@ export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1); return window['go']['main']['App']['RenameDatabase'](arg1);
} }
export function RenameLogbook(arg1) {
return window['go']['main']['App']['RenameLogbook'](arg1);
}
export function RenderEQSL(arg1, arg2) { export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2); return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
} }
@@ -1482,6 +1486,10 @@ export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync'](); return window['go']['main']['App']['RetryOfflineSync']();
} }
export function RevealDataFolder() {
return window['go']['main']['App']['RevealDataFolder']();
}
export function RotatorGoTo(arg1, arg2) { export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2); return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
} }
+6
View File
@@ -2109,6 +2109,7 @@ export namespace main {
path: string; path: string;
default_path: string; default_path: string;
is_custom: boolean; is_custom: boolean;
logbook_default_path: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new DatabaseSettings(source); return new DatabaseSettings(source);
@@ -2119,6 +2120,7 @@ export namespace main {
this.path = source["path"]; this.path = source["path"];
this.default_path = source["default_path"]; this.default_path = source["default_path"];
this.is_custom = source["is_custom"]; this.is_custom = source["is_custom"];
this.logbook_default_path = source["logbook_default_path"];
} }
} }
export class DuplicateGroup { export class DuplicateGroup {
@@ -2315,6 +2317,7 @@ export namespace main {
user: string; user: string;
password: string; password: string;
database: string; database: string;
sqlite_path?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new MySQLSettings(source); return new MySQLSettings(source);
@@ -2328,6 +2331,7 @@ export namespace main {
this.user = source["user"]; this.user = source["user"];
this.password = source["password"]; this.password = source["password"];
this.database = source["database"]; this.database = source["database"];
this.sqlite_path = source["sqlite_path"];
} }
} }
export class OfflineStatus { export class OfflineStatus {
@@ -3365,6 +3369,7 @@ export namespace profile {
user: string; user: string;
password: string; password: string;
database: string; database: string;
path?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ProfileDB(source); return new ProfileDB(source);
@@ -3378,6 +3383,7 @@ export namespace profile {
this.user = source["user"]; this.user = source["user"];
this.password = source["password"]; this.password = source["password"];
this.database = source["database"]; this.database = source["database"];
this.path = source["path"];
} }
} }
export class Profile { export class Profile {
+27 -1
View File
@@ -45,7 +45,7 @@
], ],
"total": 0, "total": 0,
"builtin": true, "builtin": true,
"version": 1 "version": 3
}, },
"references": [ "references": [
{ {
@@ -54,6 +54,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Aarau|Baden|Wettingen|Wohlen|Rheinfelden|Zofingen|Lenzburg|Brugg|Oftringen|Suhr|Spreitenbach)\\b",
"valid": true "valid": true
}, },
{ {
@@ -62,6 +63,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Appenzell|Oberegg)\\b",
"valid": true "valid": true
}, },
{ {
@@ -70,6 +72,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
"valid": true "valid": true
}, },
{ {
@@ -78,6 +81,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Bern|Berne|Thun|Biel|Bienne|Köniz|Burgdorf|Langenthal|Steffisburg|Münsingen|Spiez|Interlaken|Ostermundigen|Lyss|Zollikofen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -86,6 +90,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Liestal|Allschwil|Reinach|Muttenz|Pratteln|Binningen|Münchenstein|Birsfelden|Aesch|Sissach|Oberwil)\\b",
"valid": true "valid": true
}, },
{ {
@@ -94,6 +99,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -102,6 +108,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Fribourg|Freiburg|Bulle|Marly|Düdingen|Estavayer|Murten|Morat|Villars-sur-Glâne|Guin)\\b",
"valid": true "valid": true
}, },
{ {
@@ -110,6 +117,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Genève|Geneve|Sezenove|Chene-Bourg|Chene Bourg|Geneva|Genf|Carouge|Vernier|Lancy|Meyrin|Onex|Thônex|Thonex|Versoix|Bernex|Plan-les-Ouates)\\b",
"valid": true "valid": true
}, },
{ {
@@ -118,6 +126,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
"valid": true "valid": true
}, },
{ {
@@ -126,6 +135,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Chur|Coire|Kueblis|Davos|Sankt Moritz|St\\.? Moritz|Landquart|Arosa|Klosters|Ilanz|Thusis|Poschiavo|Domat|Ems)\\b",
"valid": true "valid": true
}, },
{ {
@@ -134,6 +144,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
"valid": true "valid": true
}, },
{ {
@@ -142,6 +153,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Luzern|Lucerne|Emmen|Kriens|Horw|Ebikon|Sursee|Hochdorf|Willisau|Rothenburg)\\b",
"valid": true "valid": true
}, },
{ {
@@ -150,6 +162,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Neuchâtel|Neuchatel|Neuenburg|La Chaux-de-Fonds|Chaux-de-Fonds|Le Locle|Peseux|Boudry|Colombier|Marin)\\b",
"valid": true "valid": true
}, },
{ {
@@ -158,6 +171,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -166,6 +180,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
"valid": true "valid": true
}, },
{ {
@@ -174,6 +189,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Sankt Gallen|Saint-Gall|Rapperswil|Wil|Gossau|Rorschach|Uzwil|Flawil|Altstätten|Jona)\\b",
"valid": true "valid": true
}, },
{ {
@@ -182,6 +198,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -190,6 +207,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
"valid": true "valid": true
}, },
{ {
@@ -198,6 +216,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Schwyz|Einsiedeln|Freienbach|Küssnacht|Arth|Lachen|Wollerau|Brunnen|Goldau)\\b",
"valid": true "valid": true
}, },
{ {
@@ -206,6 +225,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Frauenfeld|Kreuzlingen|Arbon|Amriswil|Weinfelden|Romanshorn|Sirnach|Aadorf|Münchwilen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -214,6 +234,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Lugano|Bellinzona|Locarno|Mendrisio|Chiasso|Biasca|Ascona|Losone|Minusio|Giubiasco|Massagno)\\b",
"valid": true "valid": true
}, },
{ {
@@ -222,6 +243,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -230,6 +252,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Lausanne|Chavornay|Blonay|Saint-Cergue|St-Cergue|St Cergue|Yverdon|Gingins|Montreux|Nyon|Vevey|Renens|Morges|Gland|Pully|Prilly|Ecublens|Aigle|Payerne|Rolle|Lutry|Bussigny)\\b",
"valid": true "valid": true
}, },
{ {
@@ -238,6 +261,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Sion|Sitten|Martigny|Monthey|Sierre|Brig|Brigue|Naters|Visp|Viège|Conthey|Fully|Verbier|Zermatt|Crans-Montana|Saas-Fee|Savièse|Bagnes)\\b",
"valid": true "valid": true
}, },
{ {
@@ -246,6 +270,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Zug|Zoug|Baar|Cham|Steinhausen|Risch|Rotkreuz|Hünenberg|Unterägeri|Oberägeri)\\b",
"valid": true "valid": true
}, },
{ {
@@ -254,6 +279,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Zürich|Zurich|Winterthur|Uster|Dübendorf|Dietikon|Wetzikon|Kloten|Wädenswil|Horgen|Thalwil|Opfikon|Küsnacht|Meilen|Bülach|Regensdorf|Schlieren|Adliswil|Volketswil)\\b",
"valid": true "valid": true
} }
] ]
+7
View File
@@ -26,6 +26,13 @@ type ProfileDB struct {
User string `json:"user"` User string `json:"user"`
Password string `json:"password"` Password string `json:"password"`
Database string `json:"database"` Database string `json:"database"`
// Path is a per-profile SQLite logbook FILE, distinct from the shared app
// database (opslog.db, which always holds settings + profiles). Empty =
// the shared database is used as the logbook (the historical default). Set =
// this profile's QSOs live in their own .db, so a visiting operator's contacts
// don't mix into yours and switching it never touches your config. Only used
// when Backend != "mysql".
Path string `json:"path,omitempty"`
} }
// Profile is one operating configuration. A user typically keeps a few: // Profile is one operating configuration. A user typically keeps a few:
+14 -2
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.12" appVersion = "0.21.0"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
@@ -85,14 +85,26 @@ func (a *App) sendTelemetryHeartbeat() {
return // already counted today return // already counted today
} }
// distinct_id identifies the USER. Prefer the station callsign — it's stable
// across every machine/reinstall the same operator runs, so one op counts as
// one user (a callsign is public in amateur radio). The random per-install ID
// is only a fallback until a callsign is configured; without it the same op on
// a laptop + desktop + Maestro showed up as three "users". install_id rides
// along as a property so multiple machines under one callsign stay visible.
installID := a.telemetryInstallID()
distinctID := installID
if call := a.activeCallsign(); call != "" {
distinctID = call
}
payload := map[string]any{ payload := map[string]any{
"api_key": posthogAPIKey, "api_key": posthogAPIKey,
"event": "app_opened", "event": "app_opened",
"distinct_id": a.telemetryInstallID(), "distinct_id": distinctID,
"properties": map[string]any{ "properties": map[string]any{
"version": appVersion, "version": appVersion,
"os": runtime.GOOS, "os": runtime.GOOS,
"arch": runtime.GOARCH, "arch": runtime.GOARCH,
"install_id": installID,
"$lib": "opslog", "$lib": "opslog",
}, },
"timestamp": time.Now().UTC().Format(time.RFC3339), "timestamp": time.Now().UTC().Format(time.RFC3339),