diff --git a/app.go b/app.go index 3eccf35..bfbbdb3 100644 --- a/app.go +++ b/app.go @@ -1209,6 +1209,7 @@ func (a *App) startup(ctx context.Context) { applog.Printf("startup: logbook backend = %s", backend) a.logDb = logbookConn a.qso = qso.NewRepo(logbookConn) + a.tidyLogbookSchema(logbookConn) 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.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 == "" { 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 } // 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) lp = r } - c, err := db.Open(lp) + c, err := db.OpenLogbook(lp) if err != nil { 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. fb.OnSpotClick = func(call string, hz int64, mode string) { 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 // 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. tb.OnSpotClick = func(call string, hz int64) { 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) @@ -19993,3 +20003,57 @@ func lookupWhen(qsoDate string) time.Time { } 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)) +} diff --git a/changelog.json b/changelog.json index 8f1d59d..34a189e 100644 --- a/changelog.json +++ b/changelog.json @@ -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 l’onglet 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", "date": "", diff --git a/compact.go b/compact.go new file mode 100644 index 0000000..ede95a4 --- /dev/null +++ b/compact.go @@ -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() +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23f3a82..14a958a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3515,12 +3515,18 @@ export default function App() { // An explicit click always wins over whatever call is currently in the field. const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { 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. const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => { 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 total = Number(p?.total ?? 0); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 9b03460..c2413fc 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, User, Database, Radio, Cog, Server, Antenna as AntennaIcon, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil, + Minimize2, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, @@ -12,7 +13,7 @@ import { ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotorPresets, SaveRotorPresets, ResetRotorPresets, - GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, + GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, GetAntGeniusSettings, SaveAntGeniusSettings, GetTunerGeniusSettings, SaveTunerGeniusSettings, 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. 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 [rdaBusy, setRdaBusy] = useState(false); const [rdaUseCurrent, setRdaUseCurrent] = useState(true); @@ -3591,6 +3602,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan options={wkPorts} allowFreeText commitOnType + showToggle placeholder="COM3" className="font-mono flex-1" onChange={(v) => setUltrabeam((s) => ({ ...s, com: v.trim().toUpperCase() }))} @@ -6166,6 +6178,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan } 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>({}); + 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 refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} } // The chosen file is persisted (config.json) the moment it's picked; dbMsg @@ -6285,9 +6315,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {/* Pushed right: these two act on the file that is already there, while the four on the left change WHICH file is in use. */} - + + {dbSettings.is_custom && } + {compactMsg.settings &&

{compactMsg.settings}

} {/* The DB pointer is only read at startup, so offer the restart inline. */} {dbMsg && (
@@ -6350,8 +6384,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan + {mysqlCfg.sqlite_path && }
+ {compactMsg.logbook &&

{compactMsg.logbook}

} )} @@ -6376,8 +6414,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {t('db.testCreate')} + {/* 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. */} + {mysqlMsg} + {compactMsg.logbook &&

{compactMsg.logbook}

} )} diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx index 9549a76..0c25010 100644 --- a/frontend/src/components/ui/combobox.tsx +++ b/frontend/src/components/ui/combobox.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; import { Input } from './input'; 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. export function Combobox({ value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false, + showToggle = false, }: { value: string; 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 // leaving the field uses the value just typed. 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 [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(null); useEffect(() => { @@ -31,20 +45,22 @@ export function Combobox({ return () => document.removeEventListener('mousedown', onDoc); }, []); - const filtered = open - ? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60) - : []; + const filtered = !open ? [] + : browse ? options.slice(0, 60) + : options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60); function commit(v: string) { onChange(v); setQuery(v); setOpen(false); + setBrowse(false); } function onBlur() { // Defer so a click on an option registers first. setTimeout(() => { setOpen(false); + setBrowse(false); const trimmed = query.trim(); const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase()); // Only fire onChange when the value actually changed — committing an @@ -68,6 +84,7 @@ export function Combobox({ const v = e.target.value; setQuery(v); setOpen(true); + setBrowse(false); // typing filters again // 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 // 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); }} onBlur={onBlur} + className={showToggle ? 'pr-7' : undefined} 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 === 'Escape') { setQuery(value); setOpen(false); } // Tab: just let it move on; onBlur commits/closes. Options are // tabIndex=-1 so a single Tab leaves the field. }} /> + {showToggle && ( + + )} {open && filtered.length > 0 && (
{filtered.map((o) => ( diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 93b07eb..68a31fe 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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.", // Database panel '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.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 ✓', @@ -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.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.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.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 ✓', diff --git a/frontend/src/version.ts b/frontend/src/version.ts index a2aa6fa..ad78575 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,6 +1,6 @@ // 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). -export const APP_VERSION = '0.26.3'; +export const APP_VERSION = '0.26.4'; // Author / credits, shown in Help -> About. export const APP_AUTHOR = 'F4BPO'; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 44f00e7..8743c5a 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -123,6 +123,8 @@ export function CloseAutostartPrograms():Promise; export function ClusterSpotStatuses(arg1:Array):Promise>; +export function CompactDatabase(arg1:string):Promise; + export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise>; export function ComputeStationInfo(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 448b3ef..9626250 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -186,6 +186,10 @@ export function ClusterSpotStatuses(arg1) { return window['go']['main']['App']['ClusterSpotStatuses'](arg1); } +export function CompactDatabase(arg1) { + return window['go']['main']['App']['CompactDatabase'](arg1); +} + export function ComputeQSOAwardRefs(arg1) { return window['go']['main']['App']['ComputeQSOAwardRefs'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 02aaa26..ed5cd41 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2295,6 +2295,24 @@ export namespace main { 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 { band: string; count: number; diff --git a/internal/db/db.go b/internal/db/db.go index 03fb682..ae98046 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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, // 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 // otherwise read as its query/fragment delimiters. Windows separators // (\\ 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) } 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() return nil, err } + pruneForeignTables(conn, role, label) 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 // reported "migrations are very slow" and the lines gave no way to tell one // 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 // per-statement, FK-aware path); nil means a SQLite connection. This is // determined by the caller's argument, NOT the global Dialect, so the @@ -331,9 +343,18 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string) if err != nil { return fmt.Errorf("begin tx for %s: %w", name, err) } - if _, err := tx.Exec(sqlText); err != nil { - _ = tx.Rollback() - return fmt.Errorf("apply migration %s: %w", name, err) + // 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() + return fmt.Errorf("apply migration %s: %w", name, err) + } } if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil { _ = tx.Rollback() diff --git a/internal/db/mysql.go b/internal/db/mysql.go index 8dd2dc7..8bb34ca 100644 --- a/internal/db/mysql.go +++ b/internal/db/mysql.go @@ -252,7 +252,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) { return nil, rerr } // Then apply only the migrations it's missing. - err = migrate(conn, mysqlDDL, "", "mysql:"+name) + err = migrate(conn, mysqlDDL, "", "mysql:"+name, RoleLogbook) } if err != nil { _ = conn.Close() @@ -343,7 +343,9 @@ func applyMySQLBaseline(conn *sql.DB) error { // 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 // 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) } diff --git a/internal/db/roles.go b/internal/db/roles.go new file mode 100644 index 0000000..055d858 --- /dev/null +++ b/internal/db/roles.go @@ -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 ON … + // CREATE TABLE [IF NOT EXISTS]
… / DROP TABLE [IF EXISTS] + // + // 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
… + 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
+ } + 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) +} diff --git a/internal/db/roles_test.go b/internal/db/roles_test.go new file mode 100644 index 0000000..908ed83 --- /dev/null +++ b/internal/db/roles_test.go @@ -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) + } +} diff --git a/telemetry.go b/telemetry.go index 200c8b0..b3350a1 100644 --- a/telemetry.go +++ b/telemetry.go @@ -21,7 +21,7 @@ import ( const ( // 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 // to https://us.i.posthog.com for a US project.