Files
OpsLog/internal/award/award.go
T

1294 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
"embed"
"encoding/json"
"errors"
"fmt"
"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"`
// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
// the prefecture name), "both" = "ref — name". DXCC always shows the country
// name under "ref" (unchanged).
RefDisplay string `json:"ref_display,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
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
// NOTE: there is no "one reference per QSO" switch, and there was never any
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
// `Multi` flag was read by nothing; its checkbox changed nothing.
// OrRules are ordered FALLBACK searches for the primary one above: they are
// tried IN ORDER and only while nothing has matched yet — the first rule that
// yields a reference wins and the rest are skipped (short-circuit, like a
// chain of "else if"). Lets a Chinese province (WAPC) be found first from the
// province NAME in the address, and only if that fails, from a city regex
// (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also
// re-tagged, possibly differently, by a later rule.
OrRules []OrRule `json:"or_rules,omitempty"`
// --- 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
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
// controls have been removed from the editor — a checkbox that quietly does
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
// internal/adif) before showing them again.
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)
// --- Catalog updates ---
// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
// you fix a definition (a better OR chain, a corrected reference list) and want
// that fix to reach operators who already run the award: on startup, a catalog
// award whose Version is higher than the stored one REPLACES it, definition and
// references. Awards created by the operator have no version and are never touched.
Version int `json:"version,omitempty"`
// UserEdited marks an award the operator has changed. A catalog update then
// SKIPS it: their work outranks ours. Set the moment the award (or its reference
// list) is saved to something other than what the catalog ships.
UserEdited bool `json:"user_edited,omitempty"`
}
// SameContent reports whether two definitions describe the same award — ignoring
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
// which say where a definition came from, not what it does. Used to decide whether
// a save actually changed anything.
func (d Def) SameContent(o Def) bool {
a, b := d, o
a.Version, b.Version = 0, 0
a.UserEdited, b.UserEdited = false, false
a.Builtin, b.Builtin = false, false
ja, err1 := json.Marshal(a)
jb, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(ja) == string(jb)
}
// OrRule is one additional search OR'd with the award's primary matching rule.
// Same knobs as the primary (field + how to match), plus Prefix which is
// prepended to each reference it finds so a captured value can be normalised to
// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74").
type OrRule struct {
Field string `json:"field"` // QSO field to scan
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,omitempty"` // 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
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
}
// catalogFS holds the built-in award definitions as DATA, not Go code.
//
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
// recompile and a release for every new one, which is absurd for something that
// changes far more often than the engine that reads it. They now live one JSON per
// award in catalog/, embedded in the binary: adding an award is adding a file.
//
// This is the SEED only. Once a user has awards in their database, that database
// is the source of truth — the catalog never overwrites their edits behind their
// back.
//
//go:embed catalog/*.json
var catalogFS embed.FS
// CatalogEntry is one award in the catalog: its definition AND its reference list.
//
// The references are the point. An award's definition is a few lines; what makes
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
// A catalog that shipped definitions only would hand every user an empty shell —
// which is exactly the trap this design is meant to avoid.
//
// References stay as raw JSON so this package (the matching ENGINE) never has to
// import the reference-store package. The caller decodes them.
type CatalogEntry struct {
Def Def `json:"def"`
References json.RawMessage `json:"references,omitempty"`
}
// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
//
// {"def": {...}, "references": [...]} — a catalog entry
// {"version":1, "awards":[{"def":…,"references":…}]} — an exported bundle
//
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
// can be dropped straight into catalog/ and shipped to everyone, with no
// conversion step. That is the whole loop — create an award, export it, drop it in,
// everybody has it.
type catalogFile struct {
Def Def `json:"def"`
References json.RawMessage `json:"references,omitempty"`
Awards []CatalogEntry `json:"awards,omitempty"`
// A bare Def (the original catalog shape) is still read via the fields above
// being empty and Code being set at the top level.
Code string `json:"code,omitempty"`
}
// Catalog returns every built-in award: definition + references, sorted by code.
//
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
// how a user's award list quietly reorders itself on a rebuild.
func Catalog() []CatalogEntry {
entries, err := catalogFS.ReadDir("catalog")
if err != nil {
return nil // embedded: can only fail if the build is broken
}
out := make([]CatalogEntry, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
b, err := catalogFS.ReadFile("catalog/" + e.Name())
if err != nil {
continue
}
var f catalogFile
if err := json.Unmarshal(b, &f); err != nil {
// A malformed file must not take the other awards down with it.
continue
}
switch {
case len(f.Awards) > 0: // an exported bundle, dropped in as-is
for _, a := range f.Awards {
if strings.TrimSpace(a.Def.Code) != "" {
out = append(out, a)
}
}
case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
out = append(out, CatalogEntry{Def: f.Def, References: f.References})
case strings.TrimSpace(f.Code) != "": // a bare Def
var d Def
if err := json.Unmarshal(b, &d); err == nil {
out = append(out, CatalogEntry{Def: d})
}
}
}
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
// trusting the flag in the file: you drop in an award you exported, its JSON
// says builtin:false (you wrote it, it wasn't built-in then), and it would
// quietly miss every future catalog correction. Making the author remember to
// flip a flag is exactly the kind of step nobody can guess.
for i := range out {
out[i].Def.Builtin = true
}
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
return out
}
// Defaults are the built-in award definitions seeded on first run.
func Defaults() []Def {
cat := Catalog()
out := make([]Def, 0, len(cat))
for _, e := range cat {
out = append(out, e.Def)
}
return out
}
// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
// any. Awards whose list is seeded from code (DXCC entities, French departments)
// or fetched online (POTA/SOTA/WWFF) carry none.
func CatalogRefs(code string) (json.RawMessage, bool) {
code = strings.ToUpper(strings.TrimSpace(code))
for _, e := range Catalog() {
if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
return e.References, true
}
}
return nil, false
}
// 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", "grid4",
"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)
names []nameCode // (uppercased name → code) for MatchBy="description"
}
// nameCode pairs a reference's uppercased description with its code, for
// description-based matching (e.g. WAJA finding a prefecture NAME in the QTH).
type nameCode struct{ name, code string }
// 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.
// compileAwardRE compiles an award / reference regex. Award patterns are run
// over free-text log fields (QTH, NOTES) whose capitalization is arbitrary — a
// log may hold "Tokyo", "TOKYO" or "TOKIO" — so we match case-insensitively by
// default. An author who set their own flag group (e.g. "(?s)") is respected.
func compileAwardRE(p string) (*regexp.Regexp, error) {
p = strings.TrimSpace(p)
if p == "" {
return nil, errors.New("empty pattern")
}
if !strings.HasPrefix(p, "(?") {
p = "(?i)" + p
}
return regexp.Compile(p)
}
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 := compileAwardRE(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)
if nm := strings.ToUpper(strings.TrimSpace(m.Name)); nm != "" {
rl.names = append(rl.names, nameCode{name: nm, code: 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 := compileAwardRE(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]})
}
// Never return nil slices: they marshal to JSON null, and the UI calls
// .filter/.length on them (an award with nothing worked yet — e.g. a
// freshly-created WWFF/WAJA — would otherwise white-screen the panel).
if r.Refs == nil {
r.Refs = []Ref{}
}
if r.Bands == nil {
r.Bands = []BandCount{}
}
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 := compileAwardRE(p); err == nil {
re = c
} else {
return nil
}
}
rl := NewRefList(metas)
return candidates(&d, re, q, rl, len(metas) > 0)
}
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
// comma list). Honoured by MatchQSO regardless of how the award matches.
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"
// manualRefs returns the reference codes the operator assigned to award `code`
// on this QSO (from the ManualRefsKey extra).
func manualRefs(q *qso.QSO, code string) []string {
if q == nil || q.Extras == nil {
return nil
}
raw := strings.TrimSpace(q.Extras[ManualRefsKey])
if raw == "" {
return nil
}
code = strings.ToUpper(strings.TrimSpace(code))
var out []string
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
at := strings.IndexByte(entry, '@')
if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
continue
}
for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
if r = strings.TrimSpace(r); r != "" {
out = append(out, r)
}
}
}
return out
}
// 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) }
// InScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission, dates) — independent of whether a reference was
// found. Used to surface "in scope but no reference" gaps (e.g. a French QSO
// missing its department for DDFM).
func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
// 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.
// searchOne runs one matching rule (the primary or an OR rule) over a QSO and
// returns the reference codes it finds, each prefixed with `prefix` (so a
// captured "74" becomes "D74"). predefined enables list-aware matching.
func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing))
if raw == "" {
return nil
}
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
var found []string
switch {
case re != nil:
// Award-level regex: capture group 1 (or whole match) for each hit.
found = regexTokens(re, raw)
case byDesc:
// Match references by their DESCRIPTION/name appearing in the field
// (e.g. WAJA finds the prefecture name inside the QTH). ExactMatch means
// the field equals the name; otherwise the name is a substring of it.
up := strings.ToUpper(raw)
for _, nc := range rl.names {
if exact {
if up == nc.name {
found = append(found, nc.code)
}
} else if strings.Contains(up, nc.name) {
found = append(found, nc.code)
}
}
// A reference may also declare its own regex to broaden matching beyond
// its plain name (e.g. Tokyo's `\bTok[iy]o\b` also catches "TOKIO"). Test
// those too — the name match and the pattern are OR'd.
for _, code := range rl.withPattern {
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
found = append(found, code)
}
}
case predefined && !exact:
// "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 prefix != "" {
for i := range found {
found[i] = prefix + found[i]
}
}
return found
}
// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
//
// An award that silently matches nothing is the hardest kind of bug to see: the
// UI shows an empty column and the operator has no way to tell whether the QSO is
// out of scope, the field is empty, the rule looked in the wrong place, or the
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
// reports every step it took.
// Rejected is a candidate a rule produced that did not survive.
type Rejected struct {
Candidate string `json:"candidate"`
Reason string `json:"reason"`
}
// Step is one matching rule as it actually ran.
type Step struct {
Rule string `json:"rule"` // "primary", "OR 1", …
Field string `json:"field"` // the QSO field it scanned
MatchBy string `json:"match_by,omitempty"` // code | description | pattern
Exact bool `json:"exact,omitempty"` // whole field IS the reference
Pattern string `json:"pattern,omitempty"` // the rule's regex, if any
FieldValue string `json:"field_value,omitempty"` // what the field actually held
Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation
Kept []string `json:"kept,omitempty"` // what survived the reference list
Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason
Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched
Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule
}
// Explanation is the full account of one QSO against one award.
type Explanation struct {
Code string `json:"code"`
InScope bool `json:"in_scope"`
ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope
Predefined bool `json:"predefined"` // matches are validated against a reference list
RefCount int `json:"ref_count"` // size of that list
Steps []Step `json:"steps"`
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
Result []string `json:"result"` // what the QSO finally counts for
}
// Explain runs the matcher on a single QSO and reports what it did. It goes
// through the SAME code path as Compute — not a re-implementation — so what it
// shows is what actually happens.
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
rl := NewRefList(metas)
ex.Predefined = len(metas) > 0 && !d.Dynamic
ex.RefCount = len(metas)
var why string
if !inScopeWhy(&d, q, &why) {
ex.ScopeError = why
return ex // out of scope: no rule ever runs, and saying so IS the answer
}
ex.InScope = true
var re *regexp.Regexp
if p := strings.TrimSpace(d.Pattern); p != "" {
c, err := compileAwardRE(p)
if err != nil {
ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
Error: "bad regex: " + err.Error()})
return ex
}
re = c
}
candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
return ex
}
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
return candidatesTrace(d, re, q, rl, hasList, nil)
}
// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
// passes it) each rule records what it scanned, what it produced and what was
// rejected. The tracing MUST live inside the real matcher rather than in a
// parallel "explain" implementation — a trace that can drift from the code it
// describes is worse than no trace, because it is believed.
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
predefined := hasList && !d.Dynamic
// run executes one rule and, when tracing, records it.
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
kept := keepRefs(predefined, rl, raw)
if ex != nil {
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
Candidates: raw, Kept: kept}
keptSet := map[string]struct{}{}
for _, k := range kept {
keptSet[k] = struct{}{}
}
for _, c := range raw {
n := normalizeRef(c)
if _, ok := keptSet[n]; ok {
continue
}
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
}
ex.Steps = append(ex.Steps, s)
}
return kept
}
// Primary search first; the OR rules are ordered FALLBACKS — try the next
// only while nothing has matched yet, and stop at the first that yields a
// reference (short-circuit). So a province already found by NAME isn't also
// re-derived, possibly differently, from a later city-regex rule.
//
// The short-circuit tests what a rule REALLY yielded — the references that
// survive the predefined list — not its raw candidates. A rule can always
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
// Testing the raw candidate would call that a hit, skip every fallback, and
// only then drop it as unlisted — leaving the QSO unmatched even though the
// next rule ("find the code inside the QTH") would have found BG.
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
for i := range d.OrRules {
r := &d.OrRules[i]
label := fmt.Sprintf("OR %d", i+1)
if len(found) > 0 {
if ex != nil {
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
Pattern: r.Pattern, Skipped: true})
}
continue // an earlier rule already matched — fallbacks short-circuit
}
var rre *regexp.Regexp
if p := strings.TrimSpace(r.Pattern); p != "" {
c, err := compileAwardRE(p)
if err != nil {
if ex != nil {
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
Error: "bad regex: " + err.Error()})
}
continue // skip a rule with a bad regex rather than failing the award
}
rre = c
}
found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
}
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
// the operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so
// a contact whose address doesn't spell it out needs the province picked by
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
// awards panel and the per-QSO refs editor — honours overrides too. For a
// predefined award the ref is still validated against the list below.
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
if ex != nil {
ex.Manual = manual
}
found = append(found, manual...)
out := dedupe(found)
if ex != nil {
ex.Result = out
}
return out
}
// rejection explains why a candidate the operator can SEE in the trace did not
// become a reference. "Nothing matched" is the least useful thing a matcher can
// say; every one of this week's award bugs was a rejection with a plain reason
// that nothing was printing.
func rejection(predefined bool, rl refList, code string) Rejected {
switch {
case code == "":
return Rejected{Candidate: code, Reason: "empty"}
case !predefined:
return Rejected{Candidate: code, Reason: "duplicate"}
}
m, ok := rl.byCode[code]
if !ok {
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
}
if !m.Valid {
return Rejected{Candidate: code, Reason: "listed but disabled"}
}
return Rejected{Candidate: code, Reason: "duplicate"}
}
// keepRefs reduces a rule's raw candidates to the references that actually count.
// For a predefined award that means the codes on the list, enabled. 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.
func keepRefs(predefined bool, rl refList, found []string) []string {
if !predefined {
out := make([]string, 0, len(found))
for _, c := range found {
if c = normalizeRef(c); c != "" {
out = append(out, c)
}
}
return dedupe(out)
}
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 { return inScopeWhy(d, q, nil) }
// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
// which runs this for every QSO × every award, passes nil and pays nothing).
// Keeping both behind one function is the point: a scope check that disagrees with
// the scope check it explains would be worse than no explanation at all.
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
fail := func(format string, args ...any) bool {
if why != nil {
*why = fmt.Sprintf(format, args...)
}
return false
}
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
}
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
}
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
}
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
}
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
}
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
}
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 "grid4":
// VUCC: distinct 4-character grid squares. A QSO on a grid line carries
// several in VUCC_GRIDS; otherwise the 4-char prefix of GRIDSQUARE. The
// comma-joined result is split into one reference per square downstream.
return grid4Refs(q)
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 ""
}
// grid4Refs returns the distinct 4-character grid squares a QSO contributes —
// from VUCC_GRIDS (a comma list when the contact straddles grid lines) if set,
// else the 4-char prefix of GRIDSQUARE. Joined with commas so the matcher
// counts each square separately (VUCC awards).
func grid4Refs(q *qso.QSO) string {
src := strings.TrimSpace(q.VUCCGrids)
if src == "" {
src = strings.TrimSpace(q.Grid)
}
if src == "" {
return ""
}
seen := map[string]struct{}{}
var out []string
for _, tok := range strings.FieldsFunc(src, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
g := strings.ToUpper(strings.TrimSpace(tok))
if len(g) > 4 {
g = g[:4]
}
if g == "" {
continue
}
if _, ok := seen[g]; ok {
continue
}
seen[g] = struct{}{}
out = append(out, g)
}
return strings.Join(out, ",")
}
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
}