283 lines
8.8 KiB
Go
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
|
|
}
|