This commit is contained in:
2026-06-13 21:56:38 +02:00
parent 81e505e040
commit 08162fa126
9 changed files with 257 additions and 15 deletions
+87
View File
@@ -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)
}
+5 -2
View File
@@ -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;
+52 -13
View File
@@ -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<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
const [mysqlMsg, setMysqlMsg] = useState('');
const [dataDir, setDataDir] = useState('');
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
@@ -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 (
<>
<SectionHeader
title="CAT interface (OmniRig)"
title="CAT interface"
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — OpsLog just talks to it."
/>
<div className="space-y-4 max-w-lg">
@@ -1646,7 +1652,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return (
<>
<SectionHeader
title="Rotator (PstRotator)"
title="Rotator"
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup Communication UDP) before testing."
/>
<div className="space-y-4 max-w-xl">
@@ -2688,7 +2694,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<>
<SectionHeader
title="Database"
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
/>
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
@@ -2709,13 +2714,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
</div>
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
Any database change takes effect on the next launch.
</div>
{dbMsg && (
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
<span>{dbMsg}</span>
@@ -2724,13 +2722,54 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
)}
</div>
{/* Shared MySQL database (multi-operator) */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
<div>
<div className="text-sm font-medium">Shared database (multi-operator)</div>
</div>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Backend</Label>
<Select value={mysqlCfg.enabled ? 'mysql' : 'sqlite'} onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">SQLite — local file (solo)</SelectItem>
<SelectItem value="mysql">MySQL — shared server (multi-operator)</SelectItem>
</SelectContent>
</Select>
</div>
{mysqlCfg.enabled && (
<>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">Host</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
<Label className="text-sm">Port</Label>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
<Label className="text-sm">Database</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">User</Label>
<Input className="h-8" value={mysqlCfg.user} onChange={(e) => setMysqlField({ user: e.target.value })} />
<Label className="text-sm">Password</Label>
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setMysqlMsg('Testing'); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg('Connected database ready ')).catch((e: any) => setMysqlMsg('Failed: ' + String(e?.message ?? e))); }}>
Test &amp; create database
</Button>
<Button size="sm" className="h-8"
onClick={() => { SaveMySQLSettings(mysqlCfg as any).then(() => setMysqlMsg('Saved.')).catch((e: any) => setErr(String(e?.message ?? e))); }}>
Save
</Button>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div>
</>
)}
</div>
{/* Data location */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
<div>
<div className="text-sm font-medium">Data location</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
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.
</div>
</div>
<div className="space-y-1">
<Label>Current data directory</Label>
+6
View File
@@ -153,6 +153,8 @@ export function GetLogFilePath():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetPOTAToken():Promise<string>;
export function GetQSLDefaults():Promise<main.QSLDefaults>;
@@ -323,6 +325,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
@@ -383,6 +387,8 @@ export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
export function TestMySQLConnection(arg1:main.MySQLSettings):Promise<void>;
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
export function TestQRZUpload():Promise<string>;
+12
View File
@@ -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);
}
+22
View File
@@ -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;
+2
View File
@@ -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
+4
View File
@@ -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=
+67
View File
@@ -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