award
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/award"
|
||||
"hamlog/internal/awardref"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/pota"
|
||||
"hamlog/internal/db"
|
||||
@@ -91,7 +92,9 @@ const (
|
||||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||||
|
||||
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
|
||||
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
|
||||
keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp
|
||||
keyAwardRefsSeeded = "awards.refs.seeded" // "1" once built-in lists were seeded
|
||||
|
||||
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
||||
|
||||
@@ -329,6 +332,7 @@ type App struct {
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
pota *pota.Cache
|
||||
awardRefs *awardref.Repo
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
@@ -515,6 +519,8 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.qso = qso.NewRepo(conn)
|
||||
a.settings = settings.NewStore(conn)
|
||||
a.profiles = profile.NewRepo(conn)
|
||||
a.awardRefs = awardref.NewRepo(conn)
|
||||
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
|
||||
a.operating = operating.NewRepo(conn)
|
||||
a.udpRepo = udp.NewRepo(conn)
|
||||
a.udp = udp.NewManager(a.udpRepo)
|
||||
@@ -1114,6 +1120,12 @@ func (a *App) DXCCForCountry(name string) int {
|
||||
return dxcc.EntityDXCC(name)
|
||||
}
|
||||
|
||||
// DXCCName returns a display name for a DXCC entity number (or "" if unknown).
|
||||
// Used by the award editor to label the DXCC-filter chips.
|
||||
func (a *App) DXCCName(n int) string {
|
||||
return dxcc.NameForDXCC(n)
|
||||
}
|
||||
|
||||
// ComputeStationInfo resolves a station's structured metadata from the
|
||||
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
|
||||
// frontend calls this whenever Callsign or Grid changes in the Station
|
||||
@@ -1367,7 +1379,357 @@ func (a *App) GetAwards() ([]award.Result, error) {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return award.Compute(a.awardDefs(), all, nameOf), nil
|
||||
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
|
||||
// list can be huge. Enrich them after the fact: real Total from the stored
|
||||
// count, and reference names for the worked references only.
|
||||
if a.awardRefs != nil {
|
||||
counts, _ := a.awardRefs.Counts(a.ctx)
|
||||
for i := range results {
|
||||
r := &results[i]
|
||||
if _, predef := refMetas[strings.ToUpper(r.Code)]; predef {
|
||||
continue // predefined awards are already complete (totals + names)
|
||||
}
|
||||
if total := counts[strings.ToUpper(r.Code)]; total > 0 {
|
||||
r.Total = total
|
||||
}
|
||||
codes := make([]string, 0, len(r.Refs))
|
||||
for _, rf := range r.Refs {
|
||||
if rf.Name == "" {
|
||||
codes = append(codes, rf.Ref)
|
||||
}
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
continue
|
||||
}
|
||||
if names, err := a.awardRefs.NamesFor(a.ctx, r.Code, codes); err == nil {
|
||||
for j := range r.Refs {
|
||||
if r.Refs[j].Name == "" {
|
||||
r.Refs[j].Name = names[strings.ToUpper(r.Refs[j].Ref)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, 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.
|
||||
func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta {
|
||||
out := map[string][]award.RefMeta{}
|
||||
if a.awardRefs == nil {
|
||||
return out
|
||||
}
|
||||
for _, d := range defs {
|
||||
if d.Dynamic {
|
||||
continue
|
||||
}
|
||||
code := strings.ToUpper(d.Code)
|
||||
refs, err := a.awardRefs.List(a.ctx, code)
|
||||
if err != nil || len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
metas := make([]award.RefMeta, 0, len(refs))
|
||||
for _, rf := range refs {
|
||||
dxccList := rf.DXCCList
|
||||
if len(dxccList) == 0 && rf.DXCC > 0 {
|
||||
dxccList = []int{rf.DXCC}
|
||||
}
|
||||
metas = append(metas, award.RefMeta{
|
||||
Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp,
|
||||
DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid,
|
||||
})
|
||||
}
|
||||
out[code] = metas
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// QSOAwardRef is one award reference a single QSO contributes to. Pickable
|
||||
// marks awards backed by a reference list (POTA, SOTA, …) — those are assigned
|
||||
// manually; the rest (DXCC, WAZ, WPX, DDFM, …) are computed from QSO fields.
|
||||
type QSOAwardRef struct {
|
||||
Code string `json:"code"`
|
||||
Ref string `json:"ref"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Pickable bool `json:"pickable"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
|
||||
nameOf := func(field, ref string) string {
|
||||
switch field {
|
||||
case "dxcc":
|
||||
if n, err := strconv.Atoi(ref); err == nil {
|
||||
return dxcc.NameForDXCC(n)
|
||||
}
|
||||
case "cont":
|
||||
return continentName(ref)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
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)
|
||||
}
|
||||
var out []QSOAwardRef
|
||||
for i := range results {
|
||||
r := &results[i]
|
||||
pickable := counts[strings.ToUpper(r.Code)] > 0 || awardref.CanUpdate(r.Code)
|
||||
for _, rf := range r.Refs {
|
||||
if !rf.Worked {
|
||||
continue // a single QSO only contributes worked references
|
||||
}
|
||||
out = append(out, QSOAwardRef{Code: r.Code, Ref: rf.Ref, Name: rf.Name, Pickable: pickable})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AwardRefMeta describes a reference list's state for the UI.
|
||||
type AwardRefMeta struct {
|
||||
Code string `json:"code"`
|
||||
Count int `json:"count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CanUpdate bool `json:"can_update"`
|
||||
}
|
||||
|
||||
// GetAwardReferenceMeta returns the reference-list status for every defined
|
||||
// award (count + last update + whether an online updater exists).
|
||||
func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
|
||||
if a.awardRefs == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
counts, err := a.awardRefs.Counts(a.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []AwardRefMeta
|
||||
for _, d := range a.awardDefs() {
|
||||
code := strings.ToUpper(d.Code)
|
||||
updated := ""
|
||||
if a.settings != nil {
|
||||
updated, _ = a.settings.Get(a.ctx, keyAwardRefsUpdated+code)
|
||||
}
|
||||
out = append(out, AwardRefMeta{
|
||||
Code: d.Code,
|
||||
Count: counts[code],
|
||||
UpdatedAt: updated,
|
||||
CanUpdate: awardref.CanUpdate(d.Code),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateAwardReferenceList downloads the latest reference list for an award and
|
||||
// replaces the stored set. Returns the new reference count.
|
||||
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
|
||||
if a.awardRefs == nil {
|
||||
return AwardRefMeta{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if !awardref.CanUpdate(code) {
|
||||
return AwardRefMeta{}, fmt.Errorf("no online reference list for %q", code)
|
||||
}
|
||||
refs, err := awardref.Download(a.ctx, code)
|
||||
if err != nil {
|
||||
return AwardRefMeta{}, err
|
||||
}
|
||||
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
|
||||
if err != nil {
|
||||
return AwardRefMeta{}, err
|
||||
}
|
||||
now := time.Now().Format("2006-01-02 15:04")
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), now)
|
||||
}
|
||||
applog.Printf("award-refs: %s updated — %d references", strings.ToUpper(code), n)
|
||||
return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil
|
||||
}
|
||||
|
||||
// SearchAwardReferences finds references of an award by code/name (for the
|
||||
// per-QSO reference picker). dxcc>0 restricts to one entity.
|
||||
func (a *App) SearchAwardReferences(code, query string, dxcc, limit int) ([]awardref.Ref, error) {
|
||||
if a.awardRefs == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.awardRefs.Search(a.ctx, code, query, dxcc, limit)
|
||||
}
|
||||
|
||||
// ListAwardReferences returns every reference of an award (for the editor).
|
||||
func (a *App) ListAwardReferences(code string) ([]awardref.Ref, error) {
|
||||
if a.awardRefs == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.awardRefs.List(a.ctx, code)
|
||||
}
|
||||
|
||||
// SaveAwardReference inserts or updates a single reference.
|
||||
func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
|
||||
if a.awardRefs == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.awardRefs.Upsert(a.ctx, code, ref)
|
||||
}
|
||||
|
||||
// DeleteAwardReference removes one reference from an award.
|
||||
func (a *App) DeleteAwardReference(code, refCode string) error {
|
||||
if a.awardRefs == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.awardRefs.Delete(a.ctx, code, refCode)
|
||||
}
|
||||
|
||||
// ReplaceAwardReferences atomically replaces an award's whole reference list
|
||||
// (used by paste / CSV import and presets). Returns the new count.
|
||||
func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, error) {
|
||||
if a.awardRefs == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(a.ctx, keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetAwardPresets returns the catalogue of built-in reference lists.
|
||||
func (a *App) GetAwardPresets() []awardref.Preset { return awardref.Presets() }
|
||||
|
||||
// ApplyAwardPreset replaces an award's reference list with a built-in preset.
|
||||
// Returns the new reference count.
|
||||
func (a *App) ApplyAwardPreset(code, presetKey string) (int, error) {
|
||||
p, ok := awardref.PresetByKey(presetKey)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown preset %q", presetKey)
|
||||
}
|
||||
return a.ReplaceAwardReferences(code, p.Refs)
|
||||
}
|
||||
|
||||
// PopulateBuiltinReferences seeds an award's reference list from the built-in
|
||||
// data (DXCC entities, CQ zones, continents, US states, French departments).
|
||||
// Returns the new count; ok=false awards (online / custom) yield an error.
|
||||
func (a *App) PopulateBuiltinReferences(code string) (int, error) {
|
||||
refs, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("no built-in reference list for %q", code)
|
||||
}
|
||||
return a.ReplaceAwardReferences(code, refs)
|
||||
}
|
||||
|
||||
// HasBuiltinReferences reports whether an award code ships a built-in list.
|
||||
func (a *App) HasBuiltinReferences(code string) bool {
|
||||
_, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
|
||||
return ok
|
||||
}
|
||||
|
||||
// seedBuiltinReferences populates the reference lists of built-in awards on
|
||||
// first run (idempotent: only seeds an award that currently has none, and only
|
||||
// once overall, tracked by a settings flag so a user who clears a list is not
|
||||
// overruled on the next launch).
|
||||
func (a *App) seedBuiltinReferences() {
|
||||
if a.awardRefs == nil || a.settings == nil {
|
||||
return
|
||||
}
|
||||
if done, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded); done == "1" {
|
||||
return
|
||||
}
|
||||
counts, err := a.awardRefs.Counts(a.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, d := range a.awardDefs() {
|
||||
code := strings.ToUpper(d.Code)
|
||||
if counts[code] > 0 {
|
||||
continue
|
||||
}
|
||||
if refs, ok := awardref.BuiltinRefs(code); ok {
|
||||
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
|
||||
applog.Printf("award-refs: seeded %s — %d references", code, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = a.settings.Set(a.ctx, keyAwardRefsSeeded, "1")
|
||||
}
|
||||
|
||||
// ImportAwardReferencesText parses pasted lines or CSV into references and
|
||||
// replaces the award's list. Accepted per line (comma/semicolon/tab separated):
|
||||
//
|
||||
// CODE
|
||||
// CODE,Description
|
||||
// CODE,Description,Group
|
||||
// CODE,Description,Group,Subgroup
|
||||
// CODE,Description,Group,Subgroup,DXCC
|
||||
//
|
||||
// A leading header row (first field "code"/"ref"/"reference") is skipped.
|
||||
func (a *App) ImportAwardReferencesText(code, text string) (int, error) {
|
||||
refs := parseRefLines(text)
|
||||
if len(refs) == 0 {
|
||||
return 0, fmt.Errorf("no references found in input")
|
||||
}
|
||||
return a.ReplaceAwardReferences(code, refs)
|
||||
}
|
||||
|
||||
// parseRefLines turns pasted/CSV text into references (best-effort, tolerant of
|
||||
// comma, semicolon or tab delimiters).
|
||||
func parseRefLines(text string) []awardref.Ref {
|
||||
var out []awardref.Ref
|
||||
for i, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var fields []string
|
||||
switch {
|
||||
case strings.Contains(line, "\t"):
|
||||
fields = strings.Split(line, "\t")
|
||||
case strings.Contains(line, ";"):
|
||||
fields = strings.Split(line, ";")
|
||||
default:
|
||||
fields = strings.Split(line, ",")
|
||||
}
|
||||
for j := range fields {
|
||||
fields[j] = strings.TrimSpace(fields[j])
|
||||
}
|
||||
code := strings.ToUpper(fields[0])
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
// Skip a header row.
|
||||
if i == 0 {
|
||||
switch strings.ToLower(fields[0]) {
|
||||
case "code", "ref", "reference", "ref_code":
|
||||
continue
|
||||
}
|
||||
}
|
||||
ref := awardref.Ref{Code: code, Valid: true}
|
||||
if len(fields) > 1 {
|
||||
ref.Name = fields[1]
|
||||
}
|
||||
if len(fields) > 2 {
|
||||
ref.Group = fields[2]
|
||||
}
|
||||
if len(fields) > 3 {
|
||||
ref.SubGrp = fields[3]
|
||||
}
|
||||
if len(fields) > 4 {
|
||||
if n, err := strconv.Atoi(fields[4]); err == nil {
|
||||
ref.DXCC = n
|
||||
}
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func continentName(code string) string {
|
||||
|
||||
Reference in New Issue
Block a user