The one field the editor would not let you touch, and the one that was wrong on WAJA. Every other property of a reference — its name, pattern, entity list, validity window — was editable; the code was rendered readOnly, so correcting a number meant deleting all 47 references and importing a new list, throwing away anything the operator had adjusted in it. A rename in the store, not a delete plus an insert: everything the reference carries travels with it, which is the whole point of correcting a number rather than replacing an entry. A number already in use is refused — REPLACE INTO would have let one reference silently swallow another, discovered much later as a prefecture quietly missing from the list. The typed code is held apart from the selection. The list and every field patch key off the selected code, so editing it in place made the editor lose the reference mid-edit. SaveAwardReference now recomputes the log like Delete and Replace already did. A reference's name is what the award column SHOWS for awards displaying by name, and its pattern is part of what matches at all — so editing one changes rows, and the grid was left showing the old label until something else happened to trigger a pass. Changelog: the three TCI-sharing lines are merged into one. The server and the two fixes made to it while building are one unreleased feature, and an operator only ever meets the finished thing. The TCI-client PTT line stays separate — it is OpsLog driving a SunSDR, the other direction entirely.
325 lines
10 KiB
Go
325 lines
10 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,
|
|
`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,
|
|
`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
|
|
}
|
|
|
|
// Rename changes a reference's CODE, keeping everything else about it.
|
|
//
|
|
// Wanted because a shipped list can simply be wrong: WAJA went out numbered by
|
|
// the Japanese state instead of by the JARL, and the only way to correct it was
|
|
// to delete all 47 references and import a new list — losing anything the
|
|
// operator had adjusted. The number is the one field an editor could not touch.
|
|
//
|
|
// A rename, not a delete plus an insert: everything the reference carries — its
|
|
// pattern, its DXCC list, its validity window — travels with it, which is the
|
|
// whole point of correcting a number rather than replacing an entry.
|
|
func (r *Repo) Rename(ctx context.Context, awardCode, oldCode, newCode string) error {
|
|
ac := strings.ToUpper(strings.TrimSpace(awardCode))
|
|
from := strings.ToUpper(strings.TrimSpace(oldCode))
|
|
to := strings.ToUpper(strings.TrimSpace(newCode))
|
|
if ac == "" || from == "" || to == "" {
|
|
return fmt.Errorf("empty award or reference code")
|
|
}
|
|
if from == to {
|
|
return nil
|
|
}
|
|
// A collision would REPLACE the other reference and take its name, pattern
|
|
// and dates with it — one silently swallowing another, discovered much later
|
|
// as a reference that has quietly gone missing.
|
|
var n int
|
|
if err := r.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM award_references WHERE award_code = ? AND ref_code = ?`, ac, to).Scan(&n); err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return fmt.Errorf("%s already has a reference %s", ac, to)
|
|
}
|
|
res, err := r.db.ExecContext(ctx,
|
|
`UPDATE award_references SET ref_code = ? WHERE award_code = ? AND ref_code = ?`, to, ac, from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
|
return fmt.Errorf("%s has no reference %s", ac, from)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|