chore: release v0.26.4
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
+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.
|
||||
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);
|
||||
|
||||
@@ -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<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 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
|
||||
<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,
|
||||
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>}
|
||||
</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. */}
|
||||
{dbMsg && (
|
||||
<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={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" 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>}
|
||||
</div>
|
||||
{compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6376,8 +6414,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{t('db.testCreate')}
|
||||
</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>
|
||||
</div>
|
||||
{compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(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 && (
|
||||
<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 && (
|
||||
<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) => (
|
||||
|
||||
@@ -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 ✓',
|
||||
|
||||
@@ -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';
|
||||
|
||||
Vendored
+2
@@ -123,6 +123,8 @@ export function CloseAutostartPrograms():Promise<void>;
|
||||
|
||||
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 ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+27
-6
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user