award
This commit is contained in:
+331
-47
@@ -21,31 +21,83 @@ import (
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Def defines one award.
|
||||
// AwardType selects how a QSO is matched to an award's references.
|
||||
//
|
||||
// "DXCC" — match the QSO's DXCC entity number (references keyed by entity)
|
||||
// "QSOFIELDS" — search a QSO field for a reference code/description/pattern
|
||||
// "REFERENCE" — the reference is carried by a dedicated field (POTA_REF, …) or
|
||||
// the per-reference DXCC list (e.g. RAC provinces by state)
|
||||
// "GRID" — match a Maidenhead grid square
|
||||
type AwardType = string
|
||||
|
||||
const (
|
||||
TypeDXCC AwardType = "DXCC"
|
||||
TypeQSOFields AwardType = "QSOFIELDS"
|
||||
TypeReference AwardType = "REFERENCE"
|
||||
TypeGrid AwardType = "GRID"
|
||||
)
|
||||
|
||||
// Def defines one award. Fields mirror Log4OM's Award Management model: an
|
||||
// identity + scope (when the award applies) + a matching rule (how a QSO maps
|
||||
// to a reference) + confirmation rules. Most fields are optional; the zero
|
||||
// value of a legacy Def (only Field/Pattern/DXCCFilter/Confirm/Total set) still
|
||||
// behaves as before.
|
||||
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)
|
||||
// --- Identity ---
|
||||
Code string `json:"code"` // unique key, e.g. "DXCC"
|
||||
Name string `json:"name"` // friendly name
|
||||
Description string `json:"description,omitempty"` // free text
|
||||
Valid bool `json:"valid"` // award enabled
|
||||
Protected bool `json:"protected,omitempty"` // shipped/locked award
|
||||
URL string `json:"url,omitempty"` // award home page
|
||||
DownloadURL string `json:"download_url,omitempty"` // reference-list source
|
||||
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
|
||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||
Alias string `json:"alias,omitempty"`
|
||||
|
||||
// --- Type & matching ---
|
||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
||||
|
||||
// --- Scope ---
|
||||
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
|
||||
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
|
||||
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
|
||||
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
|
||||
|
||||
// --- Confirmation ---
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||
|
||||
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||
}
|
||||
|
||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||
func Defaults() []Def {
|
||||
lq := []string{"lotw", "qsl"}
|
||||
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},
|
||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lq, Total: 340, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lq, Total: 50, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lq, Total: 40, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: []string{"lotw", "qsl", "eqsl"}, Total: 6, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lq, Total: 96, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: []string{"qsl"}, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +122,11 @@ type BandCount struct {
|
||||
type Ref struct {
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
SubGrp string `json:"subgrp,omitempty"`
|
||||
Worked bool `json:"worked"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Validated bool `json:"validated"`
|
||||
Bands []string `json:"bands"`
|
||||
ConfirmedBands []string `json:"confirmed_bands"`
|
||||
}
|
||||
@@ -83,6 +138,7 @@ type Result struct {
|
||||
Field string `json:"field"`
|
||||
Worked int `json:"worked"`
|
||||
Confirmed int `json:"confirmed"`
|
||||
Validated int `json:"validated"`
|
||||
Total int `json:"total"`
|
||||
Bands []BandCount `json:"bands"`
|
||||
Refs []Ref `json:"refs"`
|
||||
@@ -96,11 +152,63 @@ type refAgg struct {
|
||||
bands map[string]struct{}
|
||||
confirmedBands map[string]struct{}
|
||||
anyConfirmed bool
|
||||
anyValidated 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).
|
||||
// refList is the per-award reference data Compute needs (a thin view of
|
||||
// awardref.Ref, kept local so the award package stays storage-agnostic).
|
||||
type refList struct {
|
||||
byCode map[string]RefMeta // uppercased code → metadata
|
||||
codes []string // codes in input order (for stable unworked listing)
|
||||
}
|
||||
|
||||
// RefMeta is one reference's metadata for the engine: enough to enforce a
|
||||
// predefined list, per-reference DXCC scoping, a per-reference pattern, and to
|
||||
// label results.
|
||||
type RefMeta struct {
|
||||
Code string
|
||||
Name string
|
||||
Group string
|
||||
SubGrp string
|
||||
DXCCList []int // nil = any
|
||||
Pattern string
|
||||
re *regexp.Regexp
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||
func NewRefList(metas []RefMeta) refList {
|
||||
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||
for _, m := range metas {
|
||||
code := normalizeRef(m.Code)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||
if re, err := regexp.Compile(p); err == nil {
|
||||
m.re = re
|
||||
}
|
||||
}
|
||||
m.Code = code
|
||||
if _, dup := rl.byCode[code]; !dup {
|
||||
rl.codes = append(rl.codes, code)
|
||||
}
|
||||
rl.byCode[code] = m
|
||||
}
|
||||
return rl
|
||||
}
|
||||
|
||||
// Compute runs every definition over the QSOs in a single pass. refMetas maps an
|
||||
// award code to its reference metadata; awards present there with Dynamic=false
|
||||
// are "predefined" (only listed references count, and the full list — including
|
||||
// unworked references — appears in the result).
|
||||
func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf NameResolver) []Result {
|
||||
refLists := make(map[string]refList, len(refMetas))
|
||||
for code, metas := range refMetas {
|
||||
refLists[strings.ToUpper(strings.TrimSpace(code))] = NewRefList(metas)
|
||||
}
|
||||
|
||||
// Pre-compile award-level patterns once.
|
||||
res := make([]*regexp.Regexp, len(defs))
|
||||
perr := make([]string, len(defs))
|
||||
for i := range defs {
|
||||
@@ -123,18 +231,17 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
q := &qsos[qi]
|
||||
for i := range defs {
|
||||
d := &defs[i]
|
||||
if perr[i] != "" {
|
||||
if perr[i] != "" || !inScope(d, q) {
|
||||
continue
|
||||
}
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
continue
|
||||
}
|
||||
refs := refValues(d, res[i], q)
|
||||
rl, hasList := refLists[strings.ToUpper(d.Code)]
|
||||
refs := candidates(d, res[i], q, rl, hasList)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
isVal := confirmed(q, d.Validate)
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
@@ -150,6 +257,9 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
a.confirmedBands[band] = struct{}{}
|
||||
}
|
||||
}
|
||||
if isVal {
|
||||
a.anyValidated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +267,8 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
out := make([]Result, len(defs))
|
||||
for i := range defs {
|
||||
d := &defs[i]
|
||||
rl, hasList := refLists[strings.ToUpper(d.Code)]
|
||||
predefined := hasList && !d.Dynamic
|
||||
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{}
|
||||
@@ -165,10 +277,12 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
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)
|
||||
if a.anyValidated {
|
||||
r.Validated++
|
||||
}
|
||||
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
|
||||
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
|
||||
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
||||
r.Refs = append(r.Refs, rf)
|
||||
for b := range a.bands {
|
||||
bandWorked[b]++
|
||||
@@ -177,7 +291,29 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
bandConfirmed[b]++
|
||||
}
|
||||
}
|
||||
// Predefined awards: the full list is the denominator, and unworked
|
||||
// references are listed too (greyed in the UI).
|
||||
if predefined {
|
||||
r.Total = len(rl.codes)
|
||||
for _, code := range rl.codes {
|
||||
if _, worked := agg[i][code]; worked {
|
||||
continue
|
||||
}
|
||||
m := rl.byCode[code]
|
||||
if !m.Valid {
|
||||
continue
|
||||
}
|
||||
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}}
|
||||
if rf.Name == "" && nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, code)
|
||||
}
|
||||
r.Refs = append(r.Refs, rf)
|
||||
}
|
||||
}
|
||||
sort.Slice(r.Refs, func(a, b int) bool {
|
||||
if r.Refs[a].Worked != r.Refs[b].Worked {
|
||||
return r.Refs[a].Worked // worked first
|
||||
}
|
||||
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
|
||||
return r.Refs[a].Confirmed
|
||||
}
|
||||
@@ -191,41 +327,189 @@ func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
|
||||
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) == "" {
|
||||
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||
// name resolver as a fallback).
|
||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||
if hasList {
|
||||
if m, ok := rl.byCode[code]; ok {
|
||||
rf.Name, rf.Group, rf.SubGrp = m.Name, m.Group, m.SubGrp
|
||||
}
|
||||
}
|
||||
if rf.Name == "" && nameOf != nil {
|
||||
rf.Name = nameOf(d.Field, code)
|
||||
}
|
||||
}
|
||||
|
||||
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
|
||||
// a predefined list when one applies.
|
||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if re == nil {
|
||||
return []string{normalizeRef(raw)}
|
||||
predefined := hasList && !d.Dynamic
|
||||
|
||||
var found []string
|
||||
switch {
|
||||
case re != nil:
|
||||
// Award-level regex: capture group 1 (or whole match) for each hit.
|
||||
found = regexTokens(re, raw)
|
||||
case predefined && !d.ExactMatch:
|
||||
// "Search reference inside the field": keep any reference code that
|
||||
// appears as a token (or whose per-reference pattern matches).
|
||||
up := strings.ToUpper(raw)
|
||||
for _, code := range rl.codes {
|
||||
m := rl.byCode[code]
|
||||
if (m.re != nil && m.re.MatchString(raw)) || containsToken(up, code) {
|
||||
found = append(found, code)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Whole field value is the candidate.
|
||||
found = []string{normalizeRef(raw)}
|
||||
}
|
||||
matches := re.FindAllStringSubmatch(raw, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
|
||||
if !predefined {
|
||||
return dedupe(found)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
// Enforce the predefined list: keep only listed, valid references whose
|
||||
// per-reference DXCC scope (if any) includes the QSO's entity.
|
||||
var out []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range found {
|
||||
c = normalizeRef(c)
|
||||
m, ok := rl.byCode[c]
|
||||
if !ok || !m.Valid {
|
||||
continue
|
||||
}
|
||||
if len(m.DXCCList) > 0 && !dxccAllowed(q.DXCC, m.DXCCList) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[c]; dup {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func regexTokens(re *regexp.Regexp, raw string) []string {
|
||||
matches := re.FindAllStringSubmatch(raw, -1)
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
ref := m[0]
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
ref = m[1]
|
||||
}
|
||||
ref = normalizeRef(ref)
|
||||
if ref == "" {
|
||||
if ref = normalizeRef(ref); ref != "" {
|
||||
out = append(out, ref)
|
||||
}
|
||||
}
|
||||
return dedupe(out)
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
if len(in) <= 1 {
|
||||
return in
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := in[:0]
|
||||
for _, s := range in {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[ref]; dup {
|
||||
continue
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
out = append(out, ref)
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containsToken reports whether code appears in up (already uppercased) as a
|
||||
// whole token (delimited by non-alphanumerics), so "D6" doesn't match "D60".
|
||||
func containsToken(up, code string) bool {
|
||||
for from := 0; ; {
|
||||
idx := strings.Index(up[from:], code)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
i := from + idx
|
||||
j := i + len(code)
|
||||
leftOK := i == 0 || !isAlnum(up[i-1])
|
||||
rightOK := j == len(up) || !isAlnum(up[j])
|
||||
if leftOK && rightOK {
|
||||
return true
|
||||
}
|
||||
from = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isAlnum(b byte) bool {
|
||||
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
|
||||
}
|
||||
|
||||
// stripAffix removes a leading and/or trailing literal string before matching.
|
||||
func stripAffix(s, lead, trail string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if lead != "" {
|
||||
s = strings.TrimPrefix(s, lead)
|
||||
}
|
||||
if trail != "" {
|
||||
s = strings.TrimSuffix(s, trail)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
||||
|
||||
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||
// bands, modes, emission category, validity dates).
|
||||
func inScope(d *Def, q *qso.QSO) bool {
|
||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||
return false
|
||||
}
|
||||
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
||||
return false
|
||||
}
|
||||
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
||||
return false
|
||||
}
|
||||
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
||||
return false
|
||||
}
|
||||
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
||||
return false
|
||||
}
|
||||
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsFold(list []string, v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(strings.TrimSpace(x), v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// emissionOf maps an ADIF mode to its broad emission category.
|
||||
func emissionOf(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
return "CW"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "PHONE", "C4FM":
|
||||
return "PHONE"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "DIGITAL"
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestComputeDXCCAndConfirm(t *testing.T) {
|
||||
{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)
|
||||
res := Compute(Defaults(), qsos, nil, nil)
|
||||
by := map[string]Result{}
|
||||
for _, r := range res {
|
||||
by[r.Code] = r
|
||||
@@ -73,3 +73,37 @@ func refCodes(r Result) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A predefined award only counts references present in its list, lists the
|
||||
// unworked ones too, and uses the list size as the denominator.
|
||||
func TestComputePredefinedList(t *testing.T) {
|
||||
def := Def{Code: "RAC", Name: "RAC", Type: TypeQSOFields, Field: "state", ExactMatch: true,
|
||||
DXCCFilter: []int{1}, Confirm: []string{"lotw", "qsl"}, Validate: []string{"lotw"}, Valid: true}
|
||||
qsos := []qso.QSO{
|
||||
{Callsign: "VE3AAA", Band: "20m", DXCC: ip(1), State: "ON", LOTWRcvd: "Y"}, // worked+confirmed+validated
|
||||
{Callsign: "VE7BBB", Band: "40m", DXCC: ip(1), State: "BC", QSLRcvd: "Y"}, // worked+confirmed (not validated)
|
||||
{Callsign: "VE9CCC", Band: "20m", DXCC: ip(1), State: "ZZ"}, // not a real province → ignored
|
||||
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // wrong DXCC → ignored
|
||||
}
|
||||
refMetas := map[string][]RefMeta{"RAC": {
|
||||
{Code: "ON", Name: "Ontario", Valid: true},
|
||||
{Code: "BC", Name: "British Columbia", Valid: true},
|
||||
{Code: "AB", Name: "Alberta", Valid: true},
|
||||
}}
|
||||
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||
if r.Worked != 2 {
|
||||
t.Errorf("RAC worked = %d, want 2 (%v)", r.Worked, refCodes(r))
|
||||
}
|
||||
if r.Confirmed != 2 {
|
||||
t.Errorf("RAC confirmed = %d, want 2", r.Confirmed)
|
||||
}
|
||||
if r.Validated != 1 { // only ON via LoTW
|
||||
t.Errorf("RAC validated = %d, want 1", r.Validated)
|
||||
}
|
||||
if r.Total != 3 { // denominator = list size
|
||||
t.Errorf("RAC total = %d, want 3", r.Total)
|
||||
}
|
||||
if len(r.Refs) != 3 { // ON, BC worked + AB unworked
|
||||
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Award reference lists (Parks On The Air, SOTA summits, WWFF, IOTA…).
|
||||
-- Each row is one valid reference for an award, used to provide award totals,
|
||||
-- reference names, and (later) per-QSO reference assignment + per-DXCC filtering.
|
||||
-- Lists are downloaded/updated from each program's published file.
|
||||
CREATE TABLE IF NOT EXISTS award_references (
|
||||
award_code TEXT NOT NULL,
|
||||
ref_code TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
dxcc INTEGER NOT NULL DEFAULT 0,
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
subgrp TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (award_code, ref_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_award_ref_dxcc ON award_references(award_code, dxcc);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Richer per-reference metadata, mirroring Log4OM's reference editor:
|
||||
-- a per-reference regexp, validity window, score/bonus, grid, alias, a
|
||||
-- "valid" flag, and a multi-DXCC list (JSON array) on top of the single
|
||||
-- primary dxcc kept for fast filtering.
|
||||
ALTER TABLE award_references ADD COLUMN dxcc_list TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN pattern TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN valid INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE award_references ADD COLUMN valid_from TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN valid_to TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE award_references ADD COLUMN bonus INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE award_references ADD COLUMN gridsquare TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE award_references ADD COLUMN alias TEXT NOT NULL DEFAULT '';
|
||||
@@ -1,6 +1,9 @@
|
||||
package dxcc
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The dxccByName table itself lives in dxcc_names_gen.go (generated by joining
|
||||
// cty.dat to the authoritative ARRL/ADIF entity list). cty.dat doesn't carry
|
||||
@@ -55,6 +58,23 @@ func NameForDXCC(n int) string {
|
||||
return strings.Title(name) //nolint:staticcheck // ASCII entity names
|
||||
}
|
||||
|
||||
// EntityNumberName pairs a DXCC entity number with its display name.
|
||||
type EntityNumberName struct {
|
||||
Num int
|
||||
Name string
|
||||
}
|
||||
|
||||
// AllEntities returns every known DXCC entity (number + display name), sorted by
|
||||
// number. Used to seed the DXCC award's reference list.
|
||||
func AllEntities() []EntityNumberName {
|
||||
out := make([]EntityNumberName, 0, len(nameByDXCC))
|
||||
for num := range nameByDXCC {
|
||||
out = append(out, EntityNumberName{Num: num, Name: NameForDXCC(num)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Num < out[j].Num })
|
||||
return out
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
Reference in New Issue
Block a user