This commit is contained in:
2026-06-06 00:02:56 +02:00
parent 51d3a734e8
commit 922a185208
10 changed files with 941 additions and 131 deletions
+153 -37
View File
@@ -101,6 +101,48 @@ func Defaults() []Def {
}
}
// 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{
@@ -129,6 +171,7 @@ type Ref struct {
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.
@@ -151,6 +194,7 @@ 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
}
@@ -158,8 +202,9 @@ type refAgg struct {
// 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)
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
@@ -187,6 +232,7 @@ func NewRefList(metas []RefMeta) refList {
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
@@ -245,7 +291,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
for _, ref := range refs {
a := agg[i][ref]
if a == nil {
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}}
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
agg[i][ref] = a
}
if band != "" {
@@ -259,6 +305,9 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
}
if isVal {
a.anyValidated = true
if band != "" {
a.validatedBands[band] = struct{}{}
}
}
}
}
@@ -281,7 +330,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
r.Validated++
}
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
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 {
@@ -303,7 +352,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
if !m.Valid {
continue
}
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}}
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)
}
@@ -317,7 +366,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
return r.Refs[a].Confirmed
}
return r.Refs[a].Ref < r.Refs[b].Ref
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]})
@@ -327,6 +376,32 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
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) {
@@ -355,12 +430,16 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// 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) {
// "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)
}
}
@@ -372,8 +451,11 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
if !predefined {
return dedupe(found)
}
// Enforce the predefined list: keep only listed, valid references whose
// per-reference DXCC scope (if any) includes the QSO's entity.
// 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 {
@@ -382,9 +464,6 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
if !ok || !m.Valid {
continue
}
if len(m.DXCCList) > 0 && !dxccAllowed(q.DXCC, m.DXCCList) {
continue
}
if _, dup := seen[c]; dup {
continue
}
@@ -425,27 +504,28 @@ func dedupe(in []string) []string {
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
// 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
}
}
func isAlnum(b byte) bool {
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
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.
@@ -462,6 +542,42 @@ func stripAffix(s, lead, trail string) string {
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 {