diff --git a/app.go b/app.go index 977e5a5..8aaa7f8 100644 --- a/app.go +++ b/app.go @@ -1020,6 +1020,84 @@ func (a *App) GetDatabaseSettings() DatabaseSettings { return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def} } +// MySQLSettings is the shared-database (multi-operator) connection config. When +// enabled, OpsLog logs to a central MySQL server so several operators see each +// other's QSOs live (à la Log4OM). SQLite stays the default. +type MySQLSettings struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + Database string `json:"database"` +} + +const ( + keyMySQLEnabled = "mysql.enabled" + keyMySQLHost = "mysql.host" + keyMySQLPort = "mysql.port" + keyMySQLUser = "mysql.user" + keyMySQLPassword = "mysql.password" + keyMySQLDatabase = "mysql.database" +) + +// GetMySQLSettings returns the stored shared-database config (defaults applied). +func (a *App) GetMySQLSettings() (MySQLSettings, error) { + out := MySQLSettings{Port: 3306} + if a.settings == nil { + return out, nil + } + m, err := a.settings.GetMany(a.ctx, keyMySQLEnabled, keyMySQLHost, keyMySQLPort, keyMySQLUser, keyMySQLPassword, keyMySQLDatabase) + if err != nil { + return out, err + } + out.Enabled = m[keyMySQLEnabled] == "1" + out.Host = m[keyMySQLHost] + if p, _ := strconv.Atoi(m[keyMySQLPort]); p > 0 { + out.Port = p + } + out.User = m[keyMySQLUser] + out.Password = m[keyMySQLPassword] + out.Database = m[keyMySQLDatabase] + return out, nil +} + +// SaveMySQLSettings persists the shared-database config. (Switching the active +// backend takes effect on restart — wired in a later phase.) +func (a *App) SaveMySQLSettings(s MySQLSettings) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + if s.Port <= 0 { + s.Port = 3306 + } + enabled := "0" + if s.Enabled { + enabled = "1" + } + for k, v := range map[string]string{ + keyMySQLEnabled: enabled, + keyMySQLHost: strings.TrimSpace(s.Host), + keyMySQLPort: strconv.Itoa(s.Port), + keyMySQLUser: strings.TrimSpace(s.User), + keyMySQLPassword: s.Password, + keyMySQLDatabase: strings.TrimSpace(s.Database), + } { + if err := a.settings.Set(a.ctx, k, v); err != nil { + return err + } + } + 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 { + return db.PingMySQL(db.MySQLConfig{ + Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, Database: s.Database, + }) +} + // PickOpenDatabase opens a file dialog to choose an existing .db file. func (a *App) PickOpenDatabase() (string, error) { if a.ctx == nil { @@ -2819,6 +2897,15 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err if a.qso == nil { return qso.WorkedBefore{}, fmt.Errorf("db not initialized") } + // When the frontend lookup didn't carry a DXCC number (a QRZ cache hit may + // have the country name but no number), resolve it from the callsign via + // cty.dat + Clublog exceptions — the same source QSOs are logged with — so + // the entity matrix populates even for a call we've never worked directly. + if dxccHint == 0 && a.dxcc != nil { + if m, ok := a.dxcc.Lookup(callsign); ok && m.Entity != nil { + dxccHint = dxcc.EntityDXCC(m.Entity.Name) + } + } return a.qso.WorkedBefore(a.ctx, callsign, dxccHint) } diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index 76b906a..368e99f 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -102,10 +102,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal }, [wb]); // "Newness" of the current band+mode entry, for the award/DX-chase badges. + // Derived straight from the entity's real band_status (all bands it was + // worked on — not just the operator's configured column list). const curClass = CLASSES.find((c) => classMatchesMode(c, currentMode)); + const statusKeys = useMemo(() => Array.from(statusMap.keys()), [statusMap]); const slotStatus = curClass ? statusMap.get(`${currentBand}|${curClass}`) : undefined; - const bandWorked = CLASSES.some((c) => statusMap.get(`${currentBand}|${c}`)); - const modeWorked = !!curClass && cols.some((b) => statusMap.get(`${b.tag}|${curClass}`)); + const bandWorked = statusKeys.some((k) => k.split('|')[0] === currentBand); + const modeWorked = !!curClass && statusKeys.some((k) => k.split('|')[1] === curClass); const newBand = hasDxcc && !newOne && !bandWorked; const newMode = hasDxcc && !newOne && !!curClass && !modeWorked; const newBandMode = hasDxcc && !newOne && !!curClass && !slotStatus; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 8cb969f..8c6d45f 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -25,6 +25,7 @@ import { ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase, + GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDataDir, GetQSLDefaults, SaveQSLDefaults, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, @@ -538,6 +539,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false }); const [dbMsg, setDbMsg] = useState(''); + type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string }; + const [mysqlCfg, setMysqlCfg] = useState({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' }); + const setMysqlField = (patch: Partial) => setMysqlCfg((s) => ({ ...s, ...patch })); + const [mysqlMsg, setMysqlMsg] = useState(''); const [dataDir, setDataDir] = useState(''); const [clusterServers, setClusterServers] = useState([]); @@ -627,6 +632,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { setQslDefaults(qd as any); setExtSvc(es as any); try { setDbSettings(await GetDatabaseSettings() as any); } catch {} + try { setMysqlCfg(await GetMySQLSettings() as any); } catch {} try { setDataDir(await GetDataDir()); } catch {} try { const locs: any = await ListTQSLStationLocations(); @@ -1461,7 +1467,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { return ( <>
@@ -1646,7 +1652,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { return ( <>
@@ -2688,7 +2694,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { <>
@@ -2709,13 +2714,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { {dbSettings.is_custom && }
-
- New creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '} - Open existing points OpsLog at a file you already have.{' '} - Save a copy clones the current database elsewhere and switches to it.{' '} - Any database change takes effect on the next launch. -
- {dbMsg && (
{dbMsg} @@ -2724,13 +2722,54 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { )}
+ {/* Shared MySQL database (multi-operator) */} +
+
+
Shared database (multi-operator)
+
+
+ + +
+ {mysqlCfg.enabled && ( + <> +
+ + setMysqlField({ host: e.target.value })} /> + + setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} /> + + setMysqlField({ database: e.target.value })} /> + + setMysqlField({ user: e.target.value })} /> + + setMysqlField({ password: e.target.value })} /> +
+
+ + + {mysqlMsg} +
+ + )} +
+ {/* Data location */}
Data location
-
- OpsLog is fully portable — all data lives next to the executable so you can run it from a USB stick or reinstall Windows without losing anything. -
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index d169712..a3a05f0 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -153,6 +153,8 @@ export function GetLogFilePath():Promise; export function GetLookupSettings():Promise; +export function GetMySQLSettings():Promise; + export function GetPOTAToken():Promise; export function GetQSLDefaults():Promise; @@ -323,6 +325,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise; export function SaveLookupSettings(arg1:main.LookupSettings):Promise; +export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise; + export function SaveOperatingAntenna(arg1:operating.Antenna):Promise; export function SaveOperatingStation(arg1:operating.Station):Promise; @@ -383,6 +387,8 @@ export function TestLoTWUpload():Promise; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise; +export function TestMySQLConnection(arg1:main.MySQLSettings):Promise; + export function TestPTT(arg1:main.AudioSettings):Promise; export function TestQRZUpload():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 4a2eee0..ca7e930 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -278,6 +278,10 @@ export function GetLookupSettings() { return window['go']['main']['App']['GetLookupSettings'](); } +export function GetMySQLSettings() { + return window['go']['main']['App']['GetMySQLSettings'](); +} + export function GetPOTAToken() { return window['go']['main']['App']['GetPOTAToken'](); } @@ -618,6 +622,10 @@ export function SaveLookupSettings(arg1) { return window['go']['main']['App']['SaveLookupSettings'](arg1); } +export function SaveMySQLSettings(arg1) { + return window['go']['main']['App']['SaveMySQLSettings'](arg1); +} + export function SaveOperatingAntenna(arg1) { return window['go']['main']['App']['SaveOperatingAntenna'](arg1); } @@ -738,6 +746,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) { return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4); } +export function TestMySQLConnection(arg1) { + return window['go']['main']['App']['TestMySQLConnection'](arg1); +} + export function TestPTT(arg1) { return window['go']['main']['App']['TestPTT'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ed3c8cd..4ee2ac1 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1011,6 +1011,28 @@ export namespace main { } } + export class MySQLSettings { + enabled: boolean; + host: string; + port: number; + user: string; + password: string; + database: string; + + static createFrom(source: any = {}) { + return new MySQLSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.host = source["host"]; + this.port = source["port"]; + this.user = source["user"]; + this.password = source["password"]; + this.database = source["database"]; + } + } export class POTAUnmatched { activator: string; date: string; diff --git a/go.mod b/go.mod index e256bdf..b73988e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/braheezy/shine-mp3 v0.1.0 github.com/go-ole/go-ole v1.3.0 + github.com/go-sql-driver/mysql v1.10.0 github.com/moutend/go-wca v0.3.0 github.com/wailsapp/wails/v2 v2.11.0 github.com/wneessen/go-mail v0.7.3 @@ -16,6 +17,7 @@ require ( ) require ( + filippo.io/edwards25519 v1.2.0 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect diff --git a/go.sum b/go.sum index 4bd292a..dbd796b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8= @@ -9,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= diff --git a/internal/db/db.go b/internal/db/db.go index 2b3011c..269448c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -7,10 +7,77 @@ import ( "fmt" "sort" "strings" + "time" + _ "github.com/go-sql-driver/mysql" _ "modernc.org/sqlite" ) +// MySQLConfig targets a shared MySQL database for multi-operator logging +// (multiple OpsLog instances on one logbook, à la Log4OM). +type MySQLConfig struct { + Host string + Port int + User string + Password string + Database string +} + +func (c MySQLConfig) dsn() string { + port := c.Port + if port == 0 { + port = 3306 + } + // parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full + // Unicode (names, comments…). + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4", + c.User, c.Password, c.Host, port, c.Database) +} + +// validDBIdent guards a database name we splice into DDL (CREATE DATABASE can't +// use a placeholder). Only plain identifiers allowed. +func validDBIdent(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r != '_' && !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') { + return false + } + } + return true +} + +// PingMySQL verifies a shared-database connection and creates the logbook +// database if it doesn't exist yet. It connects at server level first (no +// database selected) so a not-yet-created DB isn't an error, then runs +// CREATE DATABASE IF NOT EXISTS. Backs the settings "Test connection" button. +func PingMySQL(c MySQLConfig) error { + if strings.TrimSpace(c.Host) == "" { + return fmt.Errorf("host is required") + } + server := c + server.Database = "" // connect to the server, not a specific DB + conn, err := sql.Open("mysql", server.dsn()) + if err != nil { + return fmt.Errorf("open mysql: %w", err) + } + defer conn.Close() + conn.SetConnMaxLifetime(5 * time.Second) + if err := conn.Ping(); err != nil { + return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err) + } + if name := strings.TrimSpace(c.Database); name != "" { + if !validDBIdent(name) { + return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name) + } + if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + return fmt.Errorf("create database %q: %w", name, err) + } + } + return nil +} + //go:embed migrations/*.sql var migrationsFS embed.FS