Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5f79b18c2 | ||
|
|
1605e30f60 | ||
|
|
6aff322be6 | ||
|
|
ed3c132f70 | ||
|
|
385e8c4c86 | ||
|
|
61e5736f1e | ||
|
|
6ca808a6c9 | ||
|
|
f698096f53 | ||
|
|
ef628e066a | ||
|
|
4ffdfc2548 | ||
|
|
83e7727aab | ||
|
|
1348dc2177 | ||
|
|
57ac382f91 | ||
|
|
858c04d267 | ||
|
|
2484cd2515 | ||
|
|
62256942a1 | ||
|
|
3c8aadf41f | ||
|
|
2f5fd63afd | ||
|
|
4f3c3edaee | ||
|
|
7d33379fe1 | ||
|
|
82a49150d2 | ||
|
|
1ef9e553f7 | ||
|
|
39fab68bd4 | ||
|
|
bbddba6e74 | ||
|
|
b659f6f16d | ||
|
|
b4ad12bdc6 | ||
|
|
5dd3532074 | ||
|
|
dd7b63c059 | ||
|
|
8fc6673611 | ||
|
|
d33291b521 | ||
|
|
1b32b1ddec | ||
|
|
65bbaa85f3 | ||
|
|
99d903eb44 | ||
|
|
13515c58c0 | ||
|
|
e4014e11d2 | ||
|
|
025472820b | ||
|
|
8b66030c89 | ||
|
|
d4f23a52af | ||
|
|
b4b9674d8c | ||
|
|
f7d2de0777 | ||
|
|
344e8b2091 | ||
|
|
c5e0ec9033 | ||
|
|
210a99983e | ||
|
|
2a6e09a1d7 | ||
|
|
1b7f8ec9c1 | ||
|
|
75a2f73992 | ||
|
|
deee8c4618 | ||
|
|
92a5f30ac0 | ||
|
|
d3a405f4f6 | ||
|
|
f7b9bfd0bc | ||
|
|
102097c5c4 | ||
|
|
3b90ef6b7a |
+159
@@ -0,0 +1,159 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Row colouring for the log grid, by QSL / LoTW status — the thing Logger32 does
|
||||||
|
// and the reason an operator can tell at a glance what still needs sending.
|
||||||
|
//
|
||||||
|
// Rules are ORDERED and the first match wins, because a contact is usually
|
||||||
|
// several things at once: one confirmed on LoTW and by card is confirmed, not
|
||||||
|
// "sent, awaiting reply". Putting the order in the data rather than in a chain
|
||||||
|
// of ifs is what lets the settings panel show it in the same order it applies.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RowColorRule is one status and the colour it paints.
|
||||||
|
type RowColorRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz.
|
||||||
|
//
|
||||||
|
// Empty means ALL of them — what an operator expects from a rule they have
|
||||||
|
// not narrowed, and what keeps a config written before this field meaningful.
|
||||||
|
Channels []string `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The QSL channels a rule can be scoped to.
|
||||||
|
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"}
|
||||||
|
|
||||||
|
// RowColorSettings is the whole appearance block.
|
||||||
|
type RowColorSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Style is how the colour is shown: "bar" paints a stripe down the left edge,
|
||||||
|
// "tint" washes the row, "both" does each.
|
||||||
|
//
|
||||||
|
// A bar is the default because a filled row is a poor signal in a log where
|
||||||
|
// nearly every contact has SOME QSL state: colour that is always present
|
||||||
|
// stops being information and becomes a striped background, with the data
|
||||||
|
// behind it. The stripe says the same thing and costs nothing to read.
|
||||||
|
Style string `json:"style"`
|
||||||
|
// Intensity is the tint strength in percent. Only used by "tint"/"both".
|
||||||
|
Intensity int `json:"intensity"`
|
||||||
|
// BandMapLotw marks stations that upload to LoTW on the band map, with the
|
||||||
|
// same "L" badge the cluster list uses — one visual vocabulary across the two
|
||||||
|
// views rather than a second invention.
|
||||||
|
BandMapLotw bool `json:"bandmap_lotw"`
|
||||||
|
// Configured distinguishes "saved with this off" from "saved before the
|
||||||
|
// option existed", so a new marker can default ON without silently turning
|
||||||
|
// itself back on for an operator who switched it off.
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
Rules []RowColorRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule ids, in priority order. The frontend matches on these and holds the
|
||||||
|
// labels, so a translated name never has to travel through the settings.
|
||||||
|
// The four categories, in the order they are tested — first match wins.
|
||||||
|
//
|
||||||
|
// "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and
|
||||||
|
// still owe a paper card, and the colour an operator scans for is the one that
|
||||||
|
// means "there is something left to do here". Put after "confirmed", that row
|
||||||
|
// would go green and the card would never be printed.
|
||||||
|
//
|
||||||
|
// "Worked" is the catch-all: a contact with nothing sent, nothing asked for and
|
||||||
|
// nothing back. Off by default — with it on, every remaining row is painted.
|
||||||
|
var rowColorOrder = []string{
|
||||||
|
"to_send", // requested or queued on a watched channel, not gone out
|
||||||
|
"confirmed", // a watched channel has a confirmation back
|
||||||
|
"sent", // gone out on a watched channel, nothing back yet
|
||||||
|
"worked", // none of the above
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults: green for done, amber for waiting, blue for owed. Deliberately
|
||||||
|
// muted — they are composited at low opacity over a dark grid, and a saturated
|
||||||
|
// value there reads as an error state rather than a status.
|
||||||
|
var rowColorDefaults = map[string]string{
|
||||||
|
"to_send": "#a855f7",
|
||||||
|
"confirmed": "#16a34a",
|
||||||
|
"sent": "#f59e0b",
|
||||||
|
"worked": "#64748b",
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexColor guards what reaches the stylesheet. The value is interpolated into a
|
||||||
|
// CSS color-mix() by the grid, so anything that is not plainly a hex colour is
|
||||||
|
// refused rather than passed through.
|
||||||
|
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
|
|
||||||
|
func normRowColors(s RowColorSettings) RowColorSettings {
|
||||||
|
byID := map[string]RowColorRule{}
|
||||||
|
for _, r := range s.Rules {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
out := RowColorSettings{
|
||||||
|
Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity,
|
||||||
|
BandMapLotw: s.BandMapLotw, Configured: true,
|
||||||
|
}
|
||||||
|
if !s.Configured {
|
||||||
|
out.BandMapLotw = true // new option, on unless the operator says otherwise
|
||||||
|
}
|
||||||
|
switch out.Style {
|
||||||
|
case "bar", "tint", "both":
|
||||||
|
default:
|
||||||
|
out.Style = "bar"
|
||||||
|
}
|
||||||
|
// Clamped rather than rejected: the value only shapes a colour-mix, and a
|
||||||
|
// number outside the range is a slider that got away, not a fault worth
|
||||||
|
// resetting the operator's whole choice for.
|
||||||
|
if out.Intensity < 5 {
|
||||||
|
out.Intensity = 12
|
||||||
|
} else if out.Intensity > 45 {
|
||||||
|
out.Intensity = 45
|
||||||
|
}
|
||||||
|
ok := map[string]bool{}
|
||||||
|
for _, c := range rowColorChannels {
|
||||||
|
ok[c] = true
|
||||||
|
}
|
||||||
|
for _, id := range rowColorOrder {
|
||||||
|
r := byID[id]
|
||||||
|
r.ID = id
|
||||||
|
if !hexColor.MatchString(strings.TrimSpace(r.Color)) {
|
||||||
|
r.Color = rowColorDefaults[id]
|
||||||
|
}
|
||||||
|
// Keep only channels we know, in the canonical order, de-duplicated.
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, c := range r.Channels {
|
||||||
|
want[strings.ToLower(strings.TrimSpace(c))] = true
|
||||||
|
}
|
||||||
|
chans := []string{}
|
||||||
|
for _, c := range rowColorChannels {
|
||||||
|
if want[c] {
|
||||||
|
chans = append(chans, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Channels = chans
|
||||||
|
out.Rules = append(out.Rules, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRowColors returns the row-colouring configuration, defaults included so the
|
||||||
|
// panel never has to invent one.
|
||||||
|
func (a *App) GetRowColors() RowColorSettings {
|
||||||
|
var s RowColorSettings
|
||||||
|
if raw := a.settingOr(keyRowColors, ""); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &s)
|
||||||
|
}
|
||||||
|
return normRowColors(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRowColors persists it.
|
||||||
|
func (a *App) SaveRowColors(s RowColorSettings) error {
|
||||||
|
b, err := json.Marshal(normRowColors(s))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyRowColors, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The colour is interpolated into a CSS color-mix() by the grid, so anything
|
||||||
|
// that is not plainly a hex colour has to be refused rather than passed on.
|
||||||
|
func TestRowColorsRefuseAnythingButHex(t *testing.T) {
|
||||||
|
in := RowColorSettings{Enabled: true, Rules: []RowColorRule{
|
||||||
|
{ID: "confirmed", Color: "#123abc", Enabled: true},
|
||||||
|
{ID: "sent", Color: "red; background:url(x)", Enabled: true},
|
||||||
|
{ID: "to_send", Color: "", Enabled: true},
|
||||||
|
}}
|
||||||
|
got := normRowColors(in)
|
||||||
|
|
||||||
|
byID := map[string]RowColorRule{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
if byID["confirmed"].Color != "#123abc" {
|
||||||
|
t.Errorf("a valid colour was rewritten: %q", byID["confirmed"].Color)
|
||||||
|
}
|
||||||
|
if byID["sent"].Color != rowColorDefaults["sent"] {
|
||||||
|
t.Errorf("an injection attempt survived: %q", byID["sent"].Color)
|
||||||
|
}
|
||||||
|
if byID["to_send"].Color != rowColorDefaults["to_send"] {
|
||||||
|
t.Errorf("an empty colour was kept: %q", byID["to_send"].Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority lives in the data, not in a chain of ifs: a contact confirmed on
|
||||||
|
// LoTW AND by card is confirmed, not "sent, awaiting reply". The panel shows
|
||||||
|
// the rules in the order they apply, so that order must survive a round trip.
|
||||||
|
func TestRowColorsKeepPriorityOrder(t *testing.T) {
|
||||||
|
// Saved in a jumbled order, as a hand-edited settings row could be.
|
||||||
|
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||||
|
{ID: "to_send", Color: "#111111"},
|
||||||
|
{ID: "confirmed", Color: "#222222"},
|
||||||
|
}})
|
||||||
|
if len(got.Rules) != len(rowColorOrder) {
|
||||||
|
t.Fatalf("got %d rules, want every one present", len(got.Rules))
|
||||||
|
}
|
||||||
|
for i, id := range rowColorOrder {
|
||||||
|
if got.Rules[i].ID != id {
|
||||||
|
t.Errorf("rule %d is %q, want %q", i, got.Rules[i].ID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The saved colours survived the reordering: each stayed with its own rule.
|
||||||
|
byID := map[string]string{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r.Color
|
||||||
|
}
|
||||||
|
if byID["to_send"] != "#111111" || byID["confirmed"] != "#222222" {
|
||||||
|
t.Errorf("colours moved between rules: %v", byID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style and strength are normalised, not rejected: they only shape a colour, and
|
||||||
|
// a value that got away is a slider, not a fault worth resetting the operator's
|
||||||
|
// whole choice for.
|
||||||
|
func TestRowColorsNormaliseStyleAndIntensity(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
inStyle string
|
||||||
|
inPct int
|
||||||
|
wantStyle string
|
||||||
|
wantPct int
|
||||||
|
}{
|
||||||
|
{"bar", 12, "bar", 12},
|
||||||
|
{"tint", 30, "tint", 30},
|
||||||
|
{"both", 45, "both", 45},
|
||||||
|
{"", 0, "bar", 12}, // never configured
|
||||||
|
{"wallpaper", 12, "bar", 12}, // not a style we draw
|
||||||
|
{"tint", 900, "tint", 45}, // clamped, not reset
|
||||||
|
{"tint", -5, "tint", 12},
|
||||||
|
} {
|
||||||
|
got := normRowColors(RowColorSettings{Style: tc.inStyle, Intensity: tc.inPct})
|
||||||
|
if got.Style != tc.wantStyle || got.Intensity != tc.wantPct {
|
||||||
|
t.Errorf("(%q,%d) -> (%q,%d), want (%q,%d)",
|
||||||
|
tc.inStyle, tc.inPct, got.Style, got.Intensity, tc.wantStyle, tc.wantPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels are normalised to the canonical order, and anything unknown is
|
||||||
|
// dropped. Empty stays empty, because empty means "every channel" — a rule the
|
||||||
|
// operator has not narrowed must not silently become a rule about nothing.
|
||||||
|
func TestRowColorChannelsNormalise(t *testing.T) {
|
||||||
|
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||||
|
{ID: "confirmed", Color: "#111111", Channels: []string{"LOTW", "pigeon", "qsl", "lotw"}},
|
||||||
|
{ID: "sent", Color: "#222222"},
|
||||||
|
}})
|
||||||
|
byID := map[string][]string{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r.Channels
|
||||||
|
}
|
||||||
|
if len(byID["confirmed"]) != 2 || byID["confirmed"][0] != "qsl" || byID["confirmed"][1] != "lotw" {
|
||||||
|
t.Errorf("confirmed channels = %v, want [qsl lotw] — canonical order, deduped, unknown dropped", byID["confirmed"])
|
||||||
|
}
|
||||||
|
if len(byID["sent"]) != 0 {
|
||||||
|
t.Errorf("sent channels = %v, want empty (meaning every channel)", byID["sent"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order IS the priority, and "to send" leads. A contact confirmed on LoTW
|
||||||
|
// that still owes a paper card must read as owing the card — put after
|
||||||
|
// "confirmed" it would go green and never be printed.
|
||||||
|
func TestToSendOutranksConfirmed(t *testing.T) {
|
||||||
|
got := normRowColors(RowColorSettings{})
|
||||||
|
if got.Rules[0].ID != "to_send" {
|
||||||
|
t.Errorf("first rule is %q, want to_send", got.Rules[0].ID)
|
||||||
|
}
|
||||||
|
if got.Rules[len(got.Rules)-1].ID != "worked" {
|
||||||
|
t.Errorf("last rule is %q, want worked as the catch-all", got.Rules[len(got.Rules)-1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-5
@@ -21,6 +21,8 @@ import (
|
|||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
"hamlog/internal/bandopen"
|
"hamlog/internal/bandopen"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
|
"hamlog/internal/geo"
|
||||||
|
"hamlog/internal/gridcache"
|
||||||
"hamlog/internal/pskr"
|
"hamlog/internal/pskr"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,18 +128,53 @@ func (a *App) startBandOpenFeed() {
|
|||||||
a.pskr = nil
|
a.pskr = nil
|
||||||
}
|
}
|
||||||
s := a.GetBandOpenSettings()
|
s := a.GetBandOpenSettings()
|
||||||
|
a.bandOpen.on.Store(s.Enabled)
|
||||||
|
a.setBandOpenBands(s.Bands)
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
|
// Put out whatever is currently lit. Leaving the badges up would keep
|
||||||
|
// announcing an opening from a watch that is now off, and they only fade
|
||||||
|
// on a timer fed by spots this path no longer looks at — so they would
|
||||||
|
// hang there until the app restarted.
|
||||||
|
a.clearBandOpenings()
|
||||||
|
}
|
||||||
|
chaseGrids := a.gridStore != nil
|
||||||
|
if !s.Enabled && !chaseGrids {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Every spot is measured from the operator's position. Without one there is
|
// Every spot is measured from the operator's position. Without one there is
|
||||||
// nothing to measure, and a detector fed unmeasurable spots reports nothing
|
// nothing to measure, and a detector fed unmeasurable spots reports nothing
|
||||||
// while looking like it is working.
|
// while looking like it is working.
|
||||||
if !a.opSet {
|
if !a.opSet {
|
||||||
applog.Printf("bandopen: no station grid set — the opening watch needs one to measure a path")
|
applog.Printf("pskr: no station grid set — the feed needs one to measure a path")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grid chasing wants every band; the opening watch wants its four. "+" is the
|
||||||
|
// MQTT single-level wildcard, so one subscription per square covers the lot.
|
||||||
|
bands := s.Bands
|
||||||
|
if chaseGrids {
|
||||||
|
bands = []string{"+"}
|
||||||
|
}
|
||||||
|
// Filter at the BROKER on the receiver's square rather than receiving the
|
||||||
|
// world and discarding it here. Measured on the live feed: the four opening
|
||||||
|
// bands unfiltered are 83 messages a second, of which roughly one in a
|
||||||
|
// hundred survived the NearKm test below. One ring of squares — about the
|
||||||
|
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
||||||
|
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
||||||
|
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
||||||
|
|
||||||
|
var onGrid func(call, grid string)
|
||||||
|
if chaseGrids {
|
||||||
|
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
|
||||||
|
}
|
||||||
|
var onSpot func(pskr.Spot)
|
||||||
|
if s.Enabled {
|
||||||
|
onSpot = a.feedBandOpen
|
||||||
|
}
|
||||||
|
|
||||||
a.pskr = pskr.New(pskr.Config{
|
a.pskr = pskr.New(pskr.Config{
|
||||||
Bands: s.Bands,
|
Bands: bands,
|
||||||
|
RxGrids: rxGrids,
|
||||||
OpLat: a.opLat, OpLon: a.opLon,
|
OpLat: a.opLat, OpLon: a.opLon,
|
||||||
Geo: func(grid string) (int, int, bool) {
|
Geo: func(grid string) (int, int, bool) {
|
||||||
lat, lon, ok := gridToLatLon(grid)
|
lat, lon, ok := gridToLatLon(grid)
|
||||||
@@ -150,12 +187,16 @@ func (a *App) startBandOpenFeed() {
|
|||||||
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
|
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
|
||||||
return d, b, true
|
return d, b, true
|
||||||
},
|
},
|
||||||
OnSpot: a.feedBandOpen,
|
OnSpot: onSpot,
|
||||||
|
OnGrid: onGrid,
|
||||||
Logf: applog.Printf,
|
Logf: applog.Printf,
|
||||||
})
|
})
|
||||||
if err := a.pskr.Start(); err != nil {
|
if err := a.pskr.Start(); err != nil {
|
||||||
applog.Printf("bandopen: PSK Reporter feed did not start: %v", err)
|
applog.Printf("pskr: feed did not start: %v", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v)",
|
||||||
|
bands, len(rxGrids), s.Enabled, chaseGrids)
|
||||||
}
|
}
|
||||||
|
|
||||||
// feedBandOpen hands one PSK Reporter decode to the detector.
|
// feedBandOpen hands one PSK Reporter decode to the detector.
|
||||||
@@ -164,7 +205,7 @@ func (a *App) startBandOpenFeed() {
|
|||||||
// so it does the least possible: the detector's own window and de-duplication
|
// so it does the least possible: the detector's own window and de-duplication
|
||||||
// by callsign are what turn that flood into one announcement.
|
// by callsign are what turn that flood into one announcement.
|
||||||
func (a *App) feedBandOpen(s pskr.Spot) {
|
func (a *App) feedBandOpen(s pskr.Spot) {
|
||||||
if !bandopen.Watched(s.Band) {
|
if !a.bandOpenWanted(s.Band) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.bandOpen.mu.Lock()
|
a.bandOpen.mu.Lock()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
@@ -21,6 +22,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type bandOpenState struct {
|
type bandOpenState struct {
|
||||||
|
// on mirrors the "Watch for band openings" setting.
|
||||||
|
//
|
||||||
|
// Cached rather than read per spot: this is the cluster hot path, where a
|
||||||
|
// settings query per spot is exactly what the rest of this file avoids.
|
||||||
|
// startBandOpenFeed owns it — it runs at startup and again on every save, so
|
||||||
|
// the switch takes effect without a restart.
|
||||||
|
on atomic.Bool
|
||||||
|
// bands is the operator's SELECTED set, held as map[string]bool.
|
||||||
|
//
|
||||||
|
// bandopen.Watched only says which bands the detector is capable of; it
|
||||||
|
// knows nothing about the chips in Settings. Nothing checked the selection
|
||||||
|
// on either feed path, so unticking 10 m and 2 m changed the subscription and
|
||||||
|
// left the cluster path announcing them anyway.
|
||||||
|
bands atomic.Value
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
det *bandopen.Detector
|
det *bandopen.Detector
|
||||||
last []bandopen.Opening // most recent first, for the UI
|
last []bandopen.Opening // most recent first, for the UI
|
||||||
@@ -45,6 +60,16 @@ const maxRememberedOpenings = 20
|
|||||||
|
|
||||||
// detectBandOpening feeds one spot to the detector and announces a hit.
|
// detectBandOpening feeds one spot to the detector and announces a hit.
|
||||||
func (a *App) detectBandOpening(s cluster.Spot) {
|
func (a *App) detectBandOpening(s cluster.Spot) {
|
||||||
|
// The watch has to be switched on.
|
||||||
|
//
|
||||||
|
// It was not checked here at all: the setting only ever governed the extra
|
||||||
|
// DATA SOURCES (the RBN nodes and the PSK Reporter feed), while the detector
|
||||||
|
// itself ran on every ordinary cluster spot. So an operator who had never
|
||||||
|
// enabled the watch still got opening banners, from a feature they had
|
||||||
|
// deliberately left off.
|
||||||
|
if !a.bandOpen.on.Load() || !a.bandOpenWanted(s.Band) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// No operator grid = no distance and no bearing on the spot, and the whole
|
// No operator grid = no distance and no bearing on the spot, and the whole
|
||||||
// detection rests on those two. Say nothing rather than guess.
|
// detection rests on those two. Say nothing rather than guess.
|
||||||
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
||||||
@@ -122,6 +147,56 @@ func (a *App) GetLiveOpenings() []bandopen.Opening {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bandOpenWanted reports whether the operator has this band ticked. Falls back
|
||||||
|
// to the detector's own set until a selection has been stored, so a band is
|
||||||
|
// never silently dropped before the settings have been read.
|
||||||
|
func (a *App) bandOpenWanted(band string) bool {
|
||||||
|
if !bandopen.Watched(band) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sel, ok := a.bandOpen.bands.Load().(map[string]bool)
|
||||||
|
if !ok || len(sel) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return sel[strings.ToLower(strings.TrimSpace(band))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// setBandOpenBands records the selection and puts out badges for bands that
|
||||||
|
// have just been unticked — they fade on a timer fed by spots the detector no
|
||||||
|
// longer looks at, so they would otherwise stay lit until the next restart.
|
||||||
|
func (a *App) setBandOpenBands(bands []string) {
|
||||||
|
sel := make(map[string]bool, len(bands))
|
||||||
|
for _, b := range bands {
|
||||||
|
sel[strings.ToLower(strings.TrimSpace(b))] = true
|
||||||
|
}
|
||||||
|
a.bandOpen.bands.Store(sel)
|
||||||
|
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
for b := range a.bandOpen.live {
|
||||||
|
if len(sel) > 0 && !sel[b] {
|
||||||
|
delete(a.bandOpen.live, b)
|
||||||
|
delete(a.bandOpen.aliveUntil, b)
|
||||||
|
applog.Printf("bandopen: %s is no longer watched — badge cleared", strings.ToUpper(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearBandOpenings puts out every lit badge and forgets the detector's window.
|
||||||
|
// Called when the watch is switched off: the badges fade on a timer fed by
|
||||||
|
// spots the detector no longer looks at, so without this they would stay up
|
||||||
|
// until the next restart. The remembered list is left alone — those openings
|
||||||
|
// really did happen, and the operator may still want to see what he missed.
|
||||||
|
func (a *App) clearBandOpenings() {
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
a.bandOpen.live = nil
|
||||||
|
a.bandOpen.aliveUntil = nil
|
||||||
|
// Drop the accumulated spot window too, so switching the watch back on starts
|
||||||
|
// from what is on the air now rather than from an hour-old burst.
|
||||||
|
a.bandOpen.det = nil
|
||||||
|
}
|
||||||
|
|
||||||
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
||||||
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
||||||
// opening reads the same however it was noticed.
|
// opening reads the same however it was noticed.
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/bandopen"
|
||||||
|
"hamlog/internal/cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The "Watch for band openings" switch has to gate the DETECTOR, not just the
|
||||||
|
// extra data sources.
|
||||||
|
//
|
||||||
|
// It originally governed only the RBN nodes and the PSK Reporter feed, while
|
||||||
|
// the detector itself ran on every ordinary cluster spot — so an operator who
|
||||||
|
// had never enabled the watch still got opening banners for a feature he had
|
||||||
|
// deliberately left off. That is what this pins.
|
||||||
|
func TestBandOpenWatchGatesTheDetector(t *testing.T) {
|
||||||
|
spot := func() cluster.Spot {
|
||||||
|
return cluster.Spot{
|
||||||
|
DXCall: "EA1ABC", Band: "6m", DistanceKm: 1400, ShortPath: 210,
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched off: the spot must not even reach the detector.
|
||||||
|
off := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||||
|
off.detectBandOpening(spot())
|
||||||
|
if off.bandOpen.det != nil {
|
||||||
|
t.Error("the detector ran with the watch switched off")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched on: the same spot is accepted (one spot is not an opening, so
|
||||||
|
// nothing is announced — but the detector now exists and is collecting).
|
||||||
|
on := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||||
|
on.bandOpen.on.Store(true)
|
||||||
|
on.detectBandOpening(spot())
|
||||||
|
if on.bandOpen.det == nil {
|
||||||
|
t.Error("the detector did not run with the watch switched on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching the watch off must put the badges out. They fade on a timer fed by
|
||||||
|
// spots the detector no longer looks at, so left alone they would stay lit
|
||||||
|
// until the next restart.
|
||||||
|
func TestClearBandOpeningsPutsTheBadgesOut(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.bandOpen.live = map[string]bandopen.Opening{"6m": {Band: "6m", Calls: 9}}
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{"6m": time.Now().Add(time.Hour)}
|
||||||
|
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
||||||
|
a.bandOpen.last = []bandopen.Opening{{Band: "6m", Calls: 9}}
|
||||||
|
|
||||||
|
a.clearBandOpenings()
|
||||||
|
|
||||||
|
if got := a.GetLiveOpenings(); len(got) != 0 {
|
||||||
|
t.Errorf("a badge stayed lit after the watch was switched off: %v", got)
|
||||||
|
}
|
||||||
|
if a.bandOpen.det != nil {
|
||||||
|
t.Error("the accumulated spot window survived — switching back on would start from a stale burst")
|
||||||
|
}
|
||||||
|
// The history is NOT cleared: those openings really happened.
|
||||||
|
if len(a.GetBandOpenings()) != 1 {
|
||||||
|
t.Error("the remembered openings were thrown away")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unticking a band in Settings must actually stop its announcements.
|
||||||
|
//
|
||||||
|
// Nothing checked the operator's selection on either feed path: bandopen.Watched
|
||||||
|
// only says which bands the detector is CAPABLE of. So a station with just 6 m
|
||||||
|
// ticked still got 10 m and 2 m badges, from the cluster path and — once grid
|
||||||
|
// chasing widened the subscription to every band — from PSK Reporter too.
|
||||||
|
func TestBandOpenHonoursTheSelectedBands(t *testing.T) {
|
||||||
|
a := &App{opSet: true, opLat: 48, opLon: 2}
|
||||||
|
a.bandOpen.on.Store(true)
|
||||||
|
a.setBandOpenBands([]string{"6m"})
|
||||||
|
|
||||||
|
if !a.bandOpenWanted("6m") {
|
||||||
|
t.Error("6m is ticked and was refused")
|
||||||
|
}
|
||||||
|
for _, b := range []string{"10m", "2m", "4m"} {
|
||||||
|
if a.bandOpenWanted(b) {
|
||||||
|
t.Errorf("%s is not ticked and was accepted", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A band the detector cannot watch at all stays out whatever is stored.
|
||||||
|
if a.bandOpenWanted("20m") {
|
||||||
|
t.Error("20m is not a watched band and was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the cluster path must obey it too.
|
||||||
|
a.detectBandOpening(cluster.Spot{
|
||||||
|
DXCall: "K1ABC", Band: "10m", DistanceKm: 6000, ShortPath: 280, ReceivedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if a.bandOpen.det != nil {
|
||||||
|
t.Error("a spot on an unticked band reached the detector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unticking a band puts its badge out. They fade on a timer fed by spots the
|
||||||
|
// detector no longer looks at, so it would otherwise stay lit until a restart.
|
||||||
|
func TestUntickingABandClearsItsBadge(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.bandOpen.live = map[string]bandopen.Opening{
|
||||||
|
"6m": {Band: "6m", Calls: 9},
|
||||||
|
"10m": {Band: "10m", Calls: 5},
|
||||||
|
}
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{
|
||||||
|
"6m": time.Now().Add(time.Hour),
|
||||||
|
"10m": time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
a.setBandOpenBands([]string{"6m"})
|
||||||
|
|
||||||
|
live := a.GetLiveOpenings()
|
||||||
|
if len(live) != 1 || live[0].Band != "6m" {
|
||||||
|
t.Errorf("live openings = %v, want only 6m", live)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A bulk-editable field passes through THREE tables: the field list in the UI,
|
||||||
|
// bulkFieldColumns here, and bulkEditableCols in internal/qso. Mode and RST were
|
||||||
|
// added to the first and the third and not the second, so the dialog offered
|
||||||
|
// them and the save failed with "unknown field" — reported from the field.
|
||||||
|
//
|
||||||
|
// Nothing warns about that: each table is valid on its own. This is the check.
|
||||||
|
func TestEveryMappedBulkFieldIsWhitelisted(t *testing.T) {
|
||||||
|
for id, col := range bulkFieldColumns {
|
||||||
|
if !qso.BulkEditable(col) {
|
||||||
|
t.Errorf("bulk field %q maps to column %q, which internal/qso refuses to write", id, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the reverse: a column the qso layer allows but nothing maps to is dead
|
||||||
|
// weight — it looks supported from the inside and cannot be reached from outside.
|
||||||
|
func TestNoUnreachableBulkColumn(t *testing.T) {
|
||||||
|
mapped := map[string]bool{}
|
||||||
|
for _, col := range bulkFieldColumns {
|
||||||
|
mapped[col] = true
|
||||||
|
}
|
||||||
|
for _, col := range qso.BulkEditableColumns() {
|
||||||
|
if !mapped[col] {
|
||||||
|
t.Errorf("column %q is bulk-writable but no field maps to it — unreachable", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,96 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.24.9",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Appearance: row colouring now defaults to a left stripe, with a filled row and its strength offered as choices.",
|
||||||
|
"The band map now follows the cluster filters — LoTW only, spotter continent, hide worked, status and mode chips.",
|
||||||
|
"Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.",
|
||||||
|
"LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded.",
|
||||||
|
"FT8: a contact is logged with the grid the station actually sent, not the home square from QRZ — expeditions were logged in the wrong place.",
|
||||||
|
"SPE amplifier: switching it off is shown at once, instead of needing a second press of OFF.",
|
||||||
|
"Appearance: row colouring is now Worked / Confirmed / QSL sent / To be sent, each scoped to the channels you pick — paper, LoTW, eQSL, QRZ.com."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.",
|
||||||
|
"La band map suit désormais les filtres du cluster — LoTW seulement, continent du spotter, masquer les contactés, statuts et modes.",
|
||||||
|
"Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.",
|
||||||
|
"LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés.",
|
||||||
|
"FT8 : un contact est enregistré avec le locator réellement émis par la station, et non le carré du domicile depuis QRZ — les expéditions étaient logguées au mauvais endroit.",
|
||||||
|
"Amplificateur SPE : l extinction s affiche immédiatement, au lieu de demander un second appui sur OFF.",
|
||||||
|
"Apparence : la coloration des lignes devient Contacté / Confirmé / QSL envoyée / À envoyer, chacune limitée aux canaux choisis — papier, LoTW, eQSL, QRZ.com."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.24.8",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Cluster: the grid cache now holds 100,000 callsigns and rotates instead of emptying itself, so locators stop vanishing from the list.",
|
||||||
|
"New option \"Chase new grids\": locators learnt from your decodes and from PSK Reporter are kept in their own database, with their source.",
|
||||||
|
"Band openings: the PSK Reporter feed is now filtered at the broker, which cuts it from about 83 messages a second to under two.",
|
||||||
|
"Band openings: unticking a band now actually stops its announcements, and puts its badge out.",
|
||||||
|
"Callsign lookup: the website, postal code and HamQTH profile picture are now read — the QSO web column was never filled by any lookup.",
|
||||||
|
"New selectable column \"QSO number\": position in the log, 1 for the oldest contact.",
|
||||||
|
"New Appearance settings: colour whole log rows by QSL status, with your own colours from a palette.",
|
||||||
|
"The QSO number was missing on contacts logged from WSJT-X — that path bypassed the numbering."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cluster : le cache de locators garde 100 000 indicatifs et tourne au lieu de se vider, les locators ne disparaissent donc plus de la liste.",
|
||||||
|
"Nouvelle option « Chasse aux nouveaux carrés » : les locators appris de tes décodes et de PSK Reporter sont conservés dans leur propre base, avec leur source.",
|
||||||
|
"Ouvertures de bande : le flux PSK Reporter est désormais filtré chez le broker, ce qui le fait passer d environ 83 messages par seconde à moins de deux.",
|
||||||
|
"Ouvertures de bande : décocher une bande arrête réellement ses annonces et éteint son badge.",
|
||||||
|
"Recherche d indicatif : le site web, le code postal et la photo de profil HamQTH sont désormais lus — la colonne web du QSO n était jamais remplie.",
|
||||||
|
"Nouvelle colonne sélectionnable « Numéro de QSO » : position dans le log, 1 pour le contact le plus ancien.",
|
||||||
|
"Nouveaux réglages Apparence : colorer les lignes entières du log selon le statut QSL, avec tes couleurs choisies dans une palette.",
|
||||||
|
"Le numéro de QSO manquait sur les contacts enregistrés depuis WSJT-X — ce chemin contournait la numérotation."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.24.7",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"QSOs logged from WSJT-X now carry the space weather and the distance, like hand-logged ones.",
|
||||||
|
"Band openings: EME contacts are no longer mistaken for an opening. Each band now has the longest path the atmosphere can carry.",
|
||||||
|
"Kenwood: WSJT-X \"Fake It\" no longer leaves the dial on the transmit frequency.",
|
||||||
|
"PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio.",
|
||||||
|
"Motorized antennas: tracking now offers three modes — every frequency change, past a step, or band change only.",
|
||||||
|
"Motorized antennas: each covered band now has its own tune frequency, set in Settings.",
|
||||||
|
"Awards: a single award can now be exported on its own.",
|
||||||
|
"Awards: each reference now has its own validity window, so a reference that ceased to exist stops counting for later QSOs.",
|
||||||
|
"Band openings: the watch switch now governs the detection itself, not just the extra data sources."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main.",
|
||||||
|
"Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Chaque bande a désormais la distance maximale que l atmosphère peut porter.",
|
||||||
|
"Kenwood : le « Fake It » de WSJT-X ne laisse plus le VFO sur la fréquence d émission.",
|
||||||
|
"PowerGenius XL : la carte du Contrôle station affiche puissance, courant, ROS et température sans FlexRadio.",
|
||||||
|
"Antennes motorisées : le suivi propose trois modes — à chaque changement de fréquence, au-delà d un pas, ou au changement de bande.",
|
||||||
|
"Antennes motorisées : chaque bande couverte a désormais sa propre fréquence d accord, réglable dans les Réglages.",
|
||||||
|
"Diplômes : un diplôme peut désormais être exporté seul.",
|
||||||
|
"Diplômes : chaque référence a désormais sa fenêtre de validité, une référence disparue cesse donc de compter pour les QSO suivants.",
|
||||||
|
"Ouvertures de bande : l interrupteur de la veille gouverne désormais la détection elle-même, plus seulement les sources de données."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.24.6",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Bulk edit can now set mode, submode and RST. They were excluded as per-QSO fields, which missed the point: bulk edit is for repairing a batch — an import that mapped every contact to SSB, an ADIF with no mode at all — and refusing meant editing a hundred rows one at a time. Setting the mode clears the submode, since one left over from the old mode contradicts the new one. Band stays with frequency, which already sets the two together.",
|
||||||
|
"Web publishing: the page now widens to fit the table. It was capped at a comfortable reading width, so with more than about eight columns the rest sat behind a scrollbar on a screen wide enough to show them all — and Windows hides that scrollbar until something moves, which made a table that scrolls look like a table missing columns. Columns also take the width their contents need instead of being squeezed to fit first.",
|
||||||
|
"Every QSO now carries its distance. Nothing ever recorded one, so the field went out empty in every ADIF export and left the Distance column blank on a published page. It is computed from the two locators when a contact is logged and when an ADIF is imported, and a one-time pass fills in the QSOs already in your log the first time this version runs — in the background, without asking. A distance the imported file supplied is always kept.",
|
||||||
|
"RDA: 1015 districts were filed under the wrong DXCC entity. Every reference sat on European Russia; 991 belong to Asiatic Russia and the 24 KA- districts to Kaliningrad, which is a separate entity altogether. Corrected against the reference list, and Kaliningrad added to the award filter so those 24 can be claimed at all.",
|
||||||
|
"QSO filter: asking a field to equal nothing now finds the empty ones. SQL answers that question with nothing at all — a missing value never equals an empty one — so the filter looked broken rather than wrong. Empty on a numeric field also covers zero as well as missing, which SQLite and MySQL disagreed about.",
|
||||||
|
"Callsign lookup: a /QRP call now returns its locator. The lookup falls back to the home callsign when the slashed form is not registered, and then cleared the location — right for /P and /M, where the operator is somewhere other than their registered address, and wrong for /QRP, which says something about power and nothing about place. The grid came back empty while the same call without the suffix answered perfectly."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"L édition groupée sait enfin régler le mode, le sous-mode et le RST. Ils étaient exclus comme champs propres à chaque QSO, ce qui manquait l essentiel : l édition groupée sert à RÉPARER un lot — un import qui a tout mis en SSB, un ADIF sans aucun mode — et refuser obligeait à corriger cent lignes une par une. Régler le mode efface le sous-mode, celui de l ancien mode contredisant le nouveau. La bande reste avec la fréquence, qui pose déjà les deux ensemble.",
|
||||||
|
"Publication web : la page s élargit désormais à la taille du tableau. Elle était bridée à une largeur de lecture confortable, donc au-delà de huit colonnes environ le reste passait derrière une barre de défilement sur un écran assez large pour tout montrer — et Windows masque cette barre tant que rien ne bouge, si bien qu un tableau qui défile ressemblait à un tableau amputé. Les colonnes prennent aussi la largeur qu il leur faut au lieu d être comprimées d abord.",
|
||||||
|
"Chaque QSO porte désormais sa distance. Rien ne l enregistrait, elle partait donc vide dans chaque export ADIF et laissait la colonne Distance blanche sur une page publiée. Elle est calculée depuis les deux locators à l enregistrement d un contact et à l import d un ADIF, et une passe unique complète les QSO déjà présents au premier lancement de cette version — en tâche de fond, sans rien demander. Une distance fournie par le fichier importé est toujours conservée.",
|
||||||
|
"RDA : 1015 districts étaient rangés sous la mauvaise entité DXCC. Toutes les références étaient sur la Russie européenne ; 991 relèvent de la Russie asiatique et les 24 districts KA- de Kaliningrad, qui est une entité à part entière. Corrigé d après la liste de référence, et Kaliningrad ajouté au filtre de l award pour que ces 24 puissent être revendiqués.",
|
||||||
|
"Filtre QSO : demander à un champ d être égal à rien trouve désormais les vides. SQL répond à cette question par rien du tout — une valeur absente n est jamais égale à une valeur vide — et le filtre paraissait cassé plutôt que mal posé. Vide sur un champ numérique couvre aussi le zéro autant que l absence, ce sur quoi SQLite et MySQL n étaient pas d accord.",
|
||||||
|
"Recherche d indicatif : un indicatif en /QRP rend enfin son locator. La recherche se rabat sur l indicatif de base quand la forme avec barre n est pas enregistrée, puis effaçait la localisation — ce qui est juste pour /P et /M, où l opérateur n est pas à son adresse déclarée, et faux pour /QRP, qui parle de puissance et pas de lieu. Le locator revenait vide alors que le même indicatif sans le suffixe répondait parfaitement."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.24.5",
|
"version": "0.24.5",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/gridcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The grid cache used to drop EVERYTHING at its ceiling. That was survivable
|
||||||
|
// while the only feed was this station's own decodes, which never reached it.
|
||||||
|
// Fed by anything larger it is a cliff: every locator in the cluster list
|
||||||
|
// disappears at once, periodically, and the operator sees the column empty
|
||||||
|
// itself for no reason.
|
||||||
|
//
|
||||||
|
// Rotation keeps the previous generation, so a full cache costs the older half
|
||||||
|
// and nothing more.
|
||||||
|
func TestDecodeGridRotationKeepsThePreviousGeneration(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
|
||||||
|
// Fill exactly one generation.
|
||||||
|
for i := 0; i < decodeGridsCap; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Fatalf("first entry lost before any rotation: %q", got)
|
||||||
|
}
|
||||||
|
if a.decodeGridsOld != nil {
|
||||||
|
t.Fatal("rotated early — the cap is the ceiling of ONE generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One more entry rotates.
|
||||||
|
a.rememberDecodeGrid("NEWCALL", "IO91", gridcache.SourceDecode)
|
||||||
|
if a.decodeGridsOld == nil {
|
||||||
|
t.Fatal("did not rotate at the cap")
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||||
|
t.Errorf("the entry that caused the rotation was lost: %q", got)
|
||||||
|
}
|
||||||
|
// The whole previous generation is still readable — this is the point.
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Errorf("a locator from the previous generation was dropped: %q — that is the cliff again", got)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL099999"); got != "JN36" {
|
||||||
|
t.Errorf("previous generation incomplete: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second rotation is what finally retires the oldest half.
|
||||||
|
for i := 0; i < decodeGridsCap; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("SECOND%06d", i), "KP20", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "" {
|
||||||
|
t.Errorf("the cache is unbounded: %q survived two rotations", got)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||||
|
t.Errorf("an entry one generation old was retired too early: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callsigns are normalised on the way in AND on the way out, or a spot for
|
||||||
|
// "f4bpo" would miss a grid learnt as "F4BPO".
|
||||||
|
func TestDecodeGridCaseAndBlanks(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.rememberDecodeGrid(" f4bpo ", "JN36", gridcache.SourceDecode)
|
||||||
|
if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" {
|
||||||
|
t.Errorf("lookup of the upper-case form failed: %q", got)
|
||||||
|
}
|
||||||
|
a.rememberDecodeGrid("", "JN36", gridcache.SourceDecode)
|
||||||
|
a.rememberDecodeGrid("K1ABC", "", gridcache.SourceDecode)
|
||||||
|
if got := a.lookupDecodeGrid("K1ABC"); got != "" {
|
||||||
|
t.Errorf("stored an empty grid: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The write path runs on the decode goroutine while the cluster status builder
|
||||||
|
// reads. Rotation swaps the map headers, so an unguarded read is a data race
|
||||||
|
// rather than a stale value.
|
||||||
|
//
|
||||||
|
// This build is CGO-free, so -race is not available here; the test exercises the
|
||||||
|
// interleaving and would fault on a concurrent map access even without it.
|
||||||
|
func TestDecodeGridConcurrentAccess(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("W%05d", i), "FN31", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
_ = a.lookupDecodeGrid(fmt.Sprintf("W%05d", i))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a CHANGE may reach the write batch.
|
||||||
|
//
|
||||||
|
// The feeds repeat themselves — the same station is reported by dozens of
|
||||||
|
// receivers a minute — so queueing every report would put the whole stream in
|
||||||
|
// the batch instead of the news in it, and turn a cache into a write amplifier.
|
||||||
|
func TestOnlyChangesAreQueued(t *testing.T) {
|
||||||
|
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
a := &App{gridStore: st}
|
||||||
|
|
||||||
|
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||||
|
if n := st.Pending(); n != 1 {
|
||||||
|
t.Fatalf("a new locator queued %d writes, want 1", n)
|
||||||
|
}
|
||||||
|
if err := st.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same report, a hundred times over, is not news.
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if n := st.Pending(); n != 0 {
|
||||||
|
t.Errorf("unchanged reports queued %d writes — the batch would carry the whole feed", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A station that moved is.
|
||||||
|
a.rememberDecodeGrid("F4BPO", "KP30", gridcache.SourceDecode)
|
||||||
|
if n := st.Pending(); n != 1 {
|
||||||
|
t.Errorf("a changed locator queued %d writes, want 1", n)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("F4BPO"); got != "KP30" {
|
||||||
|
t.Errorf("the map kept the old locator: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation must be OFF while a store is attached: it would discard callsigns the
|
||||||
|
// database still holds, and the lookup would then miss something we know.
|
||||||
|
func TestNoRotationWhilePersisting(t *testing.T) {
|
||||||
|
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
a := &App{gridStore: st}
|
||||||
|
|
||||||
|
for i := 0; i < decodeGridsCap+10; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if a.decodeGridsOld != nil {
|
||||||
|
t.Error("rotated while a store was attached — locators the database holds would go missing")
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Errorf("the first entry was dropped: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-12
@@ -90,7 +90,8 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
|||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||||
|
import { GetRowColors } from '../wailsjs/go/main/App';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
||||||
@@ -1497,8 +1498,11 @@ export default function App() {
|
|||||||
// beside Hide worked and not among the status chips.
|
// beside Hide worked and not among the status chips.
|
||||||
const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
|
const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
|
||||||
const [clusterSpotterConts, setClusterSpotterConts] = useState<Set<string>>(() => lsSet<string>('opslog.clusterSpotterCont'));
|
const [clusterSpotterConts, setClusterSpotterConts] = useState<Set<string>>(() => lsSet<string>('opslog.clusterSpotterCont'));
|
||||||
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => localStorage.getItem('opslog.clusterMuteWorked') === '1');
|
// Read through lib/spotDisplay, not straight from localStorage: while the two
|
||||||
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => localStorage.getItem('opslog.clusterSlotHighlight') === '1');
|
// options are withdrawn it answers false, so the cluster list cannot end up
|
||||||
|
// applying an option the operator can no longer see or switch off.
|
||||||
|
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => readSpotDisplayOptions().muteWorked);
|
||||||
|
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => readSpotDisplayOptions().slotHighlight);
|
||||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
@@ -1920,6 +1924,10 @@ export default function App() {
|
|||||||
// change). The grid reads them straight from the row — no per-page backend
|
// change). The grid reads them straight from the row — no per-page backend
|
||||||
// recompute — so here we just parse the stored JSON string into the code→ref
|
// recompute — so here we just parse the stored JSON string into the code→ref
|
||||||
// object the award columns expect (keys are already upper-case).
|
// object the award columns expect (keys are already upper-case).
|
||||||
|
// Row colouring by QSL status (Settings → Appearance). Reloaded when the
|
||||||
|
// settings dialog closes, which is the only place it changes.
|
||||||
|
const [rowColors, setRowColors] = useState<any>(null);
|
||||||
|
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
||||||
const qsosWithAwards = useMemo(
|
const qsosWithAwards = useMemo(
|
||||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
||||||
[qsos],
|
[qsos],
|
||||||
@@ -3300,7 +3308,7 @@ export default function App() {
|
|||||||
sat_name: details.sat_name, sat_mode: details.sat_mode,
|
sat_name: details.sat_name, sat_mode: details.sat_mode,
|
||||||
contest_id: details.contest_id,
|
contest_id: details.contest_id,
|
||||||
srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
|
srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
|
||||||
email: details.email,
|
email: details.email, web: details.web,
|
||||||
};
|
};
|
||||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||||
// Contest mode overrides the exchange fields: CONTEST_ID, the sent
|
// Contest mode overrides the exchange fields: CONTEST_ID, the sent
|
||||||
@@ -3663,6 +3671,7 @@ export default function App() {
|
|||||||
ituz: d.ituz ?? (last.ituz || undefined),
|
ituz: d.ituz ?? (last.ituz || undefined),
|
||||||
cont: d.cont || last.cont || '',
|
cont: d.cont || last.cont || '',
|
||||||
email: d.email || last.email || '',
|
email: d.email || last.email || '',
|
||||||
|
web: d.web || last.web || '',
|
||||||
qsl_via: d.qsl_via || last.qsl_via || '',
|
qsl_via: d.qsl_via || last.qsl_via || '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3725,6 +3734,7 @@ export default function App() {
|
|||||||
ituz: d.ituz ?? (r.ituz || undefined),
|
ituz: d.ituz ?? (r.ituz || undefined),
|
||||||
cont: d.cont || (r.cont ?? ''),
|
cont: d.cont || (r.cont ?? ''),
|
||||||
email: d.email || (r.email ?? ''),
|
email: d.email || (r.email ?? ''),
|
||||||
|
web: d.web || (r.web ?? ''),
|
||||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||||
}));
|
}));
|
||||||
// Backfill anything the provider didn't supply from the last time we worked
|
// Backfill anything the provider didn't supply from the last time we worked
|
||||||
@@ -4707,12 +4717,21 @@ export default function App() {
|
|||||||
// Cluster spots after every active filter (band / mode / status / search /
|
// Cluster spots after every active filter (band / mode / status / search /
|
||||||
// hide-worked / group). Shared by the Cluster tab and the Main-view cluster
|
// hide-worked / group). Shared by the Cluster tab and the Main-view cluster
|
||||||
// pane so both show exactly the same list.
|
// pane so both show exactly the same list.
|
||||||
const clusterRenderedRows = useMemo(() => {
|
// Everything the operator has said about WHICH STATIONS they care about —
|
||||||
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
|
// LoTW only, the spotter's continent, hide worked, the status and mode chips,
|
||||||
|
// the search box, the source node.
|
||||||
|
//
|
||||||
|
// Shared with the band map, which used to receive the raw stream filtered by
|
||||||
|
// band alone: switching on "LoTW users only" changed the cluster list and left
|
||||||
|
// the panadapter showing everyone. Two views of one spot stream disagreeing
|
||||||
|
// about the same spot is the fault lib/spotDisplay already exists to prevent.
|
||||||
|
//
|
||||||
|
// The BAND filter is deliberately not in here: a band map's band IS its
|
||||||
|
// filter, and applying the cluster's would empty every map but one.
|
||||||
|
const spotPassesStationFilters = useCallback((s: ClusterSpot): boolean => {
|
||||||
const search = clusterSearch.trim().toUpperCase();
|
const search = clusterSearch.trim().toUpperCase();
|
||||||
const list = spots.filter((s) => {
|
{
|
||||||
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
|
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
|
||||||
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
|
||||||
if (search && !s.dx_call.includes(search)) return false;
|
if (search && !s.dx_call.includes(search)) return false;
|
||||||
if (clusterLockMode) {
|
if (clusterLockMode) {
|
||||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||||
@@ -4764,6 +4783,15 @@ export default function App() {
|
|||||||
if (e.worked_call || e.status === 'worked') return false;
|
if (e.worked_call || e.status === 'worked') return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
}, [clusterFilterSource, clusterSearch, clusterLockMode, mode, clusterModeFilter,
|
||||||
|
clusterStatusFilter, spotStatus, clusterLotwOnly, clusterSpotterConts, clusterHideWorked]);
|
||||||
|
|
||||||
|
const clusterRenderedRows = useMemo(() => {
|
||||||
|
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
|
||||||
|
const list = spots.filter((s) => {
|
||||||
|
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
||||||
|
return spotPassesStationFilters(s);
|
||||||
});
|
});
|
||||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||||
if (clusterGroup) {
|
if (clusterGroup) {
|
||||||
@@ -4843,8 +4871,11 @@ export default function App() {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)}
|
{fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)}
|
||||||
{fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)}
|
{fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)}
|
||||||
{fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
|
{/* The two display options are withdrawn for now — the flag is in
|
||||||
{fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
|
lib/spotDisplay, and it also forces them off for the band map, so
|
||||||
|
there is one place to flip when they come back. */}
|
||||||
|
{SPOT_DISPLAY_OPTIONS_EXPOSED && fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
|
||||||
|
{SPOT_DISPLAY_OPTIONS_EXPOSED && fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
|
||||||
{fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
|
{fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -5062,6 +5093,7 @@ export default function App() {
|
|||||||
// rewrote the other's widths.
|
// rewrote the other's widths.
|
||||||
storageKey="mainpane"
|
storageKey="mainpane"
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
|
rowColors={rowColors}
|
||||||
myGrid={station.my_grid}
|
myGrid={station.my_grid}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
@@ -6345,6 +6377,7 @@ export default function App() {
|
|||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
key={`rqg2-${activeProfileId ?? 'x'}`}
|
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
|
rowColors={rowColors}
|
||||||
myGrid={station.my_grid}
|
myGrid={station.my_grid}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
@@ -6760,13 +6793,14 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<BandMap
|
<BandMap
|
||||||
band={b}
|
band={b}
|
||||||
spots={spots.filter((s) => s.band === b)}
|
spots={spots.filter((s) => s.band === b && spotPassesStationFilters(s))}
|
||||||
spotStatus={spotStatus}
|
spotStatus={spotStatus}
|
||||||
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||||
onSpotClick={handleSpotClick}
|
onSpotClick={handleSpotClick}
|
||||||
onClose={() => toggleBandMapBand(b)}
|
onClose={() => toggleBandMapBand(b)}
|
||||||
hideDigital={bandMapHideFt}
|
hideDigital={bandMapHideFt}
|
||||||
fitToBand={bandMapFit}
|
fitToBand={bandMapFit}
|
||||||
|
showLotw={!!rowColors?.bandmap_lotw}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -6796,11 +6830,12 @@ export default function App() {
|
|||||||
side={bandMapSide}
|
side={bandMapSide}
|
||||||
onToggleSide={toggleBandMapSide}
|
onToggleSide={toggleBandMapSide}
|
||||||
band={band}
|
band={band}
|
||||||
spots={spots.filter((s) => s.band === band)}
|
spots={spots.filter((s) => s.band === band && spotPassesStationFilters(s))}
|
||||||
spotStatus={spotStatus}
|
spotStatus={spotStatus}
|
||||||
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||||
onSpotClick={handleSpotClick}
|
onSpotClick={handleSpotClick}
|
||||||
onClose={() => setBandMapShown(false)}
|
onClose={() => setBandMapShown(false)}
|
||||||
|
showLotw={!!rowColors?.bandmap_lotw}
|
||||||
keyNav
|
keyNav
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,6 +174,45 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||||
const connected = !!pg.connected || viaFlex;
|
const connected = !!pg.connected || viaFlex;
|
||||||
const fault = flex?.amp_fault;
|
const fault = flex?.amp_fault;
|
||||||
|
|
||||||
|
// Meters built from the amplifier's own GSCP status frame, for when the radio
|
||||||
|
// is not feeding a meter stream.
|
||||||
|
//
|
||||||
|
// Whether there is power is the amp's state field; how much is the plain
|
||||||
|
// forward figure. NOT "peakfwd" — that is a latched maximum which is never
|
||||||
|
// reset and survives in the last-known status after the amp disconnects, so it
|
||||||
|
// once claimed 1350 W from an old transmission while 10 W was going out. Same
|
||||||
|
// reason peak_id is left alone. Both readings are gated on transmit so they
|
||||||
|
// fall back to zero between overs instead of freezing on the last one.
|
||||||
|
const pgxlMeters = () => {
|
||||||
|
if (!pg.connected) return null;
|
||||||
|
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : /TRANSMIT/i.test(pg.state || '');
|
||||||
|
const fwdW = peakHold('pgfwd', txing ? Number(pg.fwd_w) || 0 : 0);
|
||||||
|
const idA = peakHold('pgid', txing ? Number(pg.id) || 0 : 0);
|
||||||
|
const swr = peakHold('pgswr', txing ? Number(pg.vswr) || 0 : 0);
|
||||||
|
const tempC = Number(pg.temperature) || 0;
|
||||||
|
// Two columns, not four. The card sits beside a tall neighbour in Station
|
||||||
|
// Control, so a single row of four leaves the height empty and squeezes each
|
||||||
|
// bar into a quarter width — two rows of two use the room that is already
|
||||||
|
// there and give every bar twice the resolution.
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||||
|
display={`${Math.round(fwdW)} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
<MeterBar label={t('ampw.id')} value={idA} lo={0} hi={25} display={`${idA.toFixed(1)} A`} accent="#16a34a" />
|
||||||
|
{/* Below 1:1 the reading is meaningless, so an idle amp shows a flat bar
|
||||||
|
rather than a zero that looks like a perfect match. */}
|
||||||
|
<MeterBar label={t('ampw.swr')} value={swr >= 1 ? swr : 1} lo={1} hi={3}
|
||||||
|
display={swr >= 1 ? swr.toFixed(1) : '—'}
|
||||||
|
segColor={(f) => (f > 0.75 ? '#dc2626' : f > 0.4 ? '#f59e0b' : '#16a34a')} />
|
||||||
|
<MeterBar label={t('ampw.temp')} value={tempC} unit="°C" lo={0} hi={100}
|
||||||
|
display={tempC > 0 ? `${Math.round(tempC)} °C` : '—'}
|
||||||
|
segColor={(f) => (f > 0.8 ? '#dc2626' : f > 0.6 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -204,13 +243,20 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
{/* Amplifier meters (FWD / ID / TEMP …).
|
||||||
{viaFlex && (() => {
|
The FlexRadio UDP stream is the preferred source — it is fast and reads
|
||||||
|
the same as SmartSDR. When there is no Flex, or it is not streaming,
|
||||||
|
the amplifier's OWN link carries the same figures; falling back to them
|
||||||
|
is what the docked widget already does. Without that fallback this card
|
||||||
|
showed an operator on a Kenwood nothing but OPERATE and the fan mode,
|
||||||
|
while the amplifier was reporting power, current and temperature all
|
||||||
|
along. */}
|
||||||
|
{(() => {
|
||||||
const meters = (flex?.meters as any[]) || [];
|
const meters = (flex?.meters as any[]) || [];
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (amps.length === 0) return null;
|
if (!viaFlex || amps.length === 0) return pgxlMeters();
|
||||||
// Power comes from the radio's meter stream and nothing else. The
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||||
// over: it is a latched maximum that is never reset, and it survives in
|
// over: it is a latched maximum that is never reset, and it survives in
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { GetRowColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { RowColorSettings } from '@/lib/rowColors';
|
||||||
|
|
||||||
|
// A fixed palette plus a free picker. Muted values on purpose: they are
|
||||||
|
// composited at low opacity over a dark grid, where a saturated colour reads as
|
||||||
|
// an error state rather than a status.
|
||||||
|
const PALETTE = [
|
||||||
|
'#16a34a', '#0ea5e9', '#f59e0b', '#a855f7',
|
||||||
|
'#dc2626', '#14b8a6', '#eab308', '#ec4899',
|
||||||
|
'#64748b', '#84cc16', '#6366f1', '#f97316',
|
||||||
|
];
|
||||||
|
|
||||||
|
// The rule ids the backend orders; the labels live here so a translation never
|
||||||
|
// travels through the settings row.
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
to_send: 'appr.ruleToSend',
|
||||||
|
confirmed: 'appr.ruleConfirmed',
|
||||||
|
sent: 'appr.ruleSent',
|
||||||
|
worked: 'appr.ruleWorked',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The channels a rule can be scoped to. "worked" is the catch-all — it means
|
||||||
|
// nothing on ANY channel — so narrowing it would say nothing.
|
||||||
|
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||||
|
const CHANNEL_LABELS: Record<string, string> = {
|
||||||
|
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppearancePanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try { setCfg((await GetRowColors()) as any); } catch { /* defaults on the backend */ }
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = (next: RowColorSettings) => {
|
||||||
|
setCfg(next);
|
||||||
|
SaveRowColors(next as any).catch(() => {});
|
||||||
|
};
|
||||||
|
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// The rule card shows the row exactly as the grid will draw it, so the choice
|
||||||
|
// is made by looking rather than by imagining.
|
||||||
|
const preview = (color: string): Record<string, string> => {
|
||||||
|
const st = cfg?.style ?? 'bar';
|
||||||
|
const pct = cfg?.intensity ?? 12;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (st === 'tint' || st === 'both') out.backgroundColor = `color-mix(in srgb, ${color} ${pct}%, transparent)`;
|
||||||
|
if (st === 'bar' || st === 'both') out.boxShadow = `inset 3px 0 0 ${color}`;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg) return <div className="p-1 text-sm text-muted-foreground">…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!cfg.bandmap_lotw} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => save({ ...cfg, bandmap_lotw: !!c } as any)} />
|
||||||
|
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||||||
|
<span>{t('appr.enable')} <span className="text-xs text-muted-foreground">{t('appr.enableHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Style first: it decides whether the colours below are a signal or a
|
||||||
|
wallpaper, which matters more than which hue they are. */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-sm">{t('appr.style')}</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||||
|
{(['bar', 'tint', 'both'] as const).map((v) => (
|
||||||
|
<button key={v} type="button" onClick={() => save({ ...cfg, style: v })}
|
||||||
|
className={cn('px-3 py-1.5 font-medium', (cfg.style ?? 'bar') === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('appr.style' + v[0].toUpperCase() + v.slice(1))}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(cfg.style ?? 'bar') !== 'bar' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
{t('appr.intensity')}
|
||||||
|
<input type="range" min={5} max={45} step={1} value={cfg.intensity ?? 12}
|
||||||
|
onChange={(e) => save({ ...cfg, intensity: parseInt(e.target.value, 10) })}
|
||||||
|
className="w-32 accent-[var(--primary)]" />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground w-8">{cfg.intensity ?? 12}%</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Order matters and is shown: a contact is usually several of these at
|
||||||
|
once, and the first match wins. */}
|
||||||
|
<p className="text-xs text-muted-foreground">{t('appr.orderHint')}</p>
|
||||||
|
{cfg.rules.map((r, i) => (
|
||||||
|
<div key={r.id} className="rounded-lg border border-border/60 p-2.5 space-y-2"
|
||||||
|
style={r.enabled ? preview(r.color) : undefined}>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={r.enabled} onCheckedChange={(c) => patchRule(r.id, { enabled: !!c })} />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
|
||||||
|
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
|
||||||
|
</label>
|
||||||
|
{/* Which channels this category looks at. None ticked = all of
|
||||||
|
them, which is what an unnarrowed rule should mean. */}
|
||||||
|
{r.enabled && r.id !== 'worked' && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
|
||||||
|
{CHANNELS.map((c) => {
|
||||||
|
const on = !r.channels?.length || r.channels.includes(c);
|
||||||
|
return (
|
||||||
|
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Checkbox checked={on} onCheckedChange={(v) => {
|
||||||
|
const cur = r.channels?.length ? r.channels : [...CHANNELS];
|
||||||
|
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
|
||||||
|
patchRule(r.id, { channels: next });
|
||||||
|
}} />
|
||||||
|
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.enabled && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap pl-6">
|
||||||
|
{PALETTE.map((c) => (
|
||||||
|
<button key={c} type="button" title={c}
|
||||||
|
onClick={() => patchRule(r.id, { color: c })}
|
||||||
|
className={cn('size-6 rounded-md border-2 transition-transform hover:scale-110',
|
||||||
|
r.color.toLowerCase() === c ? 'border-foreground' : 'border-transparent')}
|
||||||
|
style={{ backgroundColor: c }} />
|
||||||
|
))}
|
||||||
|
<input type="color" value={r.color} title={t('appr.custom')}
|
||||||
|
onChange={(e) => patchRule(r.id, { color: e.target.value })}
|
||||||
|
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle, Share2 } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
ExportAwardForCatalog,
|
ExportAwardForCatalog, ExportAward,
|
||||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -97,6 +97,13 @@ function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onTog
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Awards use a far-future date as "no end" (RDA carries 9999-12-31). Printing it
|
||||||
|
// back at the operator reads like a real deadline, so show it as open-ended.
|
||||||
|
function openEnded(d?: string): string {
|
||||||
|
if (!d) return '—';
|
||||||
|
return /^9\d{3}-/.test(d) ? '—' : d;
|
||||||
|
}
|
||||||
|
|
||||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||||
@@ -307,6 +314,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export the SELECTED award on its own — the unit you actually share. The
|
||||||
|
// bundle above is a backup: sending it to someone hands over your whole
|
||||||
|
// catalogue when they asked for one award.
|
||||||
|
//
|
||||||
|
// Distinct from the catalog publish below, which is for shipping an award INTO
|
||||||
|
// OpsLog: that one stamps a version and clears user_edited. This one is a plain
|
||||||
|
// share and leaves both alone, so the recipient's copy is correctly marked as
|
||||||
|
// someone else's work rather than as a pristine built-in.
|
||||||
|
async function exportOne() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
try {
|
||||||
|
const code = cur.code.trim().toUpperCase();
|
||||||
|
const p = await ExportAward(code);
|
||||||
|
if (p) setErr(t('awed.exportedOneTo', { code, path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
@@ -761,6 +785,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<TabsContent value="refs" className="mt-0">
|
<TabsContent value="refs" className="mt-0">
|
||||||
<ReferencesPanel
|
<ReferencesPanel
|
||||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||||
|
awardValidFrom={cur.valid_from} awardValidTo={cur.valid_to}
|
||||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||||
onChanged={loadMeta} setErr={setErr}
|
onChanged={loadMeta} setErr={setErr}
|
||||||
/>
|
/>
|
||||||
@@ -780,6 +805,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Share just the selected one. Sitting next to the whole-catalogue
|
||||||
|
export because that is where an operator looks for it, and labelled
|
||||||
|
with the code so the two are never confused at a glance. */}
|
||||||
|
{cur && (
|
||||||
|
<Button variant="outline" onClick={exportOne} title={t('awed.exportOneTitle')}>
|
||||||
|
<Share2 className="size-3.5 mr-1" /> {t('awed.exportOne', { code: cur.code.trim().toUpperCase() })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -874,8 +907,8 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||||
// the online updater (POTA/SOTA/WWFF).
|
// the online updater (POTA/SOTA/WWFF).
|
||||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, onUpdateOnline, updating, onChanged, setErr }: {
|
||||||
code: string; presets: Preset[]; meta?: RefMeta;
|
code: string; presets: Preset[]; meta?: RefMeta; awardValidFrom?: string; awardValidTo?: string;
|
||||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -1031,6 +1064,26 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
third-party list may carry the values — but they are not offered
|
third-party list may carry the values — but they are not offered
|
||||||
for editing until something actually reads them. */}
|
for editing until something actually reads them. */}
|
||||||
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||||
|
{/* This reference's own validity window. A reference is not forever:
|
||||||
|
a park is delisted, a district merged. A QSO made while it
|
||||||
|
existed still counts — it was a valid contact on the day — and
|
||||||
|
one made afterwards does not.
|
||||||
|
Left empty the award's own dates govern, which is why they show
|
||||||
|
as the placeholder: the operator can see what "empty" inherits
|
||||||
|
instead of having to remember. */}
|
||||||
|
<Field2 label={t('awed.refValidFrom')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_from ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_from: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<Field2 label={t('awed.refValidTo')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_to ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_to: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(awardValidFrom || awardValidTo)
|
||||||
|
? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
|
||||||
|
: t('awed.refValidHint')}
|
||||||
|
</p>
|
||||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type SpotStatusEntry = {
|
|||||||
new_county?: boolean;
|
new_county?: boolean;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
|
// Whether the station uploads to LoTW. Not a status — it says nothing about
|
||||||
|
// whether the spot is worth chasing — so it is drawn as a badge, never a colour.
|
||||||
|
lotw?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||||||
@@ -79,6 +82,8 @@ interface Props {
|
|||||||
// globally from the band-map tab toolbar.
|
// globally from the band-map tab toolbar.
|
||||||
hideDigital?: boolean;
|
hideDigital?: boolean;
|
||||||
fitToBand?: boolean;
|
fitToBand?: boolean;
|
||||||
|
// Mark stations that upload to LoTW (Settings → Appearance).
|
||||||
|
showLotw?: boolean;
|
||||||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||||
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||||
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||||
@@ -227,7 +232,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
|||||||
// last; ties broken by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false, showLotw = false }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// The two display options are applied ONCE here, on the whole map, so the
|
// The two display options are applied ONCE here, on the whole map, so the
|
||||||
@@ -649,6 +654,15 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
})()}
|
})()}
|
||||||
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||||
<span>{p.spot.dx_call}</span>
|
<span>{p.spot.dx_call}</span>
|
||||||
|
{/* Same badge the cluster list draws, for the same reason: the
|
||||||
|
muted-blue of a confirmation, never a status colour —
|
||||||
|
whether a station uploads to LoTW says nothing about
|
||||||
|
whether the spot is worth chasing. A glyph rather than a
|
||||||
|
colour also keeps it off the channel the statuses use. */}
|
||||||
|
{showLotw && entry?.lotw && (
|
||||||
|
<span title={t('clu.lotwBadge')} className="text-[9px] font-normal rounded px-1 py-px"
|
||||||
|
style={{ background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)' }}>L</span>
|
||||||
|
)}
|
||||||
{mode && (
|
{mode && (
|
||||||
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
||||||
{mode}
|
{mode}
|
||||||
|
|||||||
@@ -172,8 +172,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||||
[lat, lon],
|
[lat, lon],
|
||||||
);
|
);
|
||||||
|
// Stacked, not side by side. Laid out in a row this cost about 150 px of a
|
||||||
|
// header that has to hold the callsign, the badges and the band grid, and it
|
||||||
|
// was what pushed the whole row onto a second line. Two short times one above
|
||||||
|
// the other take a fraction of that and no extra height: the row is already
|
||||||
|
// taller than one line of text.
|
||||||
|
//
|
||||||
|
// "UTC" moves into the tooltip with them — the times are monospaced and always
|
||||||
|
// UTC everywhere in OpsLog, so the label was spending width to repeat a
|
||||||
|
// convention the operator already lives by.
|
||||||
const sunBlock = sun ? (
|
const sunBlock = sun ? (
|
||||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
<div className="ml-auto flex flex-col items-end leading-tight text-xs shrink-0"
|
||||||
title="Sunrise / sunset at the DX station (UTC)">
|
title="Sunrise / sunset at the DX station (UTC)">
|
||||||
{sun.polarDay ? (
|
{sun.polarDay ? (
|
||||||
<span className="font-semibold text-warning">midnight sun</span>
|
<span className="font-semibold text-warning">midnight sun</span>
|
||||||
@@ -182,14 +191,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Sunrise className="size-3.5 text-warning" />
|
<Sunrise className="size-3 text-warning" />
|
||||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Sunset className="size-3.5 text-info" />
|
<Sunset className="size-3 text-info" />
|
||||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">UTC</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||||
|
// The contact itself — repair fields, not description fields. An import that
|
||||||
|
// mapped every QSO to SSB, or an ADIF with no MODE at all, is fixed here
|
||||||
|
// instead of one row at a time.
|
||||||
|
//
|
||||||
|
// Band is deliberately absent: it travels with the frequency below, because a
|
||||||
|
// band contradicting its own frequency is invalid ADIF and every export would
|
||||||
|
// carry the contradiction.
|
||||||
|
{ id: 'mode', label: 'bulk.fMode', group: 'The contact', kind: 'text', upper: true },
|
||||||
|
{ id: 'submode', label: 'bulk.fSubmode', group: 'The contact', kind: 'text', upper: true },
|
||||||
|
{ id: 'rst_sent', label: 'bulk.fRstSent', group: 'The contact', kind: 'text' },
|
||||||
|
{ id: 'rst_rcvd', label: 'bulk.fRstRcvd', group: 'The contact', kind: 'text' },
|
||||||
// Misc
|
// Misc
|
||||||
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||||
// logged on a stale/default frequency after CAT dropped.
|
// logged on a stale/default frequency after CAT dropped.
|
||||||
@@ -110,7 +121,12 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
|||||||
{ v: '_', label: 'bulk.statusBlank' },
|
{ v: '_', label: 'bulk.statusBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
// Derived from the fields themselves, in the order they are declared.
|
||||||
|
//
|
||||||
|
// This used to be a hand-written list, and a group added to FIELDS but not to it
|
||||||
|
// simply never rendered — the fields existed, passed every check, and could not
|
||||||
|
// be picked. Two lists that must agree, with nothing to make them.
|
||||||
|
const GROUPS = [...new Set(FIELDS.map((f) => f.group))];
|
||||||
// Maps the internal group key → its i18n label key.
|
// Maps the internal group key → its i18n label key.
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
'QSL / upload': 'bulk.groupQsl',
|
'QSL / upload': 'bulk.groupQsl',
|
||||||
@@ -119,6 +135,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
'Contest': 'bulk.groupContest',
|
'Contest': 'bulk.groupContest',
|
||||||
'Propagation': 'bulk.groupPropagation',
|
'Propagation': 'bulk.groupPropagation',
|
||||||
'Misc': 'bulk.groupMisc',
|
'Misc': 'bulk.groupMisc',
|
||||||
|
'The contact': 'bulk.groupContact',
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -177,7 +194,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{GROUP_LABELS[g] ? t(GROUP_LABELS[g]) : g}</div>
|
||||||
{FIELDS.filter((f) => f.group === g)
|
{FIELDS.filter((f) => f.group === g)
|
||||||
.map((f) => ({ f, txt: t(f.label) }))
|
.map((f) => ({ f, txt: t(f.label) }))
|
||||||
.sort((a, b) => a.txt.localeCompare(b.txt))
|
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface DetailsState {
|
|||||||
srx_string?: string;
|
srx_string?: string;
|
||||||
stx_string?: string;
|
stx_string?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
web?: string;
|
||||||
// Award references for the contacted station (set via the Awards tab picker).
|
// Award references for the contacted station (set via the Awards tab picker).
|
||||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||||
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||||
@@ -373,12 +374,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label={t('detp.qslMessage')} span={7}>
|
{/* QSL via gets the room, not the message. Width should follow use, and
|
||||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
these two are nowhere near equal: a manager's callsign is filled in
|
||||||
</Field>
|
constantly and a QSL message almost never. The message had 7 columns
|
||||||
<Field label={t('detp.qslVia')} span={5}>
|
of 12 for text most operators never type. */}
|
||||||
|
<Field label={t('detp.qslVia')} span={7}>
|
||||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={t('detp.qslMessage')} span={5}>
|
||||||
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -494,8 +499,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<Field label="STX">
|
<Field label="STX">
|
||||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.contactedEmail')} span={3}>
|
{/* No placeholder: a greyed-out sample address in an empty field reads
|
||||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
as a value the lookup found, and that is exactly the field an
|
||||||
|
operator checks to see whether it found one. */}
|
||||||
|
<Field label={t('detp.contactedEmail')} span={2}>
|
||||||
|
<Input value={details.email} onChange={(e) => onChange({ email: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('detp.contactedWeb')}>
|
||||||
|
<Input value={details.web ?? ''} onChange={(e) => onChange({ web: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||||
|
import { rowStyleFor, type RowColorSettings } from '@/lib/rowColors';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
@@ -80,6 +81,8 @@ type Props = {
|
|||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
|
// Whole-row colouring by QSL / LoTW status (Settings → Appearance).
|
||||||
|
rowColors?: RowColorSettings | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
@@ -132,6 +135,10 @@ function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
|||||||
|
|
||||||
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||||
// ── QSO basics ──
|
// ── QSO basics ──
|
||||||
|
// Position in the log, oldest = 1. Filled by the backend over the WHOLE log,
|
||||||
|
// so it does not change when a filter narrows what is shown.
|
||||||
|
{ group: 'QSO', label: t('rqg.c.number'), colId: 'number', headerName: t('rqg.h.number'), field: 'number' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||||
@@ -299,7 +306,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
|||||||
return rest;
|
return rest;
|
||||||
});
|
});
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -696,6 +703,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
|
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus,
|
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -77,6 +77,7 @@ import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat'
|
|||||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||||
|
import { AppearancePanel } from '@/components/AppearancePanel';
|
||||||
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||||||
|
|
||||||
type LookupSettings = LookupSettingsForm;
|
type LookupSettings = LookupSettingsForm;
|
||||||
@@ -176,6 +177,7 @@ interface Props {
|
|||||||
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
||||||
type SectionId =
|
type SectionId =
|
||||||
| 'general'
|
| 'general'
|
||||||
|
| 'appearance'
|
||||||
| 'email'
|
| 'email'
|
||||||
| 'station'
|
| 'station'
|
||||||
| 'profiles'
|
| 'profiles'
|
||||||
@@ -266,6 +268,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{
|
{
|
||||||
kind: 'group', label: t('nav.software'), icon: Cog, defaultOpen: true, children: [
|
kind: 'group', label: t('nav.software'), icon: Cog, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: t('sec.general'), id: 'general' },
|
{ kind: 'item', label: t('sec.general'), id: 'general' },
|
||||||
|
{ kind: 'item', label: t('sec.appearance'), id: 'appearance' },
|
||||||
{ kind: 'item', label: t('sec.email'), id: 'email' },
|
{ kind: 'item', label: t('sec.email'), id: 'email' },
|
||||||
{ kind: 'item', label: t('sec.lookup'), id: 'lookup' },
|
{ kind: 'item', label: t('sec.lookup'), id: 'lookup' },
|
||||||
{ kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
|
{ kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
|
||||||
@@ -290,7 +293,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
// Map section id → i18n key (breadcrumb / placeholders).
|
// Map section id → i18n key (breadcrumb / placeholders).
|
||||||
const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||||
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||||
adifmon: 'sec.adifmon',
|
adifmon: 'sec.adifmon',
|
||||||
webpublish: 'sec.webpublish',
|
webpublish: 'sec.webpublish',
|
||||||
@@ -689,6 +692,13 @@ const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '1
|
|||||||
// Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow
|
// Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow
|
||||||
// filter is a subset of these. Must match motorBands in app.go, low → high.
|
// filter is a subset of these. Must match motorBands in app.go, low → high.
|
||||||
const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||||
|
// Shown as placeholders only — the backend owns these values (motorBands in
|
||||||
|
// app.go) and resolves what a band button actually commands. Duplicated here
|
||||||
|
// purely so an empty box can say what leaving it empty will do.
|
||||||
|
const MOTOR_BAND_DEFAULT_KHZ: Record<string, number> = {
|
||||||
|
'40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
|
||||||
|
'15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
|
||||||
|
};
|
||||||
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
||||||
|
|
||||||
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
||||||
@@ -1250,8 +1260,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
|
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; band_freqs: Record<string, number>; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
|
||||||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
|
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', band_freqs: {}, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
|
||||||
});
|
});
|
||||||
const [ubTesting, setUbTesting] = useState(false);
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -1545,6 +1555,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// has side effects there — adding the RBN nodes, bringing the PSK Reporter
|
// has side effects there — adding the RBN nodes, bringing the PSK Reporter
|
||||||
// feed up or down — so the write has to go where those live.
|
// feed up or down — so the write has to go where those live.
|
||||||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||||||
|
const [chaseGrids, setChaseGrids] = useState(false);
|
||||||
|
const [gridStat, setGridStat] = useState<any>(null);
|
||||||
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
||||||
const saveBandOpen = async (next: any) => {
|
const saveBandOpen = async (next: any) => {
|
||||||
setBandOpen(next);
|
setBandOpen(next);
|
||||||
@@ -1553,11 +1565,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||||
|
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
||||||
})();
|
})();
|
||||||
// Poll the feed while the panel is open: a live count is the only thing that
|
// Poll the feed while the panel is open: a live count is the only thing that
|
||||||
// distinguishes "connected" from "connected and receiving nothing".
|
// distinguishes "connected" from "connected and receiving nothing".
|
||||||
const t = window.setInterval(async () => {
|
const t = window.setInterval(async () => {
|
||||||
try { setPskrStatus(await GetPSKReporterStatus()); } catch { /* ignore */ }
|
try { setPskrStatus(await GetPSKReporterStatus()); } catch { /* ignore */ }
|
||||||
|
try { setGridStat(await GetGridCacheStatus()); } catch { /* ignore */ }
|
||||||
}, 3000);
|
}, 3000);
|
||||||
return () => window.clearInterval(t);
|
return () => window.clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -3140,11 +3154,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
||||||
Follow rig frequency (auto-tune the antenna)
|
{t('hw.motorFollow')}
|
||||||
</label>
|
</label>
|
||||||
{ultrabeam.follow && (
|
{ultrabeam.follow && (
|
||||||
<div className="flex items-center gap-3 pl-6">
|
<div className="space-y-2 pl-6">
|
||||||
<Label className="text-sm">Re-tune step</Label>
|
<div className="flex items-center gap-3">
|
||||||
|
<Label className="text-sm">{t('station.trackModeTip')}</Label>
|
||||||
|
<Select value={ultrabeam.track_mode || 'step'} onValueChange={(v) => setUltrabeam((s) => ({ ...s, track_mode: v }))}>
|
||||||
|
<SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="always">{t('station.trackAlways')}</SelectItem>
|
||||||
|
<SelectItem value="step">{t('station.trackStep')}</SelectItem>
|
||||||
|
<SelectItem value="band">{t('station.trackBand')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{/* The step is only a question in step mode — the other two modes
|
||||||
|
have nothing to threshold. */}
|
||||||
|
{(ultrabeam.track_mode || 'step') === 'step' && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label className="text-sm">{t('hw.motorStep')}</Label>
|
||||||
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
|
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
|
||||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -3153,32 +3182,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectItem value="100">100 kHz</SelectItem>
|
<SelectItem value="100">100 kHz</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<span className="text-xs text-muted-foreground">re-tune only when the frequency moves this far</span>
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(ultrabeam.track_mode || 'step') === 'always' ? t('station.trackAlwaysTip')
|
||||||
|
: (ultrabeam.track_mode || 'step') === 'band' ? t('station.trackBandTip')
|
||||||
|
: t('station.trackStepTipMode')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
|
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<Label className="text-sm">{t('hw.motorBands')}</Label>
|
<Label className="text-sm">{t('hw.motorBands')}</Label>
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
{/* Band, and under it the frequency its button tunes to — the
|
||||||
|
layout of the SteppIR controller's own Bands and Frequencies
|
||||||
|
table, which is where operators expect to find this. The box
|
||||||
|
only appears on a selected band: a tune frequency for a band the
|
||||||
|
antenna is not allowed on is a setting with no effect. Left
|
||||||
|
empty it shows the default as placeholder, so the field is
|
||||||
|
self-documenting and clearing it is how you go back. */}
|
||||||
|
<div className="flex items-start gap-1.5 flex-wrap">
|
||||||
{MOTOR_BANDS.map((b) => {
|
{MOTOR_BANDS.map((b) => {
|
||||||
const on = ultrabeam.bands.includes(b);
|
const on = ultrabeam.bands.includes(b);
|
||||||
return (
|
return (
|
||||||
<button key={b} type="button"
|
<div key={b} className="flex flex-col gap-1">
|
||||||
|
<button type="button"
|
||||||
onClick={() => setUltrabeam((s) => ({
|
onClick={() => setUltrabeam((s) => ({
|
||||||
...s,
|
...s,
|
||||||
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
|
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
|
||||||
}))}
|
}))}
|
||||||
className={`h-8 min-w-[3rem] rounded-md border px-2 text-sm font-medium transition-colors ${
|
className={`h-8 w-[4.5rem] rounded-md border px-2 text-sm font-medium transition-colors ${
|
||||||
on
|
on
|
||||||
? 'border-primary bg-primary/15 text-primary'
|
? 'border-primary bg-primary/15 text-primary'
|
||||||
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{b}
|
{b}
|
||||||
</button>
|
</button>
|
||||||
|
{on && (
|
||||||
|
<input
|
||||||
|
type="text" inputMode="numeric"
|
||||||
|
value={ultrabeam.band_freqs?.[b] ? String(ultrabeam.band_freqs[b]) : ''}
|
||||||
|
placeholder={String(MOTOR_BAND_DEFAULT_KHZ[b] ?? '')}
|
||||||
|
title={t('hw.motorBandFreqHint')}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Keep only digits, and store nothing for an empty box
|
||||||
|
// so it round-trips to "use the default" rather than
|
||||||
|
// to a zero the backend would have to interpret.
|
||||||
|
const digits = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
setUltrabeam((s) => {
|
||||||
|
const next = { ...(s.band_freqs || {}) };
|
||||||
|
if (digits === '') delete next[b]; else next[b] = parseInt(digits, 10);
|
||||||
|
return { ...s, band_freqs: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 w-[4.5rem] rounded-md border border-input bg-background px-1.5 text-center text-xs font-mono outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('hw.motorBandFreqHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-1">
|
<div className="border-t border-border/60 pt-3 space-y-1">
|
||||||
@@ -4169,6 +4234,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
things set up once. A preferences dialog you reopen every ten minutes
|
things set up once. A preferences dialog you reopen every ten minutes
|
||||||
is a filter in the wrong place. */}
|
is a filter in the wrong place. */}
|
||||||
|
|
||||||
|
{/* Grid chasing. Here rather than in the filter panel because it is set
|
||||||
|
up once: it decides whether locators learnt from decodes are KEPT
|
||||||
|
across restarts, not what the list shows right now. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={chaseGrids} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
||||||
|
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
{chaseGrids && (
|
||||||
|
<p className="pl-6 text-xs text-muted-foreground">
|
||||||
|
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
|
||||||
|
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
||||||
switching it on adds two of them — the operator should see that
|
switching it on adds two of them — the operator should see that
|
||||||
happen where it happens rather than find nodes they did not add. */}
|
happen where it happens rather than find nodes they did not add. */}
|
||||||
@@ -6084,6 +6166,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// Map sections to their content + icon (for placeholder).
|
// Map sections to their content + icon (for placeholder).
|
||||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||||
general: GeneralPanel,
|
general: GeneralPanel,
|
||||||
|
appearance: () => <AppearancePanel />,
|
||||||
email: EmailPanel,
|
email: EmailPanel,
|
||||||
station: StationPanel,
|
station: StationPanel,
|
||||||
profiles: ProfilesPanel,
|
profiles: ProfilesPanel,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; bands?: string[] };
|
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record<string, number> };
|
||||||
|
|
||||||
// Where each band button points the antenna.
|
// Where each band button points the antenna.
|
||||||
//
|
//
|
||||||
@@ -241,7 +241,10 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.bands')}</div>
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.bands')}</div>
|
||||||
<div className="grid grid-cols-5 gap-1">
|
<div className="grid grid-cols-5 gap-1">
|
||||||
{(ant.bands ?? []).map((b: string) => {
|
{(ant.bands ?? []).map((b: string) => {
|
||||||
const khz = ANT_BAND_KHZ[b];
|
// Where this band tunes is resolved by the backend — the operator's
|
||||||
|
// per-band choice from Settings, or the default. ANT_BAND_KHZ is only
|
||||||
|
// the floor for a status poll that hasn't landed yet.
|
||||||
|
const khz = ant.band_freqs?.[b] || ANT_BAND_KHZ[b];
|
||||||
if (!khz) return null;
|
if (!khz) return null;
|
||||||
// "On this band" from the antenna's own frequency, not the rig's:
|
// "On this band" from the antenna's own frequency, not the rig's:
|
||||||
// the widget must show where the ANTENNA is, which is the whole
|
// the widget must show where the ANTENNA is, which is the whole
|
||||||
@@ -277,19 +280,21 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
|
|
||||||
{/* Tracking. Here rather than only in Settings because it is an operating
|
{/* Tracking. Here rather than only in Settings because it is an operating
|
||||||
decision — off to park the antenna, on to resume — not something set
|
decision — off to park the antenna, on to resume — not something set
|
||||||
up once. The step only shows when tracking is on: a threshold for
|
up once. Mode and step only show when tracking is on: settings for
|
||||||
something switched off is a question the operator cannot act on. */}
|
something switched off are questions the operator cannot act on. And
|
||||||
|
the step only shows in step mode, where it is the one thing it means. */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => run(SetMotorFollow(!ant.follow, 0))}
|
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
||||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
||||||
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
||||||
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||||
</button>
|
</button>
|
||||||
{ant.follow && (
|
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
||||||
<select
|
<select
|
||||||
value={String(ant.step_khz || 50)}
|
value={String(ant.step_khz || 50)}
|
||||||
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10)))}
|
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
|
||||||
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||||
title={t('station.trackStepTip')}
|
title={t('station.trackStepTip')}
|
||||||
>
|
>
|
||||||
@@ -297,6 +302,19 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{ant.follow && (
|
||||||
|
<select
|
||||||
|
value={ant.track_mode || 'step'}
|
||||||
|
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
||||||
|
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
||||||
|
title={t('station.trackModeTip')}
|
||||||
|
>
|
||||||
|
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
|
||||||
|
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
|
||||||
|
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="button" disabled={!ant.connected}
|
<button type="button" disabled={!ant.connected}
|
||||||
onClick={() => run(UltrabeamRetract())}
|
onClick={() => run(UltrabeamRetract())}
|
||||||
|
|||||||
+15
-15
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
|
|||||||
|
// Row colouring for the log grid, by QSL status.
|
||||||
|
//
|
||||||
|
// Four categories, each scoped to the channels the operator cares about —
|
||||||
|
// paper QSL, LoTW, eQSL, QRZ.com. The rules are ORDERED and the first match
|
||||||
|
// wins, because a contact is usually several of them at once.
|
||||||
|
|
||||||
|
export type RowColorRule = {
|
||||||
|
id: string;
|
||||||
|
color: string;
|
||||||
|
enabled: boolean;
|
||||||
|
channels?: string[]; // empty = every channel
|
||||||
|
};
|
||||||
|
export type RowColorSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
style?: 'bar' | 'tint' | 'both';
|
||||||
|
intensity?: number;
|
||||||
|
bandmap_lotw?: boolean;
|
||||||
|
rules: RowColorRule[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||||
|
|
||||||
|
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
|
||||||
|
// "upload status" rather than a QSL flag, but they carry the same Y / R letters.
|
||||||
|
const FIELDS: Record<string, { sent: string; rcvd: string }> = {
|
||||||
|
qsl: { sent: 'qsl_sent', rcvd: 'qsl_rcvd' },
|
||||||
|
lotw: { sent: 'lotw_sent', rcvd: 'lotw_rcvd' },
|
||||||
|
eqsl: { sent: 'eqsl_sent', rcvd: 'eqsl_rcvd' },
|
||||||
|
qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ADIF QSL fields are single letters. Y is the only one that means "yes";
|
||||||
|
// R (requested) and Q (queued) mean it has not gone out yet — a different state,
|
||||||
|
// and the one an operator looks for when deciding what to send.
|
||||||
|
const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y';
|
||||||
|
const owed = (v: any) => {
|
||||||
|
const s = String(v ?? '').trim().toUpperCase();
|
||||||
|
return s === 'R' || s === 'Q';
|
||||||
|
};
|
||||||
|
|
||||||
|
const chansOf = (r: RowColorRule): readonly string[] =>
|
||||||
|
r.channels && r.channels.length ? r.channels : CHANNELS;
|
||||||
|
|
||||||
|
function ruleMatches(q: any, r: RowColorRule): boolean {
|
||||||
|
const cs = chansOf(r);
|
||||||
|
switch (r.id) {
|
||||||
|
case 'confirmed':
|
||||||
|
return cs.some((c) => yes(q[FIELDS[c]?.rcvd]));
|
||||||
|
case 'sent':
|
||||||
|
return cs.some((c) => yes(q[FIELDS[c]?.sent]));
|
||||||
|
case 'to_send':
|
||||||
|
return cs.some((c) => owed(q[FIELDS[c]?.sent]));
|
||||||
|
case 'worked':
|
||||||
|
// The catch-all: nothing sent, nothing asked for, nothing back.
|
||||||
|
return !CHANNELS.some((c) => yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent]));
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks the rules IN ORDER — the order is the priority, and the settings panel
|
||||||
|
// shows it numbered so it can be read rather than guessed.
|
||||||
|
export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule | null {
|
||||||
|
if (!q || !cfg?.rules) return null;
|
||||||
|
for (const r of cfg.rules) {
|
||||||
|
if (r.enabled && ruleMatches(q, r)) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The colour is never a fill by default.
|
||||||
|
//
|
||||||
|
// A log where nearly every contact has SOME QSL state ends up with every row
|
||||||
|
// painted, and colour that is always present stops being information. The
|
||||||
|
// default is a stripe down the left edge; a tint is offered at a chosen strength.
|
||||||
|
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined {
|
||||||
|
if (!cfg?.enabled) return undefined;
|
||||||
|
const rule = matchRowRule(q, cfg);
|
||||||
|
if (!rule?.color) return undefined;
|
||||||
|
|
||||||
|
const style = cfg.style ?? 'bar';
|
||||||
|
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (style === 'tint' || style === 'both') {
|
||||||
|
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
|
||||||
|
}
|
||||||
|
if (style === 'bar' || style === 'both') {
|
||||||
|
// inset shadow rather than a border: a border would shift the cells three
|
||||||
|
// pixels on coloured rows only, and the columns would stop lining up.
|
||||||
|
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -13,7 +13,17 @@
|
|||||||
|
|
||||||
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
||||||
|
|
||||||
|
// Both options are withdrawn from the filter panel for now. The machinery below
|
||||||
|
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
||||||
|
// switches back is this one flag and the block they came from in App.tsx.
|
||||||
|
//
|
||||||
|
// The saved preferences are left untouched in localStorage rather than cleared:
|
||||||
|
// an operator who had either turned on gets them back exactly as they were the
|
||||||
|
// day the options return, instead of silently starting from off.
|
||||||
|
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
||||||
|
|
||||||
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
||||||
|
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) return { muteWorked: false, slotHighlight: false };
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.24.5';
|
export const APP_VERSION = '0.24.9';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+13
-1
@@ -88,6 +88,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
|
|||||||
|
|
||||||
export function AwardsFolder():Promise<string>;
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
|
export function BackfillDistances():Promise<main.BackfillDistancesResult>;
|
||||||
|
|
||||||
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||||
|
|
||||||
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||||
@@ -402,6 +404,8 @@ export function GetCatalogCodes():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
|
export function GetChaseNewGrids():Promise<boolean>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -438,6 +442,8 @@ export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
|||||||
|
|
||||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
|
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
@@ -486,6 +492,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
|||||||
|
|
||||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
|
export function GetRowColors():Promise<main.RowColorSettings>;
|
||||||
|
|
||||||
export function GetSPEStatus():Promise<spe.Status>;
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||||
@@ -922,6 +930,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
@@ -968,6 +978,8 @@ export function SetCIVTrace(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -980,7 +992,7 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetMotorFollow(arg1:boolean,arg2:number):Promise<void>;
|
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ export function AwardsFolder() {
|
|||||||
return window['go']['main']['App']['AwardsFolder']();
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BackfillDistances() {
|
||||||
|
return window['go']['main']['App']['BackfillDistances']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BackfillUSCounties() {
|
export function BackfillUSCounties() {
|
||||||
return window['go']['main']['App']['BackfillUSCounties']();
|
return window['go']['main']['App']['BackfillUSCounties']();
|
||||||
}
|
}
|
||||||
@@ -746,6 +750,10 @@ export function GetChangelog() {
|
|||||||
return window['go']['main']['App']['GetChangelog']();
|
return window['go']['main']['App']['GetChangelog']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChaseNewGrids() {
|
||||||
|
return window['go']['main']['App']['GetChaseNewGrids']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -818,6 +826,10 @@ export function GetFlexState() {
|
|||||||
return window['go']['main']['App']['GetFlexState']();
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetGridCacheStatus() {
|
||||||
|
return window['go']['main']['App']['GetGridCacheStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetIcomState() {
|
export function GetIcomState() {
|
||||||
return window['go']['main']['App']['GetIcomState']();
|
return window['go']['main']['App']['GetIcomState']();
|
||||||
}
|
}
|
||||||
@@ -914,6 +926,10 @@ export function GetRotators() {
|
|||||||
return window['go']['main']['App']['GetRotators']();
|
return window['go']['main']['App']['GetRotators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRowColors() {
|
||||||
|
return window['go']['main']['App']['GetRowColors']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSPEStatus() {
|
export function GetSPEStatus() {
|
||||||
return window['go']['main']['App']['GetSPEStatus']();
|
return window['go']['main']['App']['GetSPEStatus']();
|
||||||
}
|
}
|
||||||
@@ -1786,6 +1802,10 @@ export function SaveRotators(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRowColors(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRowColors'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveSelfSpotSettings(arg1) {
|
export function SaveSelfSpotSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1878,6 +1898,10 @@ export function SetCWDecoderPitch(arg1) {
|
|||||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetChaseNewGrids(arg1) {
|
||||||
|
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1) {
|
export function SetClublogCtyEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1902,8 +1926,8 @@ export function SetKenwoodKeySpeed(arg1) {
|
|||||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetMotorFollow(arg1, arg2) {
|
export function SetMotorFollow(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2);
|
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1, arg2) {
|
export function SetOpsLogQSLReceived(arg1, arg2) {
|
||||||
|
|||||||
@@ -1441,6 +1441,8 @@ export namespace lookup {
|
|||||||
cont?: string;
|
cont?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
qsl_via?: string;
|
qsl_via?: string;
|
||||||
|
web?: string;
|
||||||
|
zip?: string;
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
source: string;
|
source: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -1468,6 +1470,8 @@ export namespace lookup {
|
|||||||
this.cont = source["cont"];
|
this.cont = source["cont"];
|
||||||
this.email = source["email"];
|
this.email = source["email"];
|
||||||
this.qsl_via = source["qsl_via"];
|
this.qsl_via = source["qsl_via"];
|
||||||
|
this.web = source["web"];
|
||||||
|
this.zip = source["zip"];
|
||||||
this.image_url = source["image_url"];
|
this.image_url = source["image_url"];
|
||||||
this.source = source["source"];
|
this.source = source["source"];
|
||||||
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||||
@@ -1931,6 +1935,22 @@ export namespace main {
|
|||||||
this.to = source["to"];
|
this.to = source["to"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class BackfillDistancesResult {
|
||||||
|
scanned: number;
|
||||||
|
filled: number;
|
||||||
|
no_grid: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BackfillDistancesResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.scanned = source["scanned"];
|
||||||
|
this.filled = source["filled"];
|
||||||
|
this.no_grid = source["no_grid"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BackfillUSCountiesResult {
|
export class BackfillUSCountiesResult {
|
||||||
scanned: number;
|
scanned: number;
|
||||||
county: number;
|
county: number;
|
||||||
@@ -2403,6 +2423,22 @@ export namespace main {
|
|||||||
this.body = source["body"];
|
this.body = source["body"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class GridCacheStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
known: number;
|
||||||
|
pending: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new GridCacheStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.known = source["known"];
|
||||||
|
this.pending = source["pending"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ModePreset {
|
export class ModePreset {
|
||||||
name: string;
|
name: string;
|
||||||
default_rst_sent?: string;
|
default_rst_sent?: string;
|
||||||
@@ -2956,6 +2992,64 @@ export namespace main {
|
|||||||
this.motorized = source["motorized"];
|
this.motorized = source["motorized"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class RowColorRule {
|
||||||
|
id: string;
|
||||||
|
color: string;
|
||||||
|
enabled: boolean;
|
||||||
|
channels: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RowColorRule(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.color = source["color"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.channels = source["channels"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class RowColorSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
style: string;
|
||||||
|
intensity: number;
|
||||||
|
bandmap_lotw: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
rules: RowColorRule[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RowColorSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.style = source["style"];
|
||||||
|
this.intensity = source["intensity"];
|
||||||
|
this.bandmap_lotw = source["bandmap_lotw"];
|
||||||
|
this.configured = source["configured"];
|
||||||
|
this.rules = this.convertValues(source["rules"], RowColorRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ScpStatus {
|
export class ScpStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -3267,8 +3361,10 @@ export namespace main {
|
|||||||
baud: number;
|
baud: number;
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
track_mode: string;
|
||||||
tx_inhibit: boolean;
|
tx_inhibit: boolean;
|
||||||
bands: string[];
|
bands: string[];
|
||||||
|
band_freqs: Record<string, number>;
|
||||||
freq_min_mhz: number;
|
freq_min_mhz: number;
|
||||||
freq_max_mhz: number;
|
freq_max_mhz: number;
|
||||||
|
|
||||||
@@ -3287,8 +3383,10 @@ export namespace main {
|
|||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
|
this.track_mode = source["track_mode"];
|
||||||
this.tx_inhibit = source["tx_inhibit"];
|
this.tx_inhibit = source["tx_inhibit"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
|
this.band_freqs = source["band_freqs"];
|
||||||
this.freq_min_mhz = source["freq_min_mhz"];
|
this.freq_min_mhz = source["freq_min_mhz"];
|
||||||
this.freq_max_mhz = source["freq_max_mhz"];
|
this.freq_max_mhz = source["freq_max_mhz"];
|
||||||
}
|
}
|
||||||
@@ -3304,7 +3402,9 @@ export namespace main {
|
|||||||
elements: number[];
|
elements: number[];
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
track_mode: string;
|
||||||
bands: string[];
|
bands: string[];
|
||||||
|
band_freqs: Record<string, number>;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UltrabeamStatusInfo(source);
|
return new UltrabeamStatusInfo(source);
|
||||||
@@ -3322,7 +3422,9 @@ export namespace main {
|
|||||||
this.elements = source["elements"];
|
this.elements = source["elements"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
|
this.track_mode = source["track_mode"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
|
this.band_freqs = source["band_freqs"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UpdateInfo {
|
export class UpdateInfo {
|
||||||
@@ -4162,6 +4264,7 @@ export namespace qso {
|
|||||||
}
|
}
|
||||||
export class QSO {
|
export class QSO {
|
||||||
id: number;
|
id: number;
|
||||||
|
number?: number;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
qso_date: any;
|
qso_date: any;
|
||||||
@@ -4302,6 +4405,7 @@ export namespace qso {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.id = source["id"];
|
this.id = source["id"];
|
||||||
|
this.number = source["number"];
|
||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
this.qso_date = this.convertValues(source["qso_date"], null);
|
this.qso_date = this.convertValues(source["qso_date"], null);
|
||||||
this.qso_date_off = this.convertValues(source["qso_date_off"], null);
|
this.qso_date_off = this.convertValues(source["qso_date_off"], null);
|
||||||
|
|||||||
+50
-5
@@ -449,6 +449,34 @@ type RefMeta struct {
|
|||||||
Pattern string
|
Pattern string
|
||||||
re *regexp.Regexp
|
re *regexp.Regexp
|
||||||
Valid bool
|
Valid bool
|
||||||
|
// Per-reference validity window, ISO "2006-01-02". A reference is not
|
||||||
|
// forever: a park is delisted, a county is merged, a castle loses its
|
||||||
|
// reference number. A QSO made while it existed still counts — it was a valid
|
||||||
|
// contact on the day — and one made after it stopped existing does not.
|
||||||
|
//
|
||||||
|
// Empty means "no window of its own", and the award's own ValidFrom/ValidTo
|
||||||
|
// then govern, as they already do for every QSO in the award (see inScope).
|
||||||
|
// That fallback is deliberately NOT duplicated here: two places enforcing the
|
||||||
|
// same dates is two places for them to disagree.
|
||||||
|
ValidFrom string
|
||||||
|
ValidTo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeOn reports whether the reference existed on the day of the QSO.
|
||||||
|
//
|
||||||
|
// Compared as ISO date strings rather than parsed times on purpose: the stored
|
||||||
|
// values are "2025-08-01"-shaped and lexical order on that shape IS
|
||||||
|
// chronological order, so this cannot fail on a malformed date the way a parse
|
||||||
|
// can — a reference with a typo in its window keeps counting instead of silently
|
||||||
|
// vanishing from an operator's totals.
|
||||||
|
func (m RefMeta) activeOn(day string) bool {
|
||||||
|
if m.ValidFrom != "" && day < m.ValidFrom {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.ValidTo != "" && day > m.ValidTo {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||||
@@ -956,11 +984,13 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
// describes is worse than no trace, because it is believed.
|
// describes is worse than no trace, because it is believed.
|
||||||
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
||||||
predefined := hasList && !d.Dynamic
|
predefined := hasList && !d.Dynamic
|
||||||
|
// The day of the contact, for per-reference validity windows.
|
||||||
|
day := q.QSODate.Format("2006-01-02")
|
||||||
|
|
||||||
// run executes one rule and, when tracing, records it.
|
// run executes one rule and, when tracing, records it.
|
||||||
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
||||||
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
||||||
kept := keepRefs(predefined, rl, raw)
|
kept := keepRefs(predefined, rl, raw, day)
|
||||||
if ex != nil {
|
if ex != nil {
|
||||||
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
||||||
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
||||||
@@ -974,7 +1004,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
|||||||
if _, ok := keptSet[n]; ok {
|
if _, ok := keptSet[n]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
|
s.Rejected = append(s.Rejected, rejection(predefined, rl, n, day))
|
||||||
}
|
}
|
||||||
ex.Steps = append(ex.Steps, s)
|
ex.Steps = append(ex.Steps, s)
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1056,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
|||||||
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||||
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||||
// predefined award the ref is still validated against the list below.
|
// predefined award the ref is still validated against the list below.
|
||||||
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
|
manual := keepRefs(predefined, rl, manualRefs(q, d.Code), day)
|
||||||
if ex != nil {
|
if ex != nil {
|
||||||
ex.Manual = manual
|
ex.Manual = manual
|
||||||
}
|
}
|
||||||
@@ -1065,7 +1095,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
|||||||
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
||||||
// say; every one of this week's award bugs was a rejection with a plain reason
|
// say; every one of this week's award bugs was a rejection with a plain reason
|
||||||
// that nothing was printing.
|
// that nothing was printing.
|
||||||
func rejection(predefined bool, rl refList, code string) Rejected {
|
func rejection(predefined bool, rl refList, code, day string) Rejected {
|
||||||
switch {
|
switch {
|
||||||
case code == "":
|
case code == "":
|
||||||
return Rejected{Candidate: code, Reason: "empty"}
|
return Rejected{Candidate: code, Reason: "empty"}
|
||||||
@@ -1076,6 +1106,17 @@ func rejection(predefined bool, rl refList, code string) Rejected {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
||||||
}
|
}
|
||||||
|
// Spell the dates out. "Did not count" on a contact the operator remembers
|
||||||
|
// making is exactly the moment they need to be told it is the REFERENCE that
|
||||||
|
// has a window, not their log that is wrong.
|
||||||
|
if !m.activeOn(day) {
|
||||||
|
switch {
|
||||||
|
case m.ValidTo != "" && day > m.ValidTo:
|
||||||
|
return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference ceased to exist on %s, after this QSO of %s", m.ValidTo, day)}
|
||||||
|
default:
|
||||||
|
return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference did not exist until %s, after this QSO of %s", m.ValidFrom, day)}
|
||||||
|
}
|
||||||
|
}
|
||||||
if !m.Valid {
|
if !m.Valid {
|
||||||
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
||||||
}
|
}
|
||||||
@@ -1088,7 +1129,7 @@ func rejection(predefined bool, rl refList, code string) Rejected {
|
|||||||
// so we do NOT additionally require the QSO's entity to match the reference's own
|
// so we do NOT additionally require the QSO's entity to match the reference's own
|
||||||
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
||||||
// 291). Per-reference DXCC stays metadata for the picker.
|
// 291). Per-reference DXCC stays metadata for the picker.
|
||||||
func keepRefs(predefined bool, rl refList, found []string) []string {
|
func keepRefs(predefined bool, rl refList, found []string, day string) []string {
|
||||||
if !predefined {
|
if !predefined {
|
||||||
out := make([]string, 0, len(found))
|
out := make([]string, 0, len(found))
|
||||||
for _, c := range found {
|
for _, c := range found {
|
||||||
@@ -1106,6 +1147,10 @@ func keepRefs(predefined bool, rl refList, found []string) []string {
|
|||||||
if !ok || !m.Valid {
|
if !ok || !m.Valid {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// The reference has to have existed on the day of the contact.
|
||||||
|
if !m.activeOn(day) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, dup := seen[c]; dup {
|
if _, dup := seen[c]; dup {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+1017
-1016
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
func day(s string) time.Time {
|
||||||
|
t, err := time.Parse("2006-01-02", s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reference is not forever. A park is delisted, a district is merged, a castle
|
||||||
|
// loses its number. A contact made while it existed still counts — it was a
|
||||||
|
// valid contact on the day — and one made after it stopped existing does not.
|
||||||
|
func TestRefValidityWindow(t *testing.T) {
|
||||||
|
m := RefMeta{Code: "KL-01", Valid: true, ValidTo: "2025-08-31"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
day string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"2019-01-01", true},
|
||||||
|
{"2025-08-31", true}, // the last day it existed still counts
|
||||||
|
{"2025-09-01", false},
|
||||||
|
{"2026-08-12", false},
|
||||||
|
} {
|
||||||
|
if got := m.activeOn(tc.day); got != tc.want {
|
||||||
|
t.Errorf("KL-01 on %s: active=%v, want %v", tc.day, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reference that only came into being partway through.
|
||||||
|
n := RefMeta{Code: "KL-99", Valid: true, ValidFrom: "2025-01-15"}
|
||||||
|
if n.activeOn("2025-01-14") {
|
||||||
|
t.Error("counted a QSO from before the reference existed")
|
||||||
|
}
|
||||||
|
if !n.activeOn("2025-01-15") {
|
||||||
|
t.Error("the first day it existed must count")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No window of its own: the award's own dates govern, as they already do for
|
||||||
|
// every QSO in the award. Nothing here may narrow that.
|
||||||
|
if !(RefMeta{Code: "X", Valid: true}).activeOn("1970-01-01") {
|
||||||
|
t.Error("a reference with no window must count on any date")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: the same QSO counts before the cutoff and does not after.
|
||||||
|
func TestExpiredRefStopsCountingForLaterQSOs(t *testing.T) {
|
||||||
|
d := &Def{
|
||||||
|
Code: "RDA", Name: "Russian District Award", Valid: true,
|
||||||
|
Type: TypeQSOFields, Field: "note", MatchBy: "code",
|
||||||
|
Confirm: []string{"lotw"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{
|
||||||
|
{Code: "KL-01", Name: "Petrozavodsk", Valid: true, ValidTo: "2025-08-31"},
|
||||||
|
{Code: "KL-04", Name: "Kostomuksha", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
q := func(ref, on string) *qso.QSO {
|
||||||
|
return &qso.QSO{Callsign: "RA1ABC", Band: "20m", Notes: ref, QSODate: day(on)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := MatchQSO(*d, metas, q("KL-01", "2025-06-01")); len(got) != 1 || got[0] != "KL-01" {
|
||||||
|
t.Errorf("a QSO made while KL-01 existed must count: got %v", got)
|
||||||
|
}
|
||||||
|
if got := MatchQSO(*d, metas, q("KL-01", "2025-09-15")); len(got) != 0 {
|
||||||
|
t.Errorf("a QSO made after KL-01 ceased to exist must not count: got %v", got)
|
||||||
|
}
|
||||||
|
if got := MatchQSO(*d, metas, q("KL-04", "2025-09-15")); len(got) != 1 || got[0] != "KL-04" {
|
||||||
|
t.Errorf("a reference with no window is unaffected: got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The claim that an empty per-reference window "inherits the award's" has to be
|
||||||
|
// a fact about the code, not a comment. Compute and MatchQSO both gate on
|
||||||
|
// inScope, which enforces the award's own dates — so a reference with no window
|
||||||
|
// of its own is already bounded by them, and duplicating the check per reference
|
||||||
|
// would only create a second place for the same dates to disagree.
|
||||||
|
func TestEmptyRefWindowInheritsTheAward(t *testing.T) {
|
||||||
|
d := Def{
|
||||||
|
Code: "RDA", Name: "Russian District Award", Valid: true,
|
||||||
|
Type: TypeQSOFields, Field: "note", MatchBy: "code",
|
||||||
|
Confirm: []string{"lotw"},
|
||||||
|
ValidFrom: "1991-06-12", // the award itself starts here
|
||||||
|
}
|
||||||
|
metas := []RefMeta{{Code: "KL-04", Name: "Kostomuksha", Valid: true}} // no window of its own
|
||||||
|
|
||||||
|
q := func(on string) *qso.QSO {
|
||||||
|
return &qso.QSO{Callsign: "RA1ABC", Band: "20m", Notes: "KL-04", QSODate: day(on)}
|
||||||
|
}
|
||||||
|
if got := MatchQSO(d, metas, q("1991-06-11")); len(got) != 0 {
|
||||||
|
t.Errorf("a QSO before the AWARD's start counted for a reference with no window of its own: %v", got)
|
||||||
|
}
|
||||||
|
if got := MatchQSO(d, metas, q("1991-06-12")); len(got) != 1 {
|
||||||
|
t.Errorf("a QSO on the award's first day must count: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a reference window NARROWER than the award's still applies on top.
|
||||||
|
metas[0].ValidTo = "2025-08-31"
|
||||||
|
if got := MatchQSO(d, metas, q("2025-09-01")); len(got) != 0 {
|
||||||
|
t.Errorf("the reference's own end date was ignored: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,28 @@ var watched = map[string]bool{"10m": true, "6m": true, "4m": true, "2m": true}
|
|||||||
// Watched reports whether a band is one the detector looks at.
|
// Watched reports whether a band is one the detector looks at.
|
||||||
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
|
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
|
||||||
|
|
||||||
|
// maxTerrestrialKm is the longest path a band can carry through the atmosphere.
|
||||||
|
// Zero means no limit.
|
||||||
|
//
|
||||||
|
// The flat 2400 km ceiling was removed because it threw away real multi-hop Es
|
||||||
|
// on 6 m, and that was right — but "no limit anywhere" then let something else
|
||||||
|
// through. A 2 m opening was announced at 9650 km towards Japan, on stations
|
||||||
|
// that were unmistakably working EME: the moon is not an opening, and pointing
|
||||||
|
// an antenna at that bearing would find nothing.
|
||||||
|
//
|
||||||
|
// So the limit is per band, and it is physics rather than a threshold. Two
|
||||||
|
// metres reaches a few thousand kilometres by tropospheric duct or a chain of Es
|
||||||
|
// clouds and no further; beyond that the path went via the moon or a satellite,
|
||||||
|
// neither of which says anything about the band. Six and ten metres have no
|
||||||
|
// ceiling at all — multi-hop Es and F2 genuinely go round the world.
|
||||||
|
var maxTerrestrialKm = map[string]int{
|
||||||
|
"2m": 3500,
|
||||||
|
"4m": 4000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxKmFor returns the plausibility ceiling for a band, 0 for none.
|
||||||
|
func MaxKmFor(band string) int { return maxTerrestrialKm[strings.ToLower(strings.TrimSpace(band))] }
|
||||||
|
|
||||||
// Opening is a detected opening, ready to be announced.
|
// Opening is a detected opening, ready to be announced.
|
||||||
type Opening struct {
|
type Opening struct {
|
||||||
Band string `json:"band"`
|
Band string `json:"band"`
|
||||||
@@ -130,6 +152,12 @@ func (d *Detector) Add(s Spot, lat float64) *Opening {
|
|||||||
if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) {
|
if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Past what the atmosphere can carry on this band, the path went via the moon
|
||||||
|
// or a satellite. Those are real contacts and real reports; they are simply
|
||||||
|
// not evidence about the band.
|
||||||
|
if m := MaxKmFor(band); m > 0 && s.DistKm > m {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
d.recent = append(d.recent, s)
|
d.recent = append(d.recent, s)
|
||||||
d.prune(s.At)
|
d.prune(s.At)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package bandopen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A 2 m "opening" was announced at 9650 km towards Japan on stations that were
|
||||||
|
// plainly working EME. The moon is not an opening: an operator pointing an
|
||||||
|
// antenna at that bearing finds nothing.
|
||||||
|
func TestEMEIsNotAnOpeningOnTwoMetres(t *testing.T) {
|
||||||
|
d := New(DefaultConfig())
|
||||||
|
base := time.Date(2026, 8, 12, 6, 0, 0, 0, time.UTC)
|
||||||
|
for i, call := range []string{"7M4RRM", "JA7RPC", "JF1AWC", "JK1TPA", "JH1JCQ"} {
|
||||||
|
if op := d.Add(Spot{
|
||||||
|
Call: call, Band: "2m", DistKm: 9650, Bearing: 40 + i*3,
|
||||||
|
At: base.Add(time.Duration(i) * time.Minute),
|
||||||
|
}, 47.0); op != nil {
|
||||||
|
t.Fatalf("a 9650 km 2 m path was announced as an opening: %+v", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// But a real 2 m opening — an Es chain at a plausible distance — must survive.
|
||||||
|
func TestLongButPlausibleTwoMetresStillCounts(t *testing.T) {
|
||||||
|
d := New(DefaultConfig())
|
||||||
|
base := time.Date(2026, 6, 20, 18, 0, 0, 0, time.UTC)
|
||||||
|
var got *Opening
|
||||||
|
for i, call := range []string{"EA1AA", "CT1BB", "EA7CC", "CT7DD"} {
|
||||||
|
if op := d.Add(Spot{
|
||||||
|
Call: call, Band: "2m", DistKm: 2000 + i*30, Bearing: 200 + i*4,
|
||||||
|
At: base.Add(time.Duration(i) * time.Minute),
|
||||||
|
}, 47.0); op != nil {
|
||||||
|
got = op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("a 2000 km 2 m burst in one sector was not reported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Six metres keeps no ceiling: multi-hop Es genuinely goes that far, which is
|
||||||
|
// why the flat limit was removed in the first place.
|
||||||
|
func TestSixMetresHasNoCeiling(t *testing.T) {
|
||||||
|
if MaxKmFor("6m") != 0 || MaxKmFor("10m") != 0 {
|
||||||
|
t.Error("6 m and 10 m must have no distance ceiling")
|
||||||
|
}
|
||||||
|
if MaxKmFor("2m") == 0 {
|
||||||
|
t.Error("2 m must have one")
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-1
@@ -440,7 +440,31 @@ func (k *Kenwood) SetFrequency(hz int64) error {
|
|||||||
if k.curVFO == "B" {
|
if k.curVFO == "B" {
|
||||||
cmd = "FB"
|
cmd = "FB"
|
||||||
}
|
}
|
||||||
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
|
if err := k.write(fmt.Sprintf("%s%011d;", cmd, hz)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Remember what we just commanded.
|
||||||
|
//
|
||||||
|
// While PTT is held the poll is skipped and State() hands back lastState — the
|
||||||
|
// rig answers "?;" to IF; mid-transmission, and reading that as a fault used
|
||||||
|
// to drop the whole link. But a frequency SET during that window then went
|
||||||
|
// unrecorded, so the cache kept describing the dial as it was before.
|
||||||
|
//
|
||||||
|
// WSJT-X's "Fake It" is exactly that sequence: move the dial, key, transmit,
|
||||||
|
// and afterwards put it back. Polling during the over, it was told the rig was
|
||||||
|
// still on the receive frequency — so there was nothing to put back, and the
|
||||||
|
// dial stayed on the transmit frequency for good. Every following over
|
||||||
|
// started from there, which is the drift that was reported.
|
||||||
|
//
|
||||||
|
// Only simplex is updated here. Under split, FreqHz means the transmit
|
||||||
|
// frequency while this write lands on whichever VFO the operator is on, and
|
||||||
|
// guessing which side moved would be worse than a stale value the next poll
|
||||||
|
// corrects on its own.
|
||||||
|
if !k.lastState.Split {
|
||||||
|
k.curFreq = hz
|
||||||
|
k.lastState.FreqHz = hz
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *Kenwood) SetMode(mode string) error {
|
func (k *Kenwood) SetMode(mode string) error {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// WSJT-X "Fake It" against the transmit-window cache.
|
||||||
|
//
|
||||||
|
// Fake It keeps the radio on one dial frequency and shifts it only for the
|
||||||
|
// duration of each over: set the transmit frequency, key, transmit, unkey, set
|
||||||
|
// it back. The restore is not unconditional — WSJT-X reads the frequency back
|
||||||
|
// and puts the dial where it believes it should be.
|
||||||
|
//
|
||||||
|
// That read lands inside the window where this backend deliberately stops
|
||||||
|
// polling, because a Kenwood answers "?;" to IF; while it is transmitting and
|
||||||
|
// treating that as a fault used to drop the whole shared link. The cache
|
||||||
|
// answers instead. So the cache has to account for frequency SETS made during
|
||||||
|
// the window, or it describes the dial as it was before the over — and WSJT-X,
|
||||||
|
// told the radio is already on the receive frequency, has nothing to restore.
|
||||||
|
//
|
||||||
|
// This reproduces the sequence from a reported session: the dial stayed on the
|
||||||
|
// transmit frequency after the first over and every later one started there.
|
||||||
|
func TestKenwoodFakeItRestoresAfterTransmit(t *testing.T) {
|
||||||
|
const (
|
||||||
|
rxHz = 7074000 // where the operator is listening
|
||||||
|
txHz = 7075500 // where Fake It moves the dial to transmit
|
||||||
|
)
|
||||||
|
|
||||||
|
rig := &ts2000{vfoA: rxHz, mode: '2'}
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
|
||||||
|
if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz {
|
||||||
|
t.Fatalf("before the over: %d (err %v) — want %d", s.FreqHz, err, rxHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The over: shift the dial, then key.
|
||||||
|
if err := k.SetFrequency(txHz); err != nil {
|
||||||
|
t.Fatalf("set transmit frequency: %v", err)
|
||||||
|
}
|
||||||
|
if err := k.SetPTT(true); err != nil {
|
||||||
|
t.Fatalf("ptt on: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSJT-X reads back mid-over. The wire is not polled here — this is the
|
||||||
|
// cache talking, and it must not still be saying rxHz.
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read during the over: %v", err)
|
||||||
|
}
|
||||||
|
if s.FreqHz != txHz {
|
||||||
|
t.Errorf("during the over the backend reported %d, want %d — "+
|
||||||
|
"reporting the pre-over frequency is what stops Fake It restoring the dial", s.FreqHz, txHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := k.SetPTT(false); err != nil {
|
||||||
|
t.Fatalf("ptt off: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The restore, once the over is done.
|
||||||
|
if err := k.SetFrequency(rxHz); err != nil {
|
||||||
|
t.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
if rig.vfoA != rxHz {
|
||||||
|
t.Errorf("dial left on %d after the over, want %d", rig.vfoA, rxHz)
|
||||||
|
}
|
||||||
|
if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz {
|
||||||
|
t.Errorf("after the over: %d (err %v) — want %d", s.FreqHz, err, rxHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under split the same write must NOT touch the cache: FreqHz means the
|
||||||
|
// transmit frequency while the write lands on whichever VFO the operator is on,
|
||||||
|
// so guessing which side moved would put a wrong number in front of the
|
||||||
|
// operator. A stale one survives only until the next poll.
|
||||||
|
func TestKenwoodSplitCacheLeftToThePoll(t *testing.T) {
|
||||||
|
rig := &ts2000{vfoA: 14025000, vfoB: 14030000, mode: '3', split: true}
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "CW")
|
||||||
|
k.dialPort = dialTo(rig)
|
||||||
|
if err := k.Connect(); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer k.Disconnect()
|
||||||
|
|
||||||
|
s, err := k.ReadState()
|
||||||
|
if err != nil || !s.Split {
|
||||||
|
t.Fatalf("split not seen: %+v (err %v)", s, err)
|
||||||
|
}
|
||||||
|
before := s.FreqHz
|
||||||
|
|
||||||
|
if err := k.SetFrequency(14026000); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
if k.lastState.FreqHz != before {
|
||||||
|
t.Errorf("split cache moved to %d on a VFO write, want it left at %d for the poll",
|
||||||
|
k.lastState.FreqHz, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- The lookup cache gains the two fields the providers were already sending and
|
||||||
|
-- nothing was reading: the operator's own site, and the postal code.
|
||||||
|
--
|
||||||
|
-- `web` matters beyond the cache: the qso table has had a `web` column since
|
||||||
|
-- 0003 and no lookup ever filled it.
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN web TEXT;
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN zip TEXT;
|
||||||
@@ -142,4 +142,10 @@ type UploadResult struct {
|
|||||||
OK bool // the service accepted (or already had) the QSO
|
OK bool // the service accepted (or already had) the QSO
|
||||||
LogID string // service-assigned record id, when provided
|
LogID string // service-assigned record id, when provided
|
||||||
Message string // human-readable detail (reason on failure)
|
Message string // human-readable detail (reason on failure)
|
||||||
|
// Ignored is set when the service accepted the submission but left some or
|
||||||
|
// all of the QSOs out of it. TQSL does this for contacts already uploaded
|
||||||
|
// AND for contacts outside the certificate's date range, and reports both
|
||||||
|
// with the same exit code — so the caller must show the message rather than
|
||||||
|
// decide on its own that everything went through.
|
||||||
|
Ignored bool
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-4
@@ -178,8 +178,12 @@ func fileExists(p string) bool {
|
|||||||
//
|
//
|
||||||
// tqsl -d -x -a all -l "<location>" -u [-p <keypass>] <file.adi>
|
// tqsl -d -x -a all -l "<location>" -u [-p <keypass>] <file.adi>
|
||||||
//
|
//
|
||||||
// Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of
|
// Exit codes are TQSL's own (see the table in its cmdline help). 8 and 9 are
|
||||||
// range); 9 = some uploaded, some skipped; anything else = failure.
|
// the ones that matter and both used to be read as plain success: 8 means NO
|
||||||
|
// QSOs were processed and 9 means some were left out — in each case because
|
||||||
|
// they were already uploaded OR outside the callsign certificate's date range.
|
||||||
|
// Reporting either as success is how a contact came to be stamped "uploaded"
|
||||||
|
// while LoTW had never seen it.
|
||||||
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
||||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||||
loc := strings.TrimSpace(cfg.StationLocation)
|
loc := strings.TrimSpace(cfg.StationLocation)
|
||||||
@@ -249,11 +253,31 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TQSL's exit codes, from its own cmdline documentation. Two of them used to
|
||||||
|
// be read as plain success, and that is how contacts came to be stamped
|
||||||
|
// "uploaded" while LoTW had never seen them:
|
||||||
|
//
|
||||||
|
// 8 NO QSOs were processed — already uploaded OR OUT OF DATE RANGE
|
||||||
|
// 9 some processed, some ignored — same two reasons
|
||||||
|
// 14 some already uploaded, the rest signed
|
||||||
|
//
|
||||||
|
// "Out of date range" is the one that bites: a contact older than the
|
||||||
|
// callsign certificate's validity is silently left out, and reporting that as
|
||||||
|
// success stamped it sent for ever. TQSL says which case it is in its output,
|
||||||
|
// so the message is carried up rather than replaced with a guess.
|
||||||
switch code {
|
switch code {
|
||||||
case 0, 9:
|
case 0:
|
||||||
return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil
|
return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil
|
||||||
|
case 9, 14:
|
||||||
|
return UploadResult{OK: true, Ignored: true, Message: tqslDetail(msg,
|
||||||
|
"uploaded — but TQSL left some contacts out (already uploaded, or outside the certificate's date range)")}, nil
|
||||||
case 8:
|
case 8:
|
||||||
return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil
|
// Nothing reached LoTW. NOT stamped as sent: a duplicate left at "R" is
|
||||||
|
// harmless and will be refused again, while a contact wrongly marked sent
|
||||||
|
// is one the operator will never think to look at again.
|
||||||
|
return UploadResult{OK: false, Ignored: true, Message: tqslDetail(msg,
|
||||||
|
"TQSL uploaded nothing — every contact was already uploaded, or outside the certificate's date range")},
|
||||||
|
fmt.Errorf("lotw: no QSOs processed")
|
||||||
default:
|
default:
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
msg = fmt.Sprintf("tqsl exit code %d", code)
|
msg = fmt.Sprintf("tqsl exit code %d", code)
|
||||||
@@ -262,6 +286,29 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tqslDetail keeps the lines of TQSL's own output that say what happened to the
|
||||||
|
// contacts, and appends the summary.
|
||||||
|
//
|
||||||
|
// TQSL is explicit — "414 QSO records were already uploaded", "N QSO records
|
||||||
|
// are out of date range" — and that sentence is the whole answer to "why is my
|
||||||
|
// contact not on LoTW". It used to be captured and thrown away.
|
||||||
|
func tqslDetail(out, summary string) string {
|
||||||
|
var keep []string
|
||||||
|
for _, ln := range strings.Split(out, "\n") {
|
||||||
|
ln = strings.TrimSpace(ln)
|
||||||
|
l := strings.ToLower(ln)
|
||||||
|
if strings.Contains(l, "qso") && (strings.Contains(l, "already uploaded") ||
|
||||||
|
strings.Contains(l, "date range") || strings.Contains(l, "ignored") ||
|
||||||
|
strings.Contains(l, "duplicate")) {
|
||||||
|
keep = append(keep, ln)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keep) == 0 {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
return summary + " — " + strings.Join(keep, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoTW validates the LoTW config: tqsl present and the chosen station
|
// TestLoTW validates the LoTW config: tqsl present and the chosen station
|
||||||
// location exists in station_data.
|
// location exists in station_data.
|
||||||
func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
|
func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TQSL's own exit codes, from its cmdline documentation:
|
||||||
|
//
|
||||||
|
// 0 success
|
||||||
|
// 8 NO QSOs were processed — already uploaded OR out of date range
|
||||||
|
// 9 some processed, some ignored — same two reasons
|
||||||
|
// 14 some already uploaded, the rest signed
|
||||||
|
//
|
||||||
|
// 8 and 9 both used to be read as plain success, and that is how a contact came
|
||||||
|
// to be stamped "uploaded to LoTW" while LoTW had never seen it. The one that
|
||||||
|
// bites is "out of date range": a contact older than the callsign certificate's
|
||||||
|
// validity is silently left out of the submission.
|
||||||
|
func TestTQSLDetailKeepsTheReason(t *testing.T) {
|
||||||
|
out := `13:22:01: Signing using Callsign G0ABC, DXCC Entity ENGLAND
|
||||||
|
13:22:02: /tmp/x.adi: 20 QSO records are out of date range
|
||||||
|
13:22:02: Final Status: No QSOs were processed (8)`
|
||||||
|
got := tqslDetail(out, "summary")
|
||||||
|
if got == "summary" {
|
||||||
|
t.Fatal("threw away TQSL's explanation — that sentence is the whole answer to \"why is my contact not on LoTW\"")
|
||||||
|
}
|
||||||
|
if !contains(got, "out of date range") {
|
||||||
|
t.Errorf("the reason was lost: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTQSLDetailFallsBackToTheSummary(t *testing.T) {
|
||||||
|
if got := tqslDetail("nothing useful here", "summary"); got != "summary" {
|
||||||
|
t.Errorf("got %q, want the plain summary when TQSL said nothing specific", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(h, n string) bool {
|
||||||
|
return len(h) >= len(n) && (func() bool {
|
||||||
|
for i := 0; i+len(n) <= len(h); i++ {
|
||||||
|
if h[i:i+len(n)] == n {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})()
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Package geo is the one place that turns Maidenhead locators into positions,
|
||||||
|
// and positions into distances and bearings.
|
||||||
|
//
|
||||||
|
// It exists because there were about to be three copies. These functions lived
|
||||||
|
// in package main, which the internal packages cannot import, so the PSK
|
||||||
|
// Reporter watcher had its geometry injected from main and the web publisher
|
||||||
|
// was about to grow its own. A bearing that disagrees with itself between two
|
||||||
|
// panels is the kind of fault nobody reports, because each screen looks
|
||||||
|
// plausible on its own.
|
||||||
|
package geo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GridToLatLon parses a Maidenhead locator (4 or 6 characters) and returns the
|
||||||
|
// centre of that square in degrees. ok=false on malformed input.
|
||||||
|
func GridToLatLon(grid string) (lat, lon float64, ok bool) {
|
||||||
|
g := strings.ToUpper(strings.TrimSpace(grid))
|
||||||
|
if len(g) < 4 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
A := g[0] - 'A'
|
||||||
|
B := g[1] - 'A'
|
||||||
|
C := g[2] - '0'
|
||||||
|
D := g[3] - '0'
|
||||||
|
if A > 17 || B > 17 || C > 9 || D > 9 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
lon = -180 + float64(A)*20 + float64(C)*2
|
||||||
|
lat = -90 + float64(B)*10 + float64(D)*1
|
||||||
|
if len(g) >= 6 {
|
||||||
|
E := g[4] - 'A'
|
||||||
|
F := g[5] - 'A'
|
||||||
|
if E <= 23 && F <= 23 {
|
||||||
|
lon += float64(E)*(5.0/60.0) + 2.5/60.0
|
||||||
|
lat += float64(F)*(2.5/60.0) + 1.25/60.0
|
||||||
|
return lat, lon, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4-character locator: aim at the centre of the square.
|
||||||
|
lon += 1
|
||||||
|
lat += 0.5
|
||||||
|
return lat, lon, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HaversineKm returns the great-circle distance between two positions in
|
||||||
|
// kilometres. Mean Earth radius 6371 km.
|
||||||
|
func HaversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
|
const R = 6371.0
|
||||||
|
rad := math.Pi / 180.0
|
||||||
|
dLat := (lat2 - lat1) * rad
|
||||||
|
dLon := (lon2 - lon1) * rad
|
||||||
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||||
|
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||||
|
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistanceBetweenGrids is the distance in kilometres between two locators,
|
||||||
|
// ok=false when either cannot be parsed.
|
||||||
|
func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
|
||||||
|
lat1, lon1, ok1 := GridToLatLon(a)
|
||||||
|
lat2, lon2, ok2 := GridToLatLon(b)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return HaversineKm(lat1, lon1, lat2, lon2), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatLonToGrid returns the 4-character Maidenhead square for a position.
|
||||||
|
func LatLonToGrid(lat, lon float64) string {
|
||||||
|
lon = math.Mod(lon+180, 360)
|
||||||
|
if lon < 0 {
|
||||||
|
lon += 360
|
||||||
|
}
|
||||||
|
lat = lat + 90
|
||||||
|
if lat < 0 {
|
||||||
|
lat = 0
|
||||||
|
} else if lat > 180 {
|
||||||
|
lat = 180
|
||||||
|
}
|
||||||
|
return string([]byte{
|
||||||
|
byte('A' + int(lon/20)),
|
||||||
|
byte('A' + int(lat/10)),
|
||||||
|
byte('0' + int(math.Mod(lon, 20)/2)),
|
||||||
|
byte('0' + int(math.Mod(lat, 10)/1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeighbourGrids returns the square holding (lat, lon) and the ring of squares
|
||||||
|
// around it — 9 squares for ring 1, 25 for ring 2.
|
||||||
|
//
|
||||||
|
// Used to filter the PSK Reporter feed at the BROKER rather than in OpsLog. A
|
||||||
|
// square is about 111 km tall and 150 km wide at mid latitudes, so one ring is
|
||||||
|
// roughly the 300 km "around here" the feed already meant, and the traffic that
|
||||||
|
// used to be received and discarded is never sent.
|
||||||
|
func NeighbourGrids(lat, lon float64, ring int) []string {
|
||||||
|
if ring < 0 {
|
||||||
|
ring = 0
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for dLat := -ring; dLat <= ring; dLat++ {
|
||||||
|
for dLon := -ring; dLon <= ring; dLon++ {
|
||||||
|
// One square step: 1° of latitude, 2° of longitude.
|
||||||
|
la := lat + float64(dLat)
|
||||||
|
lo := lon + float64(dLon)*2
|
||||||
|
if la > 90 || la < -90 {
|
||||||
|
continue // past a pole there is no square, not a wrapped one
|
||||||
|
}
|
||||||
|
g := LatLonToGrid(la, lo)
|
||||||
|
if !seen[g] {
|
||||||
|
seen[g] = true
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package geo
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The squares the PSK Reporter feed is filtered on. A wrong ring means either
|
||||||
|
// receiving the world again or hearing nothing.
|
||||||
|
func TestNeighbourGrids(t *testing.T) {
|
||||||
|
// JN36 is around 46.5N 5.5E.
|
||||||
|
lat, lon, ok := GridToLatLon("JN36")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("JN36 did not resolve")
|
||||||
|
}
|
||||||
|
if got := LatLonToGrid(lat, lon); got != "JN36" {
|
||||||
|
t.Fatalf("round trip gave %q, want JN36", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ring := NeighbourGrids(lat, lon, 1)
|
||||||
|
if len(ring) != 9 {
|
||||||
|
t.Errorf("ring 1 has %d squares, want 9: %v", len(ring), ring)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, g := range ring {
|
||||||
|
if g == "JN36" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if len(g) != 4 {
|
||||||
|
t.Errorf("not a 4-character square: %q", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("the operator's own square is missing from %v", ring)
|
||||||
|
}
|
||||||
|
if n := len(NeighbourGrids(lat, lon, 0)); n != 1 {
|
||||||
|
t.Errorf("ring 0 has %d squares, want just the operator's", n)
|
||||||
|
}
|
||||||
|
if n := len(NeighbourGrids(lat, lon, 2)); n != 25 {
|
||||||
|
t.Errorf("ring 2 has %d squares, want 25", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Near a pole a step north has nowhere to go; it must be dropped, not wrapped
|
||||||
|
// onto a square on the far side of the world.
|
||||||
|
func TestNeighbourGridsNearThePole(t *testing.T) {
|
||||||
|
for _, g := range NeighbourGrids(89.5, 25, 1) {
|
||||||
|
if len(g) != 4 {
|
||||||
|
t.Errorf("bad square near the pole: %q", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// Package gridcache is the long-term callsign→grid store behind grid chasing.
|
||||||
|
//
|
||||||
|
// Its own SQLite file, not a table in the settings database: that one sits
|
||||||
|
// wherever the operator put it, often a synchronised folder, and this rewrites
|
||||||
|
// itself every minute. Deleting the file costs a few days of listening.
|
||||||
|
//
|
||||||
|
// A callsign has one grid and the newest report wins — a stale locator reads as
|
||||||
|
// a square already worked, which is worse than none.
|
||||||
|
package gridcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Retention bounds a store that would otherwise only ever grow. Two years is
|
||||||
|
// chosen to be far longer than any propagation interest and short enough that a
|
||||||
|
// reassigned callsign eventually stops carrying its previous holder's square —
|
||||||
|
// the one way this cache can be actively wrong rather than merely empty.
|
||||||
|
const Retention = 2 * 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
// FlushEvery is the batch interval. Long enough that a burst of reports costs
|
||||||
|
// one transaction, short enough that a crash loses a minute of learning.
|
||||||
|
const FlushEvery = 60 * time.Second
|
||||||
|
|
||||||
|
// Sources a locator can come from.
|
||||||
|
const (
|
||||||
|
SourceDecode = "decode" // a CQ this station's own receiver decoded
|
||||||
|
SourceMQTT = "mqtt" // a PSK Reporter report
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one locator and where it came from.
|
||||||
|
type Entry struct {
|
||||||
|
Grid string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
dirty map[string]Entry // call → what to write
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open creates or opens the store at path and prunes what has aged out.
|
||||||
|
func Open(path string, logf func(string, ...any)) (*Store, error) {
|
||||||
|
if logf == nil {
|
||||||
|
logf = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
// WAL so a flush never blocks a read, and a busy timeout because the flush
|
||||||
|
// goroutine and the startup load can overlap on a slow disk.
|
||||||
|
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gridcache: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("CREATE TABLE IF NOT EXISTS grids (" +
|
||||||
|
"call TEXT PRIMARY KEY, grid TEXT NOT NULL, updated_at INTEGER NOT NULL, " +
|
||||||
|
"source TEXT NOT NULL DEFAULT '')"); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("gridcache: schema: %w", err)
|
||||||
|
}
|
||||||
|
// A file written before the column existed keeps its rows; this fails
|
||||||
|
// harmlessly when the column is already there.
|
||||||
|
db.Exec("ALTER TABLE grids ADD COLUMN source TEXT NOT NULL DEFAULT ''")
|
||||||
|
s := &Store{db: db, dirty: map[string]Entry{}, stop: make(chan struct{}), logf: logf}
|
||||||
|
if n, err := s.prune(); err != nil {
|
||||||
|
s.logf("gridcache: prune failed: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
s.logf("gridcache: pruned %d locators not heard in %d days", n, int(Retention.Hours()/24))
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) prune() (int64, error) {
|
||||||
|
cut := time.Now().Add(-Retention).Unix()
|
||||||
|
res, err := s.db.Exec(`DELETE FROM grids WHERE updated_at < ?`, cut)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAll returns every stored locator, for seeding the in-memory map at
|
||||||
|
// startup. One query and one map build — the point of the whole package is that
|
||||||
|
// nothing afterwards has to ask the database anything.
|
||||||
|
func (s *Store) LoadAll() (map[string]string, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT call, grid FROM grids`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gridcache: load: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]string, 4096)
|
||||||
|
for rows.Next() {
|
||||||
|
var call, grid string
|
||||||
|
if err := rows.Scan(&call, &grid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[call] = grid
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put queues a locator for writing. Callers pass only what CHANGED — an
|
||||||
|
// unchanged report is the common case by a wide margin and must not reach here,
|
||||||
|
// or the batch would carry the whole feed instead of the news in it.
|
||||||
|
func (s *Store) Put(call, grid, source string) {
|
||||||
|
call = strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
grid = strings.TrimSpace(grid)
|
||||||
|
if call == "" || grid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dirty[call] = Entry{Grid: grid, Source: source}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start runs the flush loop until Close.
|
||||||
|
func (s *Store) Start(ctx context.Context) {
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
t := time.NewTicker(FlushEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
s.logf("gridcache: flush failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush writes the pending batch in one transaction. Safe to call with nothing
|
||||||
|
// pending, which is most of the time on a quiet band.
|
||||||
|
func (s *Store) Flush() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if len(s.dirty) == 0 {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
batch := s.dirty
|
||||||
|
s.dirty = map[string]Entry{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
s.requeue(batch)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := tx.Prepare(`INSERT INTO grids (call, grid, updated_at, source) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(call) DO UPDATE SET grid = excluded.grid, updated_at = excluded.updated_at,
|
||||||
|
source = excluded.source`)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
s.requeue(batch)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for call, e := range batch {
|
||||||
|
if _, err := st.Exec(call, e.Grid, now, e.Source); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
s.requeue(batch)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
s.requeue(batch)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Logged because a batch that never reaches the disk looks exactly like one
|
||||||
|
// that does: the locators are in memory either way until the next restart,
|
||||||
|
// which is the one moment the difference shows.
|
||||||
|
s.logf("gridcache: wrote %d locators", len(batch))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requeue puts a failed batch back, without overwriting anything learnt while it
|
||||||
|
// was in flight — the newer value is the right one.
|
||||||
|
func (s *Store) requeue(batch map[string]Entry) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for call, e := range batch {
|
||||||
|
if _, newer := s.dirty[call]; !newer {
|
||||||
|
s.dirty[call] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending reports how many locators are waiting to be written.
|
||||||
|
func (s *Store) Pending() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.dirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the loop and writes whatever is pending. A restart is the moment
|
||||||
|
// the cache is most valuable, so losing the last minute of learning to a clean
|
||||||
|
// shutdown would be a poor trade.
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
s.stopOnce.Do(func() { close(s.stop) })
|
||||||
|
s.wg.Wait()
|
||||||
|
err := s.Flush()
|
||||||
|
if cerr := s.db.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package gridcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func open(t *testing.T) (*Store, string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "grids.db")
|
||||||
|
s, err := Open(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
return s, path
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole reason the store exists: what was learnt is still there after a
|
||||||
|
// restart, so the cluster's locator column is full in the first second instead
|
||||||
|
// of after an hour of listening.
|
||||||
|
func TestSurvivesRestart(t *testing.T) {
|
||||||
|
s, path := open(t)
|
||||||
|
s.Put("F4BPO", "JN36", SourceDecode)
|
||||||
|
s.Put("OH5CX", "KP30", SourceDecode)
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatalf("flush: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Close(); err != nil {
|
||||||
|
t.Fatalf("close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
again, err := Open(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
defer again.Close()
|
||||||
|
got, err := again.LoadAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if got["F4BPO"] != "JN36" || got["OH5CX"] != "KP30" {
|
||||||
|
t.Errorf("locators lost across a restart: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callsign has ONE grid and the newest report wins. Operators move, go
|
||||||
|
// portable, go on expedition — and a stale locator is worse than none for grid
|
||||||
|
// chasing, because it reads as a square already worked.
|
||||||
|
func TestNewestReportWins(t *testing.T) {
|
||||||
|
s, _ := open(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
s.Put("F4BPO", "JN36", SourceDecode)
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Put("F4BPO", "KP30", SourceDecode) // moved
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.LoadAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["F4BPO"] != "KP30" {
|
||||||
|
t.Errorf("grid = %q, want the newer KP30", got["F4BPO"])
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Errorf("a callsign must hold one row, got %d: %v", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing reaches the disk until a flush, and a flush with nothing pending is
|
||||||
|
// not an error — that is most minutes on a quiet band.
|
||||||
|
func TestBatching(t *testing.T) {
|
||||||
|
s, _ := open(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
for _, c := range []string{"A1AA", "B2BB", "C3CC"} {
|
||||||
|
s.Put(c, "JN36", SourceDecode)
|
||||||
|
}
|
||||||
|
if n := s.Pending(); n != 3 {
|
||||||
|
t.Errorf("pending = %d, want 3 queued and unwritten", n)
|
||||||
|
}
|
||||||
|
if got, _ := s.LoadAll(); len(got) != 0 {
|
||||||
|
t.Errorf("wrote before the flush: %v", got)
|
||||||
|
}
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := s.Pending(); n != 0 {
|
||||||
|
t.Errorf("pending = %d after a flush, want 0", n)
|
||||||
|
}
|
||||||
|
if got, _ := s.LoadAll(); len(got) != 3 {
|
||||||
|
t.Errorf("flush wrote %d rows, want 3", len(got))
|
||||||
|
}
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Errorf("empty flush must be a no-op, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age is what bounds a store that would otherwise only grow. A callsign not
|
||||||
|
// heard in two years is likely reassigned, and carrying its previous holder's
|
||||||
|
// square is the one way this cache can be actively wrong rather than empty.
|
||||||
|
func TestPruneOnOpen(t *testing.T) {
|
||||||
|
s, path := open(t)
|
||||||
|
s.Put("FRESH", "JN36", SourceDecode)
|
||||||
|
s.Put("STALE", "IO91", SourceDecode)
|
||||||
|
if err := s.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Backdate one row past the retention window.
|
||||||
|
old := time.Now().Add(-Retention - 24*time.Hour).Unix()
|
||||||
|
if _, err := s.db.Exec(`UPDATE grids SET updated_at = ? WHERE call = 'STALE'`, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
|
||||||
|
again, err := Open(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer again.Close()
|
||||||
|
got, _ := again.LoadAll()
|
||||||
|
if _, ok := got["STALE"]; ok {
|
||||||
|
t.Error("an entry past the retention window survived — the store is unbounded")
|
||||||
|
}
|
||||||
|
if got["FRESH"] != "JN36" {
|
||||||
|
t.Error("pruning took a live entry with it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close has to write what the last minute learnt: a restart is exactly when the
|
||||||
|
// cache is worth the most, so losing it to a clean shutdown would be a poor
|
||||||
|
// trade.
|
||||||
|
func TestCloseFlushes(t *testing.T) {
|
||||||
|
s, path := open(t)
|
||||||
|
s.Put("LATE", "JN36", SourceDecode)
|
||||||
|
if err := s.Close(); err != nil {
|
||||||
|
t.Fatalf("close: %v", err)
|
||||||
|
}
|
||||||
|
again, err := Open(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer again.Close()
|
||||||
|
got, _ := again.LoadAll()
|
||||||
|
if got["LATE"] != "JN36" {
|
||||||
|
t.Error("what was pending at shutdown was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,13 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
return parseHamQTHSearch(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHamQTHSearch turns one answer into a Result. Split out from the request
|
||||||
|
// so the field mapping can be checked against a captured payload — which is how
|
||||||
|
// the missing full name and picture were found.
|
||||||
|
func parseHamQTHSearch(body []byte) (Result, error) {
|
||||||
var resp hamqthRoot
|
var resp hamqthRoot
|
||||||
if err := xml.Unmarshal(body, &resp); err != nil {
|
if err := xml.Unmarshal(body, &resp); err != nil {
|
||||||
return Result{}, fmt.Errorf("hamqth: parse callsign: %w", err)
|
return Result{}, fmt.Errorf("hamqth: parse callsign: %w", err)
|
||||||
@@ -112,11 +119,20 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
|||||||
if s.Callsign == "" {
|
if s.Callsign == "" {
|
||||||
return Result{}, ErrNotFound
|
return Result{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
// <nick> is the name the operator goes BY on the air, which is what belongs
|
||||||
|
// in a log — "Igor", not "Igor Vladimirovich Getman". adr_name is the postal
|
||||||
|
// name and is used only when there is nothing else, so a record carrying
|
||||||
|
// neither nick nor name no longer resolves to blank.
|
||||||
|
name := strings.TrimSpace(s.Nick + " " + s.LastName)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(s.AdrName)
|
||||||
|
}
|
||||||
r := Result{
|
r := Result{
|
||||||
Callsign: strings.ToUpper(s.Callsign),
|
Callsign: strings.ToUpper(s.Callsign),
|
||||||
Name: strings.TrimSpace(s.Nick + " " + s.LastName),
|
Name: name,
|
||||||
QTH: firstNonEmpty(s.QTH, s.AdrCity),
|
QTH: firstNonEmpty(s.QTH, s.AdrCity),
|
||||||
Address: s.AdrStreet1,
|
Address: s.AdrStreet1,
|
||||||
|
Zip: s.AdrZip,
|
||||||
State: strings.ToUpper(s.USState),
|
State: strings.ToUpper(s.USState),
|
||||||
County: s.USCounty,
|
County: s.USCounty,
|
||||||
Country: firstNonEmpty(s.AdrCountry, s.Country),
|
Country: firstNonEmpty(s.AdrCountry, s.Country),
|
||||||
@@ -124,6 +140,8 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
|||||||
Continent: strings.ToUpper(s.Continent),
|
Continent: strings.ToUpper(s.Continent),
|
||||||
Email: s.Email,
|
Email: s.Email,
|
||||||
QSLVia: s.QSLVia,
|
QSLVia: s.QSLVia,
|
||||||
|
Web: s.Web,
|
||||||
|
ImageURL: s.Picture,
|
||||||
}
|
}
|
||||||
r.Lat, _ = strconv.ParseFloat(s.Latitude, 64)
|
r.Lat, _ = strconv.ParseFloat(s.Latitude, 64)
|
||||||
r.Lon, _ = strconv.ParseFloat(s.Longitude, 64)
|
r.Lon, _ = strconv.ParseFloat(s.Longitude, 64)
|
||||||
@@ -182,4 +200,14 @@ type hamqthSearch struct {
|
|||||||
Continent string `xml:"continent"`
|
Continent string `xml:"continent"`
|
||||||
Email string `xml:"email"`
|
Email string `xml:"email"`
|
||||||
QSLVia string `xml:"qsl_via"`
|
QSLVia string `xml:"qsl_via"`
|
||||||
|
// AdrName is the full postal name. Many records carry it and no <name> at
|
||||||
|
// all, so building the name from <nick> + <name> silently kept the first
|
||||||
|
// name and dropped the rest.
|
||||||
|
AdrName string `xml:"adr_name"`
|
||||||
|
AdrZip string `xml:"adr_zip"`
|
||||||
|
// Picture is HamQTH's profile photo. Result.ImageURL was documented as "QRZ
|
||||||
|
// only" because this element was never read — QRZ calls the same thing
|
||||||
|
// <image>.
|
||||||
|
Picture string `xml:"picture"`
|
||||||
|
Web string `xml:"web"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A real HamQTH answer, captured from the live API.
|
||||||
|
//
|
||||||
|
// Two things it settles. The email IS returned and must reach the QSO. And the
|
||||||
|
// element names are not the ones a reading of the documentation suggests:
|
||||||
|
// there is no <name> here at all, the full name is <adr_name>, and the picture
|
||||||
|
// is <picture> where QRZ calls it <image>. The stray <div> is an advert the
|
||||||
|
// server injects into its own XML; the parser has to shrug it off.
|
||||||
|
const hamqthEU1EU = `<?xml version="1.0"?>
|
||||||
|
<HamQTH xmlns="https://www.hamqth.com" version="2.8">
|
||||||
|
<div id="in-page-channel-node-id" data-channel-name="in_page_channel_Sux7_K"/>
|
||||||
|
<search>
|
||||||
|
<callsign>eu1eu</callsign>
|
||||||
|
<nick>Igor</nick>
|
||||||
|
<qth>Minsk-5</qth>
|
||||||
|
<country>Belarus</country>
|
||||||
|
<adif>27</adif>
|
||||||
|
<itu>29</itu>
|
||||||
|
<cq>16</cq>
|
||||||
|
<grid>KO33SV</grid>
|
||||||
|
<adr_name>Igor Vladimirovich Getman</adr_name>
|
||||||
|
<adr_street1>A/ya 143</adr_street1>
|
||||||
|
<adr_city>Minsk-5</adr_city>
|
||||||
|
<adr_zip>220005</adr_zip>
|
||||||
|
<adr_country>Belarus</adr_country>
|
||||||
|
<adr_adif>27</adr_adif>
|
||||||
|
<district>WAARB-LE</district>
|
||||||
|
<lotw>?</lotw>
|
||||||
|
<qsldirect>?</qsldirect>
|
||||||
|
<qsl>?</qsl>
|
||||||
|
<eqsl>Y</eqsl>
|
||||||
|
<email>eu1eu@mail.ru</email>
|
||||||
|
<latitude>53.88999938964844</latitude>
|
||||||
|
<longitude>27.59000015258789</longitude>
|
||||||
|
<continent>EU</continent>
|
||||||
|
<utc_offset>-2</utc_offset>
|
||||||
|
<picture>https://www.hamqth.com/images/default/ts-930_qith_old_radios.jpg</picture>
|
||||||
|
</search>
|
||||||
|
</HamQTH>`
|
||||||
|
|
||||||
|
func TestHamQTHParsesEveryUsefulField(t *testing.T) {
|
||||||
|
var root hamqthRoot
|
||||||
|
if err := xml.Unmarshal([]byte(hamqthEU1EU), &root); err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
s := root.Search
|
||||||
|
|
||||||
|
for _, c := range []struct{ name, got, want string }{
|
||||||
|
{"callsign", s.Callsign, "eu1eu"},
|
||||||
|
{"email", s.Email, "[email protected]"},
|
||||||
|
{"grid", s.Grid, "KO33SV"},
|
||||||
|
{"qth", s.QTH, "Minsk-5"},
|
||||||
|
{"street", s.AdrStreet1, "A/ya 143"},
|
||||||
|
{"city", s.AdrCity, "Minsk-5"},
|
||||||
|
{"country", s.AdrCountry, "Belarus"},
|
||||||
|
{"continent", s.Continent, "EU"},
|
||||||
|
{"dxcc", s.DXCC, "27"},
|
||||||
|
{"cq", s.CQ, "16"},
|
||||||
|
{"itu", s.ITU, "29"},
|
||||||
|
} {
|
||||||
|
if c.got != c.want {
|
||||||
|
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fields the parser was blind to. Each is data OpsLog has somewhere to put.
|
||||||
|
func TestHamQTHParsesTheFieldsThatWereMissing(t *testing.T) {
|
||||||
|
var root hamqthRoot
|
||||||
|
if err := xml.Unmarshal([]byte(hamqthEU1EU), &root); err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
s := root.Search
|
||||||
|
|
||||||
|
// The postal name, the fallback when a record carries no nick and no name.
|
||||||
|
if s.AdrName != "Igor Vladimirovich Getman" {
|
||||||
|
t.Errorf("adr_name = %q", s.AdrName)
|
||||||
|
}
|
||||||
|
if s.LastName != "" {
|
||||||
|
t.Errorf("this record has no <name>; got %q", s.LastName)
|
||||||
|
}
|
||||||
|
// The profile picture. Result.ImageURL existed and said "QRZ only" — HamQTH
|
||||||
|
// sends one too, under a different element name.
|
||||||
|
if s.Picture == "" {
|
||||||
|
t.Error("picture not parsed")
|
||||||
|
}
|
||||||
|
if s.AdrZip != "220005" {
|
||||||
|
t.Errorf("adr_zip = %q", s.AdrZip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End to end: what the provider hands back must carry the email, the full name
|
||||||
|
// and the picture.
|
||||||
|
func TestHamQTHResultCarriesTheLot(t *testing.T) {
|
||||||
|
r, err := parseHamQTHSearch([]byte(hamqthEU1EU))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if r.Email != "[email protected]" {
|
||||||
|
t.Errorf("Email = %q — this is what was reported missing", r.Email)
|
||||||
|
}
|
||||||
|
// The nick, not the postal name: a log wants the name the operator goes by
|
||||||
|
// on the air. adr_name is only the fallback when there is nothing else.
|
||||||
|
if r.Name != "Igor" {
|
||||||
|
t.Errorf("Name = %q, want the on-air nick", r.Name)
|
||||||
|
}
|
||||||
|
if r.ImageURL == "" {
|
||||||
|
t.Error("ImageURL empty — HamQTH sent a picture")
|
||||||
|
}
|
||||||
|
if r.Grid != "KO33SV" {
|
||||||
|
t.Errorf("Grid = %q", r.Grid)
|
||||||
|
}
|
||||||
|
if r.Lat < 53.8 || r.Lat > 53.9 {
|
||||||
|
t.Errorf("Lat = %v", r.Lat)
|
||||||
|
}
|
||||||
|
if r.DXCC != 27 || r.CQZ != 16 || r.ITUZ != 29 {
|
||||||
|
t.Errorf("dxcc/cq/itu = %d/%d/%d", r.DXCC, r.CQZ, r.ITUZ)
|
||||||
|
}
|
||||||
|
if r.Address != "A/ya 143" {
|
||||||
|
t.Errorf("Address = %q", r.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,13 @@ type Result struct {
|
|||||||
Continent string `json:"cont,omitempty"`
|
Continent string `json:"cont,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
QSLVia string `json:"qsl_via,omitempty"`
|
QSLVia string `json:"qsl_via,omitempty"`
|
||||||
ImageURL string `json:"image_url,omitempty"` // profile picture URL (QRZ only for now)
|
// Web is the operator's own site. The QSO table has had a `web` column all
|
||||||
|
// along and nothing ever filled it, because no provider mapping read the
|
||||||
|
// field.
|
||||||
|
Web string `json:"web,omitempty"`
|
||||||
|
// Zip is the postal code. HamQTH and QRZ both send one.
|
||||||
|
Zip string `json:"zip,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"` // profile picture URL
|
||||||
Source string `json:"source"` // "qrz", "hamqth", or "cache"
|
Source string `json:"source"` // "qrz", "hamqth", or "cache"
|
||||||
FetchedAt time.Time `json:"fetched_at"`
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
}
|
}
|
||||||
@@ -176,12 +182,20 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
r.Callsign = call
|
r.Callsign = call
|
||||||
r.Source = p.Name()
|
r.Source = p.Name()
|
||||||
r.FetchedAt = time.Now().UTC()
|
r.FetchedAt = time.Now().UTC()
|
||||||
// The home record's location is the operator's HOME, not where they
|
// The home record's location is the operator's HOME — clear it so
|
||||||
// are portable now — clear it so cty.dat fills the real entity.
|
// cty.dat fills in where they actually are.
|
||||||
|
//
|
||||||
|
// UNLESS the suffix says nothing about location. /QRP is a statement
|
||||||
|
// about power, not about place: M0BFS/QRP is M0BFS, at home, running
|
||||||
|
// five watts. Wiping the grid there threw away the one field the
|
||||||
|
// operator was looking the call up for, and it came back empty while
|
||||||
|
// the same lookup without the suffix answered perfectly.
|
||||||
|
if !saysNothingAboutLocation(call) {
|
||||||
r.Country, r.Continent = "", ""
|
r.Country, r.Continent = "", ""
|
||||||
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
||||||
r.Lat, r.Lon = 0, 0
|
r.Lat, r.Lon = 0, 0
|
||||||
r.Grid, r.State, r.County = "", "", ""
|
r.Grid, r.State, r.County = "", "", ""
|
||||||
|
}
|
||||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
_ = m.cache.Put(ctx, r)
|
_ = m.cache.Put(ctx, r)
|
||||||
@@ -232,6 +246,33 @@ var LogSink = func(string, ...any) {}
|
|||||||
// right and must be looked up exactly as entered.
|
// right and must be looked up exactly as entered.
|
||||||
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
|
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
|
||||||
|
|
||||||
|
// nonLocationSuffixes say nothing about WHERE the operator is.
|
||||||
|
//
|
||||||
|
// /QRP is a statement about power. /M and /P and their kin are not: mobile and
|
||||||
|
// portable both mean "somewhere other than the home station", which is exactly
|
||||||
|
// why the home record's location is discarded for them. Keeping that distinction
|
||||||
|
// is the difference between a grid that is stale and a grid that is absent.
|
||||||
|
var nonLocationSuffixes = map[string]bool{"QRP": true}
|
||||||
|
|
||||||
|
// saysNothingAboutLocation reports a call whose every suffix leaves the operator
|
||||||
|
// at their registered address — so the home record's location can be trusted.
|
||||||
|
func saysNothingAboutLocation(call string) bool {
|
||||||
|
parts := strings.Split(strings.ToUpper(strings.TrimSpace(call)), "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(parts[0])
|
||||||
|
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
|
||||||
|
return false // "JW/OR1A": the first part is a prefix — a location change
|
||||||
|
}
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
if !nonLocationSuffixes[strings.TrimSpace(p)] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// stripOpSuffix returns the bare callsign when call carries nothing but
|
// stripOpSuffix returns the bare callsign when call carries nothing but
|
||||||
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
|
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
|
||||||
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
|
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
|
||||||
@@ -411,12 +452,13 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
row := c.db.QueryRowContext(ctx, `
|
row := c.db.QueryRowContext(ctx, `
|
||||||
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
||||||
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||||
source, fetched_at
|
web, zip, source, fetched_at
|
||||||
FROM callsign_cache WHERE callsign = ?`, callsign)
|
FROM callsign_cache WHERE callsign = ?`, callsign)
|
||||||
var (
|
var (
|
||||||
r Result
|
r Result
|
||||||
name, qth, addr, state, cnty sql.NullString
|
name, qth, addr, state, cnty sql.NullString
|
||||||
country, grid, cont, email, qslVia, image sql.NullString
|
country, grid, cont, email, qslVia, image sql.NullString
|
||||||
|
web, zip sql.NullString
|
||||||
src string
|
src string
|
||||||
dxcc, cqz, ituz sql.NullInt64
|
dxcc, cqz, ituz sql.NullInt64
|
||||||
lat, lon sql.NullFloat64
|
lat, lon sql.NullFloat64
|
||||||
@@ -424,7 +466,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
)
|
)
|
||||||
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
|
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
|
||||||
&country, &grid, &lat, &lon,
|
&country, &grid, &lat, &lon,
|
||||||
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image,
|
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip,
|
||||||
&src, &fetched); err != nil {
|
&src, &fetched); err != nil {
|
||||||
return Result{}, false
|
return Result{}, false
|
||||||
}
|
}
|
||||||
@@ -446,6 +488,8 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
r.Lon = lon.Float64
|
r.Lon = lon.Float64
|
||||||
r.Continent = cont.String
|
r.Continent = cont.String
|
||||||
r.Email = email.String
|
r.Email = email.String
|
||||||
|
r.Web = web.String
|
||||||
|
r.Zip = zip.String
|
||||||
r.QSLVia = qslVia.String
|
r.QSLVia = qslVia.String
|
||||||
r.ImageURL = image.String
|
r.ImageURL = image.String
|
||||||
r.DXCC = int(dxcc.Int64)
|
r.DXCC = int(dxcc.Int64)
|
||||||
@@ -462,7 +506,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
updateCols := []string{
|
updateCols := []string{
|
||||||
"name", "qth", "address", "state", "cnty",
|
"name", "qth", "address", "state", "cnty",
|
||||||
"country", "grid", "lat", "lon",
|
"country", "grid", "lat", "lon",
|
||||||
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url",
|
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip",
|
||||||
"source", "fetched_at",
|
"source", "fetched_at",
|
||||||
}
|
}
|
||||||
// The lookup cache always lives in the local SQLite database, so SQLite
|
// The lookup cache always lives in the local SQLite database, so SQLite
|
||||||
@@ -475,8 +519,8 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
||||||
country, grid, lat, lon,
|
country, grid, lat, lon,
|
||||||
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||||
source, fetched_at)
|
web, zip, source, fetched_at)
|
||||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?)
|
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?, ?,?)
|
||||||
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
||||||
_, err := c.db.ExecContext(ctx, q,
|
_, err := c.db.ExecContext(ctx, q,
|
||||||
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
||||||
@@ -485,7 +529,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
nullableFloat(r.Lat), nullableFloat(r.Lon),
|
nullableFloat(r.Lat), nullableFloat(r.Lon),
|
||||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
||||||
nullable(r.ImageURL),
|
nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip),
|
||||||
r.Source, db.NowISO(),
|
r.Source, db.NowISO(),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A grid came back empty for M0BFS/QRP while the same lookup without the suffix
|
||||||
|
// answered perfectly. The home-call pass wiped the location on the grounds that
|
||||||
|
// a portable operator is not at their registered address — true for /P and /M,
|
||||||
|
// and simply wrong for /QRP, which is a statement about power.
|
||||||
|
func TestSaysNothingAboutLocation(t *testing.T) {
|
||||||
|
keep := []string{"M0BFS/QRP", "f4bpo/qrp", "G0ABC/QRP"}
|
||||||
|
for _, c := range keep {
|
||||||
|
if !saysNothingAboutLocation(c) {
|
||||||
|
t.Errorf("%s: the home location should be kept — /QRP does not move anyone", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// These DO move the operator, or change the entity outright.
|
||||||
|
drop := []string{"F4BPO/P", "F4BPO/M", "F4BPO/MM", "F4BPO/AM", "JW/OR1A", "VP8/F4BPO", "F4BPO/8", "F4BPO"}
|
||||||
|
for _, c := range drop {
|
||||||
|
if saysNothingAboutLocation(c) {
|
||||||
|
t.Errorf("%s: the home location must NOT be trusted", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A power suffix on top of a portable one still moves them.
|
||||||
|
if saysNothingAboutLocation("F4BPO/P/QRP") {
|
||||||
|
t.Error("F4BPO/P/QRP is portable — location must not be kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-4
@@ -81,6 +81,15 @@ type Config struct {
|
|||||||
// OnSpot receives every accepted decode. Called from the MQTT goroutine, so
|
// OnSpot receives every accepted decode. Called from the MQTT goroutine, so
|
||||||
// it must not block: the broker's buffer is what pays for it if it does.
|
// it must not block: the broker's buffer is what pays for it if it does.
|
||||||
OnSpot func(Spot)
|
OnSpot func(Spot)
|
||||||
|
// OnGrid receives the transmitter of EVERY message, before any geographic
|
||||||
|
// filtering, for the callsign-to-locator store. Same goroutine as OnSpot and
|
||||||
|
// the same rule: do not block.
|
||||||
|
OnGrid func(call, grid string)
|
||||||
|
// RxGrids filters at the BROKER: only reports collected by a receiver in one
|
||||||
|
// of these squares are sent at all. Empty keeps the old behaviour, which was
|
||||||
|
// to receive the world and discard it here — measured at 83 messages a second
|
||||||
|
// for the four opening bands, of which about one in a hundred survived.
|
||||||
|
RxGrids []string
|
||||||
Logf func(string, ...any)
|
Logf func(string, ...any)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +124,32 @@ func New(cfg Config) *Watcher {
|
|||||||
return &Watcher{cfg: cfg}
|
return &Watcher{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// topics builds the subscription list.
|
||||||
|
//
|
||||||
|
// The v2 topic is
|
||||||
|
//
|
||||||
|
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/<tx dxcc>/<rx dxcc>
|
||||||
|
//
|
||||||
|
// so the receiver's square is a level the broker can filter on, and a band of
|
||||||
|
// "+" means every band. Filtering by RECEIVER square rather than by receiver
|
||||||
|
// DXCC is deliberate: measured on 20 m, one country ranged from 1.2 messages a
|
||||||
|
// second (OH) to 72.5 (K), because a DXCC can be a continent. By square the
|
||||||
|
// same measurement is 0.2 to 1.2 — the load follows distance, which is what the
|
||||||
|
// feed is actually about, and it is the same for every operator.
|
||||||
|
func (w *Watcher) topics() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, b := range w.cfg.Bands {
|
||||||
|
if len(w.cfg.RxGrids) == 0 {
|
||||||
|
out = append(out, "pskr/filter/v2/"+b+"/#")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, g := range w.cfg.RxGrids {
|
||||||
|
out = append(out, "pskr/filter/v2/"+b+"/+/+/+/+/"+strings.ToUpper(g)+"/+/+")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Start connects and subscribes. Safe to call when already running.
|
// Start connects and subscribes. Safe to call when already running.
|
||||||
func (w *Watcher) Start() error {
|
func (w *Watcher) Start() error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
@@ -143,10 +178,7 @@ func (w *Watcher) Start() error {
|
|||||||
|
|
||||||
opts.OnConnect = func(c mqtt.Client) {
|
opts.OnConnect = func(c mqtt.Client) {
|
||||||
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
|
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
|
||||||
for _, b := range w.cfg.Bands {
|
for _, topic := range w.topics() {
|
||||||
// Every mode, every pair of stations, on this band. That firehose IS
|
|
||||||
// the point: the detector's job is to find the shape in it.
|
|
||||||
topic := "pskr/filter/v2/" + b + "/#"
|
|
||||||
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||||
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
|
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
|
||||||
continue
|
continue
|
||||||
@@ -206,6 +238,13 @@ func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The locator store takes every transmitter, before any of the geography
|
||||||
|
// below. What it wants is "which square is this callsign in", and that is
|
||||||
|
// true whoever happened to hear the report.
|
||||||
|
if w.cfg.OnGrid != nil {
|
||||||
|
w.cfg.OnGrid(call, grid[:4])
|
||||||
|
}
|
||||||
|
|
||||||
// THE RECEIVER HAS TO BE NEAR THE OPERATOR. This is the whole difference
|
// THE RECEIVER HAS TO BE NEAR THE OPERATOR. This is the whole difference
|
||||||
// between a useful feed and a world map.
|
// between a useful feed and a world map.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pskr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The receiver square is a level the BROKER can filter on, which is the whole
|
||||||
|
// point: measured on the live feed, the four opening bands unfiltered are 83
|
||||||
|
// messages a second of which about one in a hundred survives the NearKm test.
|
||||||
|
// One ring of squares is under two a second, and the same for every operator —
|
||||||
|
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K) on one band.
|
||||||
|
func TestTopicsFilterOnTheReceiverSquare(t *testing.T) {
|
||||||
|
w := New(Config{Bands: []string{"6m"}, RxGrids: []string{"jn36", "JN37"}})
|
||||||
|
got := w.topics()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want one subscription per band × square, got %v", got)
|
||||||
|
}
|
||||||
|
// Level order: band/mode/txcall/rxcall/txgrid/RXGRID/txdxcc/rxdxcc
|
||||||
|
want := "pskr/filter/v2/6m/+/+/+/+/JN36/+/+"
|
||||||
|
if got[0] != want {
|
||||||
|
t.Errorf("topic = %q, want %q", got[0], want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[1], "/JN37/") {
|
||||||
|
t.Errorf("square not upper-cased into the topic: %q", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no squares the old behaviour stands: receive the band and decide here.
|
||||||
|
func TestTopicsWithoutSquares(t *testing.T) {
|
||||||
|
w := New(Config{Bands: []string{"10m", "2m"}})
|
||||||
|
got := w.topics()
|
||||||
|
if len(got) != 2 || got[0] != "pskr/filter/v2/10m/#" {
|
||||||
|
t.Errorf("unfiltered topics = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid chasing wants every band. "+" is the MQTT single-level wildcard, so one
|
||||||
|
// subscription per square covers the lot instead of one per band per square.
|
||||||
|
func TestTopicsAllBands(t *testing.T) {
|
||||||
|
w := New(Config{Bands: []string{"+"}, RxGrids: []string{"JN36"}})
|
||||||
|
got := w.topics()
|
||||||
|
if len(got) != 1 || got[0] != "pskr/filter/v2/+/+/+/+/+/JN36/+/+" {
|
||||||
|
t.Errorf("all-band topic = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Mode, submode and RST are repair fields: an import that mapped every contact
|
||||||
|
// to SSB, or an ADIF that carried no MODE, is fixed in one pass instead of one
|
||||||
|
// row at a time. They were excluded as "per-QSO", which confused describing a
|
||||||
|
// QSO with repairing a batch of them.
|
||||||
|
func TestModeAndRSTAreBulkEditable(t *testing.T) {
|
||||||
|
for _, col := range []string{"mode", "submode", "rst_sent", "rst_rcvd"} {
|
||||||
|
if !bulkEditableCols[col] {
|
||||||
|
t.Errorf("%s should be bulk-editable", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Band must NOT be bulk-editable on its own: it travels with the frequency
|
||||||
|
// through BulkSetFrequency. A band contradicting its own frequency is invalid
|
||||||
|
// ADIF, and every export would carry the contradiction out into the world.
|
||||||
|
func TestBandIsNotBulkEditableAlone(t *testing.T) {
|
||||||
|
for _, col := range []string{"band", "freq_hz", "callsign", "qso_date"} {
|
||||||
|
if bulkEditableCols[col] {
|
||||||
|
t.Errorf("%s must not be bulk-editable on its own", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "equals nothing" and "is empty" are the same question. SQL answers the first
|
||||||
|
// with nothing at all — a NULL never equals ” — so a filter written in plain
|
||||||
|
// words returned zero rows and looked broken rather than wrong.
|
||||||
|
func TestEqualsBlankMeansEmpty(t *testing.T) {
|
||||||
|
sql, args, err := conditionSQL(Condition{Field: "freq_hz", Op: "eq", Value: ""})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eq blank: %v", err)
|
||||||
|
}
|
||||||
|
if len(args) != 0 || !strings.Contains(sql, "IS NULL") {
|
||||||
|
t.Errorf("sql = %q args = %v — want the empty test", sql, args)
|
||||||
|
}
|
||||||
|
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "ne", Value: " "})
|
||||||
|
if !strings.Contains(sql, "<> ''") {
|
||||||
|
t.Errorf("ne blank on text gave %q — want the not-empty test", sql)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A numeric column is empty when NULL *or* zero, and that must be explicit:
|
||||||
|
// SQLite compares 0 against ” as false while MySQL calls it true, so one
|
||||||
|
// expression would answer two different questions depending on the backend.
|
||||||
|
func TestEmptyOnNumericCoversZeroAndNull(t *testing.T) {
|
||||||
|
sql, _, err := conditionSQL(Condition{Field: "freq_hz", Op: "empty"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sql, "IS NULL") || !strings.Contains(sql, "= 0") {
|
||||||
|
t.Errorf("sql = %q — want both NULL and zero", sql)
|
||||||
|
}
|
||||||
|
// Text keeps the string test: '' is a real value there, 0 is not.
|
||||||
|
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "empty"})
|
||||||
|
if !strings.Contains(sql, "IFNULL") || strings.Contains(sql, "= 0") {
|
||||||
|
t.Errorf("text empty gave %q", sql)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderedIDs against a real SQLite file, with qso_date stored the way the repo
|
||||||
|
// actually writes it.
|
||||||
|
//
|
||||||
|
// The first version scanned that column straight into a time.Time. It compiles,
|
||||||
|
// it reads correctly, and it fails at run time — the column holds a formatted
|
||||||
|
// STRING. The error was swallowed into "the column will be empty", which is
|
||||||
|
// exactly what shipped: a column that was always blank.
|
||||||
|
func TestOrderedIDsAgainstRealSQLite(t *testing.T) {
|
||||||
|
conn, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "t.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
if _, err := conn.Exec(`CREATE TABLE qso (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
callsign TEXT NOT NULL,
|
||||||
|
qso_date TEXT NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserted NEWEST first, so the ids run opposite to the dates — what an
|
||||||
|
// imported ADIF produces, and the whole reason the id is not the number.
|
||||||
|
for _, row := range []struct {
|
||||||
|
call string
|
||||||
|
at time.Time
|
||||||
|
}{
|
||||||
|
{"NEWEST", time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)},
|
||||||
|
{"MIDDLE", time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)},
|
||||||
|
{"OLDEST", time.Date(1999, 5, 5, 10, 0, 0, 0, time.UTC)},
|
||||||
|
} {
|
||||||
|
if _, err := conn.Exec(`INSERT INTO qso (callsign, qso_date) VALUES (?, ?)`,
|
||||||
|
row.call, row.at.UTC().Format(isoMillis)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, newest, err := NewRepo(conn).OrderedIDs(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OrderedIDs: %v — this is the failure that emptied the column", err)
|
||||||
|
}
|
||||||
|
if len(ids) != 3 {
|
||||||
|
t.Fatalf("got %d ids, want 3", len(ids))
|
||||||
|
}
|
||||||
|
// Rows 1,2,3 were inserted newest→oldest, so chronological order is 3,2,1.
|
||||||
|
if ids[0] != 3 || ids[1] != 2 || ids[2] != 1 {
|
||||||
|
t.Errorf("order = %v, want the oldest contact first (3,2,1)", ids)
|
||||||
|
}
|
||||||
|
if newest.Year() != 2026 {
|
||||||
|
t.Errorf("newest = %v, want the 2026 contact", newest)
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-8
@@ -48,6 +48,14 @@ func MergeNonZero(dst *QSO, src QSO) {
|
|||||||
// Anything in ADIF that is not a promoted column lands in Extras.
|
// Anything in ADIF that is not a promoted column lands in Extras.
|
||||||
type QSO struct {
|
type QSO struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
// Number is the contact's position in the log, oldest = 1.
|
||||||
|
//
|
||||||
|
// NOT a column and NOT the ID: the primary key follows insertion order, so
|
||||||
|
// importing an old ADIF gives the oldest contacts the highest ids. This is a
|
||||||
|
// rank over qso_date, computed by the App layer and stamped on the way out —
|
||||||
|
// which is why it is `json:"-"`-adjacent in spirit: nothing reads or writes
|
||||||
|
// it in SQL.
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
QSODate time.Time `json:"qso_date"` // start, UTC
|
QSODate time.Time `json:"qso_date"` // start, UTC
|
||||||
QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC
|
QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC
|
||||||
@@ -734,13 +742,26 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bulkEditableCols whitelists the columns BulkSetField may write. Limited to
|
// bulkEditableCols whitelists the columns BulkSetField may write.
|
||||||
// TEXT fields where setting one value across many QSOs is meaningful: the
|
//
|
||||||
// per-service QSL/upload status fields, plus "my station"/operator fields that
|
// Mostly TEXT fields where one value across many QSOs is meaningful: the
|
||||||
// are naturally constant across a run (grid, antenna, rig, address, …). It
|
// per-service QSL/upload status fields, plus "my station"/operator fields
|
||||||
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the
|
// naturally constant across a run (grid, antenna, rig, address, …).
|
||||||
// contacted station's details) and numeric columns (power, zones, lat/lon),
|
//
|
||||||
// which would be corrupted or meaningless if bulk-set to a single value.
|
// Mode, submode and RST are here too, which the original rule excluded as
|
||||||
|
// "per-QSO". That rule confused two different things. Bulk edit is not for
|
||||||
|
// describing QSOs, it is for REPAIRING a batch — an import that mapped every
|
||||||
|
// contact to SSB, an ADIF with no MODE at all — and refusing to fix a hundred
|
||||||
|
// rows because a hundred rows should not normally share a value leaves the
|
||||||
|
// operator editing them one at a time.
|
||||||
|
//
|
||||||
|
// Band is NOT here, and frequency is not either: both go through
|
||||||
|
// BulkSetFrequency, which writes the pair together. A band that contradicts its
|
||||||
|
// own frequency is invalid ADIF, and every export would carry the contradiction.
|
||||||
|
//
|
||||||
|
// Still excluded, and this part of the rule stands: callsign and date, which
|
||||||
|
// identify the contact rather than describe it, and the numeric columns (power,
|
||||||
|
// zones, lat/lon) that are meaningless shared.
|
||||||
var bulkEditableCols = map[string]bool{
|
var bulkEditableCols = map[string]bool{
|
||||||
// QSL / upload status
|
// QSL / upload status
|
||||||
"lotw_sent": true,
|
"lotw_sent": true,
|
||||||
@@ -820,6 +841,14 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"iota": true,
|
"iota": true,
|
||||||
"sig": true,
|
"sig": true,
|
||||||
"sig_info": true,
|
"sig_info": true,
|
||||||
|
// The contact itself. Repair fields: an import that mapped everything to SSB,
|
||||||
|
// or an ADIF that carried no MODE. Setting mode CLEARS submode (see
|
||||||
|
// BulkSetField) — a submode left over from the old mode contradicts the new
|
||||||
|
// one, and "FT8 / USB" is not a thing.
|
||||||
|
"mode": true,
|
||||||
|
"submode": true,
|
||||||
|
"rst_sent": true,
|
||||||
|
"rst_rcvd": true,
|
||||||
// Misc text
|
// Misc text
|
||||||
"comment": true,
|
"comment": true,
|
||||||
"notes": true,
|
"notes": true,
|
||||||
@@ -843,8 +872,16 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
|||||||
ph[i] = "?"
|
ph[i] = "?"
|
||||||
args = append(args, id)
|
args = append(args, id)
|
||||||
}
|
}
|
||||||
|
set := column + " = ?, updated_at = ?"
|
||||||
|
if column == "mode" {
|
||||||
|
// A submode belongs to the mode it was recorded under. Left behind, it
|
||||||
|
// contradicts the new one — "FT8" with a submode of "USB" is not a thing,
|
||||||
|
// and it is the submode that most ADIF readers believe. Clearing it is the
|
||||||
|
// only outcome that leaves the row meaning what the operator asked for.
|
||||||
|
set += ", submode = ''"
|
||||||
|
}
|
||||||
res, err := r.db.ExecContext(ctx,
|
res, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||||
args...)
|
args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||||
@@ -867,6 +904,23 @@ var bulkEditableExtras = map[string]string{
|
|||||||
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
||||||
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
|
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
|
||||||
|
|
||||||
|
// BulkEditable reports whether a COLUMN may be bulk-written. Exported so the
|
||||||
|
// app layer can check its own field mapping against this whitelist: the two
|
||||||
|
// lists are separate, valid on their own, and a field present in one and absent
|
||||||
|
// from the other fails only when an operator tries to use it.
|
||||||
|
func BulkEditable(column string) bool { return bulkEditableCols[column] }
|
||||||
|
|
||||||
|
// BulkEditableColumns lists every bulk-writable column, for the same check from
|
||||||
|
// the other side: a column nothing maps to looks supported and cannot be used.
|
||||||
|
func BulkEditableColumns() []string {
|
||||||
|
out := make([]string, 0, len(bulkEditableCols))
|
||||||
|
for c := range bulkEditableCols {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
|
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
|
||||||
// leaving the other extras untouched. An empty value REMOVES the key rather than
|
// leaving the other extras untouched. An empty value REMOVES the key rather than
|
||||||
// storing a blank — an empty extra would otherwise be carried into every export.
|
// storing a blank — an empty extra would otherwise be carried into every export.
|
||||||
@@ -1220,6 +1274,17 @@ var filterableColumns = map[string]bool{
|
|||||||
// value compares on the date part (see conditionSQL) so day filters are exact.
|
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||||
|
|
||||||
|
// numericColumns are the filterable columns holding numbers rather than text.
|
||||||
|
//
|
||||||
|
// "Empty" means something different for them: NULL *or* zero. It has to be said
|
||||||
|
// explicitly because the two backends disagree — SQLite compares 0 against ”
|
||||||
|
// as false, MySQL calls it true — so one expression would quietly answer two
|
||||||
|
// different questions depending on where the logbook lives.
|
||||||
|
var numericColumns = map[string]bool{
|
||||||
|
"freq_hz": true, "freq_rx_hz": true, "dxcc": true, "cqz": true, "ituz": true,
|
||||||
|
"srx": true, "stx": true, "tx_pwr": true,
|
||||||
|
}
|
||||||
|
|
||||||
// bareDateRe matches a plain calendar date with no time component.
|
// bareDateRe matches a plain calendar date with no time component.
|
||||||
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||||
|
|
||||||
@@ -1320,6 +1385,18 @@ func conditionSQL(c Condition) (string, []any, error) {
|
|||||||
col = "substr(" + col + ",1,10)"
|
col = "substr(" + col + ",1,10)"
|
||||||
v = strings.TrimSpace(v)
|
v = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
|
// "equals nothing" and "is empty" are the same question, and SQL answers the
|
||||||
|
// first with nothing at all: a NULL never equals '', so a filter written that
|
||||||
|
// way returns zero rows and looks broken rather than wrong. Asking it in
|
||||||
|
// plain words is not a mistake worth punishing.
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
switch c.Op {
|
||||||
|
case "eq":
|
||||||
|
c.Op = "empty"
|
||||||
|
case "ne":
|
||||||
|
c.Op = "notempty"
|
||||||
|
}
|
||||||
|
}
|
||||||
switch c.Op {
|
switch c.Op {
|
||||||
case "eq":
|
case "eq":
|
||||||
return col + " = ?", []any{v}, nil
|
return col + " = ?", []any{v}, nil
|
||||||
@@ -1370,8 +1447,18 @@ func conditionSQL(c Condition) (string, []any, error) {
|
|||||||
}
|
}
|
||||||
return col + " IN (" + ph + ")", args, nil
|
return col + " IN (" + ph + ")", args, nil
|
||||||
case "empty":
|
case "empty":
|
||||||
|
// A numeric column is empty when it is NULL *or* zero, and that has to be
|
||||||
|
// said explicitly: SQLite compares 0 against '' as false while MySQL calls
|
||||||
|
// it true, so IFNULL(col,'')='' quietly means different things on the two
|
||||||
|
// backends OpsLog supports.
|
||||||
|
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
|
||||||
|
return "(" + col + " IS NULL OR " + col + " = 0)", nil, nil
|
||||||
|
}
|
||||||
return "IFNULL(" + col + ",'') = ''", nil, nil
|
return "IFNULL(" + col + ",'') = ''", nil, nil
|
||||||
case "notempty":
|
case "notempty":
|
||||||
|
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
|
||||||
|
return "(" + col + " IS NOT NULL AND " + col + " <> 0)", nil, nil
|
||||||
|
}
|
||||||
return "IFNULL(" + col + ",'') <> ''", nil, nil
|
return "IFNULL(" + col + ",'') <> ''", nil, nil
|
||||||
default:
|
default:
|
||||||
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
|
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
|
||||||
@@ -3091,3 +3178,39 @@ func (r *Repo) WorkedGridKeys(ctx context.Context, normMode func(string) string)
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrderedIDs returns every QSO id in chronological order, oldest first.
|
||||||
|
//
|
||||||
|
// The mirror of ListFiltered's "qso_date DESC, id DESC", so the tie-break is the
|
||||||
|
// same one the grid displays and a contact cannot change number depending on
|
||||||
|
// which way it is read.
|
||||||
|
//
|
||||||
|
// One query, ids only: the caller turns it into a rank once and keeps it in
|
||||||
|
// memory. Asking the database for a row's rank per row would be a correlated
|
||||||
|
// count over the whole log for each of thirty thousand rows.
|
||||||
|
func (r *Repo) OrderedIDs(ctx context.Context) ([]int64, time.Time, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT id, qso_date FROM qso ORDER BY qso_date ASC, id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]int64, 0, 4096)
|
||||||
|
// qso_date is stored as a formatted STRING, not a driver date. Scanning it
|
||||||
|
// into a time.Time silently fails on SQLite, which is how this returned an
|
||||||
|
// error and left the whole column empty.
|
||||||
|
var lastDate string
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var at string
|
||||||
|
if err := rows.Scan(&id, &at); err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
lastDate = at // ascending, so the last row read is the newest
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
// Parsed once, for the newest row only — the ordering came from SQL.
|
||||||
|
return out, parseTimeLoose(lastDate), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package spe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decodeCSV marks the client connected on EVERY frame it parses, and the poll
|
||||||
|
// goroutine can be mid-read while PowerOff runs. So the frame from a second ago
|
||||||
|
// landed after PowerOff had marked the amp offline and put it straight back —
|
||||||
|
// the console kept showing the amplifier on until the operator pressed OFF a
|
||||||
|
// second time.
|
||||||
|
func TestFrameInFlightDoesNotResurrectASwitchedOffAmp(t *testing.T) {
|
||||||
|
c := &Client{}
|
||||||
|
const frame = ",13K,O,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,"
|
||||||
|
|
||||||
|
// Normal running: a frame marks it connected and reads OPERATE.
|
||||||
|
c.decodeCSV(frame)
|
||||||
|
if !c.GetStatus().Connected || !c.GetStatus().Operate {
|
||||||
|
t.Fatal("a status frame should mark the amp connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The operator presses OFF. This is what PowerOff does, in order.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.offUntil = time.Now().Add(3 * time.Second)
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
c.setErr(errSwitchedOff{})
|
||||||
|
|
||||||
|
// A frame that was already on the wire arrives now.
|
||||||
|
c.decodeCSV(frame)
|
||||||
|
|
||||||
|
if c.GetStatus().Connected {
|
||||||
|
t.Error("a frame from before the switch-off put the amp back on — this is the double-click on OFF")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An amp switched back on at its own front panel must reappear without OpsLog
|
||||||
|
// being told, so the suppression has to expire rather than latch.
|
||||||
|
func TestSuppressionExpires(t *testing.T) {
|
||||||
|
c := &Client{}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.offUntil = time.Now().Add(-time.Second) // window already past
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
c.setErr(errSwitchedOff{})
|
||||||
|
|
||||||
|
c.decodeCSV(",13K,O,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,")
|
||||||
|
if !c.GetStatus().Connected {
|
||||||
|
t.Error("an amp answering after the window must come back on its own")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errSwitchedOff struct{}
|
||||||
|
|
||||||
|
func (errSwitchedOff) Error() string { return "switched off" }
|
||||||
+56
-2
@@ -91,6 +91,15 @@ type Client struct {
|
|||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
lastRaw string // last raw status payload logged (log only on change)
|
lastRaw string // last raw status payload logged (log only on change)
|
||||||
|
// offUntil suppresses status frames that were already in flight when the amp
|
||||||
|
// was switched off. decodeCSV marks the client connected on every frame it
|
||||||
|
// parses, and the poll goroutine can be mid-read while PowerOff runs — so the
|
||||||
|
// frame from a second ago resurrected an amplifier the operator had just
|
||||||
|
// switched off, and the console only caught up when they pressed OFF twice.
|
||||||
|
//
|
||||||
|
// Time-bounded rather than a latch: an amp switched back on at its own front
|
||||||
|
// panel must reappear on its own.
|
||||||
|
offUntil time.Time
|
||||||
|
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
running bool
|
running bool
|
||||||
@@ -177,6 +186,12 @@ func (c *Client) PowerOn() error {
|
|||||||
if c.GetStatus().Connected {
|
if c.GetStatus().Connected {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Switching on cancels the suppression window: the operator wants frames
|
||||||
|
// believed again, and waiting out a timer they cannot see would read as the
|
||||||
|
// power-on having failed.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.offUntil = time.Time{}
|
||||||
|
c.statusMu.Unlock()
|
||||||
// The poll goroutine may still be inside a blocking read on the old handle;
|
// The poll goroutine may still be inside a blocking read on the old handle;
|
||||||
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
|
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
|
||||||
// the open for a little longer than that.
|
// the open for a little longer than that.
|
||||||
@@ -205,9 +220,17 @@ func (c *Client) PowerOn() error {
|
|||||||
time.Sleep(wakePulse)
|
time.Sleep(wakePulse)
|
||||||
return set(true)
|
return set(true)
|
||||||
}
|
}
|
||||||
if err = pulse(sp.SetRTS); err == nil {
|
// Each line reported separately. Not every USB-serial chip honours the modem
|
||||||
err = pulse(sp.SetDTR)
|
// lines, and some refuse them without saying so — on an amplifier that never
|
||||||
|
// wakes, "which of the two failed, or neither" is the whole diagnosis, and a
|
||||||
|
// single combined error cannot give it.
|
||||||
|
rtsErr := pulse(sp.SetRTS)
|
||||||
|
dtrErr := pulse(sp.SetDTR)
|
||||||
|
if err = rtsErr; err == nil {
|
||||||
|
err = dtrErr
|
||||||
}
|
}
|
||||||
|
applog.Printf("spe: power ON pulse on %s (model=%q transport=%s) — RTS err=%v, DTR err=%v",
|
||||||
|
c.cfg.ComPort, c.GetStatus().Model, c.cfg.Transport, rtsErr, dtrErr)
|
||||||
// Booting, the amp re-enumerates its USB interface, which leaves this handle
|
// Booting, the amp re-enumerates its USB interface, which leaves this handle
|
||||||
// pointing at a device that no longer exists — the amp came up and OpsLog
|
// pointing at a device that no longer exists — the amp came up and OpsLog
|
||||||
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
|
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
|
||||||
@@ -216,6 +239,26 @@ func (c *Client) PowerOn() error {
|
|||||||
c.dropLocked()
|
c.dropLocked()
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
|
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
|
||||||
|
|
||||||
|
// Say whether it actually woke. "The ON button does nothing" is a report with
|
||||||
|
// three different causes — the pulse was refused, the pulse went out and the
|
||||||
|
// amp ignored it, or the amp came up and the UI missed it — and only the log
|
||||||
|
// can separate them. Watched off the caller's goroutine so the click returns
|
||||||
|
// at once.
|
||||||
|
go func() {
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
if c.GetStatus().Connected {
|
||||||
|
applog.Printf("spe: power ON — amp answered after %.0fs",
|
||||||
|
15-time.Until(deadline).Seconds())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applog.Printf("spe: power ON — no answer within 15s on %s. The pulse was sent; "+
|
||||||
|
"either this amplifier does not wake on RTS/DTR, or the adapter does not drive those lines",
|
||||||
|
c.cfg.ComPort)
|
||||||
|
}()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +269,14 @@ func (c *Client) PowerOn() error {
|
|||||||
// the poll loop keeps reopening the port with both lines high and that has
|
// the poll loop keeps reopening the port with both lines high and that has
|
||||||
// never woken it — only the deliberate low→high sequence in PowerOn does.
|
// never woken it — only the deliberate low→high sequence in PowerOn does.
|
||||||
func (c *Client) PowerOff() error {
|
func (c *Client) PowerOff() error {
|
||||||
|
// Close the window BEFORE the key goes out, so a frame already travelling up
|
||||||
|
// the wire cannot land after setErr and undo it. Three seconds covers a poll
|
||||||
|
// interval with room to spare; after that a real answer means the amp is
|
||||||
|
// genuinely alive again, which is what happens if it is switched back on at
|
||||||
|
// its own front panel.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.offUntil = time.Now().Add(3 * time.Second)
|
||||||
|
c.statusMu.Unlock()
|
||||||
err := c.sendCmd(cmdOff)
|
err := c.sendCmd(cmdOff)
|
||||||
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
||||||
// Drop the link and mark the amp offline at once, exactly as a fresh start
|
// Drop the link and mark the amp offline at once, exactly as a fresh start
|
||||||
@@ -482,6 +533,9 @@ func (c *Client) decodeCSV(payload string) {
|
|||||||
c.lastRaw = payload
|
c.lastRaw = payload
|
||||||
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||||
}
|
}
|
||||||
|
if time.Now().Before(c.offUntil) {
|
||||||
|
return // a frame from before the switch-off — the amp is on its way down
|
||||||
|
}
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.status.LastError = ""
|
c.status.LastError = ""
|
||||||
// The real frame carries a leading empty field (it starts with a comma), so the
|
// The real frame carries a leading empty field (it starts with a comma), so the
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jlaffaye/ftp"
|
"github.com/jlaffaye/ftp"
|
||||||
|
|
||||||
|
"hamlog/internal/geo"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,6 +94,26 @@ func flt(p *float64) string {
|
|||||||
return strconv.FormatFloat(*p, 'f', -1, 64)
|
return strconv.FormatFloat(*p, 'f', -1, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distanceKm is the path length for the Distance column.
|
||||||
|
//
|
||||||
|
// The stored DISTANCE field is only ever filled by an ADIF import that carried
|
||||||
|
// one — OpsLog does not compute it when logging — so publishing it straight gave
|
||||||
|
// an empty column for every QSO made here, which is how this was reported.
|
||||||
|
//
|
||||||
|
// So it falls back to the two locators, which are on the QSO already. Rounded to
|
||||||
|
// whole kilometres: the grids are squares tens of kilometres across, and a
|
||||||
|
// decimal on a figure that imprecise claims an accuracy nobody has.
|
||||||
|
func distanceKm(q *qso.QSO) string {
|
||||||
|
if q.Distance != nil && *q.Distance > 0 {
|
||||||
|
return flt(q.Distance)
|
||||||
|
}
|
||||||
|
km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
|
||||||
|
if !ok || km <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(km, 'f', 0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
func stamp(t time.Time) string {
|
func stamp(t time.Time) string {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return ""
|
return ""
|
||||||
@@ -218,7 +239,7 @@ var Columns = []Column{
|
|||||||
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
|
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
|
||||||
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
|
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
|
||||||
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
|
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
|
||||||
{"distance", "Distance", "Location", func(q *qso.QSO) string { return flt(q.Distance) }},
|
{"distance", "Distance", "Location", distanceKm},
|
||||||
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
|
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
|
||||||
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
|
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
|
||||||
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
|
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
|
||||||
@@ -380,11 +401,25 @@ func renderHTML(cfg Config, cols []Column, qsos []qso.QSO, stationCall string) [
|
|||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg);
|
body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg);
|
||||||
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||||
.wrap{max-width:1100px;margin:0 auto}
|
/* The page grows with its table instead of being capped at a comfortable
|
||||||
|
READING width. 1100px suits eight columns and hides the rest behind a
|
||||||
|
scrollbar on a screen wide enough to show them all — which is how this was
|
||||||
|
reported. max-content lets a wide table use the window; min-width keeps a
|
||||||
|
narrow one from collapsing to nothing on a large display. */
|
||||||
|
.wrap{max-width:max-content;min-width:min(1100px,100%);margin:0 auto}
|
||||||
h1{margin:0 0 .25rem;font-size:1.35rem}
|
h1{margin:0 0 .25rem;font-size:1.35rem}
|
||||||
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
|
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
|
||||||
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px}
|
/* A visible scrollbar. Windows hides overlay scrollbars until something moves,
|
||||||
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
so a table that scrolls looks exactly like a table that is missing columns. */
|
||||||
|
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px;
|
||||||
|
scrollbar-width:thin;scrollbar-color:var(--line) transparent}
|
||||||
|
.scroll::-webkit-scrollbar{height:10px}
|
||||||
|
.scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
|
||||||
|
/* width:auto, not 100%: columns take the width their contents need. With
|
||||||
|
width:100% the browser squeezes them to fit the container first and only then
|
||||||
|
overflows, so a callsign could end up wrapped while empty space sat further
|
||||||
|
along the row. min-width keeps a two-column table filling the frame. */
|
||||||
|
table{border-collapse:collapse;width:auto;min-width:100%;font-variant-numeric:tabular-nums}
|
||||||
th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||||
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
|
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
|
||||||
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
|
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package webpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Distance column was published empty for every QSO logged in OpsLog: the
|
||||||
|
// stored DISTANCE field is only ever filled by an ADIF import that carried one,
|
||||||
|
// and nothing computes it when logging. It falls back to the two locators.
|
||||||
|
func TestDistanceFallsBackToTheGrids(t *testing.T) {
|
||||||
|
// JN36 (French Alps) to IO91 (southern England): a few hundred kilometres.
|
||||||
|
got := distanceKm(&qso.QSO{MyGrid: "JN36DG", Grid: "IO91"})
|
||||||
|
if got == "" {
|
||||||
|
t.Fatal("no distance from two perfectly good locators")
|
||||||
|
}
|
||||||
|
if got == "0" {
|
||||||
|
t.Errorf("distance = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored distance wins: it came from the log that recorded the QSO, which knew
|
||||||
|
// more than two four-character squares do.
|
||||||
|
func TestStoredDistanceWins(t *testing.T) {
|
||||||
|
d := 1234.0
|
||||||
|
if got := distanceKm(&qso.QSO{Distance: &d, MyGrid: "JN36", Grid: "IO91"}); got != "1234" {
|
||||||
|
t.Errorf("distance = %q, want the stored 1234", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No grids, no invention. An empty cell is honest; a zero is a claim.
|
||||||
|
func TestNoGridsNoDistance(t *testing.T) {
|
||||||
|
for _, q := range []qso.QSO{{}, {MyGrid: "JN36"}, {Grid: "IO91"}, {MyGrid: "??", Grid: "IO91"}} {
|
||||||
|
if got := distanceKm(&q); got != "" {
|
||||||
|
t.Errorf("distance(%+v) = %q, want empty", q, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A per-band tune frequency goes straight to the antenna as a command, so a
|
||||||
|
// value that is not actually in that band has to be refused rather than obeyed.
|
||||||
|
// One wrong digit sends the elements travelling to a length that is wrong for
|
||||||
|
// the band the operator is on — and on a SteppIR that journey inhibits transmit
|
||||||
|
// the whole way.
|
||||||
|
func TestNormMotorBandFreqsRefusesOutOfBand(t *testing.T) {
|
||||||
|
in := map[string]int{
|
||||||
|
"20m": 14050, // fine, CW end
|
||||||
|
"40m": 7005, // fine
|
||||||
|
"6m": 50313, // fine, FT8
|
||||||
|
"15m": 1450, // a digit lost — lands in the broadcast band
|
||||||
|
"10m": 28400000, // Hz typed where kHz was asked
|
||||||
|
"17m": 14100, // right number, wrong band
|
||||||
|
"30m": 0, // not set
|
||||||
|
"80m": 3750, // not a band this antenna covers at all
|
||||||
|
"bogus": 14100, // not a band
|
||||||
|
}
|
||||||
|
got := normMotorBandFreqs(in)
|
||||||
|
want := map[string]int{"40m": 7005, "20m": 14050, "6m": 50313}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("kept %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("%s = %d, want %d", k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored form round-trips, in canonical band order rather than map order so
|
||||||
|
// the settings row does not churn between saves.
|
||||||
|
func TestMotorBandFreqsRoundTrip(t *testing.T) {
|
||||||
|
m := map[string]int{"20m": 14050, "40m": 7005, "6m": 50313}
|
||||||
|
enc := encodeMotorBandFreqs(m)
|
||||||
|
if enc != "40m=7005,20m=14050,6m=50313" {
|
||||||
|
t.Errorf("encoded %q — want canonical low→high order", enc)
|
||||||
|
}
|
||||||
|
back := decodeMotorBandFreqs(enc)
|
||||||
|
for k, v := range m {
|
||||||
|
if back[k] != v {
|
||||||
|
t.Errorf("round trip lost %s: %d → %d", k, v, back[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Garbage in one entry must cost only that entry.
|
||||||
|
part := decodeMotorBandFreqs("40m=7005,20m=oops,6m=50313")
|
||||||
|
if part["40m"] != 7005 || part["6m"] != 50313 {
|
||||||
|
t.Errorf("one bad entry took the others down: %v", part)
|
||||||
|
}
|
||||||
|
if _, ok := part["20m"]; ok {
|
||||||
|
t.Errorf("kept an unparseable entry: %v", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unset band falls back to its default, which is what makes the Settings box
|
||||||
|
// safe to leave empty.
|
||||||
|
func TestMotorTuneKHzForBandFallsBack(t *testing.T) {
|
||||||
|
m := map[string]int{"20m": 14050}
|
||||||
|
if got := motorTuneKHzForBand(m, "20m"); got != 14050 {
|
||||||
|
t.Errorf("chosen frequency ignored: %d", got)
|
||||||
|
}
|
||||||
|
if got := motorTuneKHzForBand(m, "15m"); got != 21150 {
|
||||||
|
t.Errorf("15m = %d, want the 21150 default", got)
|
||||||
|
}
|
||||||
|
if got := motorTuneKHzForBand(m, "80m"); got != 0 {
|
||||||
|
t.Errorf("80m = %d, want 0 — not a motor band", got)
|
||||||
|
}
|
||||||
|
// Every default must itself be in its band, or the fallback ships the very
|
||||||
|
// fault normMotorBandFreqs exists to catch.
|
||||||
|
for _, b := range motorBands {
|
||||||
|
if got := bandForHz(int64(b.defKHz) * 1000); got != b.name {
|
||||||
|
t.Errorf("default %d kHz for %s reads as %q", b.defKHz, b.name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The tracking mode decides how often a motorized antenna's elements run, so a
|
||||||
|
// value that fails to parse must not silently become the most aggressive
|
||||||
|
// setting. Anything unrecognised — including the empty string every config
|
||||||
|
// written before this option existed contains — has to land on the threshold
|
||||||
|
// mode, which is exactly what those configs already did.
|
||||||
|
func TestNormMotorTrackMode(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"always", motorTrackAlways},
|
||||||
|
{"ALWAYS", motorTrackAlways},
|
||||||
|
{" band ", motorTrackBand},
|
||||||
|
{"step", motorTrackStep},
|
||||||
|
{"", motorTrackStep}, // never configured — behaves as before
|
||||||
|
{"everytime", motorTrackStep}, // near-miss, not "always"
|
||||||
|
{"per-band", motorTrackStep}, // near-miss, not "band"
|
||||||
|
{"25", motorTrackStep}, // a step value fed in by mistake
|
||||||
|
} {
|
||||||
|
if got := normMotorTrackMode(tc.in); got != tc.want {
|
||||||
|
t.Errorf("normMotorTrackMode(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Band mode compares the band the rig is on against the band the antenna was
|
||||||
|
// last commanded for. That comparison is bandForHz, and the property it has to
|
||||||
|
// have is that two frequencies far apart within one band agree while two
|
||||||
|
// frequencies close together across a band edge do not — otherwise the antenna
|
||||||
|
// either never moves or moves on every QSY.
|
||||||
|
func TestBandForHzDrivesBandTracking(t *testing.T) {
|
||||||
|
same := [][2]int64{
|
||||||
|
{14000000, 14350000}, // both ends of 20 m — one band, no move
|
||||||
|
{7000000, 7200000}, // 40 m
|
||||||
|
{50000000, 52000000}, // 6 m, a wide one
|
||||||
|
}
|
||||||
|
for _, p := range same {
|
||||||
|
if a, b := bandForHz(p[0]), bandForHz(p[1]); a != b || a == "" {
|
||||||
|
t.Errorf("%.3f MHz is %q but %.3f MHz is %q — band mode would re-tune inside one band",
|
||||||
|
float64(p[0])/1e6, a, float64(p[1])/1e6, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A small QSY that crosses from 30 m into 20 m has to read as a band change.
|
||||||
|
if a, b := bandForHz(10150000), bandForHz(14000000); a == b {
|
||||||
|
t.Errorf("30 m and 20 m both read %q — band mode would never re-tune between them", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
func at(s string) time.Time {
|
||||||
|
t, err := time.Parse("2006-01-02 15:04", s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// The number is the contact's rank in the WHOLE log, not its position in what
|
||||||
|
// the grid happens to be showing. Ranking inside the result would renumber
|
||||||
|
// every contact the moment a filter is applied, and QSO #1 would change
|
||||||
|
// identity as the operator typed.
|
||||||
|
func TestQSONumberIsGlobalNotPerPage(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
// The log, oldest first: ids are deliberately NOT in date order, which is
|
||||||
|
// exactly what an imported ADIF produces.
|
||||||
|
a.qsoNumbers = map[int64]int{
|
||||||
|
770: 1, // oldest, imported last so it has the highest id
|
||||||
|
101: 2,
|
||||||
|
102: 3,
|
||||||
|
103: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// A filtered page holding only two of them, newest first as the grid asks.
|
||||||
|
page := []qso.QSO{{ID: 103}, {ID: 770}}
|
||||||
|
a.stampQSONumbers(page)
|
||||||
|
|
||||||
|
if page[0].Number != 4 {
|
||||||
|
t.Errorf("id 103 numbered %d, want 4", page[0].Number)
|
||||||
|
}
|
||||||
|
if page[1].Number != 1 {
|
||||||
|
t.Errorf("id 770 numbered %d, want 1 — the oldest contact, whatever its id", page[1].Number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A contact logged now is the newest, so it takes the next number without
|
||||||
|
// rereading the log — a full scan per QSO would be felt in a contest run.
|
||||||
|
func TestNewQSOTakesTheNextNumber(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.qsoNumbers = map[int64]int{1: 1, 2: 2, 3: 3}
|
||||||
|
a.qsoNumMax = at("2026-08-13 10:00")
|
||||||
|
|
||||||
|
a.noteQSONumbered(9, at("2026-08-13 11:00"))
|
||||||
|
if got := a.qsoNumbers[9]; got != 4 {
|
||||||
|
t.Errorf("new QSO numbered %d, want 4", got)
|
||||||
|
}
|
||||||
|
if !a.qsoNumMax.Equal(at("2026-08-13 11:00")) {
|
||||||
|
t.Error("the newest date was not carried forward")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A contact entered with an OLDER date belongs in the middle of the order.
|
||||||
|
// Appending it would number it last, which is wrong — so the map is dropped and
|
||||||
|
// rebuilt correctly instead.
|
||||||
|
func TestBackdatedQSOForcesARebuild(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.qsoNumbers = map[int64]int{1: 1, 2: 2, 3: 3}
|
||||||
|
a.qsoNumMax = at("2026-08-13 10:00")
|
||||||
|
|
||||||
|
a.noteQSONumbered(9, at("2020-01-01 09:00"))
|
||||||
|
if a.qsoNumbers != nil {
|
||||||
|
t.Errorf("a back-dated QSO was appended as the newest: %v", a.qsoNumbers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing built yet: the lazy build will see the new contact anyway, so this
|
||||||
|
// must not create a one-entry map that then numbers the whole log wrongly.
|
||||||
|
func TestNoteBeforeAnyBuildDoesNothing(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.noteQSONumbered(9, at("2026-08-13 11:00"))
|
||||||
|
if a.qsoNumbers != nil {
|
||||||
|
t.Errorf("built a map from a single contact: %v", a.qsoNumbers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no index available the column is simply empty — never wrong.
|
||||||
|
func TestStampWithoutIndexLeavesZero(t *testing.T) {
|
||||||
|
a := &App{} // no qso repo, so the index cannot be built
|
||||||
|
page := []qso.QSO{{ID: 1}, {ID: 2}}
|
||||||
|
a.stampQSONumbers(page)
|
||||||
|
for _, q := range page {
|
||||||
|
if q.Number != 0 {
|
||||||
|
t.Errorf("invented a number without an index: %d", q.Number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.24.5"
|
appVersion = "0.24.9"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A CQ in FT8 carries the operator's square and it is where they are NOW.
|
||||||
|
// QRZ and HamQTH describe where they LIVE, which for an expedition is the
|
||||||
|
// wrong side of the planet — RI0FA transmitted QN35 all evening and was logged
|
||||||
|
// with its licence-holder's home square.
|
||||||
|
//
|
||||||
|
// refineGrid is the referee. What it must never do is let a directory value
|
||||||
|
// replace one heard on the air.
|
||||||
|
func TestRefineGridKeepsWhatWasHeardOnAir(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ have, found, want string }{
|
||||||
|
// The expedition case: heard QN35, directory says the home square.
|
||||||
|
{"QN35", "KO85", "QN35"},
|
||||||
|
// Nothing heard: the directory is all there is.
|
||||||
|
{"", "KO85", "KO85"},
|
||||||
|
// A finer square from the SAME field is an upgrade, not a contradiction.
|
||||||
|
{"QN35", "QN35SL", "QN35SL"},
|
||||||
|
// A finer square from a DIFFERENT field is a contradiction — refuse it.
|
||||||
|
{"QN35", "KO85AB", "QN35"},
|
||||||
|
// Case and padding must not decide anything.
|
||||||
|
{" qn35 ", "KO85", "QN35"},
|
||||||
|
{"QN35", "", "QN35"},
|
||||||
|
} {
|
||||||
|
if got := refineGrid(tc.have, tc.found); got != tc.want {
|
||||||
|
t.Errorf("refineGrid(%q, %q) = %q, want %q", tc.have, tc.found, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the ordering that makes it work: the decoded grid has to be applied
|
||||||
|
// BEFORE the lookup, or refineGrid has nothing to defend.
|
||||||
|
func TestDecodedGridWinsOverTheDirectory(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.rememberDecodeGrid("RI0FA", "QN35", "decode")
|
||||||
|
|
||||||
|
grid := "" // WSJT-X's ADIF carried no GRIDSQUARE, which is the usual case
|
||||||
|
if g := a.lookupDecodeGrid("RI0FA"); g != "" {
|
||||||
|
grid = refineGrid(grid, g)
|
||||||
|
}
|
||||||
|
grid = refineGrid(grid, "KO85") // then the lookup answers with the home square
|
||||||
|
|
||||||
|
if grid != "QN35" {
|
||||||
|
t.Errorf("logged grid = %q, want QN35 — the square the station actually sent", grid)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user