Files
OpsLog/internal/db/roles.go
T
rouggy 1a169fdb4f feat(labels): the Label Designer — QSO and address labels for paper QSL
The designer's data model lives in internal/labels: STOCKS are the physical
roll in the printer (geometry in mm, margins, dpi — seeded with the common
Brother DK sizes, the QL family being what prompted the feature), TEMPLATES are
one design each, of two kinds: the QSO label glued on the card, whose repeating
table carries several contacts of the same station, and the address label for
the envelope, whose lines collapse when a variable is empty.

Everything is measured in millimetres — labels are sold in mm, and an operator
lining a design up against a physical sticker thinks in mm; pixels exist only in
the renderer, at the stock's dpi. The canvas renderer is shared between the
editor's preview and the future print path, so there is no second
implementation for the preview to disagree with.

The preview is fed with the log's latest contacts rather than lorem ipsum: real
data shows a too-narrow column immediately.

Printing (PDF, one page per label at exact size) is the next module; nothing
here prints yet.
2026-08-28 19:41:15 +02:00

324 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",
"label_stocks",
"label_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)
}