up
This commit is contained in:
@@ -520,6 +520,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.settings = settings.NewStore(conn)
|
||||
a.profiles = profile.NewRepo(conn)
|
||||
a.awardRefs = awardref.NewRepo(conn)
|
||||
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
|
||||
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
|
||||
a.operating = operating.NewRepo(conn)
|
||||
a.udpRepo = udp.NewRepo(conn)
|
||||
@@ -1322,7 +1323,9 @@ func (a *App) awardDefs() []award.Def {
|
||||
if s, _ := a.settings.Get(a.ctx, keyAwardDefs); strings.TrimSpace(s) != "" {
|
||||
var defs []award.Def
|
||||
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
|
||||
return defs
|
||||
// Upgrade legacy defs (pre-rich-model) in memory on every load.
|
||||
migrated, _ := award.Migrate(defs)
|
||||
return migrated
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1335,6 +1338,31 @@ func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }
|
||||
// AwardFields lists the scannable QSO fields for the award editor.
|
||||
func (a *App) AwardFields() []string { return award.Fields() }
|
||||
|
||||
// migrateAwardDefs upgrades legacy award definitions in storage once, so the
|
||||
// editor and persisted state reflect the new model (enabled awards + filled
|
||||
// matching/confirmation fields). No-op when there is nothing to migrate.
|
||||
func (a *App) migrateAwardDefs() {
|
||||
if a.settings == nil {
|
||||
return
|
||||
}
|
||||
s, _ := a.settings.Get(a.ctx, keyAwardDefs)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return // nothing saved yet → Defaults() (already on the new model)
|
||||
}
|
||||
var defs []award.Def
|
||||
if json.Unmarshal([]byte(s), &defs) != nil || len(defs) == 0 {
|
||||
return
|
||||
}
|
||||
migrated, changed := award.Migrate(defs)
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
if b, err := json.Marshal(migrated); err == nil {
|
||||
_ = a.settings.Set(a.ctx, keyAwardDefs, string(b))
|
||||
applog.Printf("awards: migrated %d legacy definitions to the new model", len(migrated))
|
||||
}
|
||||
}
|
||||
|
||||
// SaveAwardDefs persists edited award definitions.
|
||||
func (a *App) SaveAwardDefs(defs []award.Def) error {
|
||||
if a.settings == nil {
|
||||
@@ -1356,13 +1384,41 @@ func (a *App) ResetAwardDefs() ([]award.Def, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// GetAwards computes award progress (worked/confirmed) across the whole log.
|
||||
// GetAwards computes progress for EVERY award (whole-log scan). Kept for
|
||||
// callers that need all results at once; the UI now computes one award at a
|
||||
// time via GetAward to stay responsive on large logs.
|
||||
func (a *App) GetAwards() ([]award.Result, error) {
|
||||
return a.computeAwards(a.awardDefs())
|
||||
}
|
||||
|
||||
// GetAward computes progress for a single award by code (one whole-log scan,
|
||||
// matching only that award). This is what the awards UI calls when an award is
|
||||
// selected, so opening the panel doesn't scan every award up front.
|
||||
func (a *App) GetAward(code string) (award.Result, error) {
|
||||
for _, d := range a.awardDefs() {
|
||||
if strings.EqualFold(d.Code, code) {
|
||||
results, err := a.computeAwards([]award.Def{d})
|
||||
if err != nil {
|
||||
return award.Result{}, err
|
||||
}
|
||||
if len(results) > 0 {
|
||||
return results[0], nil
|
||||
}
|
||||
return award.Result{}, nil
|
||||
}
|
||||
}
|
||||
return award.Result{}, fmt.Errorf("unknown award %q", code)
|
||||
}
|
||||
|
||||
// computeAwards runs the engine for the given award definitions over the whole
|
||||
// log and enriches dynamic awards (totals + worked-reference names).
|
||||
func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
|
||||
if a.qso == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
var all []qso.QSO
|
||||
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||||
a.enrichQSOForAwards(&q)
|
||||
all = append(all, q)
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -1379,7 +1435,6 @@ func (a *App) GetAwards() ([]award.Result, error) {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
defs := a.awardDefs()
|
||||
refMetas := a.awardRefMetas(defs)
|
||||
results := award.Compute(defs, all, refMetas, nameOf)
|
||||
// Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their
|
||||
@@ -1416,6 +1471,166 @@ func (a *App) GetAwards() ([]award.Result, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// AwardCellQSOs returns the QSOs that contribute to one award reference,
|
||||
// optionally on a single band (band="" = all bands). Powers the award-grid
|
||||
// cell drill-down ("show me every Canada contact on 20m").
|
||||
func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) {
|
||||
if a.qso == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
defs := a.awardDefs()
|
||||
var def *award.Def
|
||||
for i := range defs {
|
||||
if strings.EqualFold(defs[i].Code, code) {
|
||||
def = &defs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if def == nil {
|
||||
return nil, fmt.Errorf("unknown award %q", code)
|
||||
}
|
||||
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
|
||||
wantRef := strings.ToUpper(strings.TrimSpace(ref))
|
||||
wantBand := strings.ToLower(strings.TrimSpace(band))
|
||||
|
||||
var out []qso.QSO
|
||||
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||||
if wantBand != "" && strings.ToLower(strings.TrimSpace(q.Band)) != wantBand {
|
||||
return nil
|
||||
}
|
||||
a.enrichQSOForAwards(&q)
|
||||
for _, c := range award.MatchQSO(*def, metas, &q) {
|
||||
if strings.ToUpper(c) == wantRef {
|
||||
out = append(out, q)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// AwardStatRow is one row of the award statistics matrix (e.g. "CONFIRMED CW"):
|
||||
// distinct-reference counts per band, plus Total (distinct on any band) and
|
||||
// GrandTotal (sum of the per-band band-slots).
|
||||
type AwardStatRow struct {
|
||||
Label string `json:"label"`
|
||||
Cells []int `json:"cells"`
|
||||
Total int `json:"total"`
|
||||
GrandTotal int `json:"grand_total"`
|
||||
}
|
||||
|
||||
// AwardStatsResult is the statistics matrix for one award (Log4OM "Statistics").
|
||||
type AwardStatsResult struct {
|
||||
Code string `json:"code"`
|
||||
Bands []string `json:"bands"`
|
||||
Rows []AwardStatRow `json:"rows"`
|
||||
}
|
||||
|
||||
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"}
|
||||
|
||||
// GetAwardStats computes the worked/confirmed/validated reference counts of one
|
||||
// award, broken down by band and by mode category (All/CW/Digital/Phone).
|
||||
func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
|
||||
if a.qso == nil {
|
||||
return AwardStatsResult{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
defs := a.awardDefs()
|
||||
var def *award.Def
|
||||
for i := range defs {
|
||||
if strings.EqualFold(defs[i].Code, code) {
|
||||
def = &defs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if def == nil {
|
||||
return AwardStatsResult{}, fmt.Errorf("unknown award %q", code)
|
||||
}
|
||||
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
|
||||
|
||||
bandIdx := make(map[string]int, len(statsBands))
|
||||
for i, b := range statsBands {
|
||||
bandIdx[b] = i
|
||||
}
|
||||
cats := []string{"ALL", "CW", "DIGITAL", "PHONE"}
|
||||
stats := []string{"WORKED", "CONFIRMED", "VALIDATED"}
|
||||
|
||||
// acc[cat][stat]: per-band ref sets + an overall (any-band) ref set.
|
||||
type acc struct {
|
||||
perBand []map[string]struct{}
|
||||
overall map[string]struct{}
|
||||
}
|
||||
accs := map[string]map[string]*acc{}
|
||||
for _, c := range cats {
|
||||
accs[c] = map[string]*acc{}
|
||||
for _, s := range stats {
|
||||
pb := make([]map[string]struct{}, len(statsBands))
|
||||
for i := range pb {
|
||||
pb[i] = map[string]struct{}{}
|
||||
}
|
||||
accs[c][s] = &acc{perBand: pb, overall: map[string]struct{}{}}
|
||||
}
|
||||
}
|
||||
|
||||
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
|
||||
a.enrichQSOForAwards(&q)
|
||||
refs := award.MatchQSO(*def, metas, &q)
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))]
|
||||
isConf := award.Confirmed(&q, def.Confirm)
|
||||
isVal := award.Confirmed(&q, def.Validate)
|
||||
cat := strings.ToUpper(award.EmissionOf(q.Mode))
|
||||
|
||||
record := func(c string) {
|
||||
put := func(stat string) {
|
||||
ac := accs[c][stat]
|
||||
for _, r := range refs {
|
||||
ac.overall[r] = struct{}{}
|
||||
if hasBand {
|
||||
ac.perBand[bi][r] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
put("WORKED")
|
||||
if isConf {
|
||||
put("CONFIRMED")
|
||||
}
|
||||
if isVal {
|
||||
put("VALIDATED")
|
||||
}
|
||||
}
|
||||
record("ALL")
|
||||
if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" {
|
||||
record(cat)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return AwardStatsResult{}, err
|
||||
}
|
||||
|
||||
res := AwardStatsResult{Code: def.Code, Bands: statsBands}
|
||||
for _, c := range cats {
|
||||
for _, s := range stats {
|
||||
ac := accs[c][s]
|
||||
cells := make([]int, len(statsBands))
|
||||
grand := 0
|
||||
for i := range ac.perBand {
|
||||
cells[i] = len(ac.perBand[i])
|
||||
grand += cells[i]
|
||||
}
|
||||
label := s
|
||||
if c != "ALL" {
|
||||
label = s + " " + c
|
||||
}
|
||||
res.Rows = append(res.Rows, AwardStatRow{Label: label, Cells: cells, Total: len(ac.overall), GrandTotal: grand})
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
|
||||
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
|
||||
// are large and not needed for matching; their names are filled afterwards.
|
||||
@@ -1459,6 +1674,85 @@ type QSOAwardRef struct {
|
||||
Pickable bool `json:"pickable"`
|
||||
}
|
||||
|
||||
// enrichQSOForAwards fills in CQ/ITU zone, continent and DXCC entity from
|
||||
// cty.dat when the QSO doesn't carry them, so computed awards (WAZ/WITUZ/WAC/
|
||||
// DXCC) work even on records that were never enriched. Non-destructive: it
|
||||
// mutates the in-memory copy used for award matching only, never the database.
|
||||
func (a *App) enrichQSOForAwards(q *qso.QSO) {
|
||||
if a.dxcc == nil {
|
||||
return
|
||||
}
|
||||
// Recover the band from the frequency when missing, so per-band award
|
||||
// statistics aren't lost for QSOs that carry only a frequency.
|
||||
if strings.TrimSpace(q.Band) == "" && q.FreqHz != nil && *q.FreqHz > 0 {
|
||||
if b := bandForHz(*q.FreqHz); b != "" {
|
||||
q.Band = b
|
||||
}
|
||||
}
|
||||
needCQ := q.CQZ == nil || *q.CQZ == 0
|
||||
needITU := q.ITUZ == nil || *q.ITUZ == 0
|
||||
needCont := strings.TrimSpace(q.Continent) == ""
|
||||
needDXCC := q.DXCC == nil || *q.DXCC == 0
|
||||
if !needCQ && !needITU && !needCont && !needDXCC {
|
||||
return
|
||||
}
|
||||
m, ok := a.dxcc.Lookup(q.Callsign)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if needCQ && m.CQZone > 0 {
|
||||
z := m.CQZone
|
||||
q.CQZ = &z
|
||||
}
|
||||
if needITU && m.ITUZone > 0 {
|
||||
z := m.ITUZone
|
||||
q.ITUZ = &z
|
||||
}
|
||||
if needCont && m.Continent != "" {
|
||||
q.Continent = m.Continent
|
||||
}
|
||||
if needDXCC && m.Entity != nil {
|
||||
if n := dxcc.EntityDXCC(m.Entity.Name); n > 0 {
|
||||
q.DXCC = &n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// awardBandPlan maps a frequency (Hz) to its ADIF band. Used to recover the
|
||||
// band for award statistics when a QSO has a frequency but no band field.
|
||||
var awardBandPlan = []struct {
|
||||
name string
|
||||
lo, hi int64
|
||||
}{
|
||||
{"2190m", 135700, 137800}, {"630m", 472000, 479000}, {"160m", 1800000, 2000000},
|
||||
{"80m", 3500000, 4000000}, {"60m", 5060000, 5450000}, {"40m", 7000000, 7300000},
|
||||
{"30m", 10100000, 10150000}, {"20m", 14000000, 14350000}, {"17m", 18068000, 18168000},
|
||||
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
|
||||
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
|
||||
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
|
||||
{"13cm", 2300000000, 2450000000},
|
||||
}
|
||||
|
||||
func bandForHz(hz int64) string {
|
||||
for _, b := range awardBandPlan {
|
||||
if hz >= b.lo && hz <= b.hi {
|
||||
return b.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isComputedAwardField reports whether an award's field is auto-derived from
|
||||
// structured QSO data (entity, zones, prefix, location) rather than a reference
|
||||
// the operator assigns by hand. Such awards are read-only in the per-QSO editor.
|
||||
func isComputedAwardField(field string) bool {
|
||||
switch field {
|
||||
case "dxcc", "cqz", "ituz", "prefix", "callsign", "state", "cont", "country", "grid":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ComputeQSOAwardRefs returns every award reference a single QSO contributes to
|
||||
// — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for
|
||||
// the per-QSO Award Refs editor. Reuses the same engine as GetAwards.
|
||||
@@ -1474,16 +1768,21 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
a.enrichQSOForAwards(&q)
|
||||
defs := a.awardDefs()
|
||||
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
|
||||
var counts map[string]int
|
||||
if a.awardRefs != nil {
|
||||
counts, _ = a.awardRefs.Counts(a.ctx)
|
||||
fieldByCode := map[string]string{}
|
||||
for _, d := range defs {
|
||||
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||||
}
|
||||
results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
|
||||
var out []QSOAwardRef
|
||||
for i := range results {
|
||||
r := &results[i]
|
||||
pickable := counts[strings.ToUpper(r.Code)] > 0 || awardref.CanUpdate(r.Code)
|
||||
// "Pickable" = the reference is manually assigned per QSO (POTA, notes…),
|
||||
// NOT auto-derived from a structured field. DXCC/WAZ/WAS/WAC/WPX are
|
||||
// computed and belong in the read-only panel even though they now have
|
||||
// reference lists.
|
||||
pickable := !isComputedAwardField(fieldByCode[strings.ToUpper(r.Code)])
|
||||
for _, rf := range r.Refs {
|
||||
if !rf.Worked {
|
||||
continue // a single QSO only contributes worked references
|
||||
|
||||
Reference in New Issue
Block a user