award
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user