awards
This commit is contained in:
+32
-9
@@ -36,29 +36,52 @@ type Exporter struct {
|
||||
IncludeAppFields bool
|
||||
}
|
||||
|
||||
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
||||
// by-ids) all match this shape so the document writer stays source-agnostic.
|
||||
type iterator func(ctx context.Context, fn func(qso.QSO) error) error
|
||||
|
||||
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
||||
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
|
||||
return e.exportFileWith(ctx, path, e.Repo.IterateAll)
|
||||
}
|
||||
|
||||
// ExportFileFiltered writes only the QSOs matching f (no row limit).
|
||||
func (e *Exporter) ExportFileFiltered(ctx context.Context, path string, f qso.QueryFilter) (ExportResult, error) {
|
||||
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
|
||||
return e.Repo.IterateFiltered(ctx, f, fn)
|
||||
})
|
||||
}
|
||||
|
||||
// ExportFileByIDs writes only the QSOs with the given ids.
|
||||
func (e *Exporter) ExportFileByIDs(ctx context.Context, path string, ids []int64) (ExportResult, error) {
|
||||
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
|
||||
return e.Repo.IterateByIDs(ctx, ids, fn)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Exporter) exportFileWith(ctx context.Context, path string, iter iterator) (ExportResult, error) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
count, err := e.Export(ctx, f)
|
||||
count, err := e.writeDoc(ctx, f, iter)
|
||||
if err != nil {
|
||||
return ExportResult{Path: path, Count: count}, err
|
||||
}
|
||||
info, _ := f.Stat()
|
||||
return ExportResult{
|
||||
Path: path,
|
||||
Count: count,
|
||||
SizeKB: info.Size() / 1024,
|
||||
}, nil
|
||||
return ExportResult{Path: path, Count: count, SizeKB: info.Size() / 1024}, nil
|
||||
}
|
||||
|
||||
// Export writes a complete ADIF document (header + records + EOF) to w.
|
||||
// Returns the number of QSOs successfully written.
|
||||
// Export writes a complete ADIF document (header + records + EOF) to w for
|
||||
// every QSO. Returns the number of QSOs written.
|
||||
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
return e.writeDoc(ctx, w, e.Repo.IterateAll)
|
||||
}
|
||||
|
||||
// writeDoc writes the ADIF header then streams every QSO from iter.
|
||||
func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (int, error) {
|
||||
bw := bufio.NewWriterSize(w, 64*1024)
|
||||
defer bw.Flush()
|
||||
|
||||
@@ -76,7 +99,7 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
|
||||
|
||||
count := 0
|
||||
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
|
||||
err := iter(ctx, func(q qso.QSO) error {
|
||||
writeRecord(bw, q, e.IncludeAppFields)
|
||||
count++
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// Package award computes amateur-radio award progress (worked / confirmed)
|
||||
// directly from the logbook. An award is defined declaratively: a QSO FIELD to
|
||||
// scan plus an optional regular-expression PATTERN that extracts the reference
|
||||
// from that field. With no pattern the whole field value is the reference; with
|
||||
// a pattern, capture group 1 (or the whole match) is the reference and a single
|
||||
// QSO may yield several references (e.g. a Note holding "D74 D73").
|
||||
//
|
||||
// Examples:
|
||||
// DXCC : field "dxcc" (no pattern) → entity number
|
||||
// WAS : field "state", DXCCFilter [291,110,6] → US state
|
||||
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
|
||||
// WPX : field "prefix" (computed from callsign)
|
||||
package award
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Def defines one award.
|
||||
type Def struct {
|
||||
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||
Name string `json:"name"` // friendly name
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
Pattern string `json:"pattern"` // optional Go regexp; group 1 = reference
|
||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||
Confirm []string `json:"confirm"` // accepted confirmations: lotw|qsl|eqsl
|
||||
Total int `json:"total"` // known denominator (0 = unknown)
|
||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||
}
|
||||
|
||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||
func Defaults() []Def {
|
||||
return []Def{
|
||||
{Code: "DXCC", Name: "DX Century Club", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340, Builtin: true},
|
||||
{Code: "WAS", Name: "Worked All States", Field: "state", DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Total: 50, Builtin: true},
|
||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Field: "cqz", Confirm: []string{"lotw", "qsl"}, Total: 40, Builtin: true},
|
||||
{Code: "WAC", Name: "Worked All Continents", Field: "cont", Confirm: []string{"lotw", "qsl", "eqsl"}, Total: 6, Builtin: true},
|
||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Field: "prefix", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: []string{"lotw", "qsl"}, Total: 96, Builtin: true},
|
||||
{Code: "IOTA", Name: "Islands On The Air", Field: "iota", Confirm: []string{"qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "POTA", Name: "Parks On The Air", Field: "pota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "SOTA", Name: "Summits On The Air", Field: "sota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Field: "wwff", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
|
||||
}
|
||||
}
|
||||
|
||||
// Fields lists the scannable QSO fields for the award editor.
|
||||
func Fields() []string {
|
||||
return []string{
|
||||
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||
"state", "cont", "country", "grid",
|
||||
"iota", "sota_ref", "pota_ref", "wwff",
|
||||
"name", "qth", "address", "comment", "note",
|
||||
}
|
||||
}
|
||||
|
||||
// BandCount holds distinct-reference counts on one band.
|
||||
type BandCount struct {
|
||||
Band string `json:"band"`
|
||||
Worked int `json:"worked"`
|
||||
Confirmed int `json:"confirmed"`
|
||||
}
|
||||
|
||||
// Ref is one reference's status within an award.
|
||||
type Ref struct {
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
}
|
||||
|
||||
// Result is an award's computed progress.
|
||||
type Result struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Field string `json:"field"`
|
||||
Worked int `json:"worked"`
|
||||
Confirmed int `json:"confirmed"`
|
||||
Total int `json:"total"`
|
||||
Bands []BandCount `json:"bands"`
|
||||
Refs []Ref `json:"refs"`
|
||||
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
|
||||
}
|
||||
|
||||
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
|
||||
type NameResolver func(field, ref string) string
|
||||
|
||||
type refAgg struct {
|
||||
bands map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
}
|
||||
|
||||
// Compute runs every definition over the QSOs in a single pass.
|
||||
func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
// Pre-compile patterns once per award (not per QSO).
|
||||
res := make([]*regexp.Regexp, len(defs))
|
||||
perr := make([]string, len(defs))
|
||||
for i := range defs {
|
||||
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
||||
re, err := regexp.Compile(p)
|
||||
if err != nil {
|
||||
perr[i] = "bad pattern: " + err.Error()
|
||||
} else {
|
||||
res[i] = re
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agg := make([]map[string]*refAgg, len(defs))
|
||||
for i := range defs {
|
||||
agg[i] = map[string]*refAgg{}
|
||||
}
|
||||
|
||||
for qi := range qsos {
|
||||
q := &qsos[qi]
|
||||
for i := range defs {
|
||||
d := &defs[i]
|
||||
if perr[i] != "" {
|
||||
continue
|
||||
}
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
continue
|
||||
}
|
||||
refs := refValues(d, res[i], q)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}}
|
||||
agg[i][ref] = a
|
||||
}
|
||||
if band != "" {
|
||||
a.bands[band] = struct{}{}
|
||||
}
|
||||
if isConf {
|
||||
a.anyConfirmed = true
|
||||
if band != "" {
|
||||
a.confirmedBands[band] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Result, len(defs))
|
||||
for i := range defs {
|
||||
d := &defs[i]
|
||||
r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]}
|
||||
bandWorked := map[string]int{}
|
||||
bandConfirmed := map[string]int{}
|
||||
for ref, a := range agg[i] {
|
||||
r.Worked++
|
||||
if a.anyConfirmed {
|
||||
r.Confirmed++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
|
||||
if nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, ref)
|
||||
}
|
||||
r.Refs = append(r.Refs, rf)
|
||||
for b := range a.bands {
|
||||
bandWorked[b]++
|
||||
}
|
||||
for b := range a.confirmedBands {
|
||||
bandConfirmed[b]++
|
||||
}
|
||||
}
|
||||
sort.Slice(r.Refs, func(a, b int) bool {
|
||||
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
|
||||
return r.Refs[a].Confirmed
|
||||
}
|
||||
return r.Refs[a].Ref < r.Refs[b].Ref
|
||||
})
|
||||
for _, b := range sortedBands(bandWorked) {
|
||||
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
|
||||
}
|
||||
out[i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// refValues extracts the reference(s) a QSO contributes to an award.
|
||||
func refValues(d *Def, re *regexp.Regexp, q *qso.QSO) []string {
|
||||
raw := fieldRaw(d.Field, q)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
if re == nil {
|
||||
return []string{normalizeRef(raw)}
|
||||
}
|
||||
matches := re.FindAllStringSubmatch(raw, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, m := range matches {
|
||||
ref := m[0]
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
ref = m[1]
|
||||
}
|
||||
ref = normalizeRef(ref)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[ref]; dup {
|
||||
continue
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
||||
|
||||
// fieldRaw returns the raw string value of a QSO field (computed for numeric /
|
||||
// derived fields). Unknown fields yield "".
|
||||
func fieldRaw(field string, q *qso.QSO) string {
|
||||
switch strings.ToLower(strings.TrimSpace(field)) {
|
||||
case "dxcc":
|
||||
if q.DXCC != nil && *q.DXCC > 0 {
|
||||
return strconv.Itoa(*q.DXCC)
|
||||
}
|
||||
case "cqz":
|
||||
if q.CQZ != nil && *q.CQZ > 0 {
|
||||
return strconv.Itoa(*q.CQZ)
|
||||
}
|
||||
case "ituz":
|
||||
if q.ITUZ != nil && *q.ITUZ > 0 {
|
||||
return strconv.Itoa(*q.ITUZ)
|
||||
}
|
||||
case "prefix":
|
||||
return wpxPrefix(q.Callsign)
|
||||
case "callsign":
|
||||
return q.Callsign
|
||||
case "state":
|
||||
return q.State
|
||||
case "cont":
|
||||
return q.Continent
|
||||
case "country":
|
||||
return q.Country
|
||||
case "grid":
|
||||
return q.Grid
|
||||
case "iota":
|
||||
return q.IOTA
|
||||
case "sota_ref":
|
||||
return q.SOTARef
|
||||
case "pota_ref":
|
||||
return q.POTARef
|
||||
case "name":
|
||||
return q.Name
|
||||
case "qth":
|
||||
return q.QTH
|
||||
case "address":
|
||||
return q.Address
|
||||
case "comment":
|
||||
return q.Comment
|
||||
case "note", "notes":
|
||||
return q.Notes
|
||||
case "wwff":
|
||||
if q.Extras != nil {
|
||||
if v := strings.TrimSpace(q.Extras["WWFF_REF"]); v != "" {
|
||||
return v
|
||||
}
|
||||
if strings.EqualFold(q.Extras["SIG"], "WWFF") {
|
||||
return q.Extras["SIG_INFO"]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dxccAllowed(dxcc *int, filter []int) bool {
|
||||
if dxcc == nil {
|
||||
return false
|
||||
}
|
||||
for _, f := range filter {
|
||||
if *dxcc == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// confirmed reports whether the QSO satisfies any accepted confirmation source.
|
||||
// ADIF *_QSL_RCVD values Y (confirmed) and V (verified) both count.
|
||||
func confirmed(q *qso.QSO, sources []string) bool {
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "lotw":
|
||||
if isYes(q.LOTWRcvd) {
|
||||
return true
|
||||
}
|
||||
case "qsl":
|
||||
if isYes(q.QSLRcvd) {
|
||||
return true
|
||||
}
|
||||
case "eqsl":
|
||||
if isYes(q.EQSLRcvd) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isYes(v string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(v)) {
|
||||
case "Y", "V":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setToSorted(m map[string]struct{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
|
||||
|
||||
func sortedBands(m map[string]int) []string {
|
||||
idx := map[string]int{}
|
||||
for i, b := range bandOrder {
|
||||
idx[b] = i
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for b := range m {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Slice(out, func(a, b int) bool {
|
||||
ia, oka := idx[out[a]]
|
||||
ib, okb := idx[out[b]]
|
||||
if oka && okb {
|
||||
return ia < ib
|
||||
}
|
||||
if oka != okb {
|
||||
return oka
|
||||
}
|
||||
return out[a] < out[b]
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
func TestWPXPrefix(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"F4BPO": "F4",
|
||||
"EA8ABC": "EA8",
|
||||
"9A1AA": "9A1",
|
||||
"OH2BH": "OH2",
|
||||
"K1ABC": "K1",
|
||||
"RAEM": "RA0",
|
||||
"F4BPO/P": "F4",
|
||||
"F4BPO/9": "F9",
|
||||
"VP8/F4BPO": "VP8",
|
||||
"PA0XYZ": "PA0",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := wpxPrefix(in); got != want {
|
||||
t.Errorf("wpxPrefix(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ip(n int) *int { return &n }
|
||||
|
||||
func TestComputeDXCCAndConfirm(t *testing.T) {
|
||||
qsos := []qso.QSO{
|
||||
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA", LOTWRcvd: "Y"},
|
||||
{Callsign: "K2DEF", Band: "40m", DXCC: ip(291), State: "NY"}, // worked, not confirmed
|
||||
{Callsign: "DL1XYZ", Band: "20m", DXCC: ip(230), QSLRcvd: "Y"}, // DXCC Germany confirmed
|
||||
{Callsign: "F4BPO", Band: "20m", DXCC: ip(227), Notes: "nice qso D74", EQSLRcvd: "Y"}, // France dept 74 in note
|
||||
{Callsign: "F5ABC", Band: "40m", DXCC: ip(227), Notes: "D2A Corsica", QSLRcvd: "Y"}, // France dept 2A, confirmed
|
||||
}
|
||||
res := Compute(Defaults(), qsos, nil)
|
||||
by := map[string]Result{}
|
||||
for _, r := range res {
|
||||
by[r.Code] = r
|
||||
}
|
||||
|
||||
dxcc := by["DXCC"]
|
||||
if dxcc.Worked != 3 { // USA, Germany, France
|
||||
t.Errorf("DXCC worked = %d, want 3", dxcc.Worked)
|
||||
}
|
||||
// DXCC confirms on lotw|qsl → USA(lotw) + Germany(qsl) + France(qsl via F5ABC).
|
||||
if dxcc.Confirmed != 3 {
|
||||
t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed)
|
||||
}
|
||||
|
||||
was := by["WAS"]
|
||||
if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter)
|
||||
t.Errorf("WAS worked = %d, want 2", was.Worked)
|
||||
}
|
||||
|
||||
// DDFM scans the Note field with pattern D(\d{1,2}[AB]?): 74 and 2A.
|
||||
ddfm := by["DDFM"]
|
||||
if ddfm.Worked != 2 {
|
||||
t.Errorf("DDFM worked = %d, want 2 (refs %v)", ddfm.Worked, refCodes(ddfm))
|
||||
}
|
||||
if ddfm.Confirmed != 1 { // 2A confirmed via QSL; 74 only eQSL (not accepted)
|
||||
t.Errorf("DDFM confirmed = %d, want 1", ddfm.Confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
func refCodes(r Result) []string {
|
||||
out := make([]string, 0, len(r.Refs))
|
||||
for _, rf := range r.Refs {
|
||||
out = append(out, rf.Ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package award
|
||||
|
||||
import "strings"
|
||||
|
||||
// wpxPrefix derives the CQ WPX prefix from a callsign. This is an approximation
|
||||
// of the official WPX rules — good enough to count distinct prefixes worked:
|
||||
// - standard call: letters+digits up to and including the LAST digit of the
|
||||
// first group (F4BPO→F4, EA8ABC→EA8, 9A1AA→9A1, OH2BH→OH2)
|
||||
// - no digit: first two letters + "0" (RAEM→RA0)
|
||||
// - portable "A/B": a short alpha(+digit) segment is treated as the prefix
|
||||
// designator; a lone-digit segment replaces the call's digit (F4BPO/9→F9)
|
||||
func wpxPrefix(call string) string {
|
||||
c := strings.ToUpper(strings.TrimSpace(call))
|
||||
if c == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(c, "/") {
|
||||
return portablePrefix(c)
|
||||
}
|
||||
return standardPrefix(c)
|
||||
}
|
||||
|
||||
func portablePrefix(c string) string {
|
||||
parts := strings.Split(c, "/")
|
||||
// Drop pure operating-modifier suffixes.
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
switch p {
|
||||
case "P", "M", "MM", "AM", "QRP", "A", "R", "B", "LH":
|
||||
continue
|
||||
}
|
||||
if p != "" {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
kept = parts
|
||||
}
|
||||
// Pick a base = the longest segment (the actual call).
|
||||
base := kept[0]
|
||||
for _, p := range kept[1:] {
|
||||
if len(p) > len(base) {
|
||||
base = p
|
||||
}
|
||||
}
|
||||
for _, p := range kept {
|
||||
if p == base {
|
||||
continue
|
||||
}
|
||||
if isAllDigits(p) {
|
||||
// Lone digit replaces the call's region digit: F4BPO/9 → F9.
|
||||
return replaceLastDigit(standardPrefix(base), p)
|
||||
}
|
||||
if len(p) <= 4 && hasLetter(p) {
|
||||
// Prefix designator wins: VP8/F4BPO → VP8 (+digit if missing).
|
||||
return ensureTrailingDigit(p)
|
||||
}
|
||||
}
|
||||
return standardPrefix(base)
|
||||
}
|
||||
|
||||
// standardPrefix applies the basic WPX rule to a plain callsign: the prefix is
|
||||
// the call up to and including its last digit (9A1AA→9A1, EA8ABC→EA8). Standard
|
||||
// callsigns carry no digit in the suffix, so "last digit" is the prefix digit.
|
||||
func standardPrefix(c string) string {
|
||||
lastDigit := -1
|
||||
for i := 0; i < len(c); i++ {
|
||||
if c[i] >= '0' && c[i] <= '9' {
|
||||
lastDigit = i
|
||||
}
|
||||
}
|
||||
if lastDigit < 0 {
|
||||
// No digit at all: first two letters + 0.
|
||||
if len(c) >= 2 {
|
||||
return c[:2] + "0"
|
||||
}
|
||||
return c + "0"
|
||||
}
|
||||
return c[:lastDigit+1]
|
||||
}
|
||||
|
||||
func ensureTrailingDigit(p string) string {
|
||||
for i := 0; i < len(p); i++ {
|
||||
if p[i] >= '0' && p[i] <= '9' {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return p + "0"
|
||||
}
|
||||
|
||||
func replaceLastDigit(prefix, digit string) string {
|
||||
for i := len(prefix) - 1; i >= 0; i-- {
|
||||
if prefix[i] >= '0' && prefix[i] <= '9' {
|
||||
return prefix[:i] + digit
|
||||
}
|
||||
}
|
||||
return prefix + digit
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasLetter(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -59,6 +59,8 @@ type Spot struct {
|
||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||
POTAName string `json:"pota_name,omitempty"` // park name
|
||||
}
|
||||
|
||||
// State enumerates the per-server lifecycle.
|
||||
|
||||
@@ -32,6 +32,29 @@ func EntityDXCC(name string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// nameByDXCC reverses dxccByName (number → a representative entity name),
|
||||
// built once. When several names share a number, the longest (usually the most
|
||||
// complete) wins. Names are Title-cased for display.
|
||||
var nameByDXCC = func() map[int]string {
|
||||
m := make(map[int]string, len(dxccByName))
|
||||
for name, num := range dxccByName {
|
||||
if cur, ok := m[num]; !ok || len(name) > len(cur) {
|
||||
m[num] = name
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// NameForDXCC returns a display name for an ADIF DXCC entity number, or "" if
|
||||
// unknown.
|
||||
func NameForDXCC(n int) string {
|
||||
name, ok := nameByDXCC[n]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.Title(name) //nolint:staticcheck // ASCII entity names
|
||||
}
|
||||
|
||||
// dxccByCanon is dxccByName re-keyed by the canonical entity form, built once.
|
||||
var dxccByCanon = func() map[string]int {
|
||||
m := make(map[string]int, len(dxccByName))
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Package pota polls the Parks On The Air activator-spots API and exposes a
|
||||
// fast in-memory lookup so DX-cluster spots can be tagged "this station is
|
||||
// currently activating a park". No API key required.
|
||||
package pota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiURL = "https://api.pota.app/spot/activator"
|
||||
|
||||
// Info is the park data we surface for a currently-active activator.
|
||||
type Info struct {
|
||||
Reference string `json:"reference"` // park id, e.g. "US-2072"
|
||||
ParkName string `json:"park_name"` // human name
|
||||
LocationDesc string `json:"location_desc"` // e.g. "US-NY"
|
||||
}
|
||||
|
||||
// apiSpot is the subset of the POTA API record we read.
|
||||
type apiSpot struct {
|
||||
Activator string `json:"activator"`
|
||||
Reference string `json:"reference"`
|
||||
ParkName string `json:"parkName"`
|
||||
Name string `json:"name"`
|
||||
LocationDesc string `json:"locationDesc"`
|
||||
}
|
||||
|
||||
// Cache holds the latest activator set, refreshed in the background.
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
byCall map[string]Info // base callsign (upper) → info
|
||||
client *http.Client
|
||||
logf func(string, ...any)
|
||||
}
|
||||
|
||||
// New creates a cache. logf may be nil.
|
||||
func New(logf func(string, ...any)) *Cache {
|
||||
return &Cache{
|
||||
byCall: map[string]Info{},
|
||||
client: &http.Client{Timeout: 20 * time.Second},
|
||||
logf: logf,
|
||||
}
|
||||
}
|
||||
|
||||
// Run refreshes immediately, then every 60 s until ctx is cancelled.
|
||||
func (c *Cache) Run(ctx context.Context) {
|
||||
c.refresh(ctx)
|
||||
t := time.NewTicker(60 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
c.refresh(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) log(format string, a ...any) {
|
||||
if c.logf != nil {
|
||||
c.logf(format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) refresh(ctx context.Context) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
c.log("pota: request: %v", err)
|
||||
return
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.log("pota: fetch: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.log("pota: http %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
var spots []apiSpot
|
||||
if err := json.NewDecoder(resp.Body).Decode(&spots); err != nil {
|
||||
c.log("pota: decode: %v", err)
|
||||
return
|
||||
}
|
||||
m := make(map[string]Info, len(spots))
|
||||
for _, s := range spots {
|
||||
call := baseCall(s.Activator)
|
||||
if call == "" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(s.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(s.ParkName)
|
||||
}
|
||||
// Keep the first reference seen for a call (most-recent-first ordering
|
||||
// from the API), but don't clobber with a blank.
|
||||
if _, exists := m[call]; exists {
|
||||
continue
|
||||
}
|
||||
m[call] = Info{Reference: s.Reference, ParkName: name, LocationDesc: s.LocationDesc}
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.byCall = m
|
||||
c.mu.Unlock()
|
||||
c.log("pota: %d active activators", len(m))
|
||||
}
|
||||
|
||||
// Lookup returns park info for a callsign if it's currently activating.
|
||||
func (c *Cache) Lookup(call string) (Info, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if len(c.byCall) == 0 {
|
||||
return Info{}, false
|
||||
}
|
||||
i, ok := c.byCall[baseCall(call)]
|
||||
return i, ok
|
||||
}
|
||||
|
||||
// baseCall normalises a callsign for matching: upper-cased, and when it carries
|
||||
// "/" segments (F4BPO/P, HB9/F4BPO) we take the longest segment, which is
|
||||
// almost always the home call.
|
||||
func baseCall(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(s, "/") {
|
||||
return s
|
||||
}
|
||||
best := ""
|
||||
for _, part := range strings.Split(s, "/") {
|
||||
if len(part) > len(best) {
|
||||
best = part
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -582,6 +583,248 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Advanced filter builder ──────────────────────────────────────────────
|
||||
//
|
||||
// QueryFilter powers the UI's filter builder: a list of field/operator/value
|
||||
// conditions joined by AND or OR, plus an always-ANDed quick callsign search.
|
||||
// Every field is validated against filterableColumns so user input can never
|
||||
// reach the SQL string — only parameterised values do.
|
||||
|
||||
// Condition is one "field OP value" clause.
|
||||
type Condition struct {
|
||||
Field string `json:"field"` // db column name (validated against whitelist)
|
||||
Op string `json:"op"` // eq|ne|gt|lt|ge|le|contains|startswith|endswith|empty|notempty
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// QueryFilter is a full filter expression.
|
||||
type QueryFilter struct {
|
||||
QuickCallsign string `json:"quick_callsign,omitempty"` // always-ANDed contains-match
|
||||
Conditions []Condition `json:"conditions,omitempty"`
|
||||
Match string `json:"match,omitempty"` // "AND" (default) | "OR"
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
// filterableColumns whitelists the columns the filter builder may reference.
|
||||
// Keep field names identical to DB columns so the frontend can send them
|
||||
// directly; anything not in this set is rejected.
|
||||
var filterableColumns = map[string]bool{
|
||||
"callsign": true, "qso_date": true, "qso_date_off": true, "band": true, "band_rx": true,
|
||||
"mode": true, "submode": true, "freq_hz": true, "freq_rx_hz": true,
|
||||
"rst_sent": true, "rst_rcvd": true,
|
||||
"name": true, "qth": true, "address": true, "email": true,
|
||||
"grid": true, "country": true, "state": true, "cnty": true,
|
||||
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
|
||||
"iota": true, "sota_ref": true, "pota_ref": true, "rig": true, "ant": true,
|
||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true,
|
||||
"contest_id": true, "srx": true, "stx": true,
|
||||
"prop_mode": true, "sat_name": true,
|
||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
||||
"tx_pwr": true, "comment": true, "notes": true,
|
||||
}
|
||||
|
||||
// filterableExtras whitelists virtual filter fields stored inside extras_json
|
||||
// (valid ADIF fields we don't promote to columns). The value is the uppercase
|
||||
// ADIF/Extras key; the SQL expression uses json_extract.
|
||||
var filterableExtras = map[string]string{
|
||||
"owner_callsign": "OWNER_CALLSIGN",
|
||||
}
|
||||
|
||||
// FilterableFields returns the whitelist (for the frontend to build its field
|
||||
// dropdown and stay in sync with the backend).
|
||||
func FilterableFields() []string {
|
||||
out := make([]string, 0, len(filterableColumns)+len(filterableExtras))
|
||||
for c := range filterableColumns {
|
||||
out = append(out, c)
|
||||
}
|
||||
for c := range filterableExtras {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// columnExpr resolves a filter field to a safe SQL expression — either a
|
||||
// whitelisted column name or a json_extract over extras_json.
|
||||
func columnExpr(field string) (string, bool) {
|
||||
f := strings.ToLower(strings.TrimSpace(field))
|
||||
if filterableColumns[f] {
|
||||
return f, true
|
||||
}
|
||||
if key, ok := filterableExtras[f]; ok {
|
||||
return "json_extract(extras_json, '$." + key + "')", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// conditionSQL turns one condition into a parameterised predicate.
|
||||
func conditionSQL(c Condition) (string, []any, error) {
|
||||
col, ok := columnExpr(c.Field)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
|
||||
}
|
||||
v := c.Value
|
||||
switch c.Op {
|
||||
case "eq":
|
||||
return col + " = ?", []any{v}, nil
|
||||
case "ne":
|
||||
return col + " <> ?", []any{v}, nil
|
||||
case "gt":
|
||||
return col + " > ?", []any{v}, nil
|
||||
case "lt":
|
||||
return col + " < ?", []any{v}, nil
|
||||
case "ge":
|
||||
return col + " >= ?", []any{v}, nil
|
||||
case "le":
|
||||
return col + " <= ?", []any{v}, nil
|
||||
case "contains":
|
||||
return col + " LIKE ?", []any{"%" + v + "%"}, nil
|
||||
case "startswith":
|
||||
return col + " LIKE ?", []any{v + "%"}, nil
|
||||
case "endswith":
|
||||
return col + " LIKE ?", []any{"%" + v}, nil
|
||||
case "empty":
|
||||
return "IFNULL(" + col + ",'') = ''", nil, nil
|
||||
case "notempty":
|
||||
return "IFNULL(" + col + ",'') <> ''", nil, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
|
||||
}
|
||||
}
|
||||
|
||||
// buildWhere assembles the predicate (everything after WHERE) + args.
|
||||
func buildWhere(f QueryFilter) (string, []any, error) {
|
||||
pred := "1=1"
|
||||
var args []any
|
||||
if qc := strings.TrimSpace(f.QuickCallsign); qc != "" {
|
||||
pred += " AND callsign LIKE ?"
|
||||
args = append(args, "%"+qc+"%")
|
||||
}
|
||||
if len(f.Conditions) > 0 {
|
||||
joiner := " AND "
|
||||
if strings.EqualFold(strings.TrimSpace(f.Match), "OR") {
|
||||
joiner = " OR "
|
||||
}
|
||||
parts := make([]string, 0, len(f.Conditions))
|
||||
for _, c := range f.Conditions {
|
||||
if strings.TrimSpace(c.Field) == "" {
|
||||
continue
|
||||
}
|
||||
p, a, err := conditionSQL(c)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
parts = append(parts, p)
|
||||
args = append(args, a...)
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
pred += " AND (" + strings.Join(parts, joiner) + ")"
|
||||
}
|
||||
}
|
||||
return pred, args, nil
|
||||
}
|
||||
|
||||
// ListFiltered returns QSOs matching a QueryFilter, newest first, limited.
|
||||
func (r *Repo) ListFiltered(ctx context.Context, f QueryFilter) ([]QSO, error) {
|
||||
pred, args, err := buildWhere(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := `SELECT ` + selectCols + ` FROM qso WHERE ` + pred + ` ORDER BY qso_date DESC, id DESC`
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
if limit > 1_000_000 {
|
||||
limit = 1_000_000
|
||||
}
|
||||
q += " LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, f.Offset)
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query qso: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]QSO, 0, 64)
|
||||
for rows.Next() {
|
||||
qrow, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, qrow)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountFiltered returns how many QSOs match a filter (ignoring limit/offset).
|
||||
func (r *Repo) CountFiltered(ctx context.Context, f QueryFilter) (int64, error) {
|
||||
pred, args, err := buildWhere(f)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var n int64
|
||||
err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM qso WHERE `+pred, args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// IterateFiltered streams all QSOs matching a filter (no limit), chronological,
|
||||
// for an ADIF export of "the current filtered view, no row limit".
|
||||
func (r *Repo) IterateFiltered(ctx context.Context, f QueryFilter, fn func(QSO) error) error {
|
||||
pred, args, err := buildWhere(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM qso WHERE `+pred+` ORDER BY qso_date ASC, id ASC`, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query qso: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// IterateByIDs streams the QSOs with the given ids, chronological — for
|
||||
// "export the rows I selected with the mouse".
|
||||
func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query qso: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// WorkedBefore summarises prior contacts at two granularities:
|
||||
// - by exact callsign → shown as "this call worked N×"
|
||||
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
|
||||
|
||||
Reference in New Issue
Block a user