Files
rouggy cb430a22ee feat: Ultrabeam over USB, Paper QSL from the awards grid, per-role schemas
Ultrabeam on a serial port never worked, and three faults were stacked so
each hid the next:

  - Stop() did not wait for the poll loop, so a stopped client kept the COM
    port. Every later client then failed with "Serial port busy" — the
    program holding it being OpsLog itself.
  - startUltrabeam tore the old client down CONCURRENTLY with starting the
    new one, and "Test connection" built a second client on a port already
    ours. Harmless over TCP, fatal on a port with one owner.
  - A silent serial port returns (0, nil) and bufio retries that a hundred
    times: a 4 s timeout became ~7 minutes of a frozen poll loop logging
    nothing.

The controller then answered at once. Confirmed on hardware: the USB cable
presents TWO COM ports, only the second reaches the controller, and only at
19200 baud — so the speed is pinned in code (an FTDI cable opens at any
speed, and a wrong one is indistinguishable from a dead controller) and the
port field says which one to pick. The first exchange after each connect is
hex-dumped, which separates silence from a wrong baud from a misread frame.

Databases now carry only the tables their role needs. Every target used to
get the whole migration set, so a shared MySQL logbook grew settings and
station_profiles tables nothing ever wrote to — an operator inspecting the
server could not tell which copy was authoritative. Statements are filtered
by role, unknown tables are kept in both (fail-safe), and existing databases
are cleaned once, dropping only EMPTY tables. Settings → Database gains a
Compact button, since SQLite frees pages inside the file and never shrinks it.

Also:
  - Awards: the callsigns behind a cell open the QSL Manager on Paper QSL,
    searched, ready for the card dates.
  - The record button no longer goes missing after an update: whether manual
    recording is possible is a per-profile question that was asked once, at
    startup, before the profile was known.
  - Alert rules and filter presets confirm that they were saved.
  - Spot clicks on the radio panadapter carry the POTA park into F3.
  - The build gate is re-checked wherever the active callsign can change; it
    ran at startup alone, and a fresh install has no callsign then.
2026-08-22 14:07:00 +02:00

322 lines
12 KiB
Go

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
}
// Once per database, recorded like a migration.
//
// The check itself is a COUNT(*) per settings table — twelve round trips,
// which is nothing locally and is paid on EVERY open of a remote MySQL
// logbook, including every profile switch. There is nothing to find after the
// first pass: the role filter means no settings table is ever created in a
// logbook again.
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, prunedMarker); err != nil {
return // already recorded (primary key), or the table is not writable
}
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)
}
}
// prunedMarker records, in schema_migrations, that a logbook has had its unused
// settings tables removed. Named like a migration and stored beside them because
// that is exactly what it is: a one-off schema step, and the applied set is
// already read in a single query at every open.
const prunedMarker = "_opslog_pruned_settings_tables"
// 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)
}