chore: release v0.26.2
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user