784 lines
25 KiB
Go
784 lines
25 KiB
Go
// 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"
|
|
)
|
|
|
|
// 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 {
|
|
// --- 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 {
|
|
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
|
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
|
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
|
// accepts it (WAC).
|
|
lq := []string{"lotw", "qsl"}
|
|
lo := []string{"lotw"}
|
|
return []Def{
|
|
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, 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: lo, 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: lo, 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: lo, 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: lo, 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: lo, 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: lo, 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: lo, 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: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
|
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
|
}
|
|
}
|
|
|
|
// Migrate upgrades award definitions saved before the richer model existed.
|
|
// Such defs have Type=="" and the zero value for the new fields (notably
|
|
// Valid==false, which would otherwise hide every legacy award). For each legacy
|
|
// def it enables the award, fills the matching/confirmation fields from the
|
|
// matching built-in default (preserving the user's field/filters/confirm), and
|
|
// fixes the DDFM capture pattern. Returns the (possibly) migrated slice and
|
|
// whether anything changed. Idempotent: a def with Type!="" is left untouched.
|
|
func Migrate(defs []Def) ([]Def, bool) {
|
|
defaults := map[string]Def{}
|
|
for _, d := range Defaults() {
|
|
defaults[strings.ToUpper(d.Code)] = d
|
|
}
|
|
const oldDDFM = `(?i)\bD(\d{1,2}[AB]?)\b`
|
|
changed := false
|
|
out := make([]Def, len(defs))
|
|
for i, d := range defs {
|
|
if d.Type != "" {
|
|
out[i] = d // already on the new model
|
|
continue
|
|
}
|
|
changed = true
|
|
d.Valid = true // legacy defs predate the Valid flag → enable them
|
|
if def, ok := defaults[strings.ToUpper(d.Code)]; ok {
|
|
d.Type = def.Type
|
|
d.ExactMatch = def.ExactMatch
|
|
d.Dynamic = def.Dynamic
|
|
d.Protected = def.Protected
|
|
if len(d.Validate) == 0 {
|
|
d.Validate = def.Validate
|
|
}
|
|
// Fix DDFM's capture group ("06" → "D06") so refs match the list.
|
|
if strings.EqualFold(d.Code, "DDFM") && (d.Pattern == "" || d.Pattern == oldDDFM) {
|
|
d.Pattern = def.Pattern
|
|
}
|
|
} else {
|
|
d.Type = TypeQSOFields // sensible default for custom legacy awards
|
|
}
|
|
out[i] = d
|
|
}
|
|
return out, changed
|
|
}
|
|
|
|
// 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"`
|
|
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"`
|
|
ValidatedBands []string `json:"validated_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"`
|
|
Validated int `json:"validated"`
|
|
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{}
|
|
validatedBands map[string]struct{}
|
|
anyConfirmed bool
|
|
anyValidated bool
|
|
}
|
|
|
|
// 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)
|
|
withPattern []string // codes whose reference declares a regex (usually none)
|
|
}
|
|
|
|
// 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
|
|
rl.withPattern = append(rl.withPattern, code)
|
|
}
|
|
}
|
|
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 {
|
|
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] != "" || !inScope(d, q) {
|
|
continue
|
|
}
|
|
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 {
|
|
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
|
|
agg[i][ref] = a
|
|
}
|
|
if band != "" {
|
|
a.bands[band] = struct{}{}
|
|
}
|
|
if isConf {
|
|
a.anyConfirmed = true
|
|
if band != "" {
|
|
a.confirmedBands[band] = struct{}{}
|
|
}
|
|
}
|
|
if isVal {
|
|
a.anyValidated = true
|
|
if band != "" {
|
|
a.validatedBands[band] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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{}
|
|
for ref, a := range agg[i] {
|
|
r.Worked++
|
|
if a.anyConfirmed {
|
|
r.Confirmed++
|
|
}
|
|
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), ValidatedBands: setToSorted(a.validatedBands)}
|
|
labelRef(&rf, d, ref, rl, hasList, nameOf)
|
|
r.Refs = append(r.Refs, rf)
|
|
for b := range a.bands {
|
|
bandWorked[b]++
|
|
}
|
|
for b := range a.confirmedBands {
|
|
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{}, ValidatedBands: []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
|
|
}
|
|
return natLess(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
|
|
}
|
|
|
|
// MatchQSO returns the reference codes a single QSO contributes to for one
|
|
// award (respecting scope + predefined enforcement). metas is the award's
|
|
// reference list (empty/nil for dynamic awards). Used for cell drill-down.
|
|
func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
|
if !inScope(&d, q) {
|
|
return nil
|
|
}
|
|
var re *regexp.Regexp
|
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
|
if c, err := regexp.Compile(p); err == nil {
|
|
re = c
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
rl := NewRefList(metas)
|
|
return candidates(&d, re, q, rl, len(metas) > 0)
|
|
}
|
|
|
|
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
|
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
|
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
|
|
|
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
|
func EmissionOf(mode string) string { return emissionOf(mode) }
|
|
|
|
// 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
|
|
}
|
|
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": look up each token of the field in
|
|
// the list — O(tokens), not O(all references) — plus test the few
|
|
// references that declare a regex.
|
|
for _, tok := range tokenize(raw) {
|
|
if _, ok := rl.byCode[tok]; ok {
|
|
found = append(found, tok)
|
|
}
|
|
}
|
|
for _, code := range rl.withPattern {
|
|
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
|
|
found = append(found, code)
|
|
}
|
|
}
|
|
default:
|
|
// Whole field value is the candidate, split on comma/semicolon so a
|
|
// multi-reference field (e.g. an n-fer POTA QSO "US-6544,US-0680")
|
|
// counts each reference separately.
|
|
found = splitRefs(raw)
|
|
}
|
|
|
|
if !predefined {
|
|
return dedupe(found)
|
|
}
|
|
// Enforce the predefined list: keep only listed, valid references. The
|
|
// award-level DXCCFilter already scopes which QSOs are considered (see
|
|
// inScope), so we do NOT additionally require the QSO's entity to match the
|
|
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
|
|
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
|
|
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 _, 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]
|
|
}
|
|
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
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// tokenize splits a field into uppercased tokens on any non-alphanumeric run,
|
|
// keeping '-' and '/' which appear inside reference codes (e.g. "FR-11553").
|
|
// The whole trimmed value is also returned so single-token fields match.
|
|
func tokenize(raw string) []string {
|
|
up := strings.ToUpper(strings.TrimSpace(raw))
|
|
if up == "" {
|
|
return nil
|
|
}
|
|
out := []string{up}
|
|
cur := strings.Builder{}
|
|
for _, r := range up {
|
|
if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == '-' || r == '/' {
|
|
cur.WriteRune(r)
|
|
} else if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
}
|
|
}
|
|
if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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)) }
|
|
|
|
// splitRefs splits a field value on comma/semicolon into normalized references,
|
|
// so a multi-reference field (n-fer POTA "US-6544,US-0680") yields one entry
|
|
// per reference. A value with no separator yields a single reference.
|
|
func splitRefs(raw string) []string {
|
|
if !strings.ContainsAny(raw, ",;") {
|
|
return []string{normalizeRef(raw)}
|
|
}
|
|
var out []string
|
|
for _, p := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' }) {
|
|
if n := normalizeRef(p); n != "" {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return dedupe(out)
|
|
}
|
|
|
|
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
|
|
|
// natLess is a natural ("human") comparison: digit runs compare as numbers, so
|
|
// references sort 1,2,…,9,10,11 (not 1,10,11,2) and "D2A" before "D10".
|
|
func natLess(a, b string) bool {
|
|
ia, ib := 0, 0
|
|
for ia < len(a) && ib < len(b) {
|
|
ca, cb := a[ia], b[ib]
|
|
if isDigit(ca) && isDigit(cb) {
|
|
ja, jb := ia, ib
|
|
for ja < len(a) && isDigit(a[ja]) {
|
|
ja++
|
|
}
|
|
for jb < len(b) && isDigit(b[jb]) {
|
|
jb++
|
|
}
|
|
na := strings.TrimLeft(a[ia:ja], "0")
|
|
nb := strings.TrimLeft(b[ib:jb], "0")
|
|
if len(na) != len(nb) {
|
|
return len(na) < len(nb) // fewer digits = smaller number
|
|
}
|
|
if na != nb {
|
|
return na < nb
|
|
}
|
|
ia, ib = ja, jb
|
|
} else {
|
|
if ca != cb {
|
|
return ca < cb
|
|
}
|
|
ia++
|
|
ib++
|
|
}
|
|
}
|
|
return len(a)-ia < len(b)-ib
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|