Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abfed4afa2 |
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
"version": "0.26.3",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+102
@@ -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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
Vendored
+2
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user