mysql
This commit is contained in:
@@ -1020,6 +1020,84 @@ func (a *App) GetDatabaseSettings() DatabaseSettings {
|
|||||||
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
|
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.
|
// PickOpenDatabase opens a file dialog to choose an existing .db file.
|
||||||
func (a *App) PickOpenDatabase() (string, error) {
|
func (a *App) PickOpenDatabase() (string, error) {
|
||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
@@ -2819,6 +2897,15 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
|
|||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return qso.WorkedBefore{}, fmt.Errorf("db not initialized")
|
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)
|
return a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,10 +102,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
}, [wb]);
|
}, [wb]);
|
||||||
|
|
||||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
// "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 curClass = CLASSES.find((c) => classMatchesMode(c, currentMode));
|
||||||
|
const statusKeys = useMemo(() => Array.from(statusMap.keys()), [statusMap]);
|
||||||
const slotStatus = curClass ? statusMap.get(`${currentBand}|${curClass}`) : undefined;
|
const slotStatus = curClass ? statusMap.get(`${currentBand}|${curClass}`) : undefined;
|
||||||
const bandWorked = CLASSES.some((c) => statusMap.get(`${currentBand}|${c}`));
|
const bandWorked = statusKeys.some((k) => k.split('|')[0] === currentBand);
|
||||||
const modeWorked = !!curClass && cols.some((b) => statusMap.get(`${b.tag}|${curClass}`));
|
const modeWorked = !!curClass && statusKeys.some((k) => k.split('|')[1] === curClass);
|
||||||
const newBand = hasDxcc && !newOne && !bandWorked;
|
const newBand = hasDxcc && !newOne && !bandWorked;
|
||||||
const newMode = hasDxcc && !newOne && !!curClass && !modeWorked;
|
const newMode = hasDxcc && !newOne && !!curClass && !modeWorked;
|
||||||
const newBandMode = hasDxcc && !newOne && !!curClass && !slotStatus;
|
const newBandMode = hasDxcc && !newOne && !!curClass && !slotStatus;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||||
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection,
|
||||||
GetDataDir,
|
GetDataDir,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
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 [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ 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 };
|
||||||
|
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 [dataDir, setDataDir] = useState('');
|
||||||
|
|
||||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||||
@@ -627,6 +632,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||||||
|
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||||||
try { setDataDir(await GetDataDir()); } catch {}
|
try { setDataDir(await GetDataDir()); } catch {}
|
||||||
try {
|
try {
|
||||||
const locs: any = await ListTQSLStationLocations();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
@@ -1461,7 +1467,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<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."
|
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">
|
<div className="space-y-4 max-w-lg">
|
||||||
@@ -1646,7 +1652,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator (PstRotator)"
|
title="Rotator"
|
||||||
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
|
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">
|
<div className="space-y-4 max-w-xl">
|
||||||
@@ -2688,7 +2694,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Database"
|
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-4 max-w-2xl">
|
||||||
<div className="space-y-1">
|
<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>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
|
||||||
</div>
|
</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 && (
|
{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">
|
<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>
|
<span>{dbMsg}</span>
|
||||||
@@ -2724,13 +2722,54 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 & 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 */}
|
{/* Data location */}
|
||||||
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
|
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">Data location</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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Current data directory</Label>
|
<Label>Current data directory</Label>
|
||||||
|
|||||||
Vendored
+6
@@ -153,6 +153,8 @@ export function GetLogFilePath():Promise<string>;
|
|||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
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 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 SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
||||||
|
|
||||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
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 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 TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
|
|||||||
@@ -278,6 +278,10 @@ export function GetLookupSettings() {
|
|||||||
return window['go']['main']['App']['GetLookupSettings']();
|
return window['go']['main']['App']['GetLookupSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetMySQLSettings() {
|
||||||
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPOTAToken() {
|
export function GetPOTAToken() {
|
||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
@@ -618,6 +622,10 @@ export function SaveLookupSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveMySQLSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveOperatingAntenna(arg1) {
|
export function SaveOperatingAntenna(arg1) {
|
||||||
return window['go']['main']['App']['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);
|
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) {
|
export function TestPTT(arg1) {
|
||||||
return window['go']['main']['App']['TestPTT'](arg1);
|
return window['go']['main']['App']['TestPTT'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export class POTAUnmatched {
|
||||||
activator: string;
|
activator: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/braheezy/shine-mp3 v0.1.0
|
github.com/braheezy/shine-mp3 v0.1.0
|
||||||
github.com/go-ole/go-ole v1.3.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/moutend/go-wca v0.3.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
github.com/wneessen/go-mail v0.7.3
|
github.com/wneessen/go-mail v0.7.3
|
||||||
@@ -16,6 +17,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
|||||||
@@ -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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
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.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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
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=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
|||||||
@@ -7,10 +7,77 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
_ "modernc.org/sqlite"
|
_ "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
|
//go:embed migrations/*.sql
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user