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.
This commit is contained in:
@@ -515,8 +515,9 @@ 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)
|
dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
|
||||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
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 +712,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 +793,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
|
||||||
@@ -1542,15 +1569,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
|
||||||
@@ -1623,18 +1674,38 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
|||||||
}
|
}
|
||||||
return c, "mysql", nil
|
return c, "mysql", nil
|
||||||
}
|
}
|
||||||
// A per-profile SQLite logbook FILE (separate from the app/config database).
|
// SQLite logbook FILE, separate from the settings/config database. A profile
|
||||||
// db.Open creates it and runs the schema migrations if it doesn't exist yet,
|
// may point at its own file (cfg.Path, e.g. a visiting operator's log); with
|
||||||
// so pointing a profile at a fresh name just works. Empty path = the shared
|
// no path it uses the default logbook.db beside the settings db. db.Open
|
||||||
// app database (a.db) is the logbook, as before.
|
// creates + migrates the file if it doesn't exist yet. Only when there is no
|
||||||
if p := strings.TrimSpace(cfg.Path); p != "" {
|
// default logbook path at all (VACUUM-INTO split failed) do we fall back to the
|
||||||
c, err := db.Open(p)
|
// settings db itself as the logbook.
|
||||||
if err != nil {
|
lp := strings.TrimSpace(cfg.Path)
|
||||||
return nil, "", fmt.Errorf("open logbook %s: %w", p, err)
|
if lp == "" {
|
||||||
}
|
lp = a.logbookPath
|
||||||
return c, "sqlite", nil
|
|
||||||
}
|
}
|
||||||
return a.db, "sqlite", nil
|
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
|
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.20.12",
|
"version": "0.20.12",
|
||||||
"date": "2026-07-24",
|
"date": "2026-07-24",
|
||||||
"en": [
|
"en": [
|
||||||
"A profile can now keep its QSOs in its own separate SQLite file (Settings → Database → 'SQLite — separate file'), not just the shared database or MySQL — so a visiting operator's log stays separate and your settings and profiles are never touched. The Database panel now clearly separates the shared app database (settings + profiles) from this profile's logbook.",
|
"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.",
|
||||||
"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.",
|
"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), 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.",
|
"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.",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Un profil peut désormais garder ses QSO dans son propre fichier SQLite séparé (Réglages → Base de données → « SQLite — fichier séparé »), et plus seulement la base partagée ou MySQL — ainsi le journal d'un opérateur de passage reste à part et tes réglages et profils ne sont jamais touchés. Le panneau Base de données sépare maintenant clairement la base applicative partagée (réglages + profils) du journal de ce profil.",
|
"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 ».",
|
||||||
"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), 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.",
|
"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é.",
|
||||||
|
|||||||
@@ -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,
|
||||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
@@ -1297,7 +1297,7 @@ 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; sqlite_path?: 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: '' });
|
||||||
@@ -4307,6 +4307,7 @@ 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))); }
|
||||||
// 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() {
|
||||||
@@ -4335,87 +4336,25 @@ 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">
|
||||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
<Label>{t('db.appDb')}</Label>
|
||||||
<Label className="text-sm">{t('db.backend')}</Label>
|
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||||
<Select
|
{dbSettings.path || '—'}
|
||||||
value={mysqlCfg.enabled ? 'mysql' : (mysqlCfg.sqlite_path ? 'sqlite_file' : 'sqlite')}
|
{dbSettings.is_custom
|
||||||
onValueChange={(v) => {
|
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
|
||||||
setRestartMsg('');
|
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
||||||
if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
|
|
||||||
if (v === 'sqlite') { useLocalLogbook(); return; } // shared app db
|
|
||||||
// sqlite_file: keep any existing path; if none, prompt to pick one.
|
|
||||||
setMysqlField({ enabled: false });
|
|
||||||
if (!mysqlCfg.sqlite_path) pickSeparateSqlite();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
|
|
||||||
<SelectItem value="sqlite_file">{t('db.optSqliteFile')}</SelectItem>
|
|
||||||
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
|
|
||||||
|
|
||||||
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
|
|
||||||
{backendStatus && (
|
|
||||||
backendStatus.fallback ? (
|
|
||||||
<div className="max-w-2xl mb-4 text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
|
|
||||||
{t('db.fallback')}
|
|
||||||
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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 */}
|
|
||||||
{/* Separate per-profile SQLite logbook FILE (config stays in opslog.db) */}
|
|
||||||
{!mysqlCfg.enabled && mysqlCfg.sqlite_path && (
|
|
||||||
<div className="space-y-3 max-w-2xl">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<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">{mysqlCfg.sqlite_path}</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={pickSeparateSqlite}><FolderOpen className="size-3.5" /> {t('db.chooseFile')}</Button>
|
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||||||
</div>
|
<Button variant="outline" size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||||
{restartMsg && <div className="text-xs text-success-muted-foreground">{restartMsg}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Shared application/config database (opslog.db) = settings + profiles +
|
|
||||||
this profile's QSOs when it uses the default logbook. */}
|
|
||||||
{!mysqlCfg.enabled && !mysqlCfg.sqlite_path && (
|
|
||||||
<div className="space-y-4 max-w-2xl">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<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>
|
|
||||||
<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.appDbHint')}</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={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={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>
|
<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>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dbMsg && (
|
{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="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">
|
<div className="flex items-start gap-2">
|
||||||
@@ -4434,6 +4373,60 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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.logbookLabel')}</Label>
|
||||||
|
<Select
|
||||||
|
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setRestartMsg('');
|
||||||
|
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>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
|
||||||
|
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
|
||||||
|
|
||||||
|
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
|
||||||
|
{backendStatus && (
|
||||||
|
backendStatus.fallback ? (
|
||||||
|
<div className="max-w-2xl mb-4 text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
|
||||||
|
{t('db.fallback')}
|
||||||
|
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
|
||||||
|
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SQLite logbook file: default logbook.db, or a dedicated file for this profile. */}
|
||||||
|
{!mysqlCfg.enabled && (
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<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">
|
||||||
|
{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>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={pickSeparateSqlite}><FolderOpen className="size-3.5" /> {t('db.chooseFile')}</Button>
|
||||||
|
{mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
|
||||||
|
</div>
|
||||||
|
{restartMsg && <div className="text-xs text-success-muted-foreground">{restartMsg}</div>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MySQL: shared logbook connection (multi-operator) */}
|
{/* MySQL: shared logbook connection (multi-operator) */}
|
||||||
|
|||||||
@@ -286,9 +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 — shared local database (default)', 'db.optSqliteFile': 'SQLite — separate file (this profile only)', '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.logbookFile': "This profile's logbook file", 'db.logbookFileHint': "Only this profile's QSOs live here. Your settings and profiles stay in the shared app database, so switching this never affects your other profiles — ideal for a visiting operator. A new name is created automatically.", 'db.chooseFile': 'Choose file…', 'db.switchedSqliteFile': 'Logbook now uses a separate SQLite file.',
|
'db.logbookLabel': 'Logbook', 'db.openFolder': 'Open folder', 'db.dedicatedFile': 'dedicated file', 'db.useDefaultLogbook': 'Use the default logbook',
|
||||||
'db.appDb': 'Shared application database', 'db.appDbHint': 'Holds your settings, profiles and (for this profile) the QSOs. Changing its location moves the WHOLE install — settings and profiles included.',
|
'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)',
|
||||||
@@ -643,9 +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 — base locale partagée (défaut)', 'db.optSqliteFile': 'SQLite — fichier séparé (ce profil seulement)', '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.logbookFile': 'Fichier journal de ce profil', 'db.logbookFileHint': "Seuls les QSO de ce profil y sont stockés. Tes réglages et profils restent dans la base applicative partagée, donc changer ceci n'affecte jamais tes autres profils — idéal pour un opérateur de passage. Un nouveau nom est créé automatiquement.", 'db.chooseFile': 'Choisir le fichier…', 'db.switchedSqliteFile': 'Le journal utilise désormais un fichier SQLite séparé.',
|
'db.logbookLabel': 'Journal', 'db.openFolder': 'Ouvrir le dossier', 'db.dedicatedFile': 'fichier dédié', 'db.useDefaultLogbook': 'Utiliser le journal par défaut',
|
||||||
'db.appDb': 'Base applicative partagée', 'db.appDbHint': "Contient tes réglages, tes profils et (pour ce profil) les QSO. Changer son emplacement déplace TOUTE l'installation — réglages et profils compris.",
|
'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)',
|
||||||
|
|||||||
Vendored
+2
@@ -764,6 +764,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>;
|
||||||
|
|||||||
@@ -1482,6 +1482,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -45,241 +45,241 @@
|
|||||||
],
|
],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"builtin": true,
|
"builtin": true,
|
||||||
"version": 2
|
"version": 3
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"code": "AG",
|
"code": "AG",
|
||||||
"name": "Aargau",
|
"name": "Aargau",
|
||||||
"pattern": "\\b(Aarau|Baden|Wettingen|Wohlen|Rheinfelden|Zofingen|Lenzburg|Brugg|Oftringen|Suhr|Spreitenbach)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "AI",
|
"code": "AI",
|
||||||
"name": "Appenzell Innerrhoden",
|
"name": "Appenzell Innerrhoden",
|
||||||
"pattern": "\\b(Appenzell|Oberegg)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Appenzell|Oberegg)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "AR",
|
"code": "AR",
|
||||||
"name": "Appenzell Ausserrhoden",
|
"name": "Appenzell Ausserrhoden",
|
||||||
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "BE",
|
"code": "BE",
|
||||||
"name": "Bern",
|
"name": "Bern",
|
||||||
"pattern": "\\b(Bern|Berne|Thun|Biel|Bienne|Köniz|Burgdorf|Langenthal|Steffisburg|Münsingen|Spiez|Interlaken|Ostermundigen|Lyss|Zollikofen)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "BL",
|
"code": "BL",
|
||||||
"name": "Basel-Landschaft",
|
"name": "Basel-Landschaft",
|
||||||
"pattern": "\\b(Liestal|Allschwil|Reinach|Muttenz|Pratteln|Binningen|Münchenstein|Birsfelden|Aesch|Sissach|Oberwil)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "BS",
|
"code": "BS",
|
||||||
"name": "Basel-Stadt",
|
"name": "Basel-Stadt",
|
||||||
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "FR",
|
"code": "FR",
|
||||||
"name": "Fribourg",
|
"name": "Fribourg",
|
||||||
"pattern": "\\b(Fribourg|Freiburg|Bulle|Marly|Düdingen|Estavayer|Murten|Morat|Villars-sur-Glâne|Guin)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "GE",
|
"code": "GE",
|
||||||
"name": "Geneva",
|
"name": "Geneva",
|
||||||
"pattern": "\\b(Genève|Geneve|Geneva|Genf|Carouge|Vernier|Lancy|Meyrin|Onex|Thônex|Thonex|Versoix|Bernex|Plan-les-Ouates)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "GL",
|
"code": "GL",
|
||||||
"name": "Glarus",
|
"name": "Glarus",
|
||||||
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "GR",
|
"code": "GR",
|
||||||
"name": "Graubünden (Grisons)",
|
"name": "Graubünden (Grisons)",
|
||||||
"pattern": "\\b(Chur|Coire|Davos|Sankt Moritz|St\\.? Moritz|Landquart|Arosa|Klosters|Ilanz|Thusis|Poschiavo|Domat|Ems)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "JU",
|
"code": "JU",
|
||||||
"name": "Jura",
|
"name": "Jura",
|
||||||
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "LU",
|
"code": "LU",
|
||||||
"name": "Lucerne",
|
"name": "Lucerne",
|
||||||
"pattern": "\\b(Luzern|Lucerne|Emmen|Kriens|Horw|Ebikon|Sursee|Hochdorf|Willisau|Rothenburg)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "NE",
|
"code": "NE",
|
||||||
"name": "Neuchâtel",
|
"name": "Neuchâtel",
|
||||||
"pattern": "\\b(Neuchâtel|Neuchatel|Neuenburg|La Chaux-de-Fonds|Chaux-de-Fonds|Le Locle|Peseux|Boudry|Colombier|Marin)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "NW",
|
"code": "NW",
|
||||||
"name": "Nidwalden",
|
"name": "Nidwalden",
|
||||||
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "OW",
|
"code": "OW",
|
||||||
"name": "Obwalden",
|
"name": "Obwalden",
|
||||||
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "SG",
|
"code": "SG",
|
||||||
"name": "St. Gallen",
|
"name": "St. Gallen",
|
||||||
"pattern": "\\b(Sankt Gallen|Saint-Gall|Rapperswil|Wil|Gossau|Rorschach|Uzwil|Flawil|Altstätten|Jona)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "SH",
|
"code": "SH",
|
||||||
"name": "Schaffhausen",
|
"name": "Schaffhausen",
|
||||||
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "SO",
|
"code": "SO",
|
||||||
"name": "Solothurn",
|
"name": "Solothurn",
|
||||||
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "SZ",
|
"code": "SZ",
|
||||||
"name": "Schwyz",
|
"name": "Schwyz",
|
||||||
"pattern": "\\b(Schwyz|Einsiedeln|Freienbach|Küssnacht|Arth|Lachen|Wollerau|Brunnen|Goldau)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "TG",
|
"code": "TG",
|
||||||
"name": "Thurgau",
|
"name": "Thurgau",
|
||||||
"pattern": "\\b(Frauenfeld|Kreuzlingen|Arbon|Amriswil|Weinfelden|Romanshorn|Sirnach|Aadorf|Münchwilen)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "TI",
|
"code": "TI",
|
||||||
"name": "Ticino",
|
"name": "Ticino",
|
||||||
"pattern": "\\b(Lugano|Bellinzona|Locarno|Mendrisio|Chiasso|Biasca|Ascona|Losone|Minusio|Giubiasco|Massagno)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "UR",
|
"code": "UR",
|
||||||
"name": "Uri",
|
"name": "Uri",
|
||||||
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
|
|
||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "VD",
|
"code": "VD",
|
||||||
"name": "Vaud",
|
"name": "Vaud",
|
||||||
"pattern": "\\b(Lausanne|Yverdon|Montreux|Nyon|Vevey|Renens|Morges|Gland|Pully|Prilly|Ecublens|Aigle|Payerne|Rolle|Lutry|Bussigny)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "VS",
|
"code": "VS",
|
||||||
"name": "Valais",
|
"name": "Valais",
|
||||||
"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",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ZG",
|
"code": "ZG",
|
||||||
"name": "Zug",
|
"name": "Zug",
|
||||||
"pattern": "\\b(Zug|Zoug|Baar|Cham|Steinhausen|Risch|Rotkreuz|Hünenberg|Unterägeri|Oberägeri)\\b",
|
|
||||||
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "ZH",
|
"code": "ZH",
|
||||||
"name": "Zurich",
|
"name": "Zurich",
|
||||||
"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",
|
|
||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user