// 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, 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", 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}, } } // 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. found = []string{normalizeRef(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)) } 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 }