Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88202efddb | ||
|
|
3f15608c59 | ||
|
|
e6a6f04ccf | ||
|
|
3b1a8ef01a | ||
|
|
fd097a647f | ||
|
|
b2bd818ac4 | ||
|
|
d71d09cbb6 | ||
|
|
557fb162c3 | ||
|
|
77a2350240 | ||
|
|
2b3d118d84 | ||
|
|
638ffcb326 | ||
|
|
bf4fba484a | ||
|
|
3ea7f44fd1 |
@@ -515,8 +515,10 @@ type App struct {
|
||||
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)
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string // active database file (may be a user-chosen location)
|
||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -711,7 +713,16 @@ func (a *App) startup(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
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
|
||||
// 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
|
||||
@@ -783,6 +794,23 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
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
|
||||
// SQLite file, so the station/profiles/settings are ready instantly. Doing
|
||||
// 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.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
|
||||
// US county resolver — its own local SQLite (data/uls.db), populated on demand
|
||||
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
|
||||
// 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.
|
||||
type DatabaseSettings struct {
|
||||
Path string `json:"path"`
|
||||
DefaultPath string `json:"default_path"`
|
||||
IsCustom bool `json:"is_custom"`
|
||||
Path string `json:"path"` // settings/config database (settings + profiles)
|
||||
DefaultPath string `json:"default_path"` // where the settings db lives by default
|
||||
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.
|
||||
func (a *App) GetDatabaseSettings() DatabaseSettings {
|
||||
def := filepath.Join(a.dataDir, "opslog.db")
|
||||
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
|
||||
// Default settings-db location: settings.db on fresh installs, opslog.db when
|
||||
// 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
|
||||
@@ -1563,6 +1616,9 @@ type MySQLSettings struct {
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
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
|
||||
@@ -1620,7 +1676,38 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
||||
}
|
||||
return c, "mysql", nil
|
||||
}
|
||||
return a.db, "sqlite", 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
|
||||
}
|
||||
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
|
||||
@@ -1684,6 +1771,7 @@ func (a *App) GetMySQLSettings() (MySQLSettings, error) {
|
||||
d := p.DB
|
||||
out.Enabled = d.Backend == "mysql"
|
||||
out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
|
||||
out.SqlitePath = d.Path
|
||||
if d.Port > 0 {
|
||||
out.Port = d.Port
|
||||
}
|
||||
@@ -1708,6 +1796,10 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
|
||||
cfg.Port = s.Port
|
||||
cfg.User = strings.TrimSpace(s.User)
|
||||
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 {
|
||||
return err
|
||||
@@ -1716,6 +1808,61 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
|
||||
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
|
||||
// (no migrations) so the user can validate connectivity from the UI.
|
||||
func (a *App) TestMySQLConnection(s MySQLSettings) error {
|
||||
@@ -1868,10 +2015,12 @@ func (a *App) groupDigitalSlots() bool {
|
||||
}
|
||||
|
||||
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
|
||||
// isn't wired yet. There's a brief window at launch where the frontend can
|
||||
// call this before OnStartup has opened the DB and built the store. The UI
|
||||
// isn't wired AND scoped to the active profile yet. There's a brief window at
|
||||
// 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
|
||||
// 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
|
||||
@@ -1882,8 +2031,8 @@ func (a *App) GetUIPref(key string) (string, error) {
|
||||
}
|
||||
|
||||
func (a *App) SetUIPref(key, value string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
if a.settings == nil || !a.settingsScoped.Load() {
|
||||
return fmt.Errorf("settings store not ready") // avoid seeding the wrong profile scope
|
||||
}
|
||||
return a.settings.Set(a.ctx, "ui."+key, value)
|
||||
}
|
||||
|
||||
+28
-2
@@ -1,10 +1,36 @@
|
||||
[
|
||||
{
|
||||
"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."
|
||||
],
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"date": "2026-07-24",
|
||||
"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 rig’s 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 (it’s 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 13–54 MHz = 20 m–6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m–6 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.",
|
||||
"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.",
|
||||
@@ -17,7 +43,7 @@
|
||||
],
|
||||
"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.",
|
||||
"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é.",
|
||||
"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.",
|
||||
|
||||
@@ -12,6 +12,34 @@ import { adif } from '@/../wailsjs/go/models';
|
||||
type FieldDef = adif.FieldDef;
|
||||
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
|
||||
// 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
|
||||
@@ -69,23 +97,14 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||
onExport(fields);
|
||||
};
|
||||
|
||||
const GroupCard = ({ title, tags, warn }: { title: string; tags: string[]; warn?: boolean }) => (
|
||||
<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={() => setMany(tags, true)}>{t('exf.all')}</button>
|
||||
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => setMany(tags, false)}>{t('exf.none')}</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) => toggle(name, !!c)} />
|
||||
<span className="font-mono break-all">{name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const allLabel = t('exf.all');
|
||||
const noneLabel = t('exf.none');
|
||||
const cardProps = {
|
||||
sel, allLabel, noneLabel,
|
||||
onAll: (tags: string[]) => setMany(tags, true),
|
||||
onNone: (tags: string[]) => setMany(tags, false),
|
||||
onToggle: toggle,
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* 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]) => (
|
||||
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} />
|
||||
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
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,
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
@@ -1297,9 +1297,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [backupRunning, setBackupRunning] = useState(false);
|
||||
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('');
|
||||
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 setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
|
||||
const [mysqlMsg, setMysqlMsg] = useState('');
|
||||
@@ -4307,13 +4307,48 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
setDbMsg(dbSettings.default_path || '');
|
||||
} 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
|
||||
// SQLite file always stays the config store; only the QSO logbook moves.
|
||||
function useLocalLogbook() {
|
||||
SaveMySQLSettings({ ...mysqlCfg, enabled: false } as any)
|
||||
.then(async () => { setMysqlField({ enabled: false }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
|
||||
SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: '' } as any)
|
||||
.then(async () => { setMysqlField({ enabled: false, sqlite_path: '' }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
|
||||
.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() {
|
||||
SaveMySQLSettings(mysqlCfg as any)
|
||||
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
|
||||
@@ -4323,18 +4358,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<>
|
||||
<SectionHeader title={t('sec.database')} />
|
||||
|
||||
{/* Logbook backend: local SQLite file (solo) or shared MySQL (multi-op).
|
||||
Switching is instant — no restart. Only changing the local SQLite
|
||||
FILE needs a restart (it also stores this operator's settings). */}
|
||||
{/* Settings / application database (settings + profiles) — always shown,
|
||||
distinct from the QSO logbook so the two are never confused. */}
|
||||
<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">
|
||||
<Label className="text-sm">{t('db.backend')}</Label>
|
||||
<Label className="text-sm">{t('db.logbookLabel')}</Label>
|
||||
<Select
|
||||
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||||
onValueChange={(v) => {
|
||||
const enabled = v === 'mysql';
|
||||
setMysqlField({ enabled });
|
||||
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>
|
||||
@@ -4356,50 +4404,29 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
) : (
|
||||
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
|
||||
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
|
||||
{backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* SQLite: local logbook file management */}
|
||||
{/* SQLite logbook file: default logbook.db, or a dedicated file for this profile. */}
|
||||
{!mysqlCfg.enabled && (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<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">
|
||||
{dbSettings.path || '—'}
|
||||
{dbSettings.is_custom
|
||||
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
|
||||
{mysqlCfg.sqlite_path || dbSettings.logbook_default_path || '—'}
|
||||
{mysqlCfg.sqlite_path
|
||||
? <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>}
|
||||
</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 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={openExisting}><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={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||
<Button variant="outline" size="sm" onClick={newLogbook}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={openLogbook}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
|
||||
{mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -442,14 +442,15 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed
|
||||
column count caps the container width so cards wrap onto more lines. Each
|
||||
card has a grip handle (left rail) as the drag initiator (the card body is
|
||||
full of buttons, so dragging the whole card was unreliable). */}
|
||||
<div className="flex flex-wrap gap-4 items-start"
|
||||
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}>
|
||||
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
|
||||
they pack tightly by height (no ragged gaps under short cards). "Auto"
|
||||
fits as many ~430px columns as the window allows; a fixed count caps the
|
||||
container width to that many columns. Each card has a grip handle (left
|
||||
rail) as the drag initiator (the body is full of buttons). */}
|
||||
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
||||
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
||||
{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'; } }}
|
||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||
<div draggable
|
||||
|
||||
@@ -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.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
|
||||
'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.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)',
|
||||
@@ -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.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.',
|
||||
'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.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)',
|
||||
|
||||
@@ -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.20.12';
|
||||
export const APP_VERSION = '0.21.0';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+4
@@ -746,6 +746,8 @@ export function RemovePassphrase(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 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 RevealDataFolder():Promise<void>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
|
||||
@@ -1446,6 +1446,10 @@ export function 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) {
|
||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||
}
|
||||
@@ -1482,6 +1486,10 @@ export function RetryOfflineSync() {
|
||||
return window['go']['main']['App']['RetryOfflineSync']();
|
||||
}
|
||||
|
||||
export function RevealDataFolder() {
|
||||
return window['go']['main']['App']['RevealDataFolder']();
|
||||
}
|
||||
|
||||
export function RotatorGoTo(arg1, arg2) {
|
||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -2109,6 +2109,7 @@ export namespace main {
|
||||
path: string;
|
||||
default_path: string;
|
||||
is_custom: boolean;
|
||||
logbook_default_path: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DatabaseSettings(source);
|
||||
@@ -2119,6 +2120,7 @@ export namespace main {
|
||||
this.path = source["path"];
|
||||
this.default_path = source["default_path"];
|
||||
this.is_custom = source["is_custom"];
|
||||
this.logbook_default_path = source["logbook_default_path"];
|
||||
}
|
||||
}
|
||||
export class DuplicateGroup {
|
||||
@@ -2315,6 +2317,7 @@ export namespace main {
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
sqlite_path?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MySQLSettings(source);
|
||||
@@ -2328,6 +2331,7 @@ export namespace main {
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
this.database = source["database"];
|
||||
this.sqlite_path = source["sqlite_path"];
|
||||
}
|
||||
}
|
||||
export class OfflineStatus {
|
||||
@@ -3365,6 +3369,7 @@ export namespace profile {
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
path?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProfileDB(source);
|
||||
@@ -3378,6 +3383,7 @@ export namespace profile {
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
this.database = source["database"];
|
||||
this.path = source["path"];
|
||||
}
|
||||
}
|
||||
export class Profile {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
],
|
||||
"total": 0,
|
||||
"builtin": true,
|
||||
"version": 1
|
||||
"version": 3
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
@@ -54,6 +54,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Aarau|Baden|Wettingen|Wohlen|Rheinfelden|Zofingen|Lenzburg|Brugg|Oftringen|Suhr|Spreitenbach)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -62,6 +63,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Appenzell|Oberegg)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -70,6 +72,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -78,6 +81,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Bern|Berne|Thun|Biel|Bienne|Köniz|Burgdorf|Langenthal|Steffisburg|Münsingen|Spiez|Interlaken|Ostermundigen|Lyss|Zollikofen)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -86,6 +90,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Liestal|Allschwil|Reinach|Muttenz|Pratteln|Binningen|Münchenstein|Birsfelden|Aesch|Sissach|Oberwil)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -94,6 +99,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -102,6 +108,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Fribourg|Freiburg|Bulle|Marly|Düdingen|Estavayer|Murten|Morat|Villars-sur-Glâne|Guin)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -110,6 +117,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"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
|
||||
},
|
||||
{
|
||||
@@ -118,6 +126,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -126,6 +135,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Chur|Coire|Kueblis|Davos|Sankt Moritz|St\\.? Moritz|Landquart|Arosa|Klosters|Ilanz|Thusis|Poschiavo|Domat|Ems)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -134,6 +144,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -142,6 +153,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Luzern|Lucerne|Emmen|Kriens|Horw|Ebikon|Sursee|Hochdorf|Willisau|Rothenburg)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -150,6 +162,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Neuchâtel|Neuchatel|Neuenburg|La Chaux-de-Fonds|Chaux-de-Fonds|Le Locle|Peseux|Boudry|Colombier|Marin)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -158,6 +171,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -166,6 +180,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -174,6 +189,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Sankt Gallen|Saint-Gall|Rapperswil|Wil|Gossau|Rorschach|Uzwil|Flawil|Altstätten|Jona)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -182,6 +198,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -190,6 +207,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -198,6 +216,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Schwyz|Einsiedeln|Freienbach|Küssnacht|Arth|Lachen|Wollerau|Brunnen|Goldau)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -206,6 +225,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Frauenfeld|Kreuzlingen|Arbon|Amriswil|Weinfelden|Romanshorn|Sirnach|Aadorf|Münchwilen)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -214,6 +234,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Lugano|Bellinzona|Locarno|Mendrisio|Chiasso|Biasca|Ascona|Losone|Minusio|Giubiasco|Massagno)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -222,6 +243,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -230,6 +252,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"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
|
||||
},
|
||||
{
|
||||
@@ -238,6 +261,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"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
|
||||
},
|
||||
{
|
||||
@@ -246,6 +270,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"pattern": "\\b(Zug|Zoug|Baar|Cham|Steinhausen|Risch|Rotkreuz|Hünenberg|Unterägeri|Oberägeri)\\b",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
@@ -254,6 +279,7 @@
|
||||
"dxcc": 287,
|
||||
"group": "",
|
||||
"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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -26,6 +26,13 @@ type ProfileDB struct {
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
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:
|
||||
|
||||
+18
-6
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// 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
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
@@ -85,15 +85,27 @@ func (a *App) sendTelemetryHeartbeat() {
|
||||
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{
|
||||
"api_key": posthogAPIKey,
|
||||
"event": "app_opened",
|
||||
"distinct_id": a.telemetryInstallID(),
|
||||
"distinct_id": distinctID,
|
||||
"properties": map[string]any{
|
||||
"version": appVersion,
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"$lib": "opslog",
|
||||
"version": appVersion,
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"install_id": installID,
|
||||
"$lib": "opslog",
|
||||
},
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user