feat: day batch — upload results surfaced as toasts (silent LoTW failures from the right-click send looked like nothing was sent); live selection count + Select all/Unselect all toggle in the grid toolbar; active filters bypass the row limit (all matches shown, 10k safety cap); NET Control: same right-click menu on worked-before + drag&drop roster<->on-air (drop on roster logs the QSO); compact mode restores previous window size/position; advanced-filter field renamed Station callsign; optional DXCC-style digital-mode grouping (Settings->General) for matrix badges + cluster colouring; changelog 0.20.9 entries (version NOT bumped)

This commit is contained in:
2026-07-22 15:38:08 +02:00
parent 5aac28f564
commit 4c75680689
10 changed files with 284 additions and 39 deletions
+73 -5
View File
@@ -530,6 +530,13 @@ type App struct {
// must be allowed through.
shuttingDown bool
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
// Geometry captured when ENTERING compact mode, restored on the way back —
// otherwise leaving compact snapped the window to a hardcoded 1400×900 at
// wherever the compact strip had been dragged.
preCompactW, preCompactH int
preCompactX, preCompactY int
preCompactMax bool
preCompactValid bool
// Cached operator location used to compute distance/bearing for
// cluster spots. Refreshed on profile activation; zero means
@@ -1811,6 +1818,17 @@ func (a *App) ResetDatabaseToDefault() error {
// widths, sort…) in the DB settings table under a "ui." namespace, so they
// travel with the logbook and survive a reinstall — unlike the WebView's
// localStorage. Values are opaque JSON blobs owned by the frontend.
// groupDigitalSlots reports the "digital modes count as one" preference
// (Settings → General; stored as a portable ui.* pref so the frontend matrix
// and this backend cluster logic read the same switch).
func (a *App) groupDigitalSlots() bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, "ui.opslog.groupDigitalSlots")
return v == "1"
}
func (a *App) GetUIPref(key string) (string, error) {
if a.settings == nil {
return "", nil
@@ -5204,6 +5222,19 @@ func (a *App) SetCompactMode(on bool) {
if a.ctx == nil {
return
}
if on && !a.compact {
// Capture the normal geometry BEFORE shrinking, so leaving compact puts
// the window back exactly where and how big it was.
a.preCompactMax = wruntime.WindowIsMaximised(a.ctx)
if a.preCompactMax {
// Unmaximise first: the size/position of a maximised window are the
// monitor's, not the user's chosen geometry.
wruntime.WindowUnmaximise(a.ctx)
}
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH
}
a.compact = on
if on {
// Lock the window to the compact size by pinning min == max. Without
@@ -5218,7 +5249,16 @@ func (a *App) SetCompactMode(on bool) {
// Release the lock first (raise the max) before growing back.
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
wruntime.WindowSetSize(a.ctx, normalW, normalH)
if a.preCompactValid {
wruntime.WindowSetSize(a.ctx, a.preCompactW, a.preCompactH)
wruntime.WindowSetPosition(a.ctx, a.preCompactX, a.preCompactY)
if a.preCompactMax {
wruntime.WindowMaximise(a.ctx)
}
} else {
// Nothing captured (compact since launch) — fall back to the default.
wruntime.WindowSetSize(a.ctx, normalW, normalH)
}
}
}
@@ -8160,6 +8200,12 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
msg = err.Error()
}
emit("LoTW upload failed: " + msg)
// The qslmgr:log console is only visible in the QSL Manager — a failure
// triggered from the Recent QSOs right-click was completely silent, which
// read as "send to LoTW does nothing". Surface it as a toast too.
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", "LoTW upload failed: "+msg)
}
} else {
for _, id := range ids {
a.markExtUploaded(svc, id, "")
@@ -8314,6 +8360,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
// Always end with a visible summary — the right-click "Send to …" runs in
// the background and the per-QSO log lives in the QSL Manager console.
label := map[extsvc.Service]string{
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
}[svc]
if label == "" {
label = string(svc)
}
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%s: %d/%d QSO uploaded", label, uploaded, len(ids)))
}
}
@@ -13065,7 +13121,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
}
return 0
}
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor)
// Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/
// RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was
// worked shows "worked", not "new-slot" — DXCC-style mode classes.
var normMode func(string) string
if a.groupDigitalSlots() {
normMode = qso.GroupDigitalMode
}
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor, normMode)
if err != nil {
return out
}
@@ -13133,12 +13196,17 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
}
// Band already worked. If this MODE was never worked on the entity (any
// band) → new-mode. If the mode was worked elsewhere but not on THIS
// band+mode → new-slot. Otherwise → worked.
if _, m := e.Modes[out[i].Mode]; !m {
// band+mode → new-slot. Otherwise → worked. The check mode goes through
// the same normaliser as the slot map (digital grouping).
checkMode := out[i].Mode
if normMode != nil {
checkMode = normMode(checkMode)
}
if _, m := e.Modes[checkMode]; !m {
out[i].Status = "new-mode"
continue
}
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
if _, ok := e.Slots[out[i].Band][checkMode]; !ok {
out[i].Status = "new-slot"
continue
}