// Package award computes amateur-radio award progress (worked / confirmed) // directly from the logbook. An award is defined declaratively: a QSO FIELD to // scan plus an optional regular-expression PATTERN that extracts the reference // from that field. With no pattern the whole field value is the reference; with // a pattern, capture group 1 (or the whole match) is the reference and a single // QSO may yield several references (e.g. a Note holding "D74 D73"). // // Examples: // DXCC : field "dxcc" (no pattern) → entity number // WAS : field "state", DXCCFilter [291,110,6] → US state // DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes // WPX : field "prefix" (computed from callsign) package award import ( "regexp" "sort" "strconv" "strings" "hamlog/internal/qso" ) // Def defines one award. type Def struct { Code string `json:"code"` // unique key, e.g. "DXCC" Name string `json:"name"` // friendly name Field string `json:"field"` // QSO field to scan (see fieldRaw) Pattern string `json:"pattern"` // optional Go regexp; group 1 = reference DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any) Confirm []string `json:"confirm"` // accepted confirmations: lotw|qsl|eqsl Total int `json:"total"` // known denominator (0 = unknown) Builtin bool `json:"builtin"` // shipped default (informational) } // Defaults are the built-in awards seeded on first run (then user-editable). func Defaults() []Def { return []Def{ {Code: "DXCC", Name: "DX Century Club", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340, Builtin: true}, {Code: "WAS", Name: "Worked All States", Field: "state", DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Total: 50, Builtin: true}, {Code: "WAZ", Name: "Worked All Zones (CQ)", Field: "cqz", Confirm: []string{"lotw", "qsl"}, Total: 40, Builtin: true}, {Code: "WAC", Name: "Worked All Continents", Field: "cont", Confirm: []string{"lotw", "qsl", "eqsl"}, Total: 6, Builtin: true}, {Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Field: "prefix", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true}, {Code: "DDFM", Name: "Départements Français Métropolitains", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: []string{"lotw", "qsl"}, Total: 96, Builtin: true}, {Code: "IOTA", Name: "Islands On The Air", Field: "iota", Confirm: []string{"qsl"}, Total: 0, Builtin: true}, {Code: "POTA", Name: "Parks On The Air", Field: "pota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true}, {Code: "SOTA", Name: "Summits On The Air", Field: "sota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true}, {Code: "WWFF", Name: "World Wide Flora & Fauna", Field: "wwff", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true}, } } // Fields lists the scannable QSO fields for the award editor. func Fields() []string { return []string{ "dxcc", "cqz", "ituz", "prefix", "callsign", "state", "cont", "country", "grid", "iota", "sota_ref", "pota_ref", "wwff", "name", "qth", "address", "comment", "note", } } // BandCount holds distinct-reference counts on one band. type BandCount struct { Band string `json:"band"` Worked int `json:"worked"` Confirmed int `json:"confirmed"` } // Ref is one reference's status within an award. type Ref struct { Ref string `json:"ref"` Name string `json:"name,omitempty"` Worked bool `json:"worked"` Confirmed bool `json:"confirmed"` Bands []string `json:"bands"` ConfirmedBands []string `json:"confirmed_bands"` } // Result is an award's computed progress. type Result struct { Code string `json:"code"` Name string `json:"name"` Field string `json:"field"` Worked int `json:"worked"` Confirmed int `json:"confirmed"` Total int `json:"total"` Bands []BandCount `json:"bands"` Refs []Ref `json:"refs"` Error string `json:"error,omitempty"` // e.g. bad regexp pattern } // NameResolver optionally maps a (field, ref) pair to a human name. May be nil. type NameResolver func(field, ref string) string type refAgg struct { bands map[string]struct{} confirmedBands map[string]struct{} anyConfirmed bool } // Compute runs every definition over the QSOs in a single pass. func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result { // Pre-compile patterns once per award (not per QSO). res := make([]*regexp.Regexp, len(defs)) perr := make([]string, len(defs)) for i := range defs { if p := strings.TrimSpace(defs[i].Pattern); p != "" { re, err := regexp.Compile(p) if err != nil { perr[i] = "bad pattern: " + err.Error() } else { res[i] = re } } } agg := make([]map[string]*refAgg, len(defs)) for i := range defs { agg[i] = map[string]*refAgg{} } for qi := range qsos { q := &qsos[qi] for i := range defs { d := &defs[i] if perr[i] != "" { continue } if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) { continue } refs := refValues(d, res[i], q) if len(refs) == 0 { continue } band := strings.ToLower(strings.TrimSpace(q.Band)) isConf := confirmed(q, d.Confirm) for _, ref := range refs { a := agg[i][ref] if a == nil { a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}} agg[i][ref] = a } if band != "" { a.bands[band] = struct{}{} } if isConf { a.anyConfirmed = true if band != "" { a.confirmedBands[band] = struct{}{} } } } } } out := make([]Result, len(defs)) for i := range defs { d := &defs[i] r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]} bandWorked := map[string]int{} bandConfirmed := map[string]int{} for ref, a := range agg[i] { r.Worked++ if a.anyConfirmed { r.Confirmed++ } rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)} if nameOf != nil { rf.Name = nameOf(d.Field, ref) } r.Refs = append(r.Refs, rf) for b := range a.bands { bandWorked[b]++ } for b := range a.confirmedBands { bandConfirmed[b]++ } } sort.Slice(r.Refs, func(a, b int) bool { if r.Refs[a].Confirmed != r.Refs[b].Confirmed { return r.Refs[a].Confirmed } return r.Refs[a].Ref < r.Refs[b].Ref }) for _, b := range sortedBands(bandWorked) { r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]}) } out[i] = r } return out } // refValues extracts the reference(s) a QSO contributes to an award. func refValues(d *Def, re *regexp.Regexp, q *qso.QSO) []string { raw := fieldRaw(d.Field, q) if strings.TrimSpace(raw) == "" { return nil } if re == nil { return []string{normalizeRef(raw)} } matches := re.FindAllStringSubmatch(raw, -1) if len(matches) == 0 { return nil } seen := map[string]struct{}{} var out []string for _, m := range matches { ref := m[0] if len(m) > 1 && m[1] != "" { ref = m[1] } ref = normalizeRef(ref) if ref == "" { continue } if _, dup := seen[ref]; dup { continue } seen[ref] = struct{}{} out = append(out, ref) } return out } func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) } // fieldRaw returns the raw string value of a QSO field (computed for numeric / // derived fields). Unknown fields yield "". func fieldRaw(field string, q *qso.QSO) string { switch strings.ToLower(strings.TrimSpace(field)) { case "dxcc": if q.DXCC != nil && *q.DXCC > 0 { return strconv.Itoa(*q.DXCC) } case "cqz": if q.CQZ != nil && *q.CQZ > 0 { return strconv.Itoa(*q.CQZ) } case "ituz": if q.ITUZ != nil && *q.ITUZ > 0 { return strconv.Itoa(*q.ITUZ) } case "prefix": return wpxPrefix(q.Callsign) case "callsign": return q.Callsign case "state": return q.State case "cont": return q.Continent case "country": return q.Country case "grid": return q.Grid case "iota": return q.IOTA case "sota_ref": return q.SOTARef case "pota_ref": return q.POTARef case "name": return q.Name case "qth": return q.QTH case "address": return q.Address case "comment": return q.Comment case "note", "notes": return q.Notes case "wwff": if q.Extras != nil { if v := strings.TrimSpace(q.Extras["WWFF_REF"]); v != "" { return v } if strings.EqualFold(q.Extras["SIG"], "WWFF") { return q.Extras["SIG_INFO"] } } } return "" } func dxccAllowed(dxcc *int, filter []int) bool { if dxcc == nil { return false } for _, f := range filter { if *dxcc == f { return true } } return false } // confirmed reports whether the QSO satisfies any accepted confirmation source. // ADIF *_QSL_RCVD values Y (confirmed) and V (verified) both count. func confirmed(q *qso.QSO, sources []string) bool { for _, s := range sources { switch s { case "lotw": if isYes(q.LOTWRcvd) { return true } case "qsl": if isYes(q.QSLRcvd) { return true } case "eqsl": if isYes(q.EQSLRcvd) { return true } } } return false } func isYes(v string) bool { switch strings.ToUpper(strings.TrimSpace(v)) { case "Y", "V": return true } return false } func setToSorted(m map[string]struct{}) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"} func sortedBands(m map[string]int) []string { idx := map[string]int{} for i, b := range bandOrder { idx[b] = i } out := make([]string, 0, len(m)) for b := range m { out = append(out, b) } sort.Slice(out, func(a, b int) bool { ia, oka := idx[out[a]] ib, okb := idx[out[b]] if oka && okb { return ia < ib } if oka != okb { return oka } return out[a] < out[b] }) return out }