// 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" "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"` // 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 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 // 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 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 } func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string { predefined := hasList && !d.Dynamic // 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 := keepRefs(predefined, rl, searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)) for i := 0; len(found) == 0 && i < len(d.OrRules); i++ { r := &d.OrRules[i] var rre *regexp.Regexp if p := strings.TrimSpace(r.Pattern); p != "" { c, err := compileAwardRE(p) if err != nil { continue // skip a rule with a bad regex rather than failing the award } rre = c } found = keepRefs(predefined, rl, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)) } // 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. found = append(found, keepRefs(predefined, rl, manualRefs(q, d.Code))...) return dedupe(found) } // 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 { 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 "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 }