This commit is contained in:
2026-06-15 23:45:14 +02:00
parent 29fd832bcd
commit 22e3bb4a18
32 changed files with 2531 additions and 362 deletions
+113
View File
@@ -454,6 +454,19 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
return inserted, nil
}
// Revision returns a cheap fingerprint of the logbook — row count and the
// highest id — so a client can poll for changes made by OTHER instances on a
// shared MySQL logbook and refresh when it differs. Inserts bump the max id;
// deletes change the count.
func (r *Repo) Revision(ctx context.Context) (string, error) {
var count, maxID int64
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(MAX(id), 0) FROM qso`).Scan(&count, &maxID); err != nil {
return "", err
}
return fmt.Sprintf("%d:%d", count, maxID), nil
}
// GetByID fetches a single QSO by primary key.
func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
row := r.db.QueryRowContext(ctx,
@@ -1308,6 +1321,106 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
return rows.Err()
}
// awardCols is the column set the award engine can read — every field reachable
// from refField/inScope/confirmed in internal/award plus enrichQSOForAwards:
// the reference fields (dxcc, zones, continent, country, state, grid/vucc, iota,
// sota, pota, wwff via extras_json, and the free-text fields name/qth/address/
// comment/notes a custom award may key on), band/mode/qso_date for scoping,
// freq_hz for band recovery, and the qsl/lotw/eqsl rcvd confirmation flags.
// This drops ~125 unused columns so award computation over a remote MySQL
// backend ships a fraction of each row instead of the whole record.
//
// IMPORTANT: when a new award references a QSO field not listed here, add the
// column to this list AND populate it in scanAwardQSO below, or that award will
// silently see an empty value during stats/computation.
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
`name, qth, address, comment, notes, ` +
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
// IterateForAwards streams a lightweight projection of every QSO — only the
// fields award computation reads (see awardCols). All other QSO fields are left
// zero. Use IterateAll when the full record is needed. Ordered by date/id like
// IterateAll for deterministic results.
func (r *Repo) IterateForAwards(ctx context.Context, fn func(QSO) error) error {
rows, err := r.db.QueryContext(ctx,
`SELECT `+awardCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
if err != nil {
return fmt.Errorf("query qso (awards): %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanAwardQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// scanAwardQSO reads one row produced by awardCols into a QSO, populating only
// the award-relevant fields. Column order MUST match awardCols.
func scanAwardQSO(s scanner) (QSO, error) {
var q QSO
var (
qsoDateStr string
freqHz sql.NullInt64
grid, vucc, country, state sql.NullString
cont, iotaRef, sota, pota sql.NullString
dxcc, cqz, ituz sql.NullInt64
name, qth, address sql.NullString
comment, notes sql.NullString
qslRcvd, lotwRcvd, eqslRcvd sql.NullString
extrasJSON sql.NullString
)
if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
&name, &qth, &address, &comment, &notes,
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
); err != nil {
return QSO{}, fmt.Errorf("scan qso (awards): %w", err)
}
q.QSODate = parseTimeLoose(qsoDateStr)
if freqHz.Valid {
v := freqHz.Int64
q.FreqHz = &v
}
q.Grid = grid.String
q.VUCCGrids = vucc.String
q.Country = country.String
q.State = state.String
q.Continent = cont.String
if cqz.Valid {
v := int(cqz.Int64)
q.CQZ = &v
}
if ituz.Valid {
v := int(ituz.Int64)
q.ITUZ = &v
}
if dxcc.Valid {
v := int(dxcc.Int64)
q.DXCC = &v
}
q.IOTA = iotaRef.String
q.SOTARef = sota.String
q.POTARef = pota.String
q.Name = name.String
q.QTH = qth.String
q.Address = address.String
q.Comment = comment.String
q.Notes = notes.String
q.QSLRcvd = qslRcvd.String
q.LOTWRcvd = lotwRcvd.String
q.EQSLRcvd = eqslRcvd.String
q.Extras = decodeExtras(extrasJSON.String)
return q, nil
}
// EntitySlot bundles every (band, mode) tuple ever worked for a given
// DXCC entity name. Used by the cluster spot colouring code to decide
// NEW / NEW SLOT / WORKED in constant time after one batched query.