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