chore: release v0.26.3
This commit is contained in:
+235
@@ -0,0 +1,235 @@
|
||||
package main
|
||||
|
||||
// "Have I worked this reference before?" — the NEW badge on a QSO's award
|
||||
// references.
|
||||
//
|
||||
// # Where the answer comes from, and where it must NOT come from
|
||||
//
|
||||
// The obvious source is the materialised award_refs column: it is already on
|
||||
// every row and one query reads it. It is also the wrong answer twice over.
|
||||
// That column holds DISPLAY LABELS, not references — depending on the award's
|
||||
// RefDisplay it can read "Krasnogvardeysky" or "ST-25 — Krasnogvardeysky", and
|
||||
// DXCC stores a country name — and it is a derived cache that lags: a QSO whose
|
||||
// references were back-filled (see BackfillRDA) counts on the Awards grid long
|
||||
// before anything recomputes its column. The first build of this badge used it
|
||||
// and marked a district worked on five bands as NEW.
|
||||
//
|
||||
// So the answer comes from the same place the Awards grid's answer comes from:
|
||||
// award.MatchQSO over the logbook. What is engineered here is the COST of that,
|
||||
// because the question is asked while the operator is logging:
|
||||
//
|
||||
// - the set is per award CODE, and built only for codes actually asked about;
|
||||
// - it reuses the award snapshot when one is already in memory, which it
|
||||
// usually is (the Awards tab, the cluster status index);
|
||||
// - otherwise it STREAMS the logbook rather than materialising a second copy
|
||||
// of it — the references of one award are a few thousand short strings;
|
||||
// - and it is built in the BACKGROUND. A caller that asks before the set is
|
||||
// ready is told so and gets no badge, never a wait and never a wrong badge.
|
||||
//
|
||||
// A freshly logged QSO's references are folded into the sets, so a normal
|
||||
// evening of logging never rebuilds anything.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/award"
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// workedRefIndex is the per-code set of references already in the log, plus the
|
||||
// logbook revision it describes.
|
||||
type workedRefIndex struct {
|
||||
rev string
|
||||
byCode map[string]map[string]struct{}
|
||||
building map[string]bool
|
||||
}
|
||||
|
||||
// AwardRefNewResult answers one badge request.
|
||||
type AwardRefNewResult struct {
|
||||
// New maps "CODE@REF" to "never worked". An entry whose award set is still
|
||||
// building is ABSENT rather than false: "not worked" and "not known yet"
|
||||
// must not look the same to the badge.
|
||||
New map[string]bool `json:"new"`
|
||||
// Pending is true when at least one award set is still being built, and the
|
||||
// caller should ask again shortly.
|
||||
Pending bool `json:"pending"`
|
||||
}
|
||||
|
||||
// AwardRefsNew reports which of the given "CODE@REF" entries have never been
|
||||
// worked. Returns immediately, always.
|
||||
func (a *App) AwardRefsNew(entries []string) AwardRefNewResult {
|
||||
res := AwardRefNewResult{New: map[string]bool{}}
|
||||
if a.qso == nil || len(entries) == 0 {
|
||||
return res
|
||||
}
|
||||
rev, err := a.qso.Revision(a.ctx)
|
||||
if err != nil {
|
||||
return res // logbook unreachable: no badge is the honest answer
|
||||
}
|
||||
|
||||
// Group the request by award code, so one code is looked up once however
|
||||
// many of its references are on the QSO.
|
||||
want := map[string][]string{}
|
||||
for _, e := range entries {
|
||||
code, _, ok := splitRefEntry(e)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
want[code] = append(want[code], e)
|
||||
}
|
||||
|
||||
a.workedRefMu.Lock()
|
||||
if a.workedRef.rev != rev || a.workedRef.byCode == nil {
|
||||
// The logbook moved on: every set describes a log that no longer exists.
|
||||
a.workedRef = workedRefIndex{rev: rev, byCode: map[string]map[string]struct{}{}, building: map[string]bool{}}
|
||||
}
|
||||
var build []string
|
||||
for code := range want {
|
||||
set, ready := a.workedRef.byCode[code]
|
||||
if !ready {
|
||||
if !a.workedRef.building[code] {
|
||||
a.workedRef.building[code] = true
|
||||
build = append(build, code)
|
||||
}
|
||||
res.Pending = true
|
||||
continue
|
||||
}
|
||||
for _, e := range want[code] {
|
||||
_, ref, _ := splitRefEntry(e)
|
||||
_, worked := set[ref]
|
||||
res.New[e] = !worked
|
||||
}
|
||||
}
|
||||
a.workedRefMu.Unlock()
|
||||
|
||||
for _, code := range build {
|
||||
go a.buildWorkedRefs(code, rev)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// splitRefEntry splits "RDA@ST-25" into its uppercased code and reference.
|
||||
func splitRefEntry(e string) (code, ref string, ok bool) {
|
||||
at := strings.Index(e, "@")
|
||||
if at <= 0 {
|
||||
return "", "", false
|
||||
}
|
||||
code = strings.ToUpper(strings.TrimSpace(e[:at]))
|
||||
ref = strings.ToUpper(strings.TrimSpace(e[at+1:]))
|
||||
return code, ref, code != "" && ref != ""
|
||||
}
|
||||
|
||||
// buildWorkedRefs collects every reference of one award already in the log.
|
||||
func (a *App) buildWorkedRefs(code string, rev string) {
|
||||
set := map[string]struct{}{}
|
||||
defer func() {
|
||||
a.workedRefMu.Lock()
|
||||
// Publish only against the revision we were asked for: a QSO logged while
|
||||
// this ran has already reset the index, and a set built from the older log
|
||||
// would claim to describe the newer one.
|
||||
if a.workedRef.rev == rev && a.workedRef.byCode != nil {
|
||||
a.workedRef.byCode[code] = set
|
||||
}
|
||||
delete(a.workedRef.building, code)
|
||||
a.workedRefMu.Unlock()
|
||||
}()
|
||||
|
||||
var def *award.Def
|
||||
for _, d := range a.awardDefs() {
|
||||
if strings.EqualFold(d.Code, code) {
|
||||
dd := d
|
||||
def = &dd
|
||||
break
|
||||
}
|
||||
}
|
||||
if def == nil {
|
||||
return // unknown award: an empty set, so nothing is ever badged NEW
|
||||
}
|
||||
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
|
||||
collect := func(q *qso.QSO) {
|
||||
for _, r := range award.MatchQSO(*def, metas, q) {
|
||||
set[strings.ToUpper(strings.TrimSpace(r))] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// The snapshot is already enriched and already in memory whenever the Awards
|
||||
// tab has been open — by far the common case, and free.
|
||||
if snap := a.awardSnapshotIfLoaded(); snap != nil {
|
||||
for i := range snap {
|
||||
collect(&snap[i])
|
||||
}
|
||||
return
|
||||
}
|
||||
// Otherwise stream. One pass, nothing retained but the reference strings.
|
||||
_ = a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
|
||||
a.enrichQSOForAwards(&q)
|
||||
collect(&q)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// awardSnapshotIfLoaded returns the award snapshot ONLY if one is already built
|
||||
// and current. Never builds one: the caller is a background convenience, and
|
||||
// reading the whole logbook for it is the cost this file exists to avoid.
|
||||
func (a *App) awardSnapshotIfLoaded() []qso.QSO {
|
||||
rev, err := a.qso.Revision(a.ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
a.awardSnapMu.Lock()
|
||||
defer a.awardSnapMu.Unlock()
|
||||
if a.awardSnap != nil && a.awardSnapRev == rev {
|
||||
return a.awardSnap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// noteWorkedQSO folds a just-logged contact's references into the built sets, so
|
||||
// logging does not throw away work already done.
|
||||
func (a *App) noteWorkedQSO(q qso.QSO) {
|
||||
a.workedRefMu.Lock()
|
||||
codes := make(map[string]bool, len(a.workedRef.byCode))
|
||||
for c := range a.workedRef.byCode {
|
||||
codes[c] = true
|
||||
}
|
||||
a.workedRefMu.Unlock()
|
||||
if len(codes) == 0 {
|
||||
return
|
||||
}
|
||||
rev, err := a.qso.Revision(a.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.enrichQSOForAwards(&q)
|
||||
found := map[string][]string{}
|
||||
for _, d := range a.awardDefs() {
|
||||
code := strings.ToUpper(d.Code)
|
||||
if !codes[code] {
|
||||
continue
|
||||
}
|
||||
metas := a.awardRefMetas([]award.Def{d})[code]
|
||||
for _, r := range award.MatchQSO(d, metas, &q) {
|
||||
found[code] = append(found[code], strings.ToUpper(strings.TrimSpace(r)))
|
||||
}
|
||||
}
|
||||
a.workedRefMu.Lock()
|
||||
defer a.workedRefMu.Unlock()
|
||||
// Carry the sets forward to the new revision rather than dropping them: they
|
||||
// were correct one contact ago, and that contact is what is being added.
|
||||
a.workedRef.rev = rev
|
||||
for code, refs := range found {
|
||||
if set := a.workedRef.byCode[code]; set != nil {
|
||||
for _, r := range refs {
|
||||
set[r] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateWorkedRefs drops every set. For the changes a revision cannot see —
|
||||
// award definitions edited, reference lists updated, QSLs confirmed.
|
||||
func (a *App) invalidateWorkedRefs() {
|
||||
a.workedRefMu.Lock()
|
||||
a.workedRef = workedRefIndex{}
|
||||
a.workedRefMu.Unlock()
|
||||
}
|
||||
Reference in New Issue
Block a user