chore: release v0.26.4

This commit is contained in:
2026-08-22 08:35:49 +02:00
parent d48420485f
commit abfed4afa2
16 changed files with 864 additions and 23 deletions
+67 -3
View File
@@ -1209,6 +1209,7 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook backend = %s", backend) applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn) a.qso = qso.NewRepo(logbookConn)
a.tidyLogbookSchema(logbookConn)
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
@@ -2319,6 +2320,11 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
} }
if lp == "" { if lp == "" {
a.logDbPath = "" // settings db serves as the logbook (split failed) — backup snapshots a.db a.logDbPath = "" // settings db serves as the logbook (split failed) — backup snapshots a.db
// It may have had its unused qso table dropped while a separate logbook
// was in use (see tidyLogbookSchema); rebuild it before handing it over.
if err := db.EnsureQSOTable(a.db); err != nil {
return nil, "", err
}
return a.db, "sqlite", nil return a.db, "sqlite", nil
} }
// Resolve against THIS install before opening. Without it, a profile carried // Resolve against THIS install before opening. Without it, a profile carried
@@ -2330,7 +2336,7 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
applog.Printf("logbook: profile path %q resolved to %q", lp, r) applog.Printf("logbook: profile path %q resolved to %q", lp, r)
lp = r lp = r
} }
c, err := db.Open(lp) c, err := db.OpenLogbook(lp)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err) return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
} }
@@ -14875,7 +14881,9 @@ func (a *App) reloadCAT() {
// Clicking one of our spots on the panadapter fills the entry form. // Clicking one of our spots on the panadapter fills the entry form.
fb.OnSpotClick = func(call string, hz int64, mode string) { fb.OnSpotClick = func(call string, hz int64, mode string) {
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "flex:spot_clicked", map[string]any{"call": call, "freq_hz": hz}) wruntime.EventsEmit(a.ctx, "flex:spot_clicked", map[string]any{
"call": call, "freq_hz": hz, "pota_ref": a.potaRefFor(call),
})
} }
// A spot clicked ON the panadapter deserves the same zoom as one // A spot clicked ON the panadapter deserves the same zoom as one
// clicked in the band map: the operator did the same thing and is // clicked in the band map: the operator did the same thing and is
@@ -14970,7 +14978,9 @@ func (a *App) reloadCAT() {
// Clicking one of our spots on the ExpertSDR panorama fills the entry form. // Clicking one of our spots on the ExpertSDR panorama fills the entry form.
tb.OnSpotClick = func(call string, hz int64) { tb.OnSpotClick = func(call string, hz int64) {
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "tci:spot_clicked", map[string]any{"call": call, "freq_hz": hz}) wruntime.EventsEmit(a.ctx, "tci:spot_clicked", map[string]any{
"call": call, "freq_hz": hz, "pota_ref": a.potaRefFor(call),
})
} }
} }
a.cat.Start(tb) a.cat.Start(tb)
@@ -19993,3 +20003,57 @@ func lookupWhen(qsoDate string) time.Time {
} }
return t return t
} }
// potaRefFor returns the park a callsign is currently activating, or "".
//
// A spot clicked on the RADIO's panadapter arrives as a callsign and a
// frequency — the radio knows nothing of parks — so the park has to be found
// again here, from the same live POTA index that tags the cluster spots. Without
// it the Awards tab stayed empty for a click on the panadapter while the very
// same spot clicked in OpsLog filled it in, which is a difference the operator
// has no way to explain.
func (a *App) potaRefFor(call string) string {
if a.pota == nil || strings.TrimSpace(call) == "" {
return ""
}
if info, ok := a.pota.Lookup(call); ok {
return info.Reference
}
return ""
}
// tidyLogbookSchema removes settings tables that an older OpsLog left inside the
// logbook.
//
// Two ways they got there: every database used to receive the full migration set
// (fixed in internal/db/roles.go, which now filters by role), and the one-time
// legacy split copies the whole settings database with VACUUM INTO and then
// treats the copy as the logbook. Empty ones are already dropped when the
// logbook is opened; the copies from the split have ROWS, and are dropped here
// because we can see the settings database still holding the originals.
//
// The check is the whole point: only when the logbook is a DIFFERENT database
// from the settings one, and that settings database still has its own profiles,
// is a profile row inside the logbook provably a duplicate. A profile pointing
// at a genuine legacy combined database as its logbook fails that test and keeps
// everything.
func (a *App) tidyLogbookSchema(logbookConn *sql.DB) {
if logbookConn == nil || a.db == nil || logbookConn == a.db {
return // the settings database IS the logbook: everything in it is in use
}
if a.dbBackend == "sqlite" && a.logDbPath != "" &&
strings.EqualFold(filepath.Clean(a.logDbPath), filepath.Clean(a.dbPath)) {
return // same file reached twice
}
var profiles int
if err := a.db.QueryRowContext(a.ctx, "SELECT COUNT(*) FROM station_profiles").Scan(&profiles); err != nil || profiles == 0 {
// No authoritative copy to point at — leave the logbook exactly as it is.
return
}
db.DropRedundantSettingsTables(logbookConn, "logbook")
// And the mirror image: the settings database keeps an empty qso table from
// the days when every database got the whole schema. It is not needed while
// the contacts have a database of their own, and EnsureQSOTable puts it back
// if this one is ever asked to serve as the logbook again.
db.DropEmptyQSOTable(a.db, filepath.Base(a.dbPath))
}
+18
View File
@@ -1,4 +1,22 @@
[ [
{
"version": "0.26.4",
"date": "",
"en": [
"Clicking a spot on the radio panadapter now carries its POTA park into the Awards tab, like a click in OpsLog does.",
"Ultrabeam over serial: the COM port field now has a chevron that opens the list of detected ports. It was a combo box that only opened on a keystroke, so it looked like a plain text box.",
"The logbook database now contains only the contacts. Settings, station profiles, award reference lists and the lookup cache were being created in every database, including a shared MySQL logbook, which made the two look interchangeable; the unused tables are dropped on the next start, and any that hold data are left alone.",
"And the mirror image: the settings database drops its own unused, empty qso table once the contacts have a database of their own. It is rebuilt automatically if that database is ever pressed into service as the logbook again.",
"Settings → Database: a Compact button on each database. SQLite frees deleted pages inside the file and never shrinks it, so this is what gives the disk space back; the result shows the size before and after. A shared MySQL logbook has the button too, running OPTIMIZE TABLE instead."
],
"fr": [
"Cliquer un spot sur le panadapter de la radio reporte maintenant son parc POTA dans longlet Diplômes, comme un clic dans OpsLog.",
"Ultrabeam en série : le champ du port COM a maintenant un chevron qui ouvre la liste des ports détectés. C'était une liste déroulante qui ne s'ouvrait qu'en tapant, donc indiscernable d'un simple champ texte.",
"La base du journal ne contient plus que les contacts. Les réglages, profils station, listes de références et le cache de recherche étaient créés dans toutes les bases, y compris un MySQL partagé, ce qui laissait croire que les deux étaient interchangeables ; les tables inutilisées sont supprimées au prochain démarrage, et celles qui contiennent des données sont laissées telles quelles.",
"Et l'inverse : la base de réglages supprime sa propre table qso, vide et inutilisée, dès lors que les contacts ont leur base à eux. Elle est reconstruite automatiquement si cette base doit à nouveau servir de journal.",
"Réglages → Base de données : un bouton Compacter sur chaque base. SQLite libère les pages supprimées à l'intérieur du fichier sans jamais le réduire ; c'est donc ce qui rend l'espace disque, et le résultat affiche la taille avant et après. Un journal MySQL partagé a aussi le bouton, avec un OPTIMIZE TABLE à la place."
]
},
{ {
"version": "0.26.3", "version": "0.26.3",
"date": "", "date": "",
+102
View File
@@ -0,0 +1,102 @@
package main
// Compacting a database.
//
// SQLite never shrinks a file on its own: deleting rows — or dropping a table,
// which is what the schema-role cleanup does — frees pages INSIDE the file and
// leaves the file the size it always was. A logbook that held 200 000 imported
// QSOs for a day is still a 200 MB file the day after they are gone, and the
// only thing that reclaims the space is a VACUUM, which rewrites the database
// from scratch.
//
// Offered as a button rather than done automatically: a VACUUM rewrites the
// whole file, needs room for a second copy of it while it runs, and takes real
// time on a large logbook. That is a decision for the operator, at a moment they
// choose — not something to spring on them during startup.
import (
"database/sql"
"fmt"
"os"
"strings"
"hamlog/internal/applog"
)
// CompactResult is what one compaction did.
type CompactResult struct {
// Path of the file compacted, empty for MySQL.
Path string `json:"path"`
// Backend is "sqlite" or "mysql" — a shared server is optimised, not vacuumed.
Backend string `json:"backend"`
// Before and After are file sizes in bytes; both 0 when there is no file.
Before int64 `json:"before"`
After int64 `json:"after"`
}
// CompactDatabase reclaims the unused space in one of the two databases.
//
// target is "settings" or "logbook". They are separate on purpose: they are
// different files, of very different sizes, and an operator compacting a 400 MB
// logbook has no reason to wait on a 2 MB settings file as well.
func (a *App) CompactDatabase(target string) (CompactResult, error) {
switch strings.ToLower(strings.TrimSpace(target)) {
case "settings":
if a.db == nil {
return CompactResult{}, fmt.Errorf("the settings database is not open")
}
return a.vacuumSQLite(a.db, a.dbPath)
case "logbook":
if a.logDb == nil {
return CompactResult{}, fmt.Errorf("the logbook is not open")
}
if a.dbBackend == "mysql" {
// A shared server's storage is the admin's business, and OPTIMIZE TABLE
// locks the table for the length of a rebuild — every other operator
// waits. Still offered, because a logbook that has had a large import
// deleted benefits from it just as much; it simply reports no sizes,
// which the server alone knows.
if _, err := a.logDb.ExecContext(a.ctx, "OPTIMIZE TABLE qso"); err != nil {
return CompactResult{}, fmt.Errorf("optimize qso: %w", err)
}
applog.Printf("compact: OPTIMIZE TABLE qso on the shared MySQL logbook")
return CompactResult{Backend: "mysql"}, nil
}
// No separate file: the settings database is serving as the logbook, and
// compacting it is the same operation.
if a.logDbPath == "" {
return a.vacuumSQLite(a.db, a.dbPath)
}
return a.vacuumSQLite(a.logDb, a.logDbPath)
}
return CompactResult{}, fmt.Errorf("unknown database %q", target)
}
// vacuumSQLite checkpoints the write-ahead log, then rewrites the file.
func (a *App) vacuumSQLite(conn *sql.DB, path string) (CompactResult, error) {
res := CompactResult{Path: path, Backend: "sqlite", Before: fileSizeOf(path)}
// Fold the WAL back into the main file first. Without it the pages freed by a
// recent DELETE can still be sitting in the -wal, and the vacuum reports a
// saving the file on disk does not show.
if _, err := conn.ExecContext(a.ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
applog.Printf("compact: wal checkpoint on %s: %v", path, err)
}
if _, err := conn.ExecContext(a.ctx, "VACUUM"); err != nil {
return res, fmt.Errorf("vacuum: %w", err)
}
res.After = fileSizeOf(path)
applog.Printf("compact: %s %d → %d bytes", path, res.Before, res.After)
return res, nil
}
// fileSizeOf returns a file's size, or 0 if it cannot be read.
func fileSizeOf(path string) int64 {
if strings.TrimSpace(path) == "" {
return 0
}
fi, err := os.Stat(path)
if err != nil {
return 0
}
return fi.Size()
}
+8 -2
View File
@@ -3515,12 +3515,18 @@ export default function App() {
// An explicit click always wins over whatever call is currently in the field. // An explicit click always wins over whatever call is currently in the field.
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call); if (!applyUdpCall(call, true)) return;
restartRecordingForNewTarget(call);
// The park, like a click in the band map: the radio reports only a
// callsign, so the backend looks it up again before sending the event.
applySpotPOTA(String(p?.pota_ref ?? ''));
}); });
// Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex. // Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => { const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call); if (!applyUdpCall(call, true)) return;
restartRecordingForNewTarget(call);
applySpotPOTA(String(p?.pota_ref ?? ''));
}); });
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => { const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
const total = Number(p?.total ?? 0); const total = Number(p?.total ?? 0);
+49 -2
View File
@@ -4,6 +4,7 @@ import {
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Antenna as AntennaIcon, User, Database, Radio, Cog, Server, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
Minimize2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -12,7 +13,7 @@ import {
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets, GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase,
GetAntGeniusSettings, SaveAntGeniusSettings, GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings, GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetPSUSettings, SavePSUSettings, GetPSUSettings, SavePSUSettings,
@@ -1838,6 +1839,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}; };
// A date for a person: the day, not the timestamp the backend stores. // A date for a person: the day, not the timestamp the backend stores.
const fmtDay = (v?: string) => (v ? String(v).slice(0, 10) : '—'); const fmtDay = (v?: string) => (v ? String(v).slice(0, 10) : '—');
// Binary units, one decimal, because the point of showing a size here is to
// compare two of them: "412.7 MB → 38.4 MB" says what a bare byte count does not.
const fmtBytes = (n: number) => {
if (!n || n < 0) return '—';
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return `${i === 0 ? v : v.toFixed(1)} ${u[i]}`;
};
const [rdaCount, setRdaCount] = useState(0); const [rdaCount, setRdaCount] = useState(0);
const [rdaBusy, setRdaBusy] = useState(false); const [rdaBusy, setRdaBusy] = useState(false);
const [rdaUseCurrent, setRdaUseCurrent] = useState(true); const [rdaUseCurrent, setRdaUseCurrent] = useState(true);
@@ -3591,6 +3602,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
options={wkPorts} options={wkPorts}
allowFreeText allowFreeText
commitOnType commitOnType
showToggle
placeholder="COM3" placeholder="COM3"
className="font-mono flex-1" className="font-mono flex-1"
onChange={(v) => setUltrabeam((s) => ({ ...s, com: v.trim().toUpperCase() }))} onChange={(v) => setUltrabeam((s) => ({ ...s, com: v.trim().toUpperCase() }))}
@@ -6166,6 +6178,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
} }
function DatabasePanel() { function DatabasePanel() {
// Compacting: which database is running, and what the last one saved.
// SQLite frees pages inside the file and never shrinks it, so this is the
// only thing that gives the disk space back after a large delete.
const [compacting, setCompacting] = useState('');
const [compactMsg, setCompactMsg] = useState<Record<string, string>>({});
function compact(target: 'settings' | 'logbook') {
setCompacting(target);
setCompactMsg((m) => ({ ...m, [target]: '' }));
CompactDatabase(target)
.then((r: any) => {
const msg = r?.backend === 'mysql'
? t('db.compactMysqlDone')
: t('db.compactDone', { before: fmtBytes(r?.before ?? 0), after: fmtBytes(r?.after ?? 0) });
setCompactMsg((m) => ({ ...m, [target]: msg }));
})
.catch((e: any) => setErr(String(e?.message ?? e)))
.finally(() => setCompacting(''));
}
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} } async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
async function refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} } async function refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} }
// The chosen file is persisted (config.json) the moment it's picked; dbMsg // The chosen file is persisted (config.json) the moment it's picked; dbMsg
@@ -6285,9 +6315,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<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>
{/* Pushed right: these two act on the file that is already there, {/* Pushed right: these two act on the file that is already there,
while the four on the left change WHICH file is in use. */} while the four on the left change WHICH file is in use. */}
<Button variant="outline" size="sm" className="ml-auto" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button> <Button variant="outline" size="sm" className="ml-auto" onClick={() => compact('settings')} disabled={compacting !== ''}>
{compacting === 'settings' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
</Button>
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</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>
{compactMsg.settings && <p className="text-[11px] text-success">{compactMsg.settings}</p>}
{/* The DB pointer is only read at startup, so offer the restart inline. */} {/* The DB pointer is only read at startup, so offer the restart inline. */}
{dbMsg && ( {dbMsg && (
<div className="text-[11px] text-success space-y-1 pt-1"> <div className="text-[11px] text-success space-y-1 pt-1">
@@ -6350,8 +6384,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Button variant="outline" size="sm" onClick={newLogbook}><Plus className="size-3.5" /> {t('db.newDb')}</Button> <Button variant="outline" size="sm" onClick={newLogbook}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openLogbook}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button> <Button variant="outline" size="sm" onClick={openLogbook}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button> <Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
<Button variant="outline" size="sm" className="ml-auto" onClick={() => compact('logbook')} disabled={compacting !== ''}>
{compacting === 'logbook' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
</Button>
{mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>} {mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
</div> </div>
{compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
</div> </div>
)} )}
@@ -6376,8 +6414,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{t('db.testCreate')} {t('db.testCreate')}
</Button> </Button>
<Button size="sm" className="h-8" onClick={connectMysql}>{t('db.connectUse')}</Button> <Button size="sm" className="h-8" onClick={connectMysql}>{t('db.connectUse')}</Button>
{/* Compacting a shared logbook is OPTIMIZE TABLE, and it rebuilds the
table with everyone else waiting on it hence the warning, and
hence its place here rather than beside the connection buttons of
a file only this operator uses. */}
<Button variant="outline" size="sm" className="h-8 ml-auto" title={t('db.compactMysqlWarn')}
onClick={() => compact('logbook')} disabled={compacting !== ''}>
{compacting === 'logbook' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
</Button>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span> <span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div> </div>
{compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
</div> </div>
)} )}
+41 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { Input } from './input'; import { Input } from './input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -7,6 +8,7 @@ import { cn } from '@/lib/utils';
// can't hold a typo'd value that isn't in the list. // can't hold a typo'd value that isn't in the list.
export function Combobox({ export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false, value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
showToggle = false,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
@@ -18,9 +20,21 @@ export function Combobox({
// fields read live by other actions — e.g. RST, so a CW macro sent without // fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed. // leaving the field uses the value just typed.
commitOnType?: boolean; commitOnType?: boolean;
// Draw a chevron that opens the full list on click.
//
// Without it this control is indistinguishable from a plain text box: it opens
// only on a keystroke or ArrowDown, both of which have to be known about
// first. That is fine for a field whose list is a convenience (RST), and wrong
// for one whose list is the ANSWER — the COM ports actually present on this
// machine, which nobody can be expected to recall.
showToggle?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
// Opened by the chevron rather than by typing: the whole list is on offer, not
// the part matching what is already in the field — a box holding COM7 would
// otherwise "open" onto COM7 alone.
const [browse, setBrowse] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -31,20 +45,22 @@ export function Combobox({
return () => document.removeEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc);
}, []); }, []);
const filtered = open const filtered = !open ? []
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60) : browse ? options.slice(0, 60)
: []; : options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
function commit(v: string) { function commit(v: string) {
onChange(v); onChange(v);
setQuery(v); setQuery(v);
setOpen(false); setOpen(false);
setBrowse(false);
} }
function onBlur() { function onBlur() {
// Defer so a click on an option registers first. // Defer so a click on an option registers first.
setTimeout(() => { setTimeout(() => {
setOpen(false); setOpen(false);
setBrowse(false);
const trimmed = query.trim(); const trimmed = query.trim();
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase()); const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
// Only fire onChange when the value actually changed — committing an // Only fire onChange when the value actually changed — committing an
@@ -68,6 +84,7 @@ export function Combobox({
const v = e.target.value; const v = e.target.value;
setQuery(v); setQuery(v);
setOpen(true); setOpen(true);
setBrowse(false); // typing filters again
// Commit-on-type pushes the value live to the parent (so a CW macro sent // Commit-on-type pushes the value live to the parent (so a CW macro sent
// without leaving the field uses what was just typed). With free text that's // without leaving the field uses what was just typed). With free text that's
// any input; a restricted field (allowFreeText=false) commits ONLY a value // any input; a restricted field (allowFreeText=false) commits ONLY a value
@@ -76,14 +93,34 @@ export function Combobox({
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v); if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
}} }}
onBlur={onBlur} onBlur={onBlur}
className={showToggle ? 'pr-7' : undefined}
onKeyDown={(e) => { onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); } if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); setBrowse(true); }
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); } else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
else if (e.key === 'Escape') { setQuery(value); setOpen(false); } else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
// Tab: just let it move on; onBlur commits/closes. Options are // Tab: just let it move on; onBlur commits/closes. Options are
// tabIndex=-1 so a single Tab leaves the field. // tabIndex=-1 so a single Tab leaves the field.
}} }}
/> />
{showToggle && (
<button
type="button"
tabIndex={-1}
aria-label="Show list"
className="absolute inset-y-0 right-0 flex w-7 items-center justify-center text-muted-foreground hover:text-foreground"
// mousedown, not click: the input's blur fires first otherwise and
// closes the list the same instant this opens it.
onMouseDown={(e) => {
e.preventDefault();
if (open) { setOpen(false); setBrowse(false); return; }
setQuery(value);
setBrowse(true);
setOpen(true);
}}
>
<ChevronDown className="size-3.5" />
</button>
)}
{open && filtered.length > 0 && ( {open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs"> <div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
{filtered.map((o) => ( {filtered.map((o) => (
+2 -2
View File
@@ -374,7 +374,7 @@ const en: Dict = {
'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 — local file', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.optSqlite': 'SQLite — local file', 'db.optMysql': 'MySQL — shared server (multi-operator)',
'db.logbookLabel': 'Logbook', 'db.openFolder': 'Open folder', 'db.dedicatedFile': 'dedicated file', 'db.useDefaultLogbook': 'Use the default logbook', 'db.renameLogbook': 'Rename / relocate…', 'db.renameLogbookTip': 'Rename or move this logbook file, carrying its QSOs across', 'db.logbookRenamed': 'Logbook renamed.', 'db.logbookLabel': 'Logbook', 'db.compact': 'Compact', 'db.compactDone': 'Compacted: {before} → {after}', 'db.compactMysqlWarn': 'Compacting locks the table while it is rebuilt — the other operators wait.', 'db.compactMysqlDone': 'Table optimised on the server.', 'db.openFolder': 'Open folder', 'db.dedicatedFile': 'dedicated file', 'db.useDefaultLogbook': 'Use the default logbook', 'db.renameLogbook': 'Rename / relocate…', 'db.renameLogbookTip': 'Rename or move this logbook file, carrying its QSOs across', 'db.logbookRenamed': 'Logbook renamed.',
'db.logbookFile': "This profile's logbook file", 'db.logbookFileHint': "Your QSOs live here — separate from the settings database. By default it's logbook.db next to your settings; choose a dedicated file to keep a visiting operator's contacts apart. A new file is created automatically.", 'db.chooseFile': 'Choose a dedicated file…', 'db.switchedSqliteFile': 'Logbook now uses a dedicated SQLite file.', 'db.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.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 ✓',
@@ -850,7 +850,7 @@ const fr: Dict = {
'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 — fichier local', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.optSqlite': 'SQLite — fichier local', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)',
'db.logbookLabel': 'Journal', 'db.openFolder': 'Ouvrir le dossier', 'db.dedicatedFile': 'fichier dédié', 'db.useDefaultLogbook': 'Utiliser le journal par défaut', 'db.renameLogbook': 'Renommer / déplacer…', 'db.renameLogbookTip': 'Renommer ou déplacer ce fichier journal, en emmenant ses QSO', 'db.logbookRenamed': 'Journal renommé.', 'db.logbookLabel': 'Journal', 'db.compact': 'Compacter', 'db.compactDone': 'Compactée : {before} → {after}', 'db.compactMysqlWarn': "Compacter verrouille la table le temps de la reconstruire : les autres opérateurs attendent.", 'db.compactMysqlDone': 'Table optimisée sur le serveur.', 'db.openFolder': 'Ouvrir le dossier', 'db.dedicatedFile': 'fichier dédié', 'db.useDefaultLogbook': 'Utiliser le journal par défaut', 'db.renameLogbook': 'Renommer / déplacer…', 'db.renameLogbookTip': 'Renommer ou déplacer ce fichier journal, en emmenant ses QSO', 'db.logbookRenamed': 'Journal renommé.',
'db.logbookFile': 'Fichier journal de ce profil', 'db.logbookFileHint': "Tes QSO sont ici — séparés de la base de réglages. Par défaut c'est logbook.db à côté de tes réglages ; choisis un fichier dédié pour isoler les contacts d'un opérateur de passage. Un nouveau fichier est créé automatiquement.", 'db.chooseFile': 'Choisir un fichier dédié…', 'db.switchedSqliteFile': 'Le journal utilise désormais un fichier SQLite dédié.', 'db.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.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 ✓',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.26.3'; export const APP_VERSION = '0.26.4';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -123,6 +123,8 @@ export function CloseAutostartPrograms():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>; export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
export function CompactDatabase(arg1:string):Promise<main.CompactResult>;
export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef>>; export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef>>;
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>; export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
+4
View File
@@ -186,6 +186,10 @@ export function ClusterSpotStatuses(arg1) {
return window['go']['main']['App']['ClusterSpotStatuses'](arg1); return window['go']['main']['App']['ClusterSpotStatuses'](arg1);
} }
export function CompactDatabase(arg1) {
return window['go']['main']['App']['CompactDatabase'](arg1);
}
export function ComputeQSOAwardRefs(arg1) { export function ComputeQSOAwardRefs(arg1) {
return window['go']['main']['App']['ComputeQSOAwardRefs'](arg1); return window['go']['main']['App']['ComputeQSOAwardRefs'](arg1);
} }
+18
View File
@@ -2295,6 +2295,24 @@ export namespace main {
this.count = source["count"]; this.count = source["count"];
} }
} }
export class CompactResult {
path: string;
backend: string;
before: number;
after: number;
static createFrom(source: any = {}) {
return new CompactResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.backend = source["backend"];
this.before = source["before"];
this.after = source["after"];
}
}
export class ContestBandRow { export class ContestBandRow {
band: string; band: string;
count: number; count: number;
+25 -4
View File
@@ -147,7 +147,17 @@ func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z"
// Open opens (and creates if needed) the SQLite database at the given path, // Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations. // enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) { //
// This is the SETTINGS database, which gets the full schema — see roles.go for
// why. Use OpenLogbook for a database that holds contacts only.
func Open(path string) (*sql.DB, error) { return open(path, RoleAll) }
// OpenLogbook opens a SQLite logbook: the qso table and nothing from the
// settings side, and any unused settings tables an earlier version left in it
// are dropped if they are empty.
func OpenLogbook(path string) (*sql.DB, error) { return open(path, RoleLogbook) }
func open(path string, role Role) (*sql.DB, error) {
// Escape only the two characters a path could contain that the DSN would // Escape only the two characters a path could contain that the DSN would
// otherwise read as its query/fragment delimiters. Windows separators // otherwise read as its query/fragment delimiters. Windows separators
// (\\ and the drive ':') are left intact — url.PathEscape would mangle them. // (\\ and the drive ':') are left intact — url.PathEscape would mangle them.
@@ -162,10 +172,12 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("ping sqlite: %w", err) return nil, fmt.Errorf("ping sqlite: %w", err)
} }
Dialect = "sqlite" Dialect = "sqlite"
if err := migrate(conn, nil, path, filepath.Base(path)); err != nil { label := filepath.Base(path)
if err := migrate(conn, nil, path, label, role); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, err return nil, err
} }
pruneForeignTables(conn, role, label)
return conn, nil return conn, nil
} }
@@ -236,7 +248,7 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
// interleaved migration runs in one log were indistinguishable — an operator // interleaved migration runs in one log were indistinguishable — an operator
// reported "migrations are very slow" and the lines gave no way to tell one // reported "migrations are very slow" and the lines gave no way to tell one
// database migrated three times from three databases migrated once. // database migrated three times from three databases migrated once.
func migrate(conn *sql.DB, translate func(string) string, dbPath, label string) error { func migrate(conn *sql.DB, translate func(string) string, dbPath, label string, role Role) error {
// A non-nil translator means this is the MySQL connection (use the // A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is // per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the // determined by the caller's argument, NOT the global Dialect, so the
@@ -331,10 +343,19 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string)
if err != nil { if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err) return fmt.Errorf("begin tx for %s: %w", name, err)
} }
if _, err := tx.Exec(sqlText); err != nil { // Statement by statement rather than one Exec of the whole file: the role
// filter works per statement, and a settings-table statement must not
// reach a logbook database (see roles.go). Still one transaction, so the
// file remains atomic.
for _, stmt := range splitStatements(sqlText) {
if !keepForRole(stmt, role) {
continue
}
if _, err := tx.Exec(stmt); err != nil {
_ = tx.Rollback() _ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err) return fmt.Errorf("apply migration %s: %w", name, err)
} }
}
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil { if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
_ = tx.Rollback() _ = tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err) return fmt.Errorf("record migration %s: %w", name, err)
+4 -2
View File
@@ -252,7 +252,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
return nil, rerr return nil, rerr
} }
// Then apply only the migrations it's missing. // Then apply only the migrations it's missing.
err = migrate(conn, mysqlDDL, "", "mysql:"+name) err = migrate(conn, mysqlDDL, "", "mysql:"+name, RoleLogbook)
} }
if err != nil { if err != nil {
_ = conn.Close() _ = conn.Close()
@@ -343,7 +343,9 @@ func applyMySQLBaseline(conn *sql.DB) error {
// database. Labelled so its (fast) migration lines are not mistaken for a // database. Labelled so its (fast) migration lines are not mistaken for a
// real database being migrated — in one operator's log this pass sat between // real database being migrated — in one operator's log this pass sat between
// two slow MySQL runs and looked like a third database. // two slow MySQL runs and looked like a third database.
if err := migrate(mem, nil, "", "baseline:memory"); err != nil { // RoleLogbook: the baseline defines what a FRESH shared logbook gets, and a
// logbook has no business holding settings or station profiles.
if err := migrate(mem, nil, "", "baseline:memory", RoleLogbook); err != nil {
return fmt.Errorf("build baseline schema: %w", err) return fmt.Errorf("build baseline schema: %w", err)
} }
+305
View File
@@ -0,0 +1,305 @@
package db
// Which tables belong in which database.
//
// OpsLog keeps settings and contacts apart — a settings database (settings.db /
// opslog.db) and a logbook (logbook.db, a per-profile file, or a shared MySQL).
// The SEPARATION of the data was real from the start; the SCHEMA was not: every
// target got the whole migration set, so a shared MySQL logbook grew a settings
// table and a station_profiles table that nothing ever wrote to. An operator
// inspecting the server with phpMyAdmin had no way to tell which copy was
// authoritative — a fair question, and the reason for this file.
//
// So a migration statement is now filtered by the ROLE of the database it is
// being applied to. Only two rules, and both fail safe:
//
// - RoleAll (the settings database) applies everything, exactly as before.
// It has to: a legacy single-file installation holds its QSOs there, and it
// still serves as the logbook when no separate one could be created.
// - RoleLogbook applies everything EXCEPT statements aimed at a table on the
// settings side. A table this file does not know about is kept, in both —
// an unrecognised future table behaves as it does today rather than
// silently going missing from one database.
import (
"database/sql"
"fmt"
"strings"
)
// Role is what a database is for.
type Role int
const (
// RoleAll applies every migration statement. The settings database.
RoleAll Role = iota
// RoleLogbook applies only what the contacts need.
RoleLogbook
)
// settingsTables are owned by the settings database. Every one of them is
// reached through the settings connection in app.go — grep NewRepo/NewStore
// there: only qso.NewRepo is given the logbook connection.
//
// Ordered children-before-parents, because pruneForeignTables drops them in
// this order and operating_antennas has a foreign key into operating_stations.
var settingsTables = []string{
"operating_antenna_bands",
"operating_antennas",
"operating_antennas_new",
"operating_stations",
"operating_stations_new",
"award_references",
"qsl_templates",
"cluster_servers",
"integrations_udp",
"callsign_cache",
"station_profiles",
"settings",
}
// keepForRole reports whether one migration statement applies to this role.
func keepForRole(stmt string, role Role) bool {
if role == RoleAll {
return true
}
t := stmtTable(stmt)
if t == "" {
return true // not a table statement (PRAGMA, or a shape we don't parse)
}
for _, s := range settingsTables {
if t == s {
return false
}
}
return true
}
// stmtTable returns the lower-cased table a statement acts on, or "".
//
// Deliberately literal: it recognises the handful of statement shapes the
// migrations actually use, and returns "" for anything else — which keeps the
// statement. Guessing would be the only way to drop something by accident.
func stmtTable(stmt string) string {
f := strings.Fields(strings.ToLower(stmt))
at := func(i int) string {
if i < len(f) {
return f[i]
}
return ""
}
// Skip the leading keywords that carry no table name.
switch {
case at(0) == "create" || at(0) == "drop":
// CREATE [UNIQUE] INDEX <name> ON <table> …
// CREATE TABLE [IF NOT EXISTS] <table> … / DROP TABLE [IF EXISTS] <t>
//
// The "ON" search is confined to an INDEX statement on purpose: a CREATE
// TABLE body is full of "ON DELETE CASCADE", and scanning the whole
// statement for "on" once made every foreign-keyed table report itself as
// a table named "delete" — unrecognised, therefore kept, therefore created
// in a logbook that had no use for it.
if at(1) == "index" || (at(1) == "unique" && at(2) == "index") {
for i, w := range f {
if w == "on" && i+1 < len(f) {
return cleanIdent(f[i+1])
}
}
}
i := 1
for at(i) == "unique" || at(i) == "table" || at(i) == "index" ||
at(i) == "if" || at(i) == "not" || at(i) == "exists" || at(i) == "view" {
i++
}
return cleanIdent(at(i))
case at(0) == "alter":
return cleanIdent(at(2)) // ALTER TABLE <table> …
case at(0) == "insert" || at(0) == "replace":
i := 1
for at(i) == "or" || at(i) == "ignore" || at(i) == "into" || at(i) == "replace" {
i++
}
return cleanIdent(at(i))
case at(0) == "update":
return cleanIdent(at(1))
case at(0) == "delete":
return cleanIdent(at(2)) // DELETE FROM <table>
}
return ""
}
// cleanIdent strips quoting and anything glued to the name — "qso(id)" from an
// index, `settings` from the MySQL translation, "qso;" from a split statement.
func cleanIdent(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "(;"); i >= 0 {
s = s[:i]
}
return strings.Trim(s, "`\"'[]")
}
// pruneForeignTables removes settings tables from a logbook database that an
// earlier version created there.
//
// ONLY WHEN EMPTY, without exception. A table with rows in it is data, whatever
// this file thinks it is for: a profile pointing at a legacy combined database
// as its logbook is a real configuration, and dropping its profiles because the
// schema now says they belong elsewhere would be destroying a log-keeping
// operator's work on the strength of a tidiness rule.
//
// Best effort throughout: a logbook the user cannot drop tables in (a restricted
// MySQL grant) keeps its empty tables and works exactly as it does today.
func pruneForeignTables(conn *sql.DB, role Role, label string) {
if role != RoleLogbook || conn == nil {
return
}
dropped := 0
for _, t := range settingsTables {
var n int
// A missing table errors here, which is the "nothing to do" answer.
if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(t)).Scan(&n); err != nil {
continue
}
if n != 0 {
logf("db[%s]: keeping %s — it has %d row(s)", label, t, n)
continue
}
if _, err := conn.Exec(`DROP TABLE ` + quoteIdent(t)); err != nil {
logf("db[%s]: could not drop unused %s: %v", label, t, err)
continue
}
dropped++
}
if dropped > 0 {
logf("db[%s]: dropped %d unused settings table(s) — this database holds contacts only", label, dropped)
}
}
// quoteIdent quotes one of OUR OWN table names for either dialect. Backticks
// work in MySQL and in SQLite alike, which is why the migrations use them.
func quoteIdent(s string) string { return "`" + s + "`" }
// DropRedundantSettingsTables removes the settings tables from a logbook
// database EVEN IF THEY HAVE ROWS.
//
// Reserved for the one case where those rows are provably a stale duplicate:
// the logbook file was seeded by VACUUM INTO from the old combined database
// (see the split at startup), so every profile and every setting in it is a
// copy of what the settings database still holds and is authoritative for.
// Nothing reads them — the settings connection is a different file entirely —
// and leaving them behind is what made the two databases look interchangeable.
//
// The caller must have established that authority. pruneForeignTables is the
// safe default for every other situation.
func DropRedundantSettingsTables(conn *sql.DB, label string) {
if conn == nil {
return
}
dropped := 0
for _, t := range settingsTables {
if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(t)).Scan(new(int)); err != nil {
continue // not there: nothing to do
}
if _, err := conn.Exec(`DROP TABLE ` + quoteIdent(t)); err != nil {
logf("db[%s]: could not drop redundant %s: %v", label, t, err)
continue
}
dropped++
}
if dropped > 0 {
logf("db[%s]: dropped %d settings table(s) copied by the logbook split — the settings database keeps the originals", label, dropped)
}
}
// EnsureQSOTable recreates the contacts table if it is missing.
//
// It exists so dropping an EMPTY qso table from the settings database is a
// reversible act. The settings database can still be pressed into service as the
// logbook — that is the fallback when no separate logbook file can be created —
// and the migrations that would have built the table are recorded as applied, so
// nothing would ever build it again. This does, from the same migrations, on
// demand.
//
// The schema is derived rather than duplicated: the migrations are replayed on a
// throwaway in-memory SQLite whose sqlite_master then holds the FINAL shape of
// the table with every ALTER-added column folded in. Same trick as the MySQL
// baseline, and for the same reason — there is no second schema to drift.
//
// SQLite only: the shared MySQL logbook is never the settings database.
func EnsureQSOTable(conn *sql.DB) error {
if conn == nil {
return nil
}
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(new(int)); err == nil {
return nil // already there
}
stmts, err := qsoSchemaDDL()
if err != nil {
return err
}
for _, st := range stmts {
if _, err := conn.Exec(st); err != nil {
return fmt.Errorf("recreate qso table: %w", err)
}
}
logf("db: recreated the qso table — this database is being used as the logbook")
return nil
}
// qsoSchemaDDL returns the CREATE statements for the qso table and its indexes,
// in that order.
func qsoSchemaDDL() ([]string, error) {
mem, err := sql.Open("sqlite", "file:opslog_qsoschema?mode=memory&cache=shared")
if err != nil {
return nil, fmt.Errorf("open schema sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil, "", "qso-schema:memory", RoleLogbook); err != nil {
return nil, fmt.Errorf("build qso schema: %w", err)
}
rows, err := mem.Query(`SELECT type, sql FROM sqlite_master
WHERE sql IS NOT NULL AND tbl_name = 'qso'`)
if err != nil {
return nil, err
}
defer rows.Close()
var tables, indexes []string
for rows.Next() {
var typ, s string
if err := rows.Scan(&typ, &s); err != nil {
return nil, err
}
if typ == "table" {
tables = append(tables, s)
} else {
indexes = append(indexes, s)
}
}
if len(tables) == 0 {
return nil, fmt.Errorf("qso table not found in the derived schema")
}
return append(tables, indexes...), rows.Err()
}
// DropEmptyQSOTable removes the contacts table from a database that is not the
// logbook — the settings database, once its QSOs live in their own file.
//
// Only when empty, and reversible: EnsureQSOTable builds it again the moment
// this database is asked to serve as the logbook.
func DropEmptyQSOTable(conn *sql.DB, label string) {
if conn == nil {
return
}
var n int
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil {
return // not there: nothing to do
}
if n != 0 {
return // contacts: this table is data, whatever the schema says
}
if _, err := conn.Exec(`DROP TABLE qso`); err != nil {
logf("db[%s]: could not drop the unused qso table: %v", label, err)
return
}
logf("db[%s]: dropped the unused qso table — the contacts live in their own database", label)
}
+215
View File
@@ -0,0 +1,215 @@
package db
import (
"path/filepath"
"sort"
"testing"
)
func TestStmtTable(t *testing.T) {
cases := map[string]string{
"CREATE TABLE station_profiles (\n id INTEGER PRIMARY KEY)": "station_profiles",
"CREATE TABLE IF NOT EXISTS settings (`key` TEXT PRIMARY KEY)": "settings",
"CREATE UNIQUE INDEX idx_qso_uid ON qso(sync_uid)": "qso",
"CREATE INDEX idx_ref ON award_references (award_code)": "award_references",
"ALTER TABLE qso ADD COLUMN ant_path TEXT NOT NULL DEFAULT ''": "qso",
"ALTER TABLE `station_profiles` ADD COLUMN my_cq_zone TEXT": "station_profiles",
"INSERT INTO settings(`key`, value) VALUES('x','y')": "settings",
"INSERT OR IGNORE INTO cluster_servers(name) VALUES('dxc')": "cluster_servers",
"UPDATE qso SET callsign = UPPER(callsign)": "qso",
"DELETE FROM operating_antennas WHERE station_id IS NULL": "operating_antennas",
"DROP TABLE IF EXISTS operating_stations_new": "operating_stations_new",
"PRAGMA foreign_keys = off": "",
"": "",
}
for in, want := range cases {
if got := stmtTable(in); got != want {
t.Errorf("stmtTable(%.40q) = %q, want %q", in, got, want)
}
}
}
func TestKeepForRole(t *testing.T) {
// The settings database takes everything, exactly as before this existed.
for _, s := range []string{"CREATE TABLE settings (a TEXT)", "CREATE TABLE qso (a TEXT)"} {
if !keepForRole(s, RoleAll) {
t.Fatalf("RoleAll dropped %q", s)
}
}
// A logbook takes the contacts and refuses the settings side.
if !keepForRole("CREATE INDEX i ON qso(callsign)", RoleLogbook) {
t.Fatal("logbook dropped a qso statement")
}
if keepForRole("CREATE TABLE station_profiles (id INTEGER)", RoleLogbook) {
t.Fatal("logbook accepted station_profiles")
}
// An unrecognised statement — a future table, a PRAGMA — is kept, so a new
// migration behaves as it does today rather than vanishing from one database.
if !keepForRole("CREATE TABLE something_new (id INTEGER)", RoleLogbook) {
t.Fatal("logbook dropped an unknown table")
}
if !keepForRole("PRAGMA foreign_keys = off", RoleLogbook) {
t.Fatal("logbook dropped a PRAGMA")
}
}
// tablesIn lists the tables of an open database.
func tablesIn(t *testing.T, path string) []string {
t.Helper()
conn, err := Open(path) // RoleAll: opening must not change what is there
if err != nil {
t.Fatal(err)
}
defer conn.Close()
rows, err := conn.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
t.Fatal(err)
}
out = append(out, n)
}
sort.Strings(out)
return out
}
// A logbook opened through OpenLogbook holds the contacts and nothing else.
func TestOpenLogbookSchema(t *testing.T) {
path := filepath.Join(t.TempDir(), "logbook.db")
conn, err := OpenLogbook(path)
if err != nil {
t.Fatal(err)
}
var n int
// The one table that matters has to be there and has to be usable.
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil {
t.Fatalf("qso table unusable: %v", err)
}
for _, forbidden := range settingsTables {
if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(forbidden)).Scan(&n); err == nil {
t.Errorf("%s was created in a logbook database", forbidden)
}
}
conn.Close()
// Reopening as a logbook is idempotent, and the migrations already recorded
// as applied must not be re-run into a half-schema.
conn2, err := OpenLogbook(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if err := conn2.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil {
t.Fatalf("qso lost on reopen: %v", err)
}
conn2.Close()
}
// An existing logbook that an older version filled with the whole schema loses
// the unused tables — and keeps any that hold rows.
func TestPruneKeepsNonEmptyTables(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy.db")
conn, err := Open(path) // the old behaviour: every table everywhere
if err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(`INSERT INTO station_profiles(name) VALUES('Home')`); err != nil {
t.Fatal(err)
}
conn.Close()
if got := tablesIn(t, path); len(got) < 10 {
t.Fatalf("expected a full legacy schema, got %v", got)
}
conn, err = OpenLogbook(path)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
var n int
// Rows are data: this one stays, whatever the schema says it is for.
if err := conn.QueryRow(`SELECT COUNT(*) FROM station_profiles`).Scan(&n); err != nil || n != 1 {
t.Fatalf("station_profiles dropped with a row in it (err=%v n=%d)", err, n)
}
// The empty ones go.
if err := conn.QueryRow(`SELECT COUNT(*) FROM settings`).Scan(&n); err == nil {
t.Error("empty settings table survived in a logbook")
}
// And the contacts are untouched throughout.
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil {
t.Fatalf("qso table lost: %v", err)
}
}
// A CREATE TABLE body is full of "ON DELETE CASCADE": the index rule must not
// read it as a table name, or the table goes unrecognised and gets created in
// every database.
func TestStmtTableForeignKeyBody(t *testing.T) {
stmt := `CREATE TABLE operating_stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE
)`
if got := stmtTable(stmt); got != "operating_stations" {
t.Fatalf("got %q", got)
}
if keepForRole(stmt, RoleLogbook) {
t.Fatal("a settings table reached a logbook database")
}
}
// The settings database loses its unused qso table — and gets it back, in full,
// the moment it has to serve as the logbook again.
func TestDropAndRecreateQSOTable(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.db")
conn, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
// A contact in it is data: the table must survive.
if _, err := conn.Exec(`INSERT INTO qso(callsign, qso_date, band, mode)
VALUES('F4BPO','2026-01-01T12:00:00Z','20m','SSB')`); err != nil {
t.Fatal(err)
}
DropEmptyQSOTable(conn, "settings")
var n int
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 {
t.Fatalf("dropped a qso table holding a contact (err=%v n=%d)", err, n)
}
// Empty, so it goes.
if _, err := conn.Exec(`DELETE FROM qso`); err != nil {
t.Fatal(err)
}
DropEmptyQSOTable(conn, "settings")
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err == nil {
t.Fatal("empty qso table survived")
}
// And comes back complete when this database is pressed into service as the
// logbook — a late column and an index included, not just a bare table.
if err := EnsureQSOTable(conn); err != nil {
t.Fatal(err)
}
if _, err := conn.Exec(`INSERT INTO qso(callsign, qso_date, band, mode, ant_path, sync_uid)
VALUES('F1TRF','2026-01-02T13:00:00Z','40m','CW','S','uid-1')`); err != nil {
t.Fatalf("recreated qso table is incomplete: %v", err)
}
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 {
t.Fatalf("recreated table unusable (err=%v n=%d)", err, n)
}
// Idempotent: a second call on a live table must not touch it.
if err := EnsureQSOTable(conn); err != nil {
t.Fatal(err)
}
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n != 1 {
t.Fatalf("EnsureQSOTable disturbed an existing table (err=%v n=%d)", err, n)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.26.3" appVersion = "0.26.4"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.