Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8790b98766 | ||
|
|
8846cba40c | ||
|
|
0cc6ad686a | ||
|
|
612cb67438 | ||
|
|
cd5d8b503b | ||
|
|
aa59431403 | ||
|
|
269701410f | ||
|
|
3cb8096141 | ||
|
|
1f667e4a4b | ||
|
|
d5f7e290ab | ||
|
|
f2ba798764 | ||
|
|
d7ee87f22d | ||
|
|
2587e6bcfe | ||
|
|
755392e0fa | ||
|
|
a9fb428a0a | ||
|
|
bbb485b9cf | ||
|
|
3a94a69be3 | ||
|
|
e4028f4bcd | ||
|
|
7c10517bf5 | ||
|
|
cd4e5b1117 | ||
|
|
a13ac7d917 | ||
|
|
4e28098a4e | ||
|
|
52e98a71f4 | ||
|
|
81ff6a9b0e | ||
|
|
be52a23868 | ||
|
|
fc1561804c | ||
|
|
1288b3f998 | ||
|
|
e00488fad8 | ||
|
|
12c61dc35a | ||
|
|
0c0e8b06ba | ||
|
|
1bd3896ca7 | ||
|
|
7e6c0b4f7e |
@@ -159,6 +159,8 @@ const (
|
||||
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
||||
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
||||
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
|
||||
keyChaseMode = "chase.mode" // "new" | "new_unconfirmed" — the global hunt, every category
|
||||
keyChaseConfirm = "chase.confirm" // CSV of confirmation sources: lotw,card,eqsl,qrz
|
||||
keyAudioMonitorOn = "audio.monitor.on" // play the network RX audio through the Listening device (the stream itself stays open for the recorder either way)
|
||||
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
||||
@@ -327,6 +329,7 @@ const (
|
||||
keyClusterSpotMax = "cluster.spot_max" // how many spots the list holds at once
|
||||
|
||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
||||
keyScpClublog = "scp.clublog" // merge Club Log's weekly SCP list (~180k calls)
|
||||
|
||||
// Web publishing: the whole config as one JSON blob. A single key rather than
|
||||
// twenty: it is read and written as a unit by one settings panel, and the FTP
|
||||
@@ -354,6 +357,7 @@ const (
|
||||
keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
|
||||
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
|
||||
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
||||
keyQSLDefaultClublogCfm = "qsl.clublog_confirmed"
|
||||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||||
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
||||
@@ -419,6 +423,8 @@ const (
|
||||
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
|
||||
keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull
|
||||
keyExtEQSLLastDownload = "extsvc.eqsl.last_download" // YYYY-MM-DD of last eQSL inbox pull
|
||||
|
||||
keyExtClublogLastDownload = "extsvc.clublog.last_download" // YYYY-MM-DD of last Club Log matches pull
|
||||
)
|
||||
|
||||
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
|
||||
@@ -434,6 +440,7 @@ type QSLDefaults struct {
|
||||
EQSLSent string `json:"eqsl_sent"`
|
||||
EQSLRcvd string `json:"eqsl_rcvd"`
|
||||
ClublogStatus string `json:"clublog_status"`
|
||||
ClublogCfm string `json:"clublog_confirmed"` // Club Log match/download status
|
||||
HRDLogStatus string `json:"hrdlog_status"`
|
||||
QRZComStatus string `json:"qrzcom_status"`
|
||||
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
|
||||
@@ -1127,7 +1134,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
conn, err := db.Open(a.dbPath)
|
||||
if err != nil {
|
||||
a.startupErr = "cannot open db: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
// In the rotating log too: the GUI subsystem discards stdout, and this
|
||||
// exact failure once hid for a whole morning behind a println.
|
||||
applog.Printf("startup: %s", a.startupErr)
|
||||
return
|
||||
}
|
||||
a.db = conn
|
||||
@@ -1393,6 +1402,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is
|
||||
// enabled, auto-(re)download it if missing or older than a week.
|
||||
a.scp = scp.NewManager(dataDir)
|
||||
if v, _ := a.settings.Get(a.ctx, keyScpClublog); v == "1" {
|
||||
a.scp.SetClublogEnabled(true)
|
||||
}
|
||||
go func() {
|
||||
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
|
||||
return
|
||||
@@ -3144,6 +3156,7 @@ type ScpStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Count int `json:"count"`
|
||||
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
|
||||
Clublog bool `json:"clublog"` // Club Log source merged in
|
||||
}
|
||||
|
||||
// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
|
||||
@@ -3155,6 +3168,7 @@ func (a *App) GetScpStatus() ScpStatus {
|
||||
}
|
||||
if a.scp != nil {
|
||||
st.Count = a.scp.Count()
|
||||
st.Clublog = a.scp.ClublogEnabled()
|
||||
if u := a.scp.Updated(); !u.IsZero() {
|
||||
st.Updated = u.UTC().Format(time.RFC3339)
|
||||
}
|
||||
@@ -3162,6 +3176,34 @@ func (a *App) GetScpStatus() ScpStatus {
|
||||
return st
|
||||
}
|
||||
|
||||
// SetScpClublogEnabled merges (or stops merging) Club Log's weekly SCP list
|
||||
// into the Super Check Partial call list, and refreshes the download so the
|
||||
// change is visible without waiting for the weekly cycle.
|
||||
func (a *App) SetScpClublogEnabled(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyScpClublog, boolStr(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.scp != nil {
|
||||
a.scp.SetClublogEnabled(on)
|
||||
if on {
|
||||
go func() {
|
||||
if n, err := a.scp.Download(context.Background()); err == nil {
|
||||
applog.Printf("scp: downloaded %d callsigns (with Club Log list)", n)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "scp:updated")
|
||||
}
|
||||
} else {
|
||||
applog.Printf("scp: download failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
|
||||
// download when the list is missing or stale.
|
||||
func (a *App) SetScpEnabled(on bool) error {
|
||||
@@ -6887,6 +6929,7 @@ var bulkFieldColumns = map[string]string{
|
||||
"qrz_sent": "qrzcom_qso_upload_status",
|
||||
"qrz_rcvd": "qrzcom_qso_download_status",
|
||||
"clublog_sent": "clublog_qso_upload_status",
|
||||
"clublog_rcvd": "clublog_qso_download_status",
|
||||
"hrdlog_sent": "hrdlog_qso_upload_status",
|
||||
// Old ids kept so anything holding one keeps working.
|
||||
"qrz_upload": "qrzcom_qso_upload_status",
|
||||
@@ -6903,6 +6946,7 @@ var bulkFieldColumns = map[string]string{
|
||||
"qrz_sent_date": "qrzcom_qso_upload_date",
|
||||
"qrz_rcvd_date": "qrzcom_qso_download_date",
|
||||
"clublog_sent_date": "clublog_qso_upload_date",
|
||||
"clublog_rcvd_date": "clublog_qso_download_date",
|
||||
"hrdlog_sent_date": "hrdlog_qso_upload_date",
|
||||
// My station / operator
|
||||
"station_callsign": "station_callsign",
|
||||
@@ -10239,7 +10283,7 @@ func titleEntity(s string) string {
|
||||
// the configured "Recording mic", transmit via "To Radio", preview via
|
||||
// "Listening".
|
||||
|
||||
const dvkSlots = 6
|
||||
const dvkSlots = 12
|
||||
|
||||
// DVKMessage is one voice-keyer slot for the UI.
|
||||
type DVKMessage struct {
|
||||
@@ -10300,6 +10344,22 @@ func (a *App) GetDVKMessages() []DVKMessage {
|
||||
return out
|
||||
}
|
||||
|
||||
// DVKDelete removes a slot's recording AND its label — a deleted message is a
|
||||
// free slot, per its operator.
|
||||
func (a *App) DVKDelete(slot int) error {
|
||||
if slot < 1 || slot > dvkSlots {
|
||||
return fmt.Errorf("bad slot")
|
||||
}
|
||||
if err := os.Remove(a.dvkPath(slot)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete message %d: %w", slot, err)
|
||||
}
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(a.ctx, dvkLabelKey(slot), "")
|
||||
}
|
||||
applog.Printf("dvk: message F%d deleted (label cleared)", slot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDVKLabel renames a voice-keyer slot.
|
||||
func (a *App) SetDVKLabel(slot int, label string) error {
|
||||
if a.settings == nil {
|
||||
@@ -10880,7 +10940,7 @@ func defaultQSLDefaults() QSLDefaults {
|
||||
QSLSent: "N", QSLRcvd: "N",
|
||||
EQSLSent: "R", EQSLRcvd: "N",
|
||||
LOTWSent: "R", LOTWRcvd: "N",
|
||||
ClublogStatus: "R", HRDLogStatus: "R",
|
||||
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
|
||||
QRZComStatus: "R", QRZComCfm: "N",
|
||||
}
|
||||
}
|
||||
@@ -10899,7 +10959,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
||||
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||||
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
|
||||
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
||||
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
|
||||
)
|
||||
@@ -10913,6 +10973,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
out.EQSLSent = m[keyQSLDefaultEQSLSent]
|
||||
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
|
||||
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||||
out.ClublogCfm = m[keyQSLDefaultClublogCfm]
|
||||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
||||
@@ -10936,6 +10997,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||||
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
|
||||
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
|
||||
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||||
keyQSLDefaultClublogCfm: strings.ToUpper(strings.TrimSpace(d.ClublogCfm)),
|
||||
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||||
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
||||
@@ -10988,6 +11050,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
|
||||
fill(&q.EQSLSent, d.EQSLSent)
|
||||
fill(&q.EQSLRcvd, d.EQSLRcvd)
|
||||
fill(&q.ClublogUploadStatus, d.ClublogStatus)
|
||||
fill(&q.ClublogDownloadStatus, d.ClublogCfm)
|
||||
fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
|
||||
fill(&q.QRZComUploadStatus, d.QRZComStatus)
|
||||
fill(&q.QRZComDownloadStatus, d.QRZComCfm)
|
||||
@@ -12917,6 +12980,90 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
||||
a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
case extsvc.ServiceClublog:
|
||||
sinceDate := resolveSince(keyExtClublogLastDownload)
|
||||
if sinceDate != "" {
|
||||
emit("Downloading Club Log matches (matched since " + sinceDate + ")…")
|
||||
} else {
|
||||
emit("Downloading ALL Club Log matches (full pull — the date filter is faster next time)…")
|
||||
}
|
||||
matches, err := extsvc.DownloadClublogMatches(ctx, nil, cfg.Clublog, sinceDate)
|
||||
if err != nil {
|
||||
emit("Download failed: " + err.Error())
|
||||
done(matched, total)
|
||||
return
|
||||
}
|
||||
mIdx, kerr := a.qso.BuildMatchIndex(ctx, a.uploadOwnerCall(extsvc.ServiceClublog))
|
||||
if kerr != nil {
|
||||
emit("Error reading local log: " + kerr.Error())
|
||||
done(matched, total)
|
||||
return
|
||||
}
|
||||
// Club Log pairs QSOs within ±15 minutes, same as LoTW.
|
||||
const clublogMatchWindow = 15 * time.Minute
|
||||
today := time.Now().UTC().Format("20060102")
|
||||
// A Club Log match is Club Log's own kind of confirmation, so NEW is
|
||||
// judged only against other Club Log matches.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"clublog_qso_download_status"})
|
||||
var items []ConfirmationItem
|
||||
var unmatched []string
|
||||
for _, m := range matches {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
total++
|
||||
id, found := int64(0), false
|
||||
if m.Band != "" && m.Mode != "" {
|
||||
id, found = mIdx.Match(m.Callsign, m.Band, m.Mode, m.When, clublogMatchWindow)
|
||||
}
|
||||
if !found && m.Band != "" {
|
||||
// Club Log reports "false" for modes it can't infer — fall back to
|
||||
// call+band+time, still unambiguous inside a 15-minute window.
|
||||
id, found = mIdx.MatchBand(m.Callsign, m.Band, m.When, clublogMatchWindow)
|
||||
}
|
||||
if found {
|
||||
if e := a.qso.MarkClublogConfirmed(ctx, id, today); e == nil {
|
||||
matched++
|
||||
}
|
||||
} else {
|
||||
unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
|
||||
m.Callsign, m.When.Format("2006-01-02 15:04Z"), m.Band, m.Mode))
|
||||
}
|
||||
it := ConfirmationItem{
|
||||
Callsign: m.Callsign,
|
||||
QSODate: m.When.Format(time.RFC3339),
|
||||
Band: m.Band, Mode: m.Mode,
|
||||
}
|
||||
if m.DXCC != 0 {
|
||||
it.Country = dxcc.NameForDXCC(m.DXCC)
|
||||
it.NewDXCC = !sets.DXCC[m.DXCC]
|
||||
it.NewBand = !sets.Band[qso.BandKey(m.DXCC, m.Band)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)]
|
||||
sets.DXCC[m.DXCC] = true
|
||||
sets.Band[qso.BandKey(m.DXCC, m.Band)] = true
|
||||
sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||||
}
|
||||
emit(fmt.Sprintf("Matched %d of %d Club Log match(es)", matched, total))
|
||||
if addNotFound && len(unmatched) > 0 {
|
||||
// A match means BOTH sides uploaded this QSO — one Club Log can't find
|
||||
// locally was deleted or edited here, not missed; adding a skeleton
|
||||
// row from call/band/time alone would just create a duplicate shadow.
|
||||
emit("Club Log matches carry too little detail to add missing QSOs — unmatched ones are listed below.")
|
||||
}
|
||||
for _, u := range unmatched {
|
||||
emit(" ⚠ no local QSO for: " + u)
|
||||
}
|
||||
if a.settings != nil {
|
||||
a.setSetting(a.profileScope()+keyExtClublogLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
default:
|
||||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||||
}
|
||||
@@ -16080,6 +16227,10 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
a.restartAsync("tuner", a.startTunerGenius)
|
||||
a.restartAsync("psu", a.startPSU)
|
||||
a.startQSORecorderIfEnabled()
|
||||
// The watchlist's Club Log enrichment (OQRS/QSO-count badges) reads
|
||||
// per-profile settings too — rebuilt so a switch doesn't keep showing the
|
||||
// previous profile's view of who is worth chasing.
|
||||
a.startWatchlistClubLog()
|
||||
}
|
||||
|
||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||
@@ -18531,19 +18682,51 @@ func (a *App) startAmps() {
|
||||
old := a.ampInsts
|
||||
a.ampInsts = map[string]*ampInst{}
|
||||
a.ampsMu.Unlock()
|
||||
a.pgxl, a.spe, a.acom = nil, nil, nil
|
||||
list, err := a.GetAmplifiers()
|
||||
if err != nil {
|
||||
for _, inst := range old {
|
||||
go inst.stopAll()
|
||||
}
|
||||
return
|
||||
}
|
||||
// An amplifier whose configuration did not change keeps its RUNNING client
|
||||
// across a settings save. Rebuilding closes the serial port, and closing a
|
||||
// COM port makes the Windows driver drop DTR/RTS — which on a KPA500 is the
|
||||
// POWER SWITCH: every save of any Settings page was switching the amplifier
|
||||
// off (F1TPL's report). Reuse also spares the other amps a reconnect blink.
|
||||
reused := map[string]bool{}
|
||||
for _, c := range list {
|
||||
if !c.Enabled {
|
||||
continue
|
||||
}
|
||||
if o, ok := old[c.ID]; ok && o.cfg == c {
|
||||
reused[c.ID] = true
|
||||
if o.pgxl != nil && a.pgxl == nil {
|
||||
a.pgxl = o.pgxl
|
||||
}
|
||||
if o.spe != nil && a.spe == nil {
|
||||
a.spe = o.spe
|
||||
}
|
||||
if o.acom != nil && a.acom == nil {
|
||||
a.acom = o.acom
|
||||
}
|
||||
a.ampsMu.Lock()
|
||||
a.ampInsts[c.ID] = o
|
||||
a.ampsMu.Unlock()
|
||||
}
|
||||
}
|
||||
for id, inst := range old {
|
||||
if reused[id] {
|
||||
continue
|
||||
}
|
||||
// Stop() can block up to the dial timeout waiting for an in-progress
|
||||
// connect; tear down in the background so saving Settings (this runs on
|
||||
// the Wails RPC goroutine) doesn't freeze the UI.
|
||||
go inst.stopAll()
|
||||
}
|
||||
a.pgxl, a.spe, a.acom = nil, nil, nil
|
||||
list, err := a.GetAmplifiers()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range list {
|
||||
if !c.Enabled {
|
||||
if !c.Enabled || reused[c.ID] {
|
||||
continue
|
||||
}
|
||||
isPGXL := c.Type == "" || c.Type == "pgxl"
|
||||
@@ -19805,6 +19988,16 @@ type SpotStatus struct {
|
||||
// NewCounty already has them in hand.
|
||||
County string `json:"county,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
// NewState says this US state has never been worked — the WAS gap the
|
||||
// decode panel's filter chases. Orthogonal to Status, like NewCounty.
|
||||
NewState bool `json:"new_state"`
|
||||
// The Unconf flags say the matching need exists ONLY because the contact
|
||||
// was never confirmed (under the operator's chosen QSL sources): a QSL to
|
||||
// chase, not a QSO to make. The badges render them dashed, the grid way.
|
||||
UnconfStatus bool `json:"unconf_status,omitempty"`
|
||||
UnconfPfx bool `json:"unconf_pfx,omitempty"`
|
||||
UnconfCty bool `json:"unconf_cty,omitempty"`
|
||||
UnconfState bool `json:"unconf_state,omitempty"`
|
||||
NewPOTA bool `json:"new_pota"`
|
||||
// Grid is the 4-character square this station announced in a CQ on the UDP
|
||||
// link, and NewGrid says that square has never been worked. Both are empty /
|
||||
@@ -19853,6 +20046,17 @@ type clusterStatusCache struct {
|
||||
workedCallSlots map[string]struct{}
|
||||
workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on
|
||||
workedCounties map[string]struct{}
|
||||
workedStates map[string]struct{}
|
||||
// The CONFIRMED-only ledgers, built when the global hunt is
|
||||
// "new + unconfirmed": verdicts are judged against these, and a need that
|
||||
// exists only because a contact was never confirmed is flagged Unconf so
|
||||
// its badge can say "a QSL to chase", not "a QSO to make".
|
||||
chaseUnconf bool
|
||||
chaseKey string
|
||||
entitiesConf map[int]*qso.EntitySlot
|
||||
workedPfxConf map[string]struct{}
|
||||
workedCountiesConf map[string]struct{}
|
||||
workedStatesConf map[string]struct{}
|
||||
// callCounties holds callsign → "STATE,County" for stations already logged
|
||||
// with a county, so a spot shows the county the entry panel showed rather
|
||||
// than the one derived from the licence ZIP. See qso.CallCounties.
|
||||
@@ -19873,6 +20077,50 @@ type clusterStatusCache struct {
|
||||
slotHighlight bool // (same: the slot index is built for either)
|
||||
}
|
||||
|
||||
// ChaseSettings is the global hunt: does "worked but never confirmed" still
|
||||
// count as something to chase, and which QSL systems count as confirmation.
|
||||
type ChaseSettings struct {
|
||||
Mode string `json:"mode"` // "new" | "new_unconfirmed"
|
||||
Sources []string `json:"sources"` // subset of lotw,card,eqsl,qrz
|
||||
}
|
||||
|
||||
// GetChaseSettings returns the global hunt settings.
|
||||
func (a *App) GetChaseSettings() ChaseSettings {
|
||||
mode := a.settingOr(keyChaseMode, "new")
|
||||
if mode != "new_unconfirmed" {
|
||||
mode = "new"
|
||||
}
|
||||
csv := a.settingOr(keyChaseConfirm, "lotw,card,eqsl")
|
||||
var src []string
|
||||
for _, part := range strings.Split(csv, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
src = append(src, part)
|
||||
}
|
||||
}
|
||||
return ChaseSettings{Mode: mode, Sources: src}
|
||||
}
|
||||
|
||||
// SaveChaseSettings stores them and drops the status snapshot so the next
|
||||
// batch is judged under the new rules.
|
||||
func (a *App) SaveChaseSettings(cs ChaseSettings) error {
|
||||
mode := cs.Mode
|
||||
if mode != "new_unconfirmed" {
|
||||
mode = "new"
|
||||
}
|
||||
a.setSetting(keyChaseMode, mode)
|
||||
a.setSetting(keyChaseConfirm, strings.Join(cs.Sources, ","))
|
||||
a.clusterStatusMu.Lock()
|
||||
a.clusterStatusIdx = nil
|
||||
a.clusterStatusMu.Unlock()
|
||||
// The frontend caches resolved verdicts and only asks about unknown keys —
|
||||
// without this it kept judging half the screen under the OLD rules until a
|
||||
// restart, which read as the new mode simply not working.
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clusterStatusMaps returns the cached worked-index snapshot, building it once
|
||||
// per logbook change (invalidated by invalidateAwardStats) or when a setting
|
||||
// that shapes the maps flips. This turns the per-batch full-logbook scans into
|
||||
@@ -19885,16 +20133,23 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
||||
// holding the status mutex across that is how two subsystems deadlock.
|
||||
gs := a.GetGridScopeSettings()
|
||||
gridScopeNow := lookupGridScope(gs.Scope)
|
||||
gridHuntNow := gs.Hunt
|
||||
// The grid hunt FOLLOWS the global chase mode now — the per-grid selector
|
||||
// grew into the global option, and two switches for one idea is one too
|
||||
// many. (Same vocabulary: "new" / "new_unconfirmed".)
|
||||
chase := a.GetChaseSettings()
|
||||
gridHuntNow := chase.Mode
|
||||
chaseKeyNow := chase.Mode + "|" + strings.Join(chase.Sources, ",")
|
||||
a.clusterStatusMu.Lock()
|
||||
defer a.clusterStatusMu.Unlock()
|
||||
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot &&
|
||||
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow {
|
||||
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow &&
|
||||
c.chaseKey == chaseKeyNow {
|
||||
return c
|
||||
}
|
||||
c := &clusterStatusCache{
|
||||
groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
|
||||
gridScope: gridScopeNow, gridHunt: gridHuntNow,
|
||||
chaseUnconf: chase.Mode == "new_unconfirmed", chaseKey: chaseKeyNow,
|
||||
}
|
||||
if a.qso == nil {
|
||||
a.clusterStatusIdx = c
|
||||
@@ -19951,6 +20206,7 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
||||
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
|
||||
// lookup) and worked POTA parks.
|
||||
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
||||
c.workedStates, _ = a.qso.WorkedStateKeys(a.ctx)
|
||||
c.callCounties, _ = a.qso.CallCounties(a.ctx)
|
||||
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
|
||||
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
|
||||
@@ -19961,6 +20217,19 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
||||
// extra query. Derived rather than read from the stored PFX column: that
|
||||
// column is only filled when an import supplied it, and deriving keeps this
|
||||
// in step with the WPX award, which does the same thing.
|
||||
if c.chaseUnconf {
|
||||
pred := qso.ConfirmSourcesPredicate(chase.Sources)
|
||||
c.entitiesConf, _ = a.qso.EntitySlotMapPred(a.ctx, keyFor, c.normMode, pred)
|
||||
c.workedCountiesConf, _ = a.qso.WorkedCountyKeysPred(a.ctx, award.USCountyKey, pred)
|
||||
c.workedStatesConf, _ = a.qso.WorkedStateKeysPred(a.ctx, pred)
|
||||
confCalls, _ := a.qso.WorkedCallsignsPred(a.ctx, pred)
|
||||
c.workedPfxConf = make(map[string]struct{}, len(confCalls))
|
||||
for call := range confCalls {
|
||||
if pfx := award.WPXPrefix(call); pfx != "" {
|
||||
c.workedPfxConf[pfx] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.workedPfx = make(map[string]struct{}, len(c.workedCalls))
|
||||
for call := range c.workedCalls {
|
||||
if p := award.WPXPrefix(call); p != "" {
|
||||
@@ -20286,6 +20555,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
workedCounties := idx.workedCounties
|
||||
workedPOTA := idx.workedPOTA
|
||||
workedPfx := idx.workedPfx
|
||||
// The hunt decides the ledger: "new + unconfirmed" judges every category
|
||||
// against the CONFIRMED sets, and the all-QSO sets then say whether a need
|
||||
// is a fresh one or a missing QSL (the Unconf flags).
|
||||
judgeEntities, judgePfx, judgeCounties, judgeStates := entities, workedPfx, workedCounties, idx.workedStates
|
||||
if idx.chaseUnconf {
|
||||
judgeEntities, judgePfx, judgeCounties, judgeStates = idx.entitiesConf, idx.workedPfxConf, idx.workedCountiesConf, idx.workedStatesConf
|
||||
}
|
||||
normMode := idx.normMode
|
||||
sameSlot := idx.sameSlot
|
||||
for i, q := range spots {
|
||||
@@ -20329,8 +20605,11 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
// NEW PFX: the spot's CQ WPX prefix, never worked before.
|
||||
if p := award.WPXPrefix(q.Call); p != "" {
|
||||
out[i].Pfx = p
|
||||
if _, done := workedPfx[p]; !done {
|
||||
if _, done := judgePfx[p]; !done {
|
||||
out[i].NewPfx = true
|
||||
if _, everWorked := workedPfx[p]; everWorked {
|
||||
out[i].UnconfPfx = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// NEW POTA: the spot's tagged park, never worked before.
|
||||
@@ -20393,19 +20672,39 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
if cnty, ok := idx.callCounties[q.Call]; ok {
|
||||
st, name, _ := strings.Cut(cnty, ",")
|
||||
out[i].State, out[i].County = st, name
|
||||
// Logged means worked, so this can never be a new county — but say
|
||||
// so through the same key the flag below uses, not by assumption.
|
||||
if st != "" {
|
||||
if _, done := judgeStates[strings.ToUpper(st)]; !done {
|
||||
out[i].NewState = true
|
||||
if _, ever := idx.workedStates[strings.ToUpper(st)]; ever {
|
||||
out[i].UnconfState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if key := award.USCountyKey(st, name); key != "" {
|
||||
if _, done := workedCounties[key]; !done {
|
||||
if _, done := judgeCounties[key]; !done {
|
||||
out[i].NewCounty = true
|
||||
if _, ever := workedCounties[key]; ever {
|
||||
out[i].UnconfCty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if a.uls != nil {
|
||||
if loc, ok := a.uls.Resolve(q.Call); ok {
|
||||
out[i].County, out[i].State = loc.County, loc.State
|
||||
if loc.State != "" {
|
||||
if _, done := judgeStates[strings.ToUpper(loc.State)]; !done {
|
||||
out[i].NewState = true
|
||||
if _, ever := idx.workedStates[strings.ToUpper(loc.State)]; ever {
|
||||
out[i].UnconfState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if key := award.USCountyKey(loc.State, loc.County); key != "" {
|
||||
if _, done := workedCounties[key]; !done {
|
||||
if _, done := judgeCounties[key]; !done {
|
||||
out[i].NewCounty = true
|
||||
if _, ever := workedCounties[key]; ever {
|
||||
out[i].UnconfCty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20433,9 +20732,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
if dxccNum == 0 {
|
||||
continue // can't resolve the spot's entity number → don't guess
|
||||
}
|
||||
e, worked := entities[dxccNum]
|
||||
e, worked := judgeEntities[dxccNum]
|
||||
if !worked {
|
||||
out[i].Status = "new"
|
||||
if _, ever := entities[dxccNum]; ever {
|
||||
out[i].UnconfStatus = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
// The check mode goes through the same normaliser as the slot map
|
||||
@@ -20450,6 +20752,26 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
|
||||
_, haveSlot := e.Slots[out[i].Band][checkMode]
|
||||
out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode)
|
||||
// A need judged against the confirmed ledger that the all-QSO ledger
|
||||
// says was already worked is a missing QSL, and its badge should read
|
||||
// as one. Judged at the SAME grain as the status itself.
|
||||
switch out[i].Status {
|
||||
case "new-band-mode", "new-band", "new-mode", "new-slot":
|
||||
if idx.chaseUnconf {
|
||||
if eAll, ok := entities[dxccNum]; ok {
|
||||
_, bAll := eAll.Bands[out[i].Band]
|
||||
_, mAll := eAll.Modes[checkMode]
|
||||
_, sAll := eAll.Slots[out[i].Band][checkMode]
|
||||
// "worked", not "": that is what the verdict function says
|
||||
// when every grain is in the log — the first version
|
||||
// compared against empty and no band/mode/slot need ever
|
||||
// dimmed, however many unconfirmed QSOs stood behind it.
|
||||
if spotEntityStatus(true, bAll, mAll, sAll, out[i].Mode) == "worked" {
|
||||
out[i].UnconfStatus = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
|
||||
id := m[1]
|
||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||
// band together) and the extras live in extras_json.
|
||||
// The integer My-station fields take their own numeric path
|
||||
// (BulkSetIntField + bulkEditableIntCols in the repo), added after this
|
||||
// guard was written — which is exactly the drift it exists to catch.
|
||||
if id == "my_dxcc" || id == "my_cq_zone" || id == "my_itu_zone" {
|
||||
continue
|
||||
}
|
||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,4 +1,66 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.5",
|
||||
"date": "",
|
||||
"en": [
|
||||
"QSL Manager: Club Log confirmations — downloads your log matches (getmatches API) and stamps two new columns, ClubLog match status and match date, available everywhere: table columns, filters, bulk edit and the QSO editor.",
|
||||
"Super Check Partial: option to merge Club Log’s weekly call list (~180k calls heard on the air in the last 3 years) with MASTER.SCP.",
|
||||
"Fixed: opening the app could silently stop at startup (settings showing as default, “db not initialized”) when a database migration targeted a table that database no longer holds — migrations now skip what does not apply, and a startup failure is written to the log.",
|
||||
"FT Map / Grid squares: the map no longer floats above the menus and the Preferences dialog.",
|
||||
"KPA500: saving ANY settings page no longer power-cycles the amplifier. A save rebuilt every amplifier connection, and closing the COM port drops DTR/RTS — the KPA500’s power switch. An unchanged amplifier now keeps its running connection across saves.",
|
||||
"Column picker: columns are listed alphabetically inside each group.",
|
||||
"Confirmations: a “Club Log received” default for new QSOs, set to No out of the box — the match download flips it to Y."
|
||||
],
|
||||
"fr": [
|
||||
"QSL Manager : confirmations Club Log — télécharge vos matches de log (API getmatches) et remplit deux nouvelles colonnes, statut et date de match ClubLog, disponibles partout : colonnes du tableau, filtres, édition groupée et éditeur de QSO.",
|
||||
"Super Check Partial : option pour fusionner la liste hebdomadaire de Club Log (~180k indicatifs entendus sur l’air ces 3 dernières années) avec MASTER.SCP.",
|
||||
"Corrigé : l’application pouvait se figer silencieusement au démarrage (réglages par défaut, « db not initialized ») quand une migration visait une table absente de cette base — les migrations ignorent désormais ce qui ne s’applique pas, et un échec de démarrage est écrit dans le log.",
|
||||
"FT Map / Grid squares : la carte ne passe plus au-dessus des menus et de la fenêtre Préférences.",
|
||||
"KPA500 : sauvegarder n’importe quelle page des réglages n’éteint plus l’ampli. Une sauvegarde reconstruisait chaque connexion d’ampli, et fermer le port COM relâche DTR/RTS — l’interrupteur d’alimentation du KPA500. Un ampli inchangé garde désormais sa connexion à travers les sauvegardes.",
|
||||
"Sélecteur de colonnes : les colonnes sont triées alphabétiquement dans chaque groupe.",
|
||||
"Confirmations : un défaut « Club Log reçu » pour les nouveaux QSO, à Non par défaut — le téléchargement des matches le passe à Y."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.4",
|
||||
"date": "",
|
||||
"en": [
|
||||
"FT decodes: a State column between the locator and the country — the two-letter badge plus the full name — for the WAS chasers.",
|
||||
"FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete.",
|
||||
"The hunt goes global: a “Chase” setting (DX Cluster page) decides for EVERY category — DXCC, band, mode, slot, prefix, county, state, grid — whether “worked but never confirmed” still counts as something to chase, judged against the confirmation sources you pick (LoTW, QSL card, eQSL, QRZ.com). Such needs show as dimmed badges: a QSL to chase, not a QSO to make. The grid’s own Chase selector folds into it.",
|
||||
"New FTx menu gathering FT Decodes, the new FT Map and the Grid squares map.",
|
||||
"FT Map: a world map of the live FTx decodes — great-circle arcs from your QTH to every station heard in the last 30 minutes, coloured by band, with the PSK-Reporter palette and a basemap picker.",
|
||||
"Maps: one single world (no more side-by-side copies), the surround follows the theme colour, and zooming stays centred — on the FT Map and the Grid squares map.",
|
||||
"Watchlist: fixed columns in the spot rows, so band, mode and frequency line up instead of drifting with the country name."
|
||||
],
|
||||
"fr": [
|
||||
"FT decodes : une colonne État entre le locator et le pays — le badge deux lettres plus le nom complet — pour les chasseurs de WAS.",
|
||||
"FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté s’allume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète.",
|
||||
"La chasse devient globale : un réglage « Chasse » (page DX Cluster) décide pour TOUTES les catégories — DXCC, bande, mode, slot, préfixe, comté, état, grille — si « contacté mais jamais confirmé » reste à chasser, jugé selon les sources de confirmation choisies (LoTW, carte QSL, eQSL, QRZ.com). Ces besoins s’affichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans.",
|
||||
"Nouveau menu FTx regroupant FT Decodes, la nouvelle FT Map et la carte Grid squares.",
|
||||
"FT Map : une carte du monde des décodages FTx en direct — arcs orthodromiques depuis votre QTH vers chaque station entendue dans les 30 dernières minutes, colorés par bande, avec la palette PSK Reporter et un choix de fond de carte.",
|
||||
"Cartes : un seul monde (fini les copies côte à côte), le pourtour suit la couleur du thème et le zoom reste centré — sur la FT Map et la carte Grid squares.",
|
||||
"Watchlist : colonnes fixes dans les lignes de spots — bande, mode et fréquence s'alignent au lieu de dériver avec le nom du pays."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.3",
|
||||
"date": "",
|
||||
"en": [
|
||||
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500’s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500’s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.",
|
||||
"FT decodes: within a period, decodes are listed in arrival order — mirroring the decoder’s own window — instead of strongest-first.",
|
||||
"Voice keyer: twelve message slots (F1–F12) instead of six.",
|
||||
"Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes.",
|
||||
"Voice keyer: a delete button per message — removes the recording and clears the label."
|
||||
],
|
||||
"fr": [
|
||||
"KPA500 : l’ampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont l’interrupteur du KPA500. Les lignes sont désormais tenues stables, le silence n’est plus traité comme un lien mort, et le baud se choisit dans une liste.",
|
||||
"FT decodes : dans une période, les décodages sont listés dans l’ordre d’arrivée — comme la fenêtre du décodeur — au lieu du plus fort d’abord.",
|
||||
"Manipulateur vocal : douze messages (F1–F12) au lieu de six.",
|
||||
"Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture.",
|
||||
"Manipulateur vocal : un bouton supprimer par message — efface l’enregistrement et le libellé."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.2",
|
||||
"date": "",
|
||||
|
||||
+98
-5
@@ -75,6 +75,7 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { FTMapPanel } from '@/components/FTMapPanel';
|
||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
@@ -1313,6 +1314,17 @@ export default function App() {
|
||||
writeUiPref('opslog.gridsTab', '0');
|
||||
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
||||
}
|
||||
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
||||
function openFtmapTab() {
|
||||
setFtmapTabOpen(true);
|
||||
writeUiPref('opslog.ftmapTab', '1');
|
||||
setActiveTab('ftmap');
|
||||
}
|
||||
function closeFtmapTab() {
|
||||
setFtmapTabOpen(false);
|
||||
writeUiPref('opslog.ftmapTab', '0');
|
||||
setActiveTab((t) => (t === 'ftmap' ? 'recent' : t));
|
||||
}
|
||||
function openDecodesTab() {
|
||||
setDecodesTabOpen(true);
|
||||
writeUiPref('opslog.decodesTab', '1');
|
||||
@@ -2088,7 +2100,7 @@ export default function App() {
|
||||
// worked_slot must be carried explicitly like every other field: this map is
|
||||
// assembled field by field, so a backend flag that nobody copies here simply
|
||||
// never reaches the panels — silently, since the extra key is just dropped.
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
|
||||
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||
// still need resolving without re-subscribing the cluster:spot listener.
|
||||
const spotStatusRef = useRef(spotStatus);
|
||||
@@ -2228,6 +2240,15 @@ export default function App() {
|
||||
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// While the Settings dialog is open, the spot/decode flushes and the CAT
|
||||
// snapshot stream are PAUSED (data keeps accumulating in the pending refs).
|
||||
// Every flush re-renders the whole App tree behind the dialog — cluster
|
||||
// grid, decodes panel, thousands of nodes — and with a busy cluster plus
|
||||
// two decoders the pointer visibly stuttered over the preferences.
|
||||
const showSettingsRef = useRef(false);
|
||||
useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]);
|
||||
const flushSpotsRef = useRef<() => void>(() => {});
|
||||
const flushDecodesRef = useRef<() => void>(() => {});
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
|
||||
@@ -3435,7 +3456,15 @@ export default function App() {
|
||||
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
|
||||
// just typed something (freeze window) or locked a field. Shared by the live
|
||||
// cat:state event and the startup poll below.
|
||||
const lastCatWhileSettingsRef = useRef(0);
|
||||
function applyCatState(s: CATState) {
|
||||
// Behind the Settings dialog nobody reads a frequency four times a second;
|
||||
// each snapshot re-renders the whole App tree under the pointer.
|
||||
if (showSettingsRef.current) {
|
||||
const now = Date.now();
|
||||
if (now - lastCatWhileSettingsRef.current < 2000) return;
|
||||
lastCatWhileSettingsRef.current = now;
|
||||
}
|
||||
setCatState(s);
|
||||
if (!s?.connected) return;
|
||||
// A snapshot arriving during the freeze used to be DROPPED, and that lost the
|
||||
@@ -3580,11 +3609,19 @@ export default function App() {
|
||||
// Commit the staged spots: resolve the status for any slot we don't know yet
|
||||
// FIRST, then insert the rows — so they appear with the right badge already
|
||||
// painted instead of flashing plain text then flipping to a pill.
|
||||
// eslint-disable-next-line prefer-const
|
||||
const flushPendingSpots = async () => {
|
||||
pendingSpotTimer.current = undefined;
|
||||
// Settings open: leave everything queued (bounded) and repaint nothing.
|
||||
if (showSettingsRef.current) {
|
||||
const cap = spotsCapRef.current;
|
||||
if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap);
|
||||
return;
|
||||
}
|
||||
const batch = pendingSpotsRef.current;
|
||||
pendingSpotsRef.current = [];
|
||||
if (batch.length === 0) return;
|
||||
// (registered below so closing Settings can drain the queue)
|
||||
// Resolve unknown statuses before the rows go in.
|
||||
try {
|
||||
const known = spotStatusRef.current;
|
||||
@@ -3614,6 +3651,11 @@ export default function App() {
|
||||
worked_call: !!(r as any).worked_call,
|
||||
worked_slot: !!(r as any).worked_slot,
|
||||
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
|
||||
new_state: !!(r as any).new_state,
|
||||
unconf_status: !!(r as any).unconf_status,
|
||||
unconf_pfx: !!(r as any).unconf_pfx,
|
||||
unconf_cty: !!(r as any).unconf_cty,
|
||||
unconf_state: !!(r as any).unconf_state,
|
||||
new_pota: !!(r as any).new_pota,
|
||||
new_pfx: !!(r as any).new_pfx,
|
||||
pfx: (r as any).pfx,
|
||||
@@ -3640,6 +3682,7 @@ export default function App() {
|
||||
return next.length > cap ? next.slice(0, cap) : next;
|
||||
});
|
||||
};
|
||||
flushSpotsRef.current = () => { void flushPendingSpots(); };
|
||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
// Stage the spot; a short timer resolves its status then commits it.
|
||||
pendingSpotsRef.current.push(sp);
|
||||
@@ -3687,6 +3730,10 @@ export default function App() {
|
||||
// decodes panel and plain worked in the cluster list two seconds later.
|
||||
const flushDecodes = async () => {
|
||||
pendingDecodeTimer.current = undefined;
|
||||
if (showSettingsRef.current) {
|
||||
if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000);
|
||||
return;
|
||||
}
|
||||
const batch = pendingDecodesRef.current;
|
||||
pendingDecodesRef.current = [];
|
||||
if (batch.length === 0) return;
|
||||
@@ -3715,6 +3762,11 @@ export default function App() {
|
||||
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
|
||||
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
|
||||
county: (r as any).county, state: (r as any).state,
|
||||
new_state: !!(r as any).new_state,
|
||||
unconf_status: !!(r as any).unconf_status,
|
||||
unconf_pfx: !!(r as any).unconf_pfx,
|
||||
unconf_cty: !!(r as any).unconf_cty,
|
||||
unconf_state: !!(r as any).unconf_state,
|
||||
new_pota: !!(r as any).new_pota,
|
||||
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
|
||||
};
|
||||
@@ -3729,6 +3781,7 @@ export default function App() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
flushDecodesRef.current = () => { void flushDecodes(); };
|
||||
|
||||
const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
|
||||
pendingDecodesRef.current.push(d);
|
||||
@@ -3821,6 +3874,12 @@ export default function App() {
|
||||
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
|
||||
// Only when something is actually in the entry, so an idle digital app doesn't
|
||||
// wipe a call being typed by hand.
|
||||
const unsubStatusInval = EventsOn('spotstatus:invalidate', () => {
|
||||
// The chase rules changed: every resolved verdict is stale. Cleared, and
|
||||
// the flushes re-ask as rows repaint.
|
||||
setSpotStatus({});
|
||||
spotStatusRef.current = {};
|
||||
});
|
||||
const unsubClear = EventsOn('udp:clear_call', () => {
|
||||
// The decoder cleared its DX Call: the next call it announces — even the
|
||||
// same one re-selected — is a fresh edge.
|
||||
@@ -3884,7 +3943,7 @@ export default function App() {
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubStatusInval?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -4375,6 +4434,10 @@ export default function App() {
|
||||
// The stable wrapper the dialog actually receives.
|
||||
const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
|
||||
const closeSettings = useCallback(() => {
|
||||
// Synchronously: the ref effect runs after the next render, and flushing
|
||||
// through a still-true ref would hit the pause gate again.
|
||||
showSettingsRef.current = false;
|
||||
window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50);
|
||||
setShowSettings(false);
|
||||
setSettingsSection(undefined);
|
||||
refreshChaseNew();
|
||||
@@ -5054,12 +5117,17 @@ export default function App() {
|
||||
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
|
||||
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
|
||||
]},
|
||||
// The digital-mode corner gets its own menu: the decode list, its map,
|
||||
// and the grid-square chase all live off the same UDP feed.
|
||||
{ name: 'ftx', label: t('menu.ftx'), items: [
|
||||
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
|
||||
{ type: 'item', label: t('ftmap.tab'), action: 'tools.ftmap' },
|
||||
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
|
||||
]},
|
||||
{ name: 'tools', label: t('menu.tools'), items: [
|
||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
|
||||
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
|
||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||
@@ -5113,6 +5181,7 @@ export default function App() {
|
||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||
case 'tools.decodes': openDecodesTab(); break;
|
||||
case 'tools.ftmap': openFtmapTab(); break;
|
||||
case 'tools.grids': openGridsTab(); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
@@ -5203,7 +5272,7 @@ export default function App() {
|
||||
if (dvkActiveRef.current) {
|
||||
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
|
||||
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
|
||||
if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; }
|
||||
return;
|
||||
}
|
||||
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
|
||||
@@ -7744,6 +7813,21 @@ export default function App() {
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{ftmapTabOpen && (
|
||||
<TabsTrigger value="ftmap" className="gap-1.5">
|
||||
{t('ftmap.tab')}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close FT map"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); closeFtmapTab(); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{stationTabOpen && (
|
||||
<TabsTrigger value="station" className="gap-1.5">
|
||||
{t('station.title')}
|
||||
@@ -8342,6 +8426,15 @@ export default function App() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{ftmapTabOpen && (
|
||||
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
{activeTab === 'ftmap' && (
|
||||
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
|
||||
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid} />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
{stationTabOpen && (
|
||||
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
<StationControlPanel
|
||||
|
||||
@@ -46,6 +46,8 @@ const FIELDS: FieldDef[] = [
|
||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'clublog_rcvd', label: 'bulk.fClublogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'clublog_rcvd_date', label: 'bulk.fClublogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||
|
||||
@@ -65,6 +65,9 @@ export type SpotStatusEntry = {
|
||||
state?: string;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
unconf_status?: boolean;
|
||||
unconf_pfx?: boolean;
|
||||
unconf_cty?: boolean;
|
||||
pfx?: string;
|
||||
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
||||
lotw?: boolean;
|
||||
@@ -304,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||
: s?.status === 'new-call' ? t('clg2.newCall')
|
||||
: t('clg2.wkdCall');
|
||||
parts.push({ text: label, color: main });
|
||||
// Dimmed when the need is only a missing confirmation — the grid's
|
||||
// own convention, now spoken by every category.
|
||||
parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main });
|
||||
}
|
||||
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
||||
// never one colour here and another there.
|
||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
|
||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
|
||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
|
||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
|
||||
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
|
||||
// held back, so it reads as "less" of the same thing rather than a
|
||||
// different fact — and the label says which.
|
||||
|
||||
@@ -72,8 +72,14 @@ type StatusEntry = {
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
new_grid?: boolean;
|
||||
new_state?: boolean;
|
||||
unconf_status?: boolean;
|
||||
unconf_pfx?: boolean;
|
||||
unconf_cty?: boolean;
|
||||
unconf_state?: boolean;
|
||||
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
|
||||
grid_state?: string;
|
||||
state?: string;
|
||||
grid?: string;
|
||||
lotw?: boolean;
|
||||
};
|
||||
@@ -112,7 +118,7 @@ interface Props {
|
||||
//
|
||||
// All off means no filtering at all: this is a decode LOG first, and a panel
|
||||
// that starts by hiding most of the band would be lying about what is on it.
|
||||
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty';
|
||||
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
|
||||
|
||||
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
||||
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
||||
@@ -123,6 +129,7 @@ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
||||
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
|
||||
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
||||
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
|
||||
{ key: 'state', label: 'dec.bgState', colour: markerColour('new_state') },
|
||||
];
|
||||
|
||||
// catsOf lists everything a decode is new for. A station can be several at once
|
||||
@@ -143,6 +150,7 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
||||
if (e.new_grid) out.add('grid');
|
||||
if (e.new_pfx) out.add('pfx');
|
||||
if (e.new_county) out.add('cty');
|
||||
if (e.new_state) out.add('state');
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -227,7 +235,7 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
||||
// One declaration per column, in display order: the header, the widths and the
|
||||
// resize handles all read from this, so a column cannot be resized in the header
|
||||
// and stay the old width in the body.
|
||||
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'country' | 'status';
|
||||
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
|
||||
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
|
||||
@@ -241,9 +249,26 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
|
||||
// an operator chasing squares could see the verdict but never the square.
|
||||
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
||||
// For the WAS chasers: the badge carries the two letters, the name spells
|
||||
// them out — "SD" alone is a quiz for a European.
|
||||
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
|
||||
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
||||
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
||||
];
|
||||
const US_STATES: Record<string, string> = {
|
||||
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
|
||||
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
|
||||
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
|
||||
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
|
||||
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi',
|
||||
MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
|
||||
NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina',
|
||||
ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',
|
||||
RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee',
|
||||
TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington',
|
||||
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
|
||||
};
|
||||
|
||||
const COL_MAX = 600;
|
||||
const COLW_KEY = 'opslog.decodeColWidths';
|
||||
|
||||
@@ -308,12 +333,15 @@ function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onR
|
||||
//
|
||||
// Colours match the cluster list and the band map — the same fact must not be
|
||||
// amber in one panel and green in the next.
|
||||
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
|
||||
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
|
||||
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
|
||||
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
|
||||
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
|
||||
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
|
||||
// colour is the category's own hue, for the UNCONFIRMED rendering: the
|
||||
// filled cls puts light text on a filled chip, and dimming that to a
|
||||
// transparent background left light-on-nothing — an invisible badge.
|
||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
|
||||
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground', colour: 'var(--warning)' },
|
||||
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground', colour: 'var(--info)' },
|
||||
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground', colour: 'var(--caution)' },
|
||||
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground', colour: 'var(--muted-foreground)' },
|
||||
};
|
||||
|
||||
// entityBadgesFor turns a status into the badges that describe it.
|
||||
@@ -324,7 +352,7 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
// passed the BAND and MODE filters and then showed no reason for being there,
|
||||
// which is precisely what was reported. catsOf has always split the status into
|
||||
// its two categories; this is the same split, on the screen.
|
||||
function entityBadgesFor(status: string): { label: string; cls: string }[] {
|
||||
function entityBadgesFor(status: string): { label: string; cls: string; colour: string }[] {
|
||||
if (status === 'new-band-mode') {
|
||||
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
|
||||
}
|
||||
@@ -343,6 +371,7 @@ function entityBadgesFor(status: string): { label: string; cls: string }[] {
|
||||
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
||||
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
||||
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
|
||||
{ key: 'new_state', marker: 'new_state', label: 'dec.bgState' },
|
||||
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
|
||||
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
||||
];
|
||||
@@ -498,10 +527,11 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
// the same way it was grouped.
|
||||
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
||||
tx: g.tx,
|
||||
// Strongest first inside a period: the eye should land on what is
|
||||
// workable, and time within a slot means nothing — they were all
|
||||
// transmitting simultaneously.
|
||||
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
|
||||
// ARRIVAL order inside a period, per the operator: it mirrors the
|
||||
// decoder's own window line for line, which makes the two screens
|
||||
// comparable at a glance — the strongest-first sort scrambled that
|
||||
// correspondence, and SNR is right there in its column anyway.
|
||||
decodes: g.decodes,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1096,6 +1126,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
||||
</span>
|
||||
|
||||
<span className={cn(CELL, 'gap-1.5')}>
|
||||
{e?.state && (
|
||||
<>
|
||||
<span className="rounded px-1.5 py-px text-[11px] font-bold bg-info/15 text-info border border-info/40 shrink-0">{e.state}</span>
|
||||
<span className="text-[11px] text-muted-foreground truncate">{US_STATES[e.state.toUpperCase()] ?? ''}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
||||
<span className="truncate">{e?.country ?? ''}</span>
|
||||
</span>
|
||||
@@ -1113,7 +1152,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
</span>
|
||||
)}
|
||||
{entities.map((b) => (
|
||||
<span key={b.label} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}>
|
||||
<span key={b.label}
|
||||
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
|
||||
className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0',
|
||||
e?.unconf_status ? 'border bg-transparent' : b.cls)}
|
||||
style={e?.unconf_status ? { color: b.colour, borderColor: b.colour, borderStyle: 'dashed', opacity: 0.6 } : undefined}>
|
||||
{t(b.label)}
|
||||
</span>
|
||||
))}
|
||||
@@ -1123,16 +1166,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
// Same hue, held back — the badge reads as "less" of the
|
||||
// same thing, and its label says which. One badge for both
|
||||
// is what made a station worked an hour ago read as new.
|
||||
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf';
|
||||
const unconf = (b.key === 'new_grid' && e?.grid_state === 'unconf')
|
||||
|| (b.key === 'new_state' && !!e?.unconf_state)
|
||||
|| (b.key === 'new_county' && !!e?.unconf_cty)
|
||||
|| (b.key === 'new_pfx' && !!e?.unconf_pfx);
|
||||
const c = markerColour(b.marker);
|
||||
return (
|
||||
<span key={b.key as string}
|
||||
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
|
||||
title={unconf ? t('dec.unconfTip') : undefined}
|
||||
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||
style={unconf
|
||||
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
||||
: { borderColor: c, color: c }}>
|
||||
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
|
||||
{t(b.label)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = {
|
||||
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||
};
|
||||
|
||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F12
|
||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead';
|
||||
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// FT Map — the live decode feed as geography: every station decoded in the
|
||||
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||
// band the way PSK Reporter taught everyone to read it. Wholly display: the
|
||||
// decode list is the same one the FT decodes tab shows, and a station with no
|
||||
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
|
||||
//
|
||||
// Performance is a design constraint, not an afterthought: the panel only
|
||||
// exists while its tab is active (the parent unmounts it otherwise), the map
|
||||
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
|
||||
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
||||
// not per frame.
|
||||
|
||||
export type FTMapDecode = {
|
||||
call: string;
|
||||
grid?: string;
|
||||
band?: string;
|
||||
snr: number;
|
||||
at: string;
|
||||
};
|
||||
|
||||
// The band palette every PSK Reporter user already knows, near enough.
|
||||
const BAND_COLOURS: Record<string, string> = {
|
||||
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
|
||||
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
|
||||
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
|
||||
'2m': '#ff1493', '70cm': '#999900',
|
||||
};
|
||||
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
||||
|
||||
const MAX_ARCS = 300;
|
||||
const MAX_AGE_MS = 30 * 60_000;
|
||||
|
||||
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
|
||||
const { t } = useI18n();
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
const baseRef = useRef<L.TileLayer | null>(null);
|
||||
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
|
||||
|
||||
// The map itself, once.
|
||||
useEffect(() => {
|
||||
if (!divRef.current || mapRef.current) return;
|
||||
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
|
||||
// and the space beyond the edge is the theme's own surface (style.css).
|
||||
const m = L.map(divRef.current, {
|
||||
zoomControl: true, attributionControl: true,
|
||||
// No maxBounds: with the world smaller than the window the clamp
|
||||
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
||||
worldCopyJump: false, preferCanvas: true,
|
||||
center: [25, 0], zoom: 2, minZoom: 2,
|
||||
});
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
return () => { m.remove(); mapRef.current = null; layerRef.current = null; };
|
||||
}, []);
|
||||
|
||||
// Basemap follows the picker.
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m) return;
|
||||
baseRef.current?.remove(); labelsRef.current?.remove();
|
||||
const bm = BASEMAPS[basemap];
|
||||
const opts: L.TileLayerOptions = {
|
||||
maxNativeZoom: bm.maxNativeZoom,
|
||||
noWrap: true,
|
||||
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
|
||||
};
|
||||
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||
localStorage.setItem('opslog.ftmapBase', basemap);
|
||||
}, [basemap]);
|
||||
|
||||
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||
useEffect(() => {
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
const from = gridToLatLon(myGrid);
|
||||
if (!from) return;
|
||||
const now = Date.now();
|
||||
const placed = decodes
|
||||
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
|
||||
.slice(-MAX_ARCS);
|
||||
// One line per CALL (its freshest sighting): the same CQer decoded thirty
|
||||
// times in ten minutes is one path on the air, not thirty strokes of it.
|
||||
const byCall = new Map<string, FTMapDecode>();
|
||||
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
|
||||
L.circleMarker([from.lat, from.lon], {
|
||||
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
|
||||
}).addTo(layer);
|
||||
for (const d of byCall.values()) {
|
||||
const to = gridToLatLon(d.grid!);
|
||||
if (!to) continue;
|
||||
const age = now - Date.parse(d.at);
|
||||
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||
const colour = bandColour(d.band);
|
||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48);
|
||||
L.polyline(pts as L.LatLngExpression[], {
|
||||
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||
}).addTo(layer);
|
||||
L.circleMarker([to.lat, to.lon], {
|
||||
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
||||
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
|
||||
.addTo(layer);
|
||||
}
|
||||
}, [decodes, myGrid]);
|
||||
|
||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||
return (
|
||||
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
|
||||
// beat the app menus and the Settings dialog. A stacking context of our own
|
||||
// keeps all of it inside this panel.
|
||||
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||
{/* Basemap picker, MainMap's own vocabulary. */}
|
||||
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{BASEMAPS[k].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Band legend — only the bands actually on screen. */}
|
||||
{bands.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||
{bands.map((b) => (
|
||||
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
||||
{b.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!gridToLatLon(myGrid) && (
|
||||
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
|
||||
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
|
||||
{t('ftmap.noGrid')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_download_status', label: 'fltb.fClublogRcvd', type: 'text' },
|
||||
{ value: 'clublog_qso_download_date', label: 'fltb.fClublogRcvdDate', type: 'adifdate' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
||||
|
||||
@@ -128,15 +128,11 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
// impossible on any window wider than it is tall: one step out was already
|
||||
// too far in.
|
||||
minZoom: 0,
|
||||
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
|
||||
|
||||
}).setView([20, 0], 2);
|
||||
// The whole world, once, whatever the window is shaped like — computed by
|
||||
// Leaflet from the container rather than guessed with a zoom number.
|
||||
m.fitWorld({ animate: false });
|
||||
// Latitude only: the poles are the edge of the projection and there is
|
||||
// nothing beyond them, while leaving longitude free keeps a drag from
|
||||
// fighting the operator near the date line.
|
||||
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
// Leaflet measures its container ONCE, when the map is created, and never
|
||||
|
||||
@@ -71,7 +71,7 @@ const CONFIRMATIONS: ConfDef[] = [
|
||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
|
||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||
];
|
||||
// i18n label keys for confirmation channels whose label has translatable words
|
||||
|
||||
@@ -235,6 +235,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||
@@ -784,7 +786,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
// Alphabetical within the group: the catalog's order is the
|
||||
// GRID's column order, which appends newcomers at the end — so
|
||||
// the two ClubLog rows sat at opposite ends of the Uploads box.
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group)
|
||||
.slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
|
||||
if (cols.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="rounded-md border border-border p-2.5">
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
GetListsSettings, SaveListsSettings,
|
||||
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios,
|
||||
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
|
||||
GetChaseSettings, SaveChaseSettings,
|
||||
GetAudioMonitorPref,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
GetScpStatus, SetScpEnabled, DownloadScp,
|
||||
GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
|
||||
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
|
||||
@@ -1718,6 +1719,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// DVK voice-keyer messages (F1–F6).
|
||||
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||
const [chaseCfg, setChaseCfg] = useState<{ mode: string; sources: string[] }>({ mode: 'new', sources: ['lotw', 'card', 'eqsl'] });
|
||||
useEffect(() => { GetChaseSettings().then((c: any) => setChaseCfg({ mode: c?.mode ?? 'new', sources: c?.sources ?? ['lotw', 'card', 'eqsl'] })).catch(() => {}); }, []);
|
||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||
const [dvkErr, setDvkErr] = useState('');
|
||||
@@ -1819,7 +1822,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
qsl_sent: string; qsl_rcvd: string;
|
||||
lotw_sent: string; lotw_rcvd: string;
|
||||
eqsl_sent: string; eqsl_rcvd: string;
|
||||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||||
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
|
||||
qrzcom_confirmed: string;
|
||||
hamlog_status: string; hamlog_confirmed: string;
|
||||
};
|
||||
@@ -1827,7 +1830,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
qsl_sent: '', qsl_rcvd: '',
|
||||
lotw_sent: '', lotw_rcvd: '',
|
||||
eqsl_sent: '', eqsl_rcvd: '',
|
||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||||
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
|
||||
qrzcom_confirmed: '',
|
||||
hamlog_status: '', hamlog_confirmed: '',
|
||||
});
|
||||
@@ -1881,6 +1884,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||
finally { setScpBusy(false); }
|
||||
};
|
||||
const refreshScp = async () => { try { const s = await GetScpStatus(); setScp(s as any); } catch {} };
|
||||
const downloadScp = async () => {
|
||||
setScpBusy(true);
|
||||
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||
@@ -4416,8 +4420,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Baud</Label>
|
||||
<Input type="number" min={1200} value={amp.baud}
|
||||
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
|
||||
{/* A list, not a free number: the KPA500 report that
|
||||
began this had its operator wondering whether a typed
|
||||
baud was the whole problem. These are the rates the
|
||||
supported amplifiers actually speak. */}
|
||||
<select value={String(amp.baud)}
|
||||
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) })}
|
||||
className="h-9 w-full px-2 rounded-md border border-border bg-background text-sm font-mono">
|
||||
{[4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
|
||||
<option key={b} value={String(b)}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -5411,6 +5424,38 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
||||
once — badge, colour, filter chip, reference column. A "new band
|
||||
+ new POTA" spot then reads NEW BAND alone. */}
|
||||
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
|
||||
county, state, grid) judged as new-only or new-plus-unconfirmed,
|
||||
against the confirmation sources the operator trusts. */}
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
|
||||
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
|
||||
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{chaseCfg.mode === 'new_unconfirmed' && (
|
||||
<div className="flex items-center gap-4 flex-wrap pl-2">
|
||||
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
|
||||
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
|
||||
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={chaseCfg.sources.includes(k)}
|
||||
onCheckedChange={(c) => {
|
||||
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
|
||||
const next = { ...chaseCfg, sources };
|
||||
setChaseCfg(next); void SaveChaseSettings(next as any);
|
||||
}} />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
|
||||
<Checkbox checked={chasePotaOn}
|
||||
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
||||
@@ -5450,19 +5495,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
|
||||
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
|
||||
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
||||
squares and chasing entities are different wants; they only share
|
||||
the PSK Reporter feed, which either one brings up. */}
|
||||
@@ -5672,7 +5709,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||||
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||||
{renderSelect('clublog_confirmed', FULL_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
{/* HRDLog */}
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
@@ -7169,6 +7209,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
>
|
||||
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline" size="sm" className="h-8 w-9 shrink-0 px-0 text-danger hover:text-danger"
|
||||
title={t('aud.deleteMsg')}
|
||||
disabled={!m.has_audio || dvkStat.recording || dvkStat.playing}
|
||||
onClick={() => DVKDelete(m.slot).then(reloadDvk).catch((err) => setDvkErr(String(err?.message ?? err)))}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -7261,6 +7310,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||||
{t('scp.enable')}
|
||||
</label>
|
||||
{scp.enabled && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer pl-6" title={t('scp.clublogHint')}>
|
||||
<Checkbox checked={!!(scp as any).clublog} disabled={scpBusy}
|
||||
onCheckedChange={(c) => { void SetScpClublogEnabled(!!c).then(() => refreshScp()); }} />
|
||||
{t('scp.clublog')}
|
||||
</label>
|
||||
)}
|
||||
{scp.enabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
||||
|
||||
@@ -391,11 +391,12 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
|
||||
!done && 'border-l-[3px] border-warning')}>
|
||||
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
||||
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
|
||||
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
|
||||
<span className="px-1.5 rounded bg-muted shrink-0">{s.band}</span>
|
||||
{mode && <span className="px-1.5 rounded shrink-0" style={{ color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' }}>{mode}</span>}
|
||||
<span className="font-mono text-muted-foreground shrink-0">{(s.freq_hz / 1e6).toFixed(4)}</span>
|
||||
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
|
||||
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
|
||||
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
|
||||
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
|
||||
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(s.freq_hz / 1e6).toFixed(4)}</span>
|
||||
{badge && chip(badge.color, badge.label)}
|
||||
<div className="flex-1" />
|
||||
{done
|
||||
|
||||
+25
-25
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@
|
||||
// grid magenta — the last hue in the categorical set that is not already
|
||||
// spoken for here and does not read as a status; a grid is
|
||||
// never urgent the way a new entity is
|
||||
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
|
||||
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid' | 'new_state';
|
||||
|
||||
export type SpotMarker = {
|
||||
key: SpotMarkerKey;
|
||||
@@ -32,6 +32,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
|
||||
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
||||
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
||||
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
|
||||
{ key: 'new_state', colour: 'var(--chart-3)', labelKey: 'clg2.newState' },
|
||||
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1086,3 +1086,10 @@
|
||||
.overflow-scroll {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* The ground beside the planet, on every map: Leaflet paints its container a
|
||||
hard-coded light grey (#ddd), which reads as a broken tile against any
|
||||
theme. The surround follows the theme's own card surface instead. */
|
||||
.leaflet-container {
|
||||
background: var(--card) !important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
export const APP_VERSION = '0.27.2';
|
||||
export const APP_VERSION = '0.27.5';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+8
@@ -160,6 +160,8 @@ export function CreateDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function DVKCancelRecord():Promise<void>;
|
||||
|
||||
export function DVKDelete(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKPlay(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKPreview(arg1:number):Promise<void>;
|
||||
@@ -450,6 +452,8 @@ export function GetChaseNewGrids():Promise<boolean>;
|
||||
|
||||
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
||||
|
||||
export function GetChaseSettings():Promise<main.ChaseSettings>;
|
||||
|
||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
@@ -1026,6 +1030,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||
|
||||
export function SaveCabrilloFile():Promise<string>;
|
||||
|
||||
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
|
||||
|
||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||
|
||||
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
||||
@@ -1196,6 +1202,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetSpotMax(arg1:number):Promise<void>;
|
||||
|
||||
@@ -258,6 +258,10 @@ export function DVKCancelRecord() {
|
||||
return window['go']['main']['App']['DVKCancelRecord']();
|
||||
}
|
||||
|
||||
export function DVKDelete(arg1) {
|
||||
return window['go']['main']['App']['DVKDelete'](arg1);
|
||||
}
|
||||
|
||||
export function DVKPlay(arg1) {
|
||||
return window['go']['main']['App']['DVKPlay'](arg1);
|
||||
}
|
||||
@@ -838,6 +842,10 @@ export function GetChaseNewSpots() {
|
||||
return window['go']['main']['App']['GetChaseNewSpots']();
|
||||
}
|
||||
|
||||
export function GetChaseSettings() {
|
||||
return window['go']['main']['App']['GetChaseSettings']();
|
||||
}
|
||||
|
||||
export function GetChatHistory(arg1) {
|
||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||
}
|
||||
@@ -1990,6 +1998,10 @@ export function SaveCabrilloFile() {
|
||||
return window['go']['main']['App']['SaveCabrilloFile']();
|
||||
}
|
||||
|
||||
export function SaveChaseSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveChaseSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveClusterServer(arg1) {
|
||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||
}
|
||||
@@ -2330,6 +2342,10 @@ export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function SetScpClublogEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetScpEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||
}
|
||||
|
||||
@@ -2430,6 +2430,20 @@ export namespace main {
|
||||
this.at = source["at"];
|
||||
}
|
||||
}
|
||||
export class ChaseSettings {
|
||||
mode: string;
|
||||
sources: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ChaseSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.mode = source["mode"];
|
||||
this.sources = source["sources"];
|
||||
}
|
||||
}
|
||||
export class ChatMessage {
|
||||
id: number;
|
||||
operator: string;
|
||||
@@ -3280,6 +3294,7 @@ export namespace main {
|
||||
eqsl_sent: string;
|
||||
eqsl_rcvd: string;
|
||||
clublog_status: string;
|
||||
clublog_confirmed: string;
|
||||
hrdlog_status: string;
|
||||
qrzcom_status: string;
|
||||
qrzcom_confirmed: string;
|
||||
@@ -3299,6 +3314,7 @@ export namespace main {
|
||||
this.eqsl_sent = source["eqsl_sent"];
|
||||
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||
this.clublog_status = source["clublog_status"];
|
||||
this.clublog_confirmed = source["clublog_confirmed"];
|
||||
this.hrdlog_status = source["hrdlog_status"];
|
||||
this.qrzcom_status = source["qrzcom_status"];
|
||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||
@@ -3838,6 +3854,7 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
updated?: string;
|
||||
clublog: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScpStatus(source);
|
||||
@@ -3848,6 +3865,7 @@ export namespace main {
|
||||
this.enabled = source["enabled"];
|
||||
this.count = source["count"];
|
||||
this.updated = source["updated"];
|
||||
this.clublog = source["clublog"];
|
||||
}
|
||||
}
|
||||
export class SecretStatus {
|
||||
@@ -3957,6 +3975,11 @@ export namespace main {
|
||||
new_county: boolean;
|
||||
county?: string;
|
||||
state?: string;
|
||||
new_state: boolean;
|
||||
unconf_status?: boolean;
|
||||
unconf_pfx?: boolean;
|
||||
unconf_cty?: boolean;
|
||||
unconf_state?: boolean;
|
||||
new_pota: boolean;
|
||||
grid?: string;
|
||||
new_grid: boolean;
|
||||
@@ -3983,6 +4006,11 @@ export namespace main {
|
||||
this.new_county = source["new_county"];
|
||||
this.county = source["county"];
|
||||
this.state = source["state"];
|
||||
this.new_state = source["new_state"];
|
||||
this.unconf_status = source["unconf_status"];
|
||||
this.unconf_pfx = source["unconf_pfx"];
|
||||
this.unconf_cty = source["unconf_cty"];
|
||||
this.unconf_state = source["unconf_state"];
|
||||
this.new_pota = source["new_pota"];
|
||||
this.grid = source["grid"];
|
||||
this.new_grid = source["new_grid"];
|
||||
@@ -5245,6 +5273,8 @@ export namespace qso {
|
||||
qrzcom_qso_upload_status?: string;
|
||||
qrzcom_qso_download_date?: string;
|
||||
qrzcom_qso_download_status?: string;
|
||||
clublog_qso_download_date?: string;
|
||||
clublog_qso_download_status?: string;
|
||||
contest_id?: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
@@ -5387,6 +5417,8 @@ export namespace qso {
|
||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
||||
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
||||
this.clublog_qso_download_date = source["clublog_qso_download_date"];
|
||||
this.clublog_qso_download_status = source["clublog_qso_download_status"];
|
||||
this.contest_id = source["contest_id"];
|
||||
this.srx = source["srx"];
|
||||
this.stx = source["stx"];
|
||||
|
||||
@@ -267,6 +267,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
||||
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
|
||||
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
|
||||
|
||||
// --- Contest ---
|
||||
w("CONTEST_ID", q.ContestID)
|
||||
|
||||
@@ -161,6 +161,9 @@ var Fields = []FieldDef{
|
||||
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
// App-defined pair (no standard ADIF field): Club Log's log-match download.
|
||||
{Name: "CLUBLOG_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "CLUBLOG_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||
|
||||
@@ -287,6 +287,7 @@ var adifPromoted = stringSet(
|
||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||
"clublog_qso_download_date", "clublog_qso_download_status",
|
||||
// Contest
|
||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||
"check", "precedence", "arrl_sect",
|
||||
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
|
||||
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
|
||||
|
||||
// Contest
|
||||
q.ContestID = rec["contest_id"]
|
||||
|
||||
@@ -240,6 +240,25 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
||||
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
|
||||
}
|
||||
|
||||
// isIgnorableSQLiteDDLError reports a benign DDL failure: the change is
|
||||
// already there, or the statement shapes a table this database does not hold.
|
||||
// Scoped to shaping statements only — a CREATE TABLE or data statement that
|
||||
// fails must still fail the migration.
|
||||
func isIgnorableSQLiteDDLError(err error, stmt string) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
if strings.Contains(msg, "duplicate column name") || strings.Contains(msg, "already exists") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "no such table") {
|
||||
head := strings.ToLower(strings.TrimSpace(stmt))
|
||||
return strings.HasPrefix(head, "alter table") ||
|
||||
strings.HasPrefix(head, "create index") ||
|
||||
strings.HasPrefix(head, "create unique index") ||
|
||||
strings.HasPrefix(head, "drop ")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||
// skipping those already applied. Intentionally minimal in-house system
|
||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||
@@ -352,6 +371,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string,
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
// Same self-healing as the MySQL path, plus one case it never
|
||||
// meets: a table-shaping statement aimed at a table this
|
||||
// database legitimately does not have. A split settings
|
||||
// database dropped its qso table when the QSOs moved to the
|
||||
// logbook, but its role is still RoleAll — so a later
|
||||
// "ALTER TABLE qso ADD COLUMN" must be a no-op there, not a
|
||||
// failure that silently kills the whole startup (v0.27.4+).
|
||||
if isIgnorableSQLiteDDLError(err, stmt) {
|
||||
continue
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Club Log log-matching (getmatches.php): a QSO both sides uploaded to Club
|
||||
-- Log is a confirmation in its own right. Mirrors qrzcom_qso_download_*.
|
||||
ALTER TABLE qso ADD COLUMN clublog_qso_download_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN clublog_qso_download_status TEXT;
|
||||
@@ -0,0 +1,126 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Club Log's log-matching API. A "match" is a QSO that BOTH stations uploaded
|
||||
// to Club Log, paired within ±15 minutes — Club Log's own equivalent of a LoTW
|
||||
// confirmation. getmatches.php returns a JSON array of 5-element arrays:
|
||||
//
|
||||
// [["G0LGJ/M","223","2005-07-16 08:00:00","20","CW"], …]
|
||||
// callsign dxcc qso datetime (UTC) band mode ("false" when unknown)
|
||||
//
|
||||
// The optional start date filters on when Club Log COMPLETED the match (not
|
||||
// the QSO date), which is exactly what an incremental "since last download"
|
||||
// pull wants.
|
||||
const clublogMatchesURL = "https://clublog.org/getmatches.php"
|
||||
|
||||
// ClublogMatch is one confirmed pairing from getmatches.php.
|
||||
type ClublogMatch struct {
|
||||
Callsign string
|
||||
DXCC int
|
||||
When time.Time
|
||||
Band string // ADIF band ("20m", "70cm"); "" if the id is unknown
|
||||
Mode string // "" when Club Log doesn't know it
|
||||
}
|
||||
|
||||
// clublogBandNames maps Club Log's numeric band ids to ADIF band names. The
|
||||
// ids are the wavelength number; the only trap is that ids past the metre
|
||||
// bands are centimetres (the docs' own example: 70 = 70CM).
|
||||
var clublogBandNames = map[string]string{
|
||||
"2200": "2200m", "630": "630m", "160": "160m", "80": "80m", "60": "60m",
|
||||
"40": "40m", "30": "30m", "20": "20m", "17": "17m", "15": "15m",
|
||||
"12": "12m", "10": "10m", "8": "8m", "6": "6m", "5": "5m", "4": "4m",
|
||||
"2": "2m", "70": "70cm", "23": "23cm", "13": "13cm", "9": "9cm", "3": "3cm",
|
||||
}
|
||||
|
||||
// DownloadClublogMatches pulls the account's log matches for cfg.Callsign,
|
||||
// optionally only those Club Log completed since sinceDate ("2006-01-02").
|
||||
func DownloadClublogMatches(ctx context.Context, client *http.Client, cfg ServiceConfig, sinceDate string) ([]ClublogMatch, error) {
|
||||
email := strings.TrimSpace(cfg.Email)
|
||||
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||
switch {
|
||||
case email == "":
|
||||
return nil, fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return nil, fmt.Errorf("clublog: password not set")
|
||||
case call == "":
|
||||
return nil, fmt.Errorf("clublog: callsign not set")
|
||||
}
|
||||
v := url.Values{}
|
||||
v.Set("api", clublogAppAPIKey)
|
||||
v.Set("email", email)
|
||||
v.Set("password", cfg.Password)
|
||||
v.Set("callsign", call)
|
||||
if t, err := time.Parse("2006-01-02", strings.TrimSpace(sinceDate)); err == nil {
|
||||
v.Set("startyear", strconv.Itoa(t.Year()))
|
||||
v.Set("startmonth", strconv.Itoa(int(t.Month())))
|
||||
v.Set("startday", strconv.Itoa(t.Day()))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogMatchesURL+"?"+v.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 120 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
text := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if looksLikeHTML(text) || len(text) > 300 {
|
||||
return nil, fmt.Errorf("clublog: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("clublog: HTTP %d: %s", resp.StatusCode, text)
|
||||
}
|
||||
if looksLikeHTML(text) {
|
||||
return nil, fmt.Errorf("clublog: got a web page instead of matches — check email/password/callsign")
|
||||
}
|
||||
var raw [][]any
|
||||
if err := json.Unmarshal([]byte(text), &raw); err != nil {
|
||||
return nil, fmt.Errorf("clublog: bad matches JSON: %w", err)
|
||||
}
|
||||
str := func(x any) string {
|
||||
switch t := x.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
out := make([]ClublogMatch, 0, len(raw))
|
||||
for _, rec := range raw {
|
||||
if len(rec) < 5 {
|
||||
continue
|
||||
}
|
||||
m := ClublogMatch{Callsign: strings.ToUpper(strings.TrimSpace(str(rec[0])))}
|
||||
m.DXCC, _ = strconv.Atoi(str(rec[1]))
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", str(rec[2])); err == nil {
|
||||
m.When = t.UTC()
|
||||
}
|
||||
m.Band = clublogBandNames[strings.TrimSpace(str(rec[3]))]
|
||||
if md := strings.TrimSpace(str(rec[4])); md != "" && !strings.EqualFold(md, "false") {
|
||||
m.Mode = strings.ToUpper(md)
|
||||
}
|
||||
if m.Callsign == "" || m.When.IsZero() {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+24
-1
@@ -82,6 +82,7 @@ type Client struct {
|
||||
mu sync.Mutex // serialises the connection: one question at a time
|
||||
conn io.ReadWriteCloser
|
||||
rd *bufio.Reader
|
||||
skipTP bool // ^TP went unanswered once — a KPA500, no ATU; never ask again
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
@@ -183,6 +184,13 @@ func (c *Client) connectLocked() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err)
|
||||
}
|
||||
// The KPA500 is POWER-CONTROLLED by these lines: the Elecraft utility
|
||||
// switches the amplifier on by raising them. Held asserted, once, and
|
||||
// never touched again — reconnect cycles that toggled them were
|
||||
// switching a KPA500 OFF twenty seconds after its operator pressed
|
||||
// nothing but Standby.
|
||||
_ = p.SetDTR(true)
|
||||
_ = p.SetRTS(true)
|
||||
_ = p.SetReadTimeout(ioTimeout)
|
||||
c.conn = p
|
||||
}
|
||||
@@ -214,7 +222,13 @@ func (c *Client) ask(cmd string) (string, error) {
|
||||
// the frame.
|
||||
line, err := c.rd.ReadString(';')
|
||||
if err != nil {
|
||||
c.dropLocked()
|
||||
// NOT dropped. A command this model simply does not know (^TP is the
|
||||
// KPA1500's ATU — a KPA500 never answers it) is silence, not a dead
|
||||
// link, and dropping here tore the connection down on every slow poll
|
||||
// cycle: two seconds of stalled commands, a reconnect, and a DTR
|
||||
// toggle the amplifier read as the off switch. Nothing arrived, so
|
||||
// nothing is left to desynchronise the next exchange. Write errors —
|
||||
// the genuinely dead link — still drop, above.
|
||||
return "", fmt.Errorf("no answer to %s: %w", cmd, err)
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
@@ -364,12 +378,21 @@ func (c *Client) pollOnce(n uint64) {
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
}
|
||||
if c.skipTP {
|
||||
return
|
||||
}
|
||||
if reply, err := c.ask("^TP;"); err == nil {
|
||||
if v, err := parseInt(reply, "^TP"); err == nil {
|
||||
c.statusMu.Lock()
|
||||
c.status.Tuning = v == 1
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
} else {
|
||||
// One silence is the model's answer for good: a KPA500 has no ATU and
|
||||
// will never answer ^TP — asking again every cycle cost a two-second
|
||||
// stall each time.
|
||||
c.skipTP = true
|
||||
applog.Printf("kpa: ^TP unanswered — no ATU on this model, not asking again")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+131
-6
@@ -123,6 +123,10 @@ type QSO struct {
|
||||
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
|
||||
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
|
||||
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
|
||||
// Club Log match download (getmatches.php) — app-defined, no standard ADIF
|
||||
// field exists; exported/imported as CLUBLOG_QSO_DOWNLOAD_*.
|
||||
ClublogDownloadDate string `json:"clublog_qso_download_date,omitempty"`
|
||||
ClublogDownloadStatus string `json:"clublog_qso_download_status,omitempty"`
|
||||
|
||||
// --- Contest ---
|
||||
ContestID string `json:"contest_id,omitempty"`
|
||||
@@ -261,6 +265,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
||||
hrdlog_qso_upload_date, hrdlog_qso_upload_status,
|
||||
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
|
||||
qrzcom_qso_download_date, qrzcom_qso_download_status,
|
||||
clublog_qso_download_date, clublog_qso_download_status,
|
||||
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
|
||||
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
|
||||
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
|
||||
@@ -336,6 +341,7 @@ func (q *QSO) args() []any {
|
||||
q.HRDLogUploadDate, q.HRDLogUploadStatus,
|
||||
q.QRZComUploadDate, q.QRZComUploadStatus,
|
||||
q.QRZComDownloadDate, q.QRZComDownloadStatus,
|
||||
q.ClublogDownloadDate, q.ClublogDownloadStatus,
|
||||
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
|
||||
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
|
||||
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
|
||||
@@ -783,6 +789,7 @@ var bulkEditableCols = map[string]bool{
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"clublog_qso_download_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
|
||||
"qsl_sent_date": true,
|
||||
@@ -794,6 +801,7 @@ var bulkEditableCols = map[string]bool{
|
||||
"qrzcom_qso_upload_date": true,
|
||||
"qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true,
|
||||
"clublog_qso_download_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
// My station / operator
|
||||
"station_callsign": true,
|
||||
@@ -1339,14 +1347,16 @@ var filterableColumns = map[string]bool{
|
||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true,
|
||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||
"clublog_qso_upload_status": true, "clublog_qso_download_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
|
||||
// also chronological — "before 20240101" works with no date parsing.
|
||||
"qsl_sent_date": true, "qsl_rcvd_date": true,
|
||||
"lotw_sent_date": true, "lotw_rcvd_date": true,
|
||||
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
|
||||
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true,
|
||||
"clublog_qso_upload_date": true, "hrdlog_qso_upload_date": true,
|
||||
"clublog_qso_upload_date": true, "clublog_qso_download_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
"contest_id": true, "srx": true, "stx": true,
|
||||
"prop_mode": true, "sat_name": true,
|
||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
||||
@@ -2657,10 +2667,20 @@ type EntitySlot struct {
|
||||
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
|
||||
// one bucket. Callers must normalise their lookup mode the same way.
|
||||
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
|
||||
return r.EntitySlotMapPred(ctx, keyFor, normMode, "")
|
||||
}
|
||||
|
||||
// EntitySlotMapPred is EntitySlotMap over only the QSOs matching pred — the
|
||||
// confirmed-only ledger the "chase unconfirmed too" mode judges against.
|
||||
func (r *Repo) EntitySlotMapPred(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string, pred string) (map[int]*EntitySlot, error) {
|
||||
where := ""
|
||||
if pred != "" {
|
||||
where = " AND " + pred
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||
WHERE band IS NOT NULL AND band != ''
|
||||
AND mode IS NOT NULL AND mode != ''`)
|
||||
AND mode IS NOT NULL AND mode != ''`+where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2710,8 +2730,18 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
|
||||
// One pass, used by the cluster spot colouring to flag "already worked this
|
||||
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
|
||||
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
|
||||
return r.WorkedCallsignsPred(ctx, "")
|
||||
}
|
||||
|
||||
// WorkedCallsignsPred restricts the callsign ledger to QSOs matching pred —
|
||||
// the confirmed-prefix (WPX) chase reads its prefixes off this set.
|
||||
func (r *Repo) WorkedCallsignsPred(ctx context.Context, pred string) (map[string]struct{}, error) {
|
||||
where := ""
|
||||
if pred != "" {
|
||||
where = " AND " + pred
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`)
|
||||
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`+where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2732,9 +2762,18 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
|
||||
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
|
||||
// are considered. Empty keys (unresolvable state/county) are skipped.
|
||||
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
|
||||
return r.WorkedCountyKeysPred(ctx, keyFn, "")
|
||||
}
|
||||
|
||||
// WorkedCountyKeysPred restricts the county ledger to QSOs matching pred.
|
||||
func (r *Repo) WorkedCountyKeysPred(ctx context.Context, keyFn func(state, cnty string) string, pred string) (map[string]struct{}, error) {
|
||||
where := ""
|
||||
if pred != "" {
|
||||
where = " AND " + pred
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
|
||||
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
|
||||
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`+where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2752,6 +2791,38 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WorkedStateKeys returns the set of US states already worked, uppercased —
|
||||
// the WAS ledger the cluster and decode panels judge NEW STATE against.
|
||||
func (r *Repo) WorkedStateKeys(ctx context.Context) (map[string]struct{}, error) {
|
||||
return r.WorkedStateKeysPred(ctx, "")
|
||||
}
|
||||
|
||||
// WorkedStateKeysPred restricts the state ledger to QSOs matching pred.
|
||||
func (r *Repo) WorkedStateKeysPred(ctx context.Context, pred string) (map[string]struct{}, error) {
|
||||
where := ""
|
||||
if pred != "" {
|
||||
where = " AND " + pred
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT UPPER(COALESCE(state,'')) FROM qso
|
||||
WHERE dxcc IN (291,110,6) AND state IS NOT NULL AND state != ''`+where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{}, 64)
|
||||
for rows.Next() {
|
||||
var st string
|
||||
if err := rows.Scan(&st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st != "" {
|
||||
out[st] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
|
||||
// instead of a manager, per isRouting.
|
||||
//
|
||||
@@ -3203,6 +3274,7 @@ type matchRef struct {
|
||||
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
|
||||
type MatchIndex struct {
|
||||
byMode map[string][]matchRef // call|band|canonMode → refs
|
||||
byBand map[string][]matchRef // call|band → refs (mode-blind fallback)
|
||||
}
|
||||
|
||||
// canonMode folds the phone sidebands into a single "SSB" bucket; every other
|
||||
@@ -3238,7 +3310,7 @@ func parseQSODate(s string) time.Time {
|
||||
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
|
||||
// another (e.g. TM2Q).
|
||||
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
|
||||
idx := &MatchIndex{byMode: map[string][]matchRef{}}
|
||||
idx := &MatchIndex{byMode: map[string][]matchRef{}, byBand: map[string][]matchRef{}}
|
||||
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
|
||||
var args []any
|
||||
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
|
||||
@@ -3266,6 +3338,11 @@ func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchInd
|
||||
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
|
||||
mk := matchKeyMode(call, band, mode)
|
||||
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
|
||||
if idx.byBand == nil { // tests build the index literally, without byBand
|
||||
idx.byBand = map[string][]matchRef{}
|
||||
}
|
||||
bk := strings.ToUpper(call) + "|" + strings.ToLower(band)
|
||||
idx.byBand[bk] = append(idx.byBand[bk], matchRef{when: when, id: id})
|
||||
}
|
||||
|
||||
// Add registers a QSO in the index (exported wrapper for callers that inserted a
|
||||
@@ -3281,6 +3358,12 @@ func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window tim
|
||||
return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window)
|
||||
}
|
||||
|
||||
// MatchBand matches ignoring the mode — for confirmations whose source doesn't
|
||||
// carry one (Club Log reports "false" for modes it can't infer).
|
||||
func (idx *MatchIndex) MatchBand(call, band string, when time.Time, window time.Duration) (int64, bool) {
|
||||
return closestRef(idx.byBand[strings.ToUpper(call)+"|"+strings.ToLower(band)], when, window)
|
||||
}
|
||||
|
||||
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
|
||||
var best int64
|
||||
bestD := window + time.Second
|
||||
@@ -3362,6 +3445,30 @@ type SlotStats struct {
|
||||
// only way those two agree.
|
||||
const ConfirmedValues = "('Y','V')"
|
||||
|
||||
// ConfirmSourcesPredicate builds the SQL that says "this QSO is confirmed",
|
||||
// from the operator's chosen sources — the same choice the award engine's
|
||||
// fixed three (LoTW, card, eQSL) used to hard-code. QRZ.com's confirmation is
|
||||
// its download status; HRDLog has no confirmation field in ADIF at all, which
|
||||
// is why it cannot be offered. Empty input falls back to the classic three.
|
||||
func ConfirmSourcesPredicate(sources []string) string {
|
||||
cols := map[string]string{
|
||||
"lotw": "lotw_rcvd",
|
||||
"card": "qsl_rcvd",
|
||||
"eqsl": "eqsl_rcvd",
|
||||
"qrz": "qrzcom_qso_download_status",
|
||||
}
|
||||
var parts []string
|
||||
for _, src := range sources {
|
||||
if col, ok := cols[strings.ToLower(strings.TrimSpace(src))]; ok {
|
||||
parts = append(parts, col+" IN "+ConfirmedValues)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "(lotw_rcvd IN " + ConfirmedValues + " OR qsl_rcvd IN " + ConfirmedValues + " OR eqsl_rcvd IN " + ConfirmedValues + ")"
|
||||
}
|
||||
return "(" + strings.Join(parts, " OR ") + ")"
|
||||
}
|
||||
|
||||
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
|
||||
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
||||
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||
@@ -3419,6 +3526,7 @@ var confirmedCols = map[string]bool{
|
||||
"qsl_rcvd": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_download_status": true,
|
||||
}
|
||||
|
||||
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
|
||||
@@ -3475,6 +3583,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkClublogConfirmed stamps CLUBLOG_QSO_DOWNLOAD_STATUS=Y and the date on a
|
||||
// QSO Club Log reports as matched. date is an ADIF YYYYMMDD string.
|
||||
func (r *Repo) MarkClublogConfirmed(ctx context.Context, id int64, date string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET clublog_qso_download_status = 'Y', clublog_qso_download_date = ?,
|
||||
updated_at = ? WHERE id = ?`,
|
||||
date, db.NowISO(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark clublog confirmed %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearQRZConfirmed takes back a QRZ confirmation.
|
||||
//
|
||||
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
|
||||
@@ -3553,6 +3674,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
hrdlogDate, hrdlogStatus sql.NullString
|
||||
qrzcomDate, qrzcomStatus sql.NullString
|
||||
qrzcomDlDate, qrzcomDlStatus sql.NullString
|
||||
clublogDlDate, clublogDlStatus sql.NullString
|
||||
contestID sql.NullString
|
||||
srx, stx sql.NullInt64
|
||||
srxStr, stxStr sql.NullString
|
||||
@@ -3597,6 +3719,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&hrdlogDate, &hrdlogStatus,
|
||||
&qrzcomDate, &qrzcomStatus,
|
||||
&qrzcomDlDate, &qrzcomDlStatus,
|
||||
&clublogDlDate, &clublogDlStatus,
|
||||
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
|
||||
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
|
||||
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
||||
@@ -3694,6 +3817,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.QRZComUploadStatus = qrzcomStatus.String
|
||||
q.QRZComDownloadDate = qrzcomDlDate.String
|
||||
q.QRZComDownloadStatus = qrzcomDlStatus.String
|
||||
q.ClublogDownloadDate = clublogDlDate.String
|
||||
q.ClublogDownloadStatus = clublogDlStatus.String
|
||||
q.ContestID = contestID.String
|
||||
if srx.Valid {
|
||||
v := int(srx.Int64)
|
||||
|
||||
+74
-6
@@ -13,6 +13,7 @@ package scp
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -29,7 +31,14 @@ import (
|
||||
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
|
||||
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
|
||||
|
||||
// clublogURL is Club Log's own SCP list, rebuilt weekly from real DX logs:
|
||||
// every call from a current entity with 40+ QSOs in the last 3 years (~180k).
|
||||
// Broader than MASTER.SCP (which leans contest), so the manager merges the two
|
||||
// when the Club Log source is enabled.
|
||||
const clublogURL = "https://cdn.clublog.org/clublog.scp.gz"
|
||||
|
||||
const cacheFile = "MASTER.SCP"
|
||||
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed
|
||||
|
||||
// Result is the two suggestion lists for a typed fragment.
|
||||
type Result struct {
|
||||
@@ -44,6 +53,7 @@ type Manager struct {
|
||||
updated time.Time // when the cache was last refreshed
|
||||
dir string
|
||||
client *http.Client
|
||||
clublog atomic.Bool // merge Club Log's list into the master list
|
||||
}
|
||||
|
||||
// NewManager loads any on-disk cache and returns a ready manager.
|
||||
@@ -57,13 +67,29 @@ func NewManager(dataDir string) *Manager {
|
||||
}
|
||||
|
||||
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
|
||||
func (m *Manager) clublogPath() string { return filepath.Join(m.dir, clublogCacheFile) }
|
||||
|
||||
// SetClublogEnabled turns the Club Log source on/off and reparses the on-disk
|
||||
// caches so the in-memory list reflects the choice immediately.
|
||||
func (m *Manager) SetClublogEnabled(on bool) {
|
||||
if m.clublog.Swap(on) != on {
|
||||
m.loadCache()
|
||||
}
|
||||
}
|
||||
|
||||
// ClublogEnabled reports whether the Club Log source is merged in.
|
||||
func (m *Manager) ClublogEnabled() bool { return m.clublog.Load() }
|
||||
|
||||
func (m *Manager) loadCache() {
|
||||
data, err := os.ReadFile(m.path())
|
||||
if err != nil {
|
||||
data, _ := os.ReadFile(m.path())
|
||||
var extra []byte
|
||||
if m.clublog.Load() {
|
||||
extra, _ = os.ReadFile(m.clublogPath())
|
||||
}
|
||||
if data == nil && extra == nil {
|
||||
return
|
||||
}
|
||||
m.parse(data)
|
||||
m.parse(data, extra)
|
||||
if fi, e := os.Stat(m.path()); e == nil {
|
||||
m.mu.Lock()
|
||||
m.updated = fi.ModTime()
|
||||
@@ -91,7 +117,17 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scp: read: %w", err)
|
||||
}
|
||||
n := m.parse(body)
|
||||
var extra []byte
|
||||
if m.clublog.Load() {
|
||||
if cl, cerr := m.downloadClublog(ctx); cerr == nil {
|
||||
extra = cl
|
||||
} else {
|
||||
// The master list alone is still worth having; fall back to any
|
||||
// cached Club Log list rather than dropping the source silently.
|
||||
extra, _ = os.ReadFile(m.clublogPath())
|
||||
}
|
||||
}
|
||||
n := m.parse(body, extra)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
|
||||
}
|
||||
@@ -102,9 +138,40 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// downloadClublog fetches Club Log's gzipped SCP list and caches it decompressed.
|
||||
func (m *Manager) downloadClublog(ctx context.Context) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpsLog")
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("scp: clublog http %d", resp.StatusCode)
|
||||
}
|
||||
gz, err := gzip.NewReader(io.LimitReader(resp.Body, 32*1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog gunzip: %w", err)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(gz, 64*1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog read: %w", err)
|
||||
}
|
||||
_ = os.WriteFile(m.clublogPath(), body, 0o644)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// parse loads the SCP bytes into the sorted call slice and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
func (m *Manager) parse(datasets ...[]byte) int {
|
||||
seen := make(map[string]struct{}, 1<<17)
|
||||
for _, data := range datasets {
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
@@ -112,7 +179,7 @@ func (m *Manager) parse(data []byte) int {
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue // blank / header comment
|
||||
}
|
||||
// A call token only (the master file is one call per line, but guard
|
||||
// A call token only (the files are one call per line, but guard
|
||||
// against stray trailing fields).
|
||||
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
|
||||
line = line[:i]
|
||||
@@ -122,6 +189,7 @@ func (m *Manager) parse(data []byte) int {
|
||||
}
|
||||
seen[line] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -190,6 +190,8 @@ var Columns = []Column{
|
||||
{"eqsl_rcvd_date", "Eqsl rcvd date", "QSL", func(q *qso.QSO) string { return q.EQSLRcvdDate }},
|
||||
{"clublog_qso_upload_date", "Clublog qso upload date", "QSL", func(q *qso.QSO) string { return q.ClublogUploadDate }},
|
||||
{"clublog_qso_upload_status", "Clublog qso upload status", "QSL", func(q *qso.QSO) string { return q.ClublogUploadStatus }},
|
||||
{"clublog_qso_download_date", "Clublog match date", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadDate }},
|
||||
{"clublog_qso_download_status", "Clublog match status", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadStatus }},
|
||||
{"hrdlog_qso_upload_date", "Hrdlog qso upload date", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadDate }},
|
||||
{"hrdlog_qso_upload_status", "Hrdlog qso upload status", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadStatus }},
|
||||
{"qrzcom_qso_upload_date", "Qrzcom qso upload date", "QSL", func(q *qso.QSO) string { return q.QRZComUploadDate }},
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.2"
|
||||
appVersion = "0.27.5"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user