chore: release v0.26.2

This commit is contained in:
2026-08-21 18:21:33 +02:00
parent c49faf9a14
commit 0cd243fe00
28 changed files with 1600 additions and 345 deletions
+304 -2
View File
@@ -55,6 +55,7 @@ import (
"hamlog/internal/psu"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/rda"
"hamlog/internal/relaydev"
"hamlog/internal/rigctld"
"hamlog/internal/rotator/dcu1"
@@ -1745,6 +1746,11 @@ func (a *App) shutdown(ctx context.Context) {
if !a.shuttingDown {
a.maybeShutdownBackup()
}
// Before the hardware and the databases: a program asked to close with
// OpsLog should get its request while everything is still up, not after the
// logger has spent seconds tearing down ports.
applog.Printf("shutdown: closing autostart programs")
a.CloseAutostartPrograms()
applog.Printf("shutdown: stopping UDP")
if a.udp != nil {
a.udp.StopAll()
@@ -3635,7 +3641,8 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
markUserEdited(defs, a.awardDefs())
prev := a.awardDefs()
markUserEdited(defs, prev)
b, err := json.Marshal(defs)
if err != nil {
return err
@@ -3644,10 +3651,55 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
return err
}
go a.mirrorAwardsToFolder(defs)
// A change that only alters HOW a match is written — reference, description
// or both in the grid column — cannot change WHICH QSOs match. Recomputing
// every award for every contact then produces a known answer at the cost of
// reading the whole logbook, and the operator waits minutes to see a column
// they expected to change at once.
if codes, ok := relabelOnly(prev, defs); ok {
a.recomputeAwardRefsForCodesAsync(codes)
return nil
}
a.recomputeAwardRefsAsync() // definitions changed → refresh every row's award_refs
return nil
}
// relabelOnly reports whether the only difference between two definition sets is
// the RefDisplay of some awards, and names them.
//
// Conservative by construction: it compares the two definitions with RefDisplay
// blanked out, so ANY other difference — a new field, a pattern, a band, an
// award added or removed — fails the test and the full recompute runs. A wrong
// "yes" here would leave stale references in the log, which is far worse than a
// slow save.
func relabelOnly(prev, next []award.Def) ([]string, bool) {
if len(prev) != len(next) {
return nil, false
}
byCode := map[string]award.Def{}
for _, d := range prev {
byCode[strings.ToUpper(strings.TrimSpace(d.Code))] = d
}
var codes []string
for _, n := range next {
o, ok := byCode[strings.ToUpper(strings.TrimSpace(n.Code))]
if !ok {
return nil, false
}
if n.RefDisplay != o.RefDisplay {
codes = append(codes, strings.ToUpper(strings.TrimSpace(n.Code)))
}
a, b := n, o
a.RefDisplay, b.RefDisplay = "", ""
ja, err1 := json.Marshal(a)
jb, err2 := json.Marshal(b)
if err1 != nil || err2 != nil || string(ja) != string(jb) {
return nil, false
}
}
return codes, len(codes) > 0
}
// mirrorAwards refreshes the awards folder from the database.
//
// It is called from EVERY path that can change an award — the definitions AND the
@@ -5103,8 +5155,13 @@ func (a *App) RecomputeAllAwardRefs() (int, error) {
ac := a.newAwardMatCtx()
// Collect updates during the scan; DON'T write mid-iteration (a nested query on
// the same connection can deadlock SQLite / trip MySQL "commands out of sync").
//
// The LEAN projection: award computation reads twenty-seven columns and the
// full record has a hundred and fifty. It used to take the whole row only
// because it needed award_refs to compare against, which the projection now
// carries — on a remote MySQL that was most of the wait.
changes := map[int64]string{}
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
changes[q.ID] = js
}
@@ -5119,6 +5176,96 @@ func (a *App) RecomputeAllAwardRefs() (int, error) {
return len(changes), nil
}
// RecomputeAwardRefsForCode refreshes the stored label of ONE award, on the rows
// that already carry it.
//
// For a change that cannot alter which QSOs match — switching the grid column
// between the reference, its description and both — recomputing every award for
// every QSO is work with a known answer. This does the only part that can
// differ: it recomputes that one award for the rows whose award_refs already
// mention it, and merges the result into their stored object, leaving every
// other award's label exactly as it was.
func (a *App) RecomputeAwardRefsForCode(code string) (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return 0, nil
}
// One definition, so award.Compute does not re-run twenty awards per row.
full := a.newAwardMatCtx()
one := full
one.defs = nil
for _, d := range full.defs {
if strings.EqualFold(d.Code, code) {
one.defs = []award.Def{d}
break
}
}
if len(one.defs) == 0 {
return 0, nil // award gone — a full recompute will clear the stale label
}
changes := map[int64]string{}
err := a.qso.IterateForAwardsWithRef(a.ctx, code, func(q qso.QSO) error {
var stored map[string]string
if strings.TrimSpace(q.AwardRefs) != "" {
if err := json.Unmarshal([]byte(q.AwardRefs), &stored); err != nil {
return nil // unreadable value — leave it for the full recompute
}
}
if stored == nil {
stored = map[string]string{}
}
fresh := a.awardRefLabels(one, q)
if fresh[code] == stored[code] {
return nil
}
if lbl := fresh[code]; lbl != "" {
stored[code] = lbl
} else {
delete(stored, code)
}
if len(stored) == 0 {
changes[q.ID] = ""
return nil
}
b, err := json.Marshal(stored)
if err != nil {
return nil
}
changes[q.ID] = string(b)
return nil
})
if err != nil {
return 0, err
}
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
return 0, err
}
return len(changes), nil
}
// recomputeAwardRefsForCodesAsync relabels a few awards and tells the frontend,
// the cheap counterpart of recomputeAwardRefsAsync.
func (a *App) recomputeAwardRefsForCodesAsync(codes []string) {
go func() {
total := 0
for _, c := range codes {
n, err := a.RecomputeAwardRefsForCode(c)
if err != nil {
applog.Printf("award_refs: relabel of %s failed: %v", c, err)
continue
}
applog.Printf("award_refs: %s relabelled on %d QSO(s)", c, n)
total += n
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "awards:recomputed", total)
}
}()
}
// recomputeAwardRefsAsync runs a full recompute off the UI goroutine and, when
// done, tells the frontend to reload so the refreshed award columns show. Used
// wherever the set of matches could shift for MANY rows at once: an award
@@ -7519,6 +7666,19 @@ func (a *App) lookupCallsign(callsign string, force bool, qsoDate string) (looku
if r.IOTA == "" && r.DXCC != 0 {
r.IOTA = awardref.IOTAForDXCC(r.DXCC)
}
// The Russian district, from the offline database, for the DAY of the QSO.
//
// It overrides what a callbook said rather than filling a blank, which is the
// opposite of every other enrichment here — and deliberately. HamQTH carries
// the district a station operates from TODAY and nothing more; this database
// carries dated activity records, so on a contact from an expedition or from
// a station that has since moved it is right where the callbook is wrong.
// For everyone else the two agree.
if when := lookupWhen(qsoDate); r.DXCC == 15 || r.DXCC == 54 || r.DXCC == 126 {
if m, ok := rda.Lookup(callsign, when); ok {
r.RDA = m.District
}
}
// Custom outbound rows bound to a lookup. After the enrichment, so a
// template sees the same grid and county the entry panel is about to show —
// two answers for one callsign is how a rotator ends up pointed elsewhere
@@ -11492,6 +11652,100 @@ func (a *App) BackfillUSCounties() (BackfillUSCountiesResult, error) {
return res, nil
}
// BackfillRDAResult reports what a district backfill did.
type BackfillRDAResult struct {
Scanned int `json:"scanned"` // Russian QSOs examined
Dated int `json:"dated"` // filled from an activity record covering the QSO day
Current int `json:"current"` // filled from the callsign's current district (no history)
Unknown int `json:"unknown"` // callsign not in the database, or no record for that day
Skipped int `json:"skipped"` // already carried an RDA reference — never overwritten
}
// BackfillRDA stamps the Russian District on existing QSOs from the offline RDA
// database, which is the one source that knows WHERE a callsign was on a GIVEN
// DAY rather than only where it is now.
//
// useCurrent decides what happens for the 43 294 callsigns the database records
// with a district and no history at all. With it off, only a dated activity
// record covering the QSO's own day is written — every stamp is then a fact.
// With it on, a callsign the database knows of no move for also gets its
// district; that is true for the great majority of stations and an assumption
// for the rest, which is why it is the operator's decision and not ours.
//
// An existing reference is never overwritten. A district assigned by hand, or
// read from the far end at the time, outranks any database.
func (a *App) BackfillRDA(useCurrent bool) (BackfillRDAResult, error) {
var res BackfillRDAResult
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
if err != nil {
return res, err
}
for i := range rows {
q := &rows[i]
// The three entities the award covers. Filtering on DXCC rather than on
// the callsign shape keeps a Russian station worked under a foreign
// prefix out, which is right: the database files them by callsign and a
// non-Russian one is not in it.
if q.DXCC == nil {
continue
}
switch *q.DXCC {
case 15, 54, 126:
default:
continue
}
res.Scanned++
if strings.TrimSpace(manualRefFor(q.Extras[award.ManualRefsKey], "RDA")) != "" {
res.Skipped++
continue
}
m, ok := rda.Lookup(q.Callsign, q.QSODate)
if !ok || (!m.Dated && !useCurrent) {
res.Unknown++
continue
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras[award.ManualRefsKey] = setOverrideRef(q.Extras[award.ManualRefsKey], "RDA", m.District)
if err := a.qso.Update(a.ctx, *q); err != nil {
applog.Printf("rda backfill: update qso %d failed: %v", q.ID, err)
continue
}
if m.Dated {
res.Dated++
} else {
res.Current++
}
}
applog.Printf("rda backfill: %d Russian QSOs — %d dated, %d current, %d unknown, %d already set",
res.Scanned, res.Dated, res.Current, res.Unknown, res.Skipped)
if res.Dated+res.Current > 0 {
a.invalidateAwardStats()
}
return res, nil
}
// RDADatabaseCount is how many callsigns the embedded district database holds,
// so the panel offering the backfill can say what it is about to use.
func (a *App) RDADatabaseCount() int { return rda.Count() }
// manualRefFor returns the reference already assigned to one award code in a
// "CODE@REF;CODE@REF" override string, or "".
func manualRefFor(existing, code string) string {
for _, entry := range strings.Split(existing, ";") {
entry = strings.TrimSpace(entry)
at := strings.IndexByte(entry, '@')
if at > 0 && strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
return strings.TrimSpace(entry[at+1:])
}
}
return ""
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same
@@ -14490,6 +14744,33 @@ func catLinkSig(s CATSettings) string {
}, "|")
}
// catShareKeyed reports whether the running share server is holding the rig
// keyed for a client.
func (a *App) catShareKeyed() bool {
if a.catShare != nil && a.catShare.Keyed() {
return true
}
return a.catShareTCI != nil && a.catShareTCI.Keyed()
}
// reloadCATShareWhenUnkeyed waits for the transmission to end, then applies the
// change. Bounded, because a rig left keyed by a client that went away must not
// postpone the operator's setting for ever — after a minute the change wins and
// the emergency unkey in Stop() covers the radio.
func (a *App) reloadCATShareWhenUnkeyed(s CATSettings) {
for i := 0; i < 120; i++ { // 120 × 500 ms = 1 minute
time.Sleep(500 * time.Millisecond)
if !a.catShareKeyed() {
break
}
}
// The signature was already recorded by the caller, so clear it: this IS the
// rebuild it stood for, and leaving it set would make the call below a no-op.
a.catShareSig = ""
applog.Printf("cat share: transmission over — applying the deferred rebuild")
a.reloadCATShare(s)
}
// catShareSig does the same for the shared-CAT server: which protocol, which
// port, on or off. Restarting it is what actually disconnects WSJT-X.
func catShareSig(s CATSettings) string {
@@ -14939,6 +15220,13 @@ func (a *App) ActivateProfile(id int64) error {
// stays as-is (the operator connects it explicitly). The frontend reloads its
// panels via profile:changed.
func (a *App) reloadAfterProfileSwitch() {
// Logged because this rebuilds EVERY settings-dependent subsystem, and a
// shared operator's log showed it running 54 times in one session — each one
// dropping WSJT-X's rigctl connection, three of them while the rig was
// keyed. The teardown itself is now guarded (see catLinkSig), but a re-apply
// arriving every few minutes still means something is asking for it, and
// nothing in the log said so.
applog.Printf("profile: re-applying every subsystem for the active profile")
a.reloadLookupProviders()
if a.extsvc != nil {
a.extsvc.SetConfig(a.loadExternalServices())
@@ -19181,6 +19469,20 @@ func (a *App) reloadCATShare(s CATSettings) {
} else {
a.catShareSig = sig
}
// NEVER while the rig is transmitting.
//
// Everything above decides that the server genuinely has to be rebuilt; this
// decides WHEN. Closing the socket mid-over is the one moment it costs
// something: WSJT-X is inside a run of Hamlib calls — VFO, split, PTT, which
// with Fake It all land at the start of a transmission — and an operator's
// log showed it happening three times in a session, followed by wsjtx.exe
// dying on a null pointer. The transmission is seconds long; the new port
// can wait for it.
if a.catShareKeyed() {
applog.Printf("cat share: rebuild deferred — the rig is transmitting")
go a.reloadCATShareWhenUnkeyed(s)
return
}
// Always tear down first: the port — or the protocol — may have changed, and
// a listener bound to the old one would keep answering while the client is
// told to use the new.