// 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 }