Files
OpsLog/internal/awardref/awardref.go
T
2026-06-05 22:35:28 +02:00

283 lines
8.8 KiB
Go

// Package awardref stores and updates award reference lists (POTA parks, SOTA
// summits, WWFF references, …). These provide award totals, reference names,
// and per-DXCC filtering. Lists are downloaded from each program's public file.
package awardref
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
)
// allCols is the column list shared by read queries so they stay in sync.
const allCols = `ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias`
func encodeDXCCList(l []int) string {
if len(l) == 0 {
return ""
}
b, err := json.Marshal(l)
if err != nil {
return ""
}
return string(b)
}
func decodeDXCCList(s string) []int {
if strings.TrimSpace(s) == "" {
return nil
}
var l []int
if json.Unmarshal([]byte(s), &l) != nil {
return nil
}
return l
}
// scanRef reads one row selected with allCols into a Ref.
func scanRef(rows *sql.Rows) (Ref, error) {
var r Ref
var dxccList string
var valid int
if err := rows.Scan(&r.Code, &r.Name, &r.DXCC, &r.Group, &r.SubGrp,
&dxccList, &r.Pattern, &valid, &r.ValidFrom, &r.ValidTo,
&r.Score, &r.Bonus, &r.GridSquare, &r.Alias); err != nil {
return r, err
}
r.DXCCList = decodeDXCCList(dxccList)
r.Valid = valid != 0
return r, nil
}
// Ref is one award reference. The first five fields are the original schema;
// the rest mirror Log4OM's per-reference editor (group/subgroup, multi-DXCC,
// per-reference regex, validity window, score/bonus, grid, alias).
type Ref struct {
Code string `json:"code"`
Name string `json:"name"` // description
DXCC int `json:"dxcc"` // primary entity (kept for compatibility / fast filter)
Group string `json:"group"`
SubGrp string `json:"subgrp"`
DXCCList []int `json:"dxcc_list,omitempty"` // all entities this ref is valid for
Pattern string `json:"pattern,omitempty"` // per-reference Go regexp
Valid bool `json:"valid"` // reference enabled
ValidFrom string `json:"valid_from,omitempty"`
ValidTo string `json:"valid_to,omitempty"`
Score int `json:"score,omitempty"`
Bonus int `json:"bonus,omitempty"`
GridSquare string `json:"gridsquare,omitempty"`
Alias string `json:"alias,omitempty"`
}
// Repo accesses the award_references table.
type Repo struct{ db *sql.DB }
// NewRepo builds a reference repo on the given connection.
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// ReplaceAll atomically replaces every reference for one award.
func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (int, error) {
code := strings.ToUpper(strings.TrimSpace(awardCode))
if code == "" {
return 0, fmt.Errorf("empty award code")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback() //nolint:errcheck
if _, err := tx.ExecContext(ctx, `DELETE FROM award_references WHERE award_code = ?`, code); err != nil {
return 0, fmt.Errorf("clear refs: %w", err)
}
stmt, err := tx.PrepareContext(ctx,
`INSERT OR REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return 0, err
}
defer stmt.Close()
n := 0
for _, ref := range refs {
rc := strings.ToUpper(strings.TrimSpace(ref.Code))
if rc == "" {
continue
}
// A bulk-replaced list is the authoritative enabled set: store every
// row as valid. Per-reference disabling is done through Upsert.
if _, err := stmt.ExecContext(ctx, code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
encodeDXCCList(ref.DXCCList), ref.Pattern, 1, ref.ValidFrom, ref.ValidTo,
ref.Score, ref.Bonus, ref.GridSquare, ref.Alias); err != nil {
return 0, fmt.Errorf("insert ref %s: %w", rc, err)
}
n++
}
if err := tx.Commit(); err != nil {
return 0, err
}
return n, nil
}
// Count returns how many references an award has stored.
func (r *Repo) Count(ctx context.Context, awardCode string) (int, error) {
var n int
err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM award_references WHERE award_code = ?`,
strings.ToUpper(strings.TrimSpace(awardCode))).Scan(&n)
return n, err
}
// Counts returns reference counts for every award.
func (r *Repo) Counts(ctx context.Context) (map[string]int, error) {
rows, err := r.db.QueryContext(ctx, `SELECT award_code, COUNT(*) FROM award_references GROUP BY award_code`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var code string
var n int
if err := rows.Scan(&code, &n); err != nil {
return nil, err
}
out[code] = n
}
return out, rows.Err()
}
// NamesFor returns ref_code → name for the given codes of one award (batched so
// we never load a 250k-row map just to label a few worked references).
func (r *Repo) NamesFor(ctx context.Context, awardCode string, codes []string) (map[string]string, error) {
out := map[string]string{}
if len(codes) == 0 {
return out, nil
}
code := strings.ToUpper(strings.TrimSpace(awardCode))
// Chunk to stay under SQLite's parameter limit.
const chunk = 400
for start := 0; start < len(codes); start += chunk {
end := start + chunk
if end > len(codes) {
end = len(codes)
}
batch := codes[start:end]
ph := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
args := make([]any, 0, len(batch)+1)
args = append(args, code)
for _, c := range batch {
args = append(args, strings.ToUpper(strings.TrimSpace(c)))
}
rows, err := r.db.QueryContext(ctx,
`SELECT ref_code, name FROM award_references WHERE award_code = ? AND ref_code IN (`+ph+`)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var rc, name string
if err := rows.Scan(&rc, &name); err != nil {
rows.Close()
return nil, err
}
out[rc] = name
}
rows.Close()
}
return out, nil
}
// Search returns up to `limit` references of an award matching a code/name
// query, optionally restricted to a DXCC entity. Drives the per-QSO picker.
func (r *Repo) Search(ctx context.Context, awardCode, query string, dxcc, limit int) ([]Ref, error) {
code := strings.ToUpper(strings.TrimSpace(awardCode))
q := strings.TrimSpace(query)
if limit <= 0 || limit > 200 {
limit = 50
}
sqlStr := `SELECT ` + allCols + ` FROM award_references WHERE award_code = ?`
args := []any{code}
if dxcc > 0 {
// Match the primary dxcc OR the multi-DXCC list (JSON contains the id).
sqlStr += ` AND (dxcc = ? OR dxcc_list LIKE ?)`
args = append(args, dxcc, fmt.Sprintf("%%%d%%", dxcc))
}
if q != "" {
sqlStr += ` AND (ref_code LIKE ? OR name LIKE ?)`
args = append(args, "%"+strings.ToUpper(q)+"%", "%"+q+"%")
}
sqlStr += ` ORDER BY ref_code LIMIT ?`
args = append(args, limit)
rows, err := r.db.QueryContext(ctx, sqlStr, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ref
for rows.Next() {
ref, err := scanRef(rows)
if err != nil {
return nil, err
}
out = append(out, ref)
}
return out, rows.Err()
}
// List returns every reference of an award, ordered by code. Used by the
// reference editor and (via the engine) to show unworked references.
func (r *Repo) List(ctx context.Context, awardCode string) ([]Ref, error) {
code := strings.ToUpper(strings.TrimSpace(awardCode))
rows, err := r.db.QueryContext(ctx,
`SELECT `+allCols+` FROM award_references WHERE award_code = ? ORDER BY ref_code`, code)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ref
for rows.Next() {
ref, err := scanRef(rows)
if err != nil {
return nil, err
}
out = append(out, ref)
}
return out, rows.Err()
}
// Upsert inserts or updates a single reference of an award.
func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
code := strings.ToUpper(strings.TrimSpace(awardCode))
rc := strings.ToUpper(strings.TrimSpace(ref.Code))
if code == "" || rc == "" {
return fmt.Errorf("empty award or reference code")
}
_, err := r.db.ExecContext(ctx,
`INSERT OR REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
encodeDXCCList(ref.DXCCList), ref.Pattern, b2i(ref.Valid), ref.ValidFrom, ref.ValidTo,
ref.Score, ref.Bonus, ref.GridSquare, ref.Alias)
return err
}
// Delete removes one reference from an award.
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
_, err := r.db.ExecContext(ctx,
`DELETE FROM award_references WHERE award_code = ? AND ref_code = ?`,
strings.ToUpper(strings.TrimSpace(awardCode)), strings.ToUpper(strings.TrimSpace(refCode)))
return err
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}