This commit is contained in:
2026-06-05 22:35:28 +02:00
parent 88623f55df
commit 51d3a734e8
21 changed files with 2613 additions and 153 deletions
+282
View File
@@ -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
}
+99
View File
@@ -0,0 +1,99 @@
package awardref
import (
"strconv"
"hamlog/internal/dxcc"
)
// BuiltinRefs returns the seed reference list for a built-in award (DXCC
// entities, CQ zones, continents, US states, French departments). ok=false for
// awards whose list is downloaded online (POTA/SOTA/WWFF) or fully custom.
//
// The reference CODE must equal what award.Compute extracts from the QSO field
// so worked references map onto the list:
// - DXCC → entity number ("291")
// - WAZ → CQ zone number ("1".."40")
// - WAC → continent code ("EU", "NA", …)
// - WAS → ADIF STATE code ("AL", …)
// - DDFM → "D06" (the award pattern captures the leading D)
func BuiltinRefs(code string) ([]Ref, bool) {
switch code {
case "DXCC":
return dxccEntities(), true
case "WAZ":
return cqZones(), true
case "WAC":
return continents(), true
case "WAS":
return usStates().Refs, true
case "DDFM":
return frenchDepartments(), true
}
return nil, false
}
func dxccEntities() []Ref {
ents := dxcc.AllEntities()
out := make([]Ref, 0, len(ents))
for _, e := range ents {
out = append(out, ref(strconv.Itoa(e.Num), e.Name, e.Num))
}
return out
}
func cqZones() []Ref {
out := make([]Ref, 0, 40)
for z := 1; z <= 40; z++ {
out = append(out, ref(strconv.Itoa(z), "CQ Zone "+strconv.Itoa(z), 0))
}
return out
}
func continents() []Ref {
pairs := [][2]string{
{"AF", "Africa"}, {"AN", "Antarctica"}, {"AS", "Asia"},
{"EU", "Europe"}, {"NA", "North America"}, {"OC", "Oceania"}, {"SA", "South America"},
}
out := make([]Ref, 0, len(pairs))
for _, p := range pairs {
out = append(out, ref(p[0], p[1], 0))
}
return out
}
// frenchDepartments — the 96 metropolitan French departments (DXCC 227).
func frenchDepartments() []Ref {
const fr = 227
deps := [][2]string{
{"D01", "Ain"}, {"D02", "Aisne"}, {"D03", "Allier"}, {"D04", "Alpes-de-Haute-Provence"},
{"D05", "Hautes-Alpes"}, {"D06", "Alpes-Maritimes"}, {"D07", "Ardèche"}, {"D08", "Ardennes"},
{"D09", "Ariège"}, {"D10", "Aube"}, {"D11", "Aude"}, {"D12", "Aveyron"},
{"D13", "Bouches-du-Rhône"}, {"D14", "Calvados"}, {"D15", "Cantal"}, {"D16", "Charente"},
{"D17", "Charente-Maritime"}, {"D18", "Cher"}, {"D19", "Corrèze"}, {"D2A", "Corse-du-Sud"},
{"D2B", "Haute-Corse"}, {"D21", "Côte-d'Or"}, {"D22", "Côtes-d'Armor"}, {"D23", "Creuse"},
{"D24", "Dordogne"}, {"D25", "Doubs"}, {"D26", "Drôme"}, {"D27", "Eure"},
{"D28", "Eure-et-Loir"}, {"D29", "Finistère"}, {"D30", "Gard"}, {"D31", "Haute-Garonne"},
{"D32", "Gers"}, {"D33", "Gironde"}, {"D34", "Hérault"}, {"D35", "Ille-et-Vilaine"},
{"D36", "Indre"}, {"D37", "Indre-et-Loire"}, {"D38", "Isère"}, {"D39", "Jura"},
{"D40", "Landes"}, {"D41", "Loir-et-Cher"}, {"D42", "Loire"}, {"D43", "Haute-Loire"},
{"D44", "Loire-Atlantique"}, {"D45", "Loiret"}, {"D46", "Lot"}, {"D47", "Lot-et-Garonne"},
{"D48", "Lozère"}, {"D49", "Maine-et-Loire"}, {"D50", "Manche"}, {"D51", "Marne"},
{"D52", "Haute-Marne"}, {"D53", "Mayenne"}, {"D54", "Meurthe-et-Moselle"}, {"D55", "Meuse"},
{"D56", "Morbihan"}, {"D57", "Moselle"}, {"D58", "Nièvre"}, {"D59", "Nord"},
{"D60", "Oise"}, {"D61", "Orne"}, {"D62", "Pas-de-Calais"}, {"D63", "Puy-de-Dôme"},
{"D64", "Pyrénées-Atlantiques"}, {"D65", "Hautes-Pyrénées"}, {"D66", "Pyrénées-Orientales"}, {"D67", "Bas-Rhin"},
{"D68", "Haut-Rhin"}, {"D69", "Rhône"}, {"D70", "Haute-Saône"}, {"D71", "Saône-et-Loire"},
{"D72", "Sarthe"}, {"D73", "Savoie"}, {"D74", "Haute-Savoie"}, {"D75", "Paris"},
{"D76", "Seine-Maritime"}, {"D77", "Seine-et-Marne"}, {"D78", "Yvelines"}, {"D79", "Deux-Sèvres"},
{"D80", "Somme"}, {"D81", "Tarn"}, {"D82", "Tarn-et-Garonne"}, {"D83", "Var"},
{"D84", "Vaucluse"}, {"D85", "Vendée"}, {"D86", "Vienne"}, {"D87", "Haute-Vienne"},
{"D88", "Vosges"}, {"D89", "Yonne"}, {"D90", "Territoire de Belfort"}, {"D91", "Essonne"},
{"D92", "Hauts-de-Seine"}, {"D93", "Seine-Saint-Denis"}, {"D94", "Val-de-Marne"}, {"D95", "Val-d'Oise"},
}
out := make([]Ref, 0, len(deps))
for _, d := range deps {
out = append(out, ref(d[0], d[1], fr))
}
return out
}
+161
View File
@@ -0,0 +1,161 @@
package awardref
import (
"context"
"encoding/csv"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Importer downloads and parses a program's reference list into []Ref.
type Importer struct {
AwardCode string
URL string
Fetch func(ctx context.Context, body io.Reader) ([]Ref, error)
}
// Importers is the registry of built-in reference-list updaters, keyed by
// award code. Awards not present here have no online list (manual only).
var Importers = map[string]Importer{
"POTA": {AwardCode: "POTA", URL: "https://pota.app/all_parks.csv", Fetch: parsePOTA},
"SOTA": {AwardCode: "SOTA", URL: "https://www.sotadata.org.uk/summitslist.csv", Fetch: parseSOTA},
"WWFF": {AwardCode: "WWFF", URL: "https://wwff.co/wwff-data/wwff_directory.csv", Fetch: parseWWFF},
}
// CanUpdate reports whether an award has an online reference list.
func CanUpdate(awardCode string) bool {
_, ok := Importers[strings.ToUpper(strings.TrimSpace(awardCode))]
return ok
}
// Download fetches and parses the reference list for an award (does not store).
func Download(ctx context.Context, awardCode string) ([]Ref, error) {
imp, ok := Importers[strings.ToUpper(strings.TrimSpace(awardCode))]
if !ok {
return nil, fmt.Errorf("no online list for award %q", awardCode)
}
req, err := http.NewRequestWithContext(ctx, "GET", imp.URL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "OpsLog")
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("download %s: %w", imp.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download %s: http %d", imp.URL, resp.StatusCode)
}
return imp.Fetch(ctx, resp.Body)
}
// headerIndex maps lowercased header names to their column index.
func headerIndex(header []string) map[string]int {
m := make(map[string]int, len(header))
for i, h := range header {
m[strings.ToLower(strings.TrimSpace(h))] = i
}
return m
}
func get(rec []string, idx int) string {
if idx < 0 || idx >= len(rec) {
return ""
}
return strings.TrimSpace(rec[idx])
}
// parsePOTA: "reference","name","active","entityId","locationDesc"
func parsePOTA(_ context.Context, body io.Reader) ([]Ref, error) {
r := csv.NewReader(body)
r.FieldsPerRecord = -1
header, err := r.Read()
if err != nil {
return nil, err
}
h := headerIndex(header)
iRef, iName, iActive, iEnt, iLoc := h["reference"], h["name"], h["active"], h["entityid"], h["locationdesc"]
var out []Ref
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
if iActive >= 0 && get(rec, iActive) == "0" {
continue
}
dxcc, _ := strconv.Atoi(get(rec, iEnt))
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), DXCC: dxcc, Group: get(rec, iLoc)})
}
return out, nil
}
// parseSOTA: first line is a title, then header
// SummitCode,AssociationName,RegionName,SummitName,…
func parseSOTA(_ context.Context, body io.Reader) ([]Ref, error) {
r := csv.NewReader(body)
r.FieldsPerRecord = -1
// First record is the "SOTA Summits List (Date=…)" title line — skip it.
if _, err := r.Read(); err != nil {
return nil, err
}
header, err := r.Read()
if err != nil {
return nil, err
}
h := headerIndex(header)
iRef, iName, iAssoc, iRegion := h["summitcode"], h["summitname"], h["associationname"], h["regionname"]
var out []Ref
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), Group: get(rec, iAssoc), SubGrp: get(rec, iRegion)})
}
return out, nil
}
// parseWWFF: reference,status,name,…,dxccEnum,… (header-driven)
func parseWWFF(_ context.Context, body io.Reader) ([]Ref, error) {
r := csv.NewReader(body)
r.FieldsPerRecord = -1
header, err := r.Read()
if err != nil {
return nil, err
}
h := headerIndex(header)
iRef, iName, iStatus, iCountry := h["reference"], h["name"], h["status"], h["country"]
iDXCC := h["dxccenum"]
if iDXCC < 0 {
iDXCC = h["dxcc"]
}
var out []Ref
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
if iStatus >= 0 && !strings.EqualFold(get(rec, iStatus), "active") {
continue
}
dxcc, _ := strconv.Atoi(get(rec, iDXCC))
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), DXCC: dxcc, Group: get(rec, iCountry)})
}
return out, nil
}
+77
View File
@@ -0,0 +1,77 @@
package awardref
// Preset is a ready-made reference list a user can apply to an award in one
// click (Canadian provinces, US states, …). Codes match the values that land
// in the corresponding QSO field (e.g. ADIF STATE codes).
type Preset struct {
Key string `json:"key"` // stable id, e.g. "ca_provinces"
Name string `json:"name"` // friendly label
Field string `json:"field"` // suggested QSO field to scan
DXCC int `json:"dxcc"` // suggested DXCC scope (0 = none)
Refs []Ref `json:"refs"`
}
// Presets is the catalogue of built-in reference lists, returned to the UI.
func Presets() []Preset {
return []Preset{
caProvinces(),
usStates(),
}
}
// PresetByKey returns a preset by its key (ok=false if unknown).
func PresetByKey(key string) (Preset, bool) {
for _, p := range Presets() {
if p.Key == key {
return p, true
}
}
return Preset{}, false
}
func ref(code, name string, dxcc int) Ref {
return Ref{Code: code, Name: name, DXCC: dxcc, Valid: true}
}
// caProvinces — RAC Canadian Provinces (DXCC 1 = Canada). Codes are ADIF STATE
// values for VE provinces/territories.
func caProvinces() Preset {
const ca = 1
return Preset{
Key: "ca_provinces", Name: "Canadian Provinces (RAC)", Field: "state", DXCC: ca,
Refs: []Ref{
ref("AB", "Alberta", ca), ref("BC", "British Columbia", ca),
ref("MB", "Manitoba", ca), ref("NB", "New Brunswick", ca),
ref("NL", "Newfoundland and Labrador", ca), ref("NS", "Nova Scotia", ca),
ref("NT", "Northwest Territories", ca), ref("NU", "Nunavut", ca),
ref("ON", "Ontario", ca), ref("PE", "Prince Edward Island", ca),
ref("QC", "Quebec", ca), ref("SK", "Saskatchewan", ca),
ref("YT", "Yukon", ca),
},
}
}
// usStates — Worked All States (DXCC 291 = United States). 50 ADIF STATE codes.
func usStates() Preset {
const us = 291
codes := [][2]string{
{"AL", "Alabama"}, {"AK", "Alaska"}, {"AZ", "Arizona"}, {"AR", "Arkansas"},
{"CA", "California"}, {"CO", "Colorado"}, {"CT", "Connecticut"}, {"DE", "Delaware"},
{"FL", "Florida"}, {"GA", "Georgia"}, {"HI", "Hawaii"}, {"ID", "Idaho"},
{"IL", "Illinois"}, {"IN", "Indiana"}, {"IA", "Iowa"}, {"KS", "Kansas"},
{"KY", "Kentucky"}, {"LA", "Louisiana"}, {"ME", "Maine"}, {"MD", "Maryland"},
{"MA", "Massachusetts"}, {"MI", "Michigan"}, {"MN", "Minnesota"}, {"MS", "Mississippi"},
{"MO", "Missouri"}, {"MT", "Montana"}, {"NE", "Nebraska"}, {"NV", "Nevada"},
{"NH", "New Hampshire"}, {"NJ", "New Jersey"}, {"NM", "New Mexico"}, {"NY", "New York"},
{"NC", "North Carolina"}, {"ND", "North Dakota"}, {"OH", "Ohio"}, {"OK", "Oklahoma"},
{"OR", "Oregon"}, {"PA", "Pennsylvania"}, {"RI", "Rhode Island"}, {"SC", "South Carolina"},
{"SD", "South Dakota"}, {"TN", "Tennessee"}, {"TX", "Texas"}, {"UT", "Utah"},
{"VT", "Vermont"}, {"VA", "Virginia"}, {"WA", "Washington"}, {"WV", "West Virginia"},
{"WI", "Wisconsin"}, {"WY", "Wyoming"},
}
refs := make([]Ref, 0, len(codes))
for _, c := range codes {
refs = append(refs, ref(c[0], c[1], us))
}
return Preset{Key: "us_states", Name: "US States (WAS)", Field: "state", DXCC: us, Refs: refs}
}