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 |
@@ -159,6 +159,8 @@ const (
|
|||||||
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
||||||
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
||||||
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
|
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)
|
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)
|
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
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
|
keyClusterSpotMax = "cluster.spot_max" // how many spots the list holds at once
|
||||||
|
|
||||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
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
|
// 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
|
// 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"
|
keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
|
||||||
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
|
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
|
||||||
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
||||||
|
keyQSLDefaultClublogCfm = "qsl.clublog_confirmed"
|
||||||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||||||
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
||||||
@@ -419,6 +423,8 @@ const (
|
|||||||
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
|
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
|
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
|
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
|
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
|
||||||
@@ -434,6 +440,7 @@ type QSLDefaults struct {
|
|||||||
EQSLSent string `json:"eqsl_sent"`
|
EQSLSent string `json:"eqsl_sent"`
|
||||||
EQSLRcvd string `json:"eqsl_rcvd"`
|
EQSLRcvd string `json:"eqsl_rcvd"`
|
||||||
ClublogStatus string `json:"clublog_status"`
|
ClublogStatus string `json:"clublog_status"`
|
||||||
|
ClublogCfm string `json:"clublog_confirmed"` // Club Log match/download status
|
||||||
HRDLogStatus string `json:"hrdlog_status"`
|
HRDLogStatus string `json:"hrdlog_status"`
|
||||||
QRZComStatus string `json:"qrzcom_status"`
|
QRZComStatus string `json:"qrzcom_status"`
|
||||||
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed 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)
|
conn, err := db.Open(a.dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.startupErr = "cannot open db: " + err.Error()
|
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
|
return
|
||||||
}
|
}
|
||||||
a.db = conn
|
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
|
// 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.
|
// enabled, auto-(re)download it if missing or older than a week.
|
||||||
a.scp = scp.NewManager(dataDir)
|
a.scp = scp.NewManager(dataDir)
|
||||||
|
if v, _ := a.settings.Get(a.ctx, keyScpClublog); v == "1" {
|
||||||
|
a.scp.SetClublogEnabled(true)
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
|
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
|
||||||
return
|
return
|
||||||
@@ -3144,6 +3156,7 @@ type ScpStatus struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
|
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.
|
// 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 {
|
if a.scp != nil {
|
||||||
st.Count = a.scp.Count()
|
st.Count = a.scp.Count()
|
||||||
|
st.Clublog = a.scp.ClublogEnabled()
|
||||||
if u := a.scp.Updated(); !u.IsZero() {
|
if u := a.scp.Updated(); !u.IsZero() {
|
||||||
st.Updated = u.UTC().Format(time.RFC3339)
|
st.Updated = u.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
@@ -3162,6 +3176,34 @@ func (a *App) GetScpStatus() ScpStatus {
|
|||||||
return st
|
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
|
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
|
||||||
// download when the list is missing or stale.
|
// download when the list is missing or stale.
|
||||||
func (a *App) SetScpEnabled(on bool) error {
|
func (a *App) SetScpEnabled(on bool) error {
|
||||||
@@ -6887,6 +6929,7 @@ var bulkFieldColumns = map[string]string{
|
|||||||
"qrz_sent": "qrzcom_qso_upload_status",
|
"qrz_sent": "qrzcom_qso_upload_status",
|
||||||
"qrz_rcvd": "qrzcom_qso_download_status",
|
"qrz_rcvd": "qrzcom_qso_download_status",
|
||||||
"clublog_sent": "clublog_qso_upload_status",
|
"clublog_sent": "clublog_qso_upload_status",
|
||||||
|
"clublog_rcvd": "clublog_qso_download_status",
|
||||||
"hrdlog_sent": "hrdlog_qso_upload_status",
|
"hrdlog_sent": "hrdlog_qso_upload_status",
|
||||||
// Old ids kept so anything holding one keeps working.
|
// Old ids kept so anything holding one keeps working.
|
||||||
"qrz_upload": "qrzcom_qso_upload_status",
|
"qrz_upload": "qrzcom_qso_upload_status",
|
||||||
@@ -6903,6 +6946,7 @@ var bulkFieldColumns = map[string]string{
|
|||||||
"qrz_sent_date": "qrzcom_qso_upload_date",
|
"qrz_sent_date": "qrzcom_qso_upload_date",
|
||||||
"qrz_rcvd_date": "qrzcom_qso_download_date",
|
"qrz_rcvd_date": "qrzcom_qso_download_date",
|
||||||
"clublog_sent_date": "clublog_qso_upload_date",
|
"clublog_sent_date": "clublog_qso_upload_date",
|
||||||
|
"clublog_rcvd_date": "clublog_qso_download_date",
|
||||||
"hrdlog_sent_date": "hrdlog_qso_upload_date",
|
"hrdlog_sent_date": "hrdlog_qso_upload_date",
|
||||||
// My station / operator
|
// My station / operator
|
||||||
"station_callsign": "station_callsign",
|
"station_callsign": "station_callsign",
|
||||||
@@ -10896,7 +10940,7 @@ func defaultQSLDefaults() QSLDefaults {
|
|||||||
QSLSent: "N", QSLRcvd: "N",
|
QSLSent: "N", QSLRcvd: "N",
|
||||||
EQSLSent: "R", EQSLRcvd: "N",
|
EQSLSent: "R", EQSLRcvd: "N",
|
||||||
LOTWSent: "R", LOTWRcvd: "N",
|
LOTWSent: "R", LOTWRcvd: "N",
|
||||||
ClublogStatus: "R", HRDLogStatus: "R",
|
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
|
||||||
QRZComStatus: "R", QRZComCfm: "N",
|
QRZComStatus: "R", QRZComCfm: "N",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10915,7 +10959,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
|||||||
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
||||||
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||||||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
|
||||||
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
||||||
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
|
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
|
||||||
)
|
)
|
||||||
@@ -10929,6 +10973,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
|||||||
out.EQSLSent = m[keyQSLDefaultEQSLSent]
|
out.EQSLSent = m[keyQSLDefaultEQSLSent]
|
||||||
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
|
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
|
||||||
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||||||
|
out.ClublogCfm = m[keyQSLDefaultClublogCfm]
|
||||||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||||
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
||||||
@@ -10952,6 +10997,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
|||||||
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
|
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
|
||||||
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
|
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
|
||||||
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||||||
|
keyQSLDefaultClublogCfm: strings.ToUpper(strings.TrimSpace(d.ClublogCfm)),
|
||||||
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||||||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||||||
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
||||||
@@ -11004,6 +11050,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
|
|||||||
fill(&q.EQSLSent, d.EQSLSent)
|
fill(&q.EQSLSent, d.EQSLSent)
|
||||||
fill(&q.EQSLRcvd, d.EQSLRcvd)
|
fill(&q.EQSLRcvd, d.EQSLRcvd)
|
||||||
fill(&q.ClublogUploadStatus, d.ClublogStatus)
|
fill(&q.ClublogUploadStatus, d.ClublogStatus)
|
||||||
|
fill(&q.ClublogDownloadStatus, d.ClublogCfm)
|
||||||
fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
|
fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
|
||||||
fill(&q.QRZComUploadStatus, d.QRZComStatus)
|
fill(&q.QRZComUploadStatus, d.QRZComStatus)
|
||||||
fill(&q.QRZComDownloadStatus, d.QRZComCfm)
|
fill(&q.QRZComDownloadStatus, d.QRZComCfm)
|
||||||
@@ -12933,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"))
|
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:
|
default:
|
||||||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||||||
}
|
}
|
||||||
@@ -16096,6 +16227,10 @@ func (a *App) reloadAfterProfileSwitch() {
|
|||||||
a.restartAsync("tuner", a.startTunerGenius)
|
a.restartAsync("tuner", a.startTunerGenius)
|
||||||
a.restartAsync("psu", a.startPSU)
|
a.restartAsync("psu", a.startPSU)
|
||||||
a.startQSORecorderIfEnabled()
|
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
|
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||||
@@ -18547,19 +18682,51 @@ func (a *App) startAmps() {
|
|||||||
old := a.ampInsts
|
old := a.ampInsts
|
||||||
a.ampInsts = map[string]*ampInst{}
|
a.ampInsts = map[string]*ampInst{}
|
||||||
a.ampsMu.Unlock()
|
a.ampsMu.Unlock()
|
||||||
|
a.pgxl, a.spe, a.acom = nil, nil, nil
|
||||||
|
list, err := a.GetAmplifiers()
|
||||||
|
if err != nil {
|
||||||
for _, inst := range old {
|
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
|
// 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
|
// connect; tear down in the background so saving Settings (this runs on
|
||||||
// the Wails RPC goroutine) doesn't freeze the UI.
|
// the Wails RPC goroutine) doesn't freeze the UI.
|
||||||
go inst.stopAll()
|
go inst.stopAll()
|
||||||
}
|
}
|
||||||
a.pgxl, a.spe, a.acom = nil, nil, nil
|
|
||||||
list, err := a.GetAmplifiers()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, c := range list {
|
for _, c := range list {
|
||||||
if !c.Enabled {
|
if !c.Enabled || reused[c.ID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
isPGXL := c.Type == "" || c.Type == "pgxl"
|
isPGXL := c.Type == "" || c.Type == "pgxl"
|
||||||
@@ -19821,6 +19988,16 @@ type SpotStatus struct {
|
|||||||
// NewCounty already has them in hand.
|
// NewCounty already has them in hand.
|
||||||
County string `json:"county,omitempty"`
|
County string `json:"county,omitempty"`
|
||||||
State string `json:"state,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"`
|
NewPOTA bool `json:"new_pota"`
|
||||||
// Grid is the 4-character square this station announced in a CQ on the UDP
|
// 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 /
|
// link, and NewGrid says that square has never been worked. Both are empty /
|
||||||
@@ -19869,6 +20046,17 @@ type clusterStatusCache struct {
|
|||||||
workedCallSlots map[string]struct{}
|
workedCallSlots map[string]struct{}
|
||||||
workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on
|
workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on
|
||||||
workedCounties map[string]struct{}
|
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
|
// callCounties holds callsign → "STATE,County" for stations already logged
|
||||||
// with a county, so a spot shows the county the entry panel showed rather
|
// 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.
|
// than the one derived from the licence ZIP. See qso.CallCounties.
|
||||||
@@ -19889,6 +20077,50 @@ type clusterStatusCache struct {
|
|||||||
slotHighlight bool // (same: the slot index is built for either)
|
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
|
// clusterStatusMaps returns the cached worked-index snapshot, building it once
|
||||||
// per logbook change (invalidated by invalidateAwardStats) or when a setting
|
// per logbook change (invalidated by invalidateAwardStats) or when a setting
|
||||||
// that shapes the maps flips. This turns the per-batch full-logbook scans into
|
// that shapes the maps flips. This turns the per-batch full-logbook scans into
|
||||||
@@ -19901,16 +20133,23 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
|||||||
// holding the status mutex across that is how two subsystems deadlock.
|
// holding the status mutex across that is how two subsystems deadlock.
|
||||||
gs := a.GetGridScopeSettings()
|
gs := a.GetGridScopeSettings()
|
||||||
gridScopeNow := lookupGridScope(gs.Scope)
|
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()
|
a.clusterStatusMu.Lock()
|
||||||
defer a.clusterStatusMu.Unlock()
|
defer a.clusterStatusMu.Unlock()
|
||||||
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot &&
|
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
|
return c
|
||||||
}
|
}
|
||||||
c := &clusterStatusCache{
|
c := &clusterStatusCache{
|
||||||
groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
|
groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
|
||||||
gridScope: gridScopeNow, gridHunt: gridHuntNow,
|
gridScope: gridScopeNow, gridHunt: gridHuntNow,
|
||||||
|
chaseUnconf: chase.Mode == "new_unconfirmed", chaseKey: chaseKeyNow,
|
||||||
}
|
}
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
a.clusterStatusIdx = c
|
a.clusterStatusIdx = c
|
||||||
@@ -19967,6 +20206,7 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
|||||||
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
|
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
|
||||||
// lookup) and worked POTA parks.
|
// lookup) and worked POTA parks.
|
||||||
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
||||||
|
c.workedStates, _ = a.qso.WorkedStateKeys(a.ctx)
|
||||||
c.callCounties, _ = a.qso.CallCounties(a.ctx)
|
c.callCounties, _ = a.qso.CallCounties(a.ctx)
|
||||||
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
|
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
|
||||||
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
|
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
|
||||||
@@ -19977,6 +20217,19 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
|||||||
// extra query. Derived rather than read from the stored PFX column: that
|
// 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
|
// column is only filled when an import supplied it, and deriving keeps this
|
||||||
// in step with the WPX award, which does the same thing.
|
// 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))
|
c.workedPfx = make(map[string]struct{}, len(c.workedCalls))
|
||||||
for call := range c.workedCalls {
|
for call := range c.workedCalls {
|
||||||
if p := award.WPXPrefix(call); p != "" {
|
if p := award.WPXPrefix(call); p != "" {
|
||||||
@@ -20302,6 +20555,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
workedCounties := idx.workedCounties
|
workedCounties := idx.workedCounties
|
||||||
workedPOTA := idx.workedPOTA
|
workedPOTA := idx.workedPOTA
|
||||||
workedPfx := idx.workedPfx
|
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
|
normMode := idx.normMode
|
||||||
sameSlot := idx.sameSlot
|
sameSlot := idx.sameSlot
|
||||||
for i, q := range spots {
|
for i, q := range spots {
|
||||||
@@ -20345,8 +20605,11 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
// NEW PFX: the spot's CQ WPX prefix, never worked before.
|
// NEW PFX: the spot's CQ WPX prefix, never worked before.
|
||||||
if p := award.WPXPrefix(q.Call); p != "" {
|
if p := award.WPXPrefix(q.Call); p != "" {
|
||||||
out[i].Pfx = p
|
out[i].Pfx = p
|
||||||
if _, done := workedPfx[p]; !done {
|
if _, done := judgePfx[p]; !done {
|
||||||
out[i].NewPfx = true
|
out[i].NewPfx = true
|
||||||
|
if _, everWorked := workedPfx[p]; everWorked {
|
||||||
|
out[i].UnconfPfx = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NEW POTA: the spot's tagged park, never worked before.
|
// NEW POTA: the spot's tagged park, never worked before.
|
||||||
@@ -20409,19 +20672,39 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
if cnty, ok := idx.callCounties[q.Call]; ok {
|
if cnty, ok := idx.callCounties[q.Call]; ok {
|
||||||
st, name, _ := strings.Cut(cnty, ",")
|
st, name, _ := strings.Cut(cnty, ",")
|
||||||
out[i].State, out[i].County = st, name
|
out[i].State, out[i].County = st, name
|
||||||
// Logged means worked, so this can never be a new county — but say
|
if st != "" {
|
||||||
// so through the same key the flag below uses, not by assumption.
|
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 key := award.USCountyKey(st, name); key != "" {
|
||||||
if _, done := workedCounties[key]; !done {
|
if _, done := judgeCounties[key]; !done {
|
||||||
out[i].NewCounty = true
|
out[i].NewCounty = true
|
||||||
|
if _, ever := workedCounties[key]; ever {
|
||||||
|
out[i].UnconfCty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if a.uls != nil {
|
} else if a.uls != nil {
|
||||||
if loc, ok := a.uls.Resolve(q.Call); ok {
|
if loc, ok := a.uls.Resolve(q.Call); ok {
|
||||||
out[i].County, out[i].State = loc.County, loc.State
|
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 key := award.USCountyKey(loc.State, loc.County); key != "" {
|
||||||
if _, done := workedCounties[key]; !done {
|
if _, done := judgeCounties[key]; !done {
|
||||||
out[i].NewCounty = true
|
out[i].NewCounty = true
|
||||||
|
if _, ever := workedCounties[key]; ever {
|
||||||
|
out[i].UnconfCty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20449,9 +20732,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
if dxccNum == 0 {
|
if dxccNum == 0 {
|
||||||
continue // can't resolve the spot's entity number → don't guess
|
continue // can't resolve the spot's entity number → don't guess
|
||||||
}
|
}
|
||||||
e, worked := entities[dxccNum]
|
e, worked := judgeEntities[dxccNum]
|
||||||
if !worked {
|
if !worked {
|
||||||
out[i].Status = "new"
|
out[i].Status = "new"
|
||||||
|
if _, ever := entities[dxccNum]; ever {
|
||||||
|
out[i].UnconfStatus = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// The check mode goes through the same normaliser as the slot map
|
// The check mode goes through the same normaliser as the slot map
|
||||||
@@ -20466,6 +20752,26 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
|
|
||||||
_, haveSlot := e.Slots[out[i].Band][checkMode]
|
_, haveSlot := e.Slots[out[i].Band][checkMode]
|
||||||
out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
|
|||||||
id := m[1]
|
id := m[1]
|
||||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||||
// band together) and the extras live in extras_json.
|
// 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) {
|
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,48 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.27.3",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+62
-4
@@ -75,6 +75,7 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
|||||||
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
|
import { FTMapPanel } from '@/components/FTMapPanel';
|
||||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
@@ -1313,6 +1314,17 @@ export default function App() {
|
|||||||
writeUiPref('opslog.gridsTab', '0');
|
writeUiPref('opslog.gridsTab', '0');
|
||||||
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
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() {
|
function openDecodesTab() {
|
||||||
setDecodesTabOpen(true);
|
setDecodesTabOpen(true);
|
||||||
writeUiPref('opslog.decodesTab', '1');
|
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
|
// 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
|
// 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.
|
// 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
|
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||||
// still need resolving without re-subscribing the cluster:spot listener.
|
// still need resolving without re-subscribing the cluster:spot listener.
|
||||||
const spotStatusRef = useRef(spotStatus);
|
const spotStatusRef = useRef(spotStatus);
|
||||||
@@ -3639,6 +3651,11 @@ export default function App() {
|
|||||||
worked_call: !!(r as any).worked_call,
|
worked_call: !!(r as any).worked_call,
|
||||||
worked_slot: !!(r as any).worked_slot,
|
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_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_pota: !!(r as any).new_pota,
|
||||||
new_pfx: !!(r as any).new_pfx,
|
new_pfx: !!(r as any).new_pfx,
|
||||||
pfx: (r as any).pfx,
|
pfx: (r as any).pfx,
|
||||||
@@ -3745,6 +3762,11 @@ export default function App() {
|
|||||||
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
|
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
|
||||||
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
|
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
|
||||||
county: (r as any).county, state: (r as any).state,
|
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_pota: !!(r as any).new_pota,
|
||||||
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
|
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
|
||||||
};
|
};
|
||||||
@@ -3852,6 +3874,12 @@ export default function App() {
|
|||||||
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
|
// 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
|
// Only when something is actually in the entry, so an idle digital app doesn't
|
||||||
// wipe a call being typed by hand.
|
// 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', () => {
|
const unsubClear = EventsOn('udp:clear_call', () => {
|
||||||
// The decoder cleared its DX Call: the next call it announces — even the
|
// The decoder cleared its DX Call: the next call it announces — even the
|
||||||
// same one re-selected — is a fresh edge.
|
// same one re-selected — is a fresh edge.
|
||||||
@@ -3915,7 +3943,7 @@ export default function App() {
|
|||||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -5089,12 +5117,17 @@ export default function App() {
|
|||||||
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
|
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
|
||||||
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
|
{ 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: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
{ 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: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
@@ -5148,6 +5181,7 @@ export default function App() {
|
|||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||||
case 'tools.decodes': openDecodesTab(); break;
|
case 'tools.decodes': openDecodesTab(); break;
|
||||||
|
case 'tools.ftmap': openFtmapTab(); break;
|
||||||
case 'tools.grids': openGridsTab(); break;
|
case 'tools.grids': openGridsTab(); break;
|
||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
@@ -7779,6 +7813,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</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 && (
|
{stationTabOpen && (
|
||||||
<TabsTrigger value="station" className="gap-1.5">
|
<TabsTrigger value="station" className="gap-1.5">
|
||||||
{t('station.title')}
|
{t('station.title')}
|
||||||
@@ -8377,6 +8426,15 @@ export default function App() {
|
|||||||
</TabsContent>
|
</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 && (
|
{stationTabOpen && (
|
||||||
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
<StationControlPanel
|
<StationControlPanel
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ 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', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ 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', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// HAMLOG.online: no promoted column, written into extras_json (see
|
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export type SpotStatusEntry = {
|
|||||||
state?: string;
|
state?: string;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
pfx?: string;
|
pfx?: string;
|
||||||
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
||||||
lotw?: boolean;
|
lotw?: boolean;
|
||||||
@@ -304,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||||
: s?.status === 'new-call' ? t('clg2.newCall')
|
: s?.status === 'new-call' ? t('clg2.newCall')
|
||||||
: t('clg2.wkdCall');
|
: 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
|
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
||||||
// never one colour here and another there.
|
// 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_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
|
// 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
|
// held back, so it reads as "less" of the same thing rather than a
|
||||||
// different fact — and the label says which.
|
// different fact — and the label says which.
|
||||||
|
|||||||
@@ -72,8 +72,14 @@ type StatusEntry = {
|
|||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
new_grid?: 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.
|
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
|
||||||
grid_state?: string;
|
grid_state?: string;
|
||||||
|
state?: string;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
lotw?: boolean;
|
lotw?: boolean;
|
||||||
};
|
};
|
||||||
@@ -112,7 +118,7 @@ interface Props {
|
|||||||
//
|
//
|
||||||
// All off means no filtering at all: this is a decode LOG first, and a panel
|
// 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.
|
// 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 }[] = [
|
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
||||||
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
{ 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: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
|
||||||
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
||||||
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
|
{ 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
|
// 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_grid) out.add('grid');
|
||||||
if (e.new_pfx) out.add('pfx');
|
if (e.new_pfx) out.add('pfx');
|
||||||
if (e.new_county) out.add('cty');
|
if (e.new_county) out.add('cty');
|
||||||
|
if (e.new_state) out.add('state');
|
||||||
return out;
|
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
|
// 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
|
// resize handles all read from this, so a column cannot be resized in the header
|
||||||
// and stay the old width in the body.
|
// 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 }[] = [
|
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||||
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
|
{ 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
|
// 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.
|
// an operator chasing squares could see the verdict but never the square.
|
||||||
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
{ 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: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
||||||
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
{ 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 COL_MAX = 600;
|
||||||
const COLW_KEY = 'opslog.decodeColWidths';
|
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
|
// Colours match the cluster list and the band map — the same fact must not be
|
||||||
// amber in one panel and green in the next.
|
// amber in one panel and green in the next.
|
||||||
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
|
||||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
|
// colour is the category's own hue, for the UNCONFIRMED rendering: the
|
||||||
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
|
// filled cls puts light text on a filled chip, and dimming that to a
|
||||||
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
|
// transparent background left light-on-nothing — an invisible badge.
|
||||||
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
|
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
|
||||||
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
|
'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.
|
// 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,
|
// 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
|
// which is precisely what was reported. catsOf has always split the status into
|
||||||
// its two categories; this is the same split, on the screen.
|
// 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') {
|
if (status === 'new-band-mode') {
|
||||||
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-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 }[] = [
|
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
||||||
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
||||||
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
|
{ 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_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
|
||||||
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
||||||
];
|
];
|
||||||
@@ -1097,6 +1126,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
||||||
</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={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
||||||
<span className="truncate">{e?.country ?? ''}</span>
|
<span className="truncate">{e?.country ?? ''}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1114,7 +1152,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entities.map((b) => (
|
{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)}
|
{t(b.label)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -1124,16 +1166,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
// Same hue, held back — the badge reads as "less" of the
|
// Same hue, held back — the badge reads as "less" of the
|
||||||
// same thing, and its label says which. One badge for both
|
// same thing, and its label says which. One badge for both
|
||||||
// is what made a station worked an hour ago read as new.
|
// 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);
|
const c = markerColour(b.marker);
|
||||||
return (
|
return (
|
||||||
<span key={b.key as string}
|
<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"
|
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||||
style={unconf
|
style={unconf
|
||||||
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
||||||
: { borderColor: c, color: c }}>
|
: { borderColor: c, color: c }}>
|
||||||
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
|
{t(b.label)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
{ 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_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||||
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
// 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
|
// impossible on any window wider than it is tall: one step out was already
|
||||||
// too far in.
|
// too far in.
|
||||||
minZoom: 0,
|
minZoom: 0,
|
||||||
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
|
|
||||||
}).setView([20, 0], 2);
|
}).setView([20, 0], 2);
|
||||||
// The whole world, once, whatever the window is shaped like — computed by
|
// The whole world, once, whatever the window is shaped like — computed by
|
||||||
// Leaflet from the container rather than guessed with a zoom number.
|
// Leaflet from the container rather than guessed with a zoom number.
|
||||||
m.fitWorld({ animate: false });
|
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;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
// Leaflet measures its container ONCE, when the map is created, and never
|
// 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: '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: '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: '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 },
|
{ 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
|
// 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_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_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.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 ──
|
// ── 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 },
|
{ 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>
|
||||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{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;
|
if (cols.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5">
|
<div key={group} className="rounded-md border border-border p-2.5">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
|
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
|
||||||
|
GetChaseSettings, SaveChaseSettings,
|
||||||
GetAudioMonitorPref,
|
GetAudioMonitorPref,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
@@ -48,7 +49,7 @@ import {
|
|||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
GetScpStatus, SetScpEnabled, DownloadScp,
|
GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp,
|
||||||
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
|
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
|
||||||
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
|
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||||
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
|
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
|
||||||
@@ -1718,6 +1719,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// DVK voice-keyer messages (F1–F6).
|
// DVK voice-keyer messages (F1–F6).
|
||||||
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||||
type DVKStat = { recording: boolean; playing: boolean; rec_slot: 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 [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
const [dvkErr, setDvkErr] = useState('');
|
const [dvkErr, setDvkErr] = useState('');
|
||||||
@@ -1819,7 +1822,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
qsl_sent: string; qsl_rcvd: string;
|
qsl_sent: string; qsl_rcvd: string;
|
||||||
lotw_sent: string; lotw_rcvd: string;
|
lotw_sent: string; lotw_rcvd: string;
|
||||||
eqsl_sent: string; eqsl_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;
|
qrzcom_confirmed: string;
|
||||||
hamlog_status: string; hamlog_confirmed: string;
|
hamlog_status: string; hamlog_confirmed: string;
|
||||||
};
|
};
|
||||||
@@ -1827,7 +1830,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
qsl_sent: '', qsl_rcvd: '',
|
qsl_sent: '', qsl_rcvd: '',
|
||||||
lotw_sent: '', lotw_rcvd: '',
|
lotw_sent: '', lotw_rcvd: '',
|
||||||
eqsl_sent: '', eqsl_rcvd: '',
|
eqsl_sent: '', eqsl_rcvd: '',
|
||||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
|
||||||
qrzcom_confirmed: '',
|
qrzcom_confirmed: '',
|
||||||
hamlog_status: '', hamlog_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 {}
|
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||||
finally { setScpBusy(false); }
|
finally { setScpBusy(false); }
|
||||||
};
|
};
|
||||||
|
const refreshScp = async () => { try { const s = await GetScpStatus(); setScp(s as any); } catch {} };
|
||||||
const downloadScp = async () => {
|
const downloadScp = async () => {
|
||||||
setScpBusy(true);
|
setScpBusy(true);
|
||||||
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||||
@@ -5420,6 +5424,38 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
||||||
once — badge, colour, filter chip, reference column. A "new band
|
once — badge, colour, filter chip, reference column. A "new band
|
||||||
+ new POTA" spot then reads NEW BAND alone. */}
|
+ 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')}>
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
|
||||||
<Checkbox checked={chasePotaOn}
|
<Checkbox checked={chasePotaOn}
|
||||||
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
||||||
@@ -5459,19 +5495,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
||||||
squares and chasing entities are different wants; they only share
|
squares and chasing entities are different wants; they only share
|
||||||
the PSK Reporter feed, which either one brings up. */}
|
the PSK Reporter feed, which either one brings up. */}
|
||||||
@@ -5681,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>
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||||||
{renderSelect('clublog_status', FULL_OPTIONS)}
|
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/* HRDLog */}
|
{/* HRDLog */}
|
||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
@@ -7279,6 +7310,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||||||
{t('scp.enable')}
|
{t('scp.enable')}
|
||||||
</label>
|
</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 && (
|
{scp.enabled && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
<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',
|
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 && 'border-l-[3px] border-warning')}>
|
||||||
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
||||||
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
|
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
|
||||||
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
|
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||||
<span className="px-1.5 rounded bg-muted shrink-0">{s.band}</span>
|
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</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="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
|
||||||
<span className="font-mono text-muted-foreground shrink-0">{(s.freq_hz / 1e6).toFixed(4)}</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)}
|
{badge && chip(badge.color, badge.label)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{done
|
{done
|
||||||
|
|||||||
+21
-21
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
|
// 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
|
// spoken for here and does not read as a status; a grid is
|
||||||
// never urgent the way a new entity 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 = {
|
export type SpotMarker = {
|
||||||
key: SpotMarkerKey;
|
key: SpotMarkerKey;
|
||||||
@@ -32,6 +32,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
|
|||||||
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
||||||
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
||||||
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
|
{ 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' },
|
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1086,3 +1086,10 @@
|
|||||||
.overflow-scroll {
|
.overflow-scroll {
|
||||||
overscroll-behavior: contain;
|
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).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.27.3';
|
export const APP_VERSION = '0.27.5';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+6
@@ -452,6 +452,8 @@ export function GetChaseNewGrids():Promise<boolean>;
|
|||||||
|
|
||||||
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
||||||
|
|
||||||
|
export function GetChaseSettings():Promise<main.ChaseSettings>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -1028,6 +1030,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveCabrilloFile():Promise<string>;
|
export function SaveCabrilloFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||||
|
|
||||||
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
||||||
@@ -1198,6 +1202,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetPassphrase(arg1:string):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 SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetSpotMax(arg1:number):Promise<void>;
|
export function SetSpotMax(arg1:number):Promise<void>;
|
||||||
|
|||||||
@@ -842,6 +842,10 @@ export function GetChaseNewSpots() {
|
|||||||
return window['go']['main']['App']['GetChaseNewSpots']();
|
return window['go']['main']['App']['GetChaseNewSpots']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChaseSettings() {
|
||||||
|
return window['go']['main']['App']['GetChaseSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1994,6 +1998,10 @@ export function SaveCabrilloFile() {
|
|||||||
return window['go']['main']['App']['SaveCabrilloFile']();
|
return window['go']['main']['App']['SaveCabrilloFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveChaseSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveChaseSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveClusterServer(arg1) {
|
export function SaveClusterServer(arg1) {
|
||||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2334,6 +2342,10 @@ export function SetPassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetScpClublogEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetScpEnabled(arg1) {
|
export function SetScpEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2430,6 +2430,20 @@ export namespace main {
|
|||||||
this.at = source["at"];
|
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 {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -3280,6 +3294,7 @@ export namespace main {
|
|||||||
eqsl_sent: string;
|
eqsl_sent: string;
|
||||||
eqsl_rcvd: string;
|
eqsl_rcvd: string;
|
||||||
clublog_status: string;
|
clublog_status: string;
|
||||||
|
clublog_confirmed: string;
|
||||||
hrdlog_status: string;
|
hrdlog_status: string;
|
||||||
qrzcom_status: string;
|
qrzcom_status: string;
|
||||||
qrzcom_confirmed: string;
|
qrzcom_confirmed: string;
|
||||||
@@ -3299,6 +3314,7 @@ export namespace main {
|
|||||||
this.eqsl_sent = source["eqsl_sent"];
|
this.eqsl_sent = source["eqsl_sent"];
|
||||||
this.eqsl_rcvd = source["eqsl_rcvd"];
|
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||||
this.clublog_status = source["clublog_status"];
|
this.clublog_status = source["clublog_status"];
|
||||||
|
this.clublog_confirmed = source["clublog_confirmed"];
|
||||||
this.hrdlog_status = source["hrdlog_status"];
|
this.hrdlog_status = source["hrdlog_status"];
|
||||||
this.qrzcom_status = source["qrzcom_status"];
|
this.qrzcom_status = source["qrzcom_status"];
|
||||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||||
@@ -3838,6 +3854,7 @@ export namespace main {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
|
clublog: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ScpStatus(source);
|
return new ScpStatus(source);
|
||||||
@@ -3848,6 +3865,7 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
this.count = source["count"];
|
this.count = source["count"];
|
||||||
this.updated = source["updated"];
|
this.updated = source["updated"];
|
||||||
|
this.clublog = source["clublog"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -3957,6 +3975,11 @@ export namespace main {
|
|||||||
new_county: boolean;
|
new_county: boolean;
|
||||||
county?: string;
|
county?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
|
new_state: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
|
unconf_state?: boolean;
|
||||||
new_pota: boolean;
|
new_pota: boolean;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
new_grid: boolean;
|
new_grid: boolean;
|
||||||
@@ -3983,6 +4006,11 @@ export namespace main {
|
|||||||
this.new_county = source["new_county"];
|
this.new_county = source["new_county"];
|
||||||
this.county = source["county"];
|
this.county = source["county"];
|
||||||
this.state = source["state"];
|
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.new_pota = source["new_pota"];
|
||||||
this.grid = source["grid"];
|
this.grid = source["grid"];
|
||||||
this.new_grid = source["new_grid"];
|
this.new_grid = source["new_grid"];
|
||||||
@@ -5245,6 +5273,8 @@ export namespace qso {
|
|||||||
qrzcom_qso_upload_status?: string;
|
qrzcom_qso_upload_status?: string;
|
||||||
qrzcom_qso_download_date?: string;
|
qrzcom_qso_download_date?: string;
|
||||||
qrzcom_qso_download_status?: string;
|
qrzcom_qso_download_status?: string;
|
||||||
|
clublog_qso_download_date?: string;
|
||||||
|
clublog_qso_download_status?: string;
|
||||||
contest_id?: string;
|
contest_id?: string;
|
||||||
srx?: number;
|
srx?: number;
|
||||||
stx?: number;
|
stx?: number;
|
||||||
@@ -5387,6 +5417,8 @@ export namespace qso {
|
|||||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||||
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
||||||
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
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.contest_id = source["contest_id"];
|
||||||
this.srx = source["srx"];
|
this.srx = source["srx"];
|
||||||
this.stx = source["stx"];
|
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_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||||
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||||
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||||
|
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
|
||||||
|
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
|
||||||
|
|
||||||
// --- Contest ---
|
// --- Contest ---
|
||||||
w("CONTEST_ID", q.ContestID)
|
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_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, 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},
|
{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_DATE", Kind: KindDate, Category: "QSL"},
|
||||||
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||||
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, 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",
|
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||||
|
"clublog_qso_download_date", "clublog_qso_download_status",
|
||||||
// Contest
|
// Contest
|
||||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||||
"check", "precedence", "arrl_sect",
|
"check", "precedence", "arrl_sect",
|
||||||
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||||
|
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
|
||||||
|
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
|
||||||
|
|
||||||
// Contest
|
// Contest
|
||||||
q.ContestID = rec["contest_id"]
|
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)
|
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,
|
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||||
// skipping those already applied. Intentionally minimal in-house system
|
// skipping those already applied. Intentionally minimal in-house system
|
||||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
// (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
|
continue
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(stmt); err != nil {
|
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()
|
_ = tx.Rollback()
|
||||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
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
|
||||||
|
}
|
||||||
+131
-6
@@ -123,6 +123,10 @@ type QSO struct {
|
|||||||
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
|
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
|
||||||
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
|
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
|
||||||
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,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 ---
|
// --- Contest ---
|
||||||
ContestID string `json:"contest_id,omitempty"`
|
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,
|
hrdlog_qso_upload_date, hrdlog_qso_upload_status,
|
||||||
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
|
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
|
||||||
qrzcom_qso_download_date, qrzcom_qso_download_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,
|
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,
|
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,
|
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.HRDLogUploadDate, q.HRDLogUploadStatus,
|
||||||
q.QRZComUploadDate, q.QRZComUploadStatus,
|
q.QRZComUploadDate, q.QRZComUploadStatus,
|
||||||
q.QRZComDownloadDate, q.QRZComDownloadStatus,
|
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.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.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,
|
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_upload_status": true,
|
||||||
"qrzcom_qso_download_status": true,
|
"qrzcom_qso_download_status": true,
|
||||||
"clublog_qso_upload_status": true,
|
"clublog_qso_upload_status": true,
|
||||||
|
"clublog_qso_download_status": true,
|
||||||
"hrdlog_qso_upload_status": true,
|
"hrdlog_qso_upload_status": true,
|
||||||
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
|
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
|
||||||
"qsl_sent_date": true,
|
"qsl_sent_date": true,
|
||||||
@@ -794,6 +801,7 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"qrzcom_qso_upload_date": true,
|
"qrzcom_qso_upload_date": true,
|
||||||
"qrzcom_qso_download_date": true,
|
"qrzcom_qso_download_date": true,
|
||||||
"clublog_qso_upload_date": true,
|
"clublog_qso_upload_date": true,
|
||||||
|
"clublog_qso_download_date": true,
|
||||||
"hrdlog_qso_upload_date": true,
|
"hrdlog_qso_upload_date": true,
|
||||||
// My station / operator
|
// My station / operator
|
||||||
"station_callsign": true,
|
"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,
|
"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,
|
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||||
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": 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
|
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
|
||||||
// also chronological — "before 20240101" works with no date parsing.
|
// also chronological — "before 20240101" works with no date parsing.
|
||||||
"qsl_sent_date": true, "qsl_rcvd_date": true,
|
"qsl_sent_date": true, "qsl_rcvd_date": true,
|
||||||
"lotw_sent_date": true, "lotw_rcvd_date": true,
|
"lotw_sent_date": true, "lotw_rcvd_date": true,
|
||||||
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
|
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
|
||||||
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_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,
|
"contest_id": true, "srx": true, "stx": true,
|
||||||
"prop_mode": true, "sat_name": true,
|
"prop_mode": true, "sat_name": true,
|
||||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": 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
|
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
|
||||||
// one bucket. Callers must normalise their lookup mode the same way.
|
// 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) {
|
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,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||||
WHERE band IS NOT NULL AND band != ''
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// One pass, used by the cluster spot colouring to flag "already worked this
|
||||||
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
|
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
|
||||||
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
|
||||||
// are considered. Empty keys (unresolvable state/county) are skipped.
|
// 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) {
|
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,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
|
`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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2752,6 +2791,38 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
|
|||||||
return out, rows.Err()
|
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
|
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
|
||||||
// instead of a manager, per isRouting.
|
// 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.
|
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
|
||||||
type MatchIndex struct {
|
type MatchIndex struct {
|
||||||
byMode map[string][]matchRef // call|band|canonMode → refs
|
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
|
// 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
|
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
|
||||||
// another (e.g. TM2Q).
|
// another (e.g. TM2Q).
|
||||||
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
|
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`
|
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
|
||||||
var args []any
|
var args []any
|
||||||
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
|
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) {
|
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
|
||||||
mk := matchKeyMode(call, band, mode)
|
mk := matchKeyMode(call, band, mode)
|
||||||
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
|
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
|
// 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)
|
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) {
|
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
|
||||||
var best int64
|
var best int64
|
||||||
bestD := window + time.Second
|
bestD := window + time.Second
|
||||||
@@ -3362,6 +3445,30 @@ type SlotStats struct {
|
|||||||
// only way those two agree.
|
// only way those two agree.
|
||||||
const ConfirmedValues = "('Y','V')"
|
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.
|
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
|
||||||
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
|
||||||
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||||
@@ -3419,6 +3526,7 @@ var confirmedCols = map[string]bool{
|
|||||||
"qsl_rcvd": true,
|
"qsl_rcvd": true,
|
||||||
"eqsl_rcvd": true,
|
"eqsl_rcvd": true,
|
||||||
"qrzcom_qso_download_status": true,
|
"qrzcom_qso_download_status": true,
|
||||||
|
"clublog_qso_download_status": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
|
// 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
|
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.
|
// ClearQRZConfirmed takes back a QRZ confirmation.
|
||||||
//
|
//
|
||||||
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
|
// 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
|
hrdlogDate, hrdlogStatus sql.NullString
|
||||||
qrzcomDate, qrzcomStatus sql.NullString
|
qrzcomDate, qrzcomStatus sql.NullString
|
||||||
qrzcomDlDate, qrzcomDlStatus sql.NullString
|
qrzcomDlDate, qrzcomDlStatus sql.NullString
|
||||||
|
clublogDlDate, clublogDlStatus sql.NullString
|
||||||
contestID sql.NullString
|
contestID sql.NullString
|
||||||
srx, stx sql.NullInt64
|
srx, stx sql.NullInt64
|
||||||
srxStr, stxStr sql.NullString
|
srxStr, stxStr sql.NullString
|
||||||
@@ -3597,6 +3719,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
&hrdlogDate, &hrdlogStatus,
|
&hrdlogDate, &hrdlogStatus,
|
||||||
&qrzcomDate, &qrzcomStatus,
|
&qrzcomDate, &qrzcomStatus,
|
||||||
&qrzcomDlDate, &qrzcomDlStatus,
|
&qrzcomDlDate, &qrzcomDlStatus,
|
||||||
|
&clublogDlDate, &clublogDlStatus,
|
||||||
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
|
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
|
||||||
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
|
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
|
||||||
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
||||||
@@ -3694,6 +3817,8 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
q.QRZComUploadStatus = qrzcomStatus.String
|
q.QRZComUploadStatus = qrzcomStatus.String
|
||||||
q.QRZComDownloadDate = qrzcomDlDate.String
|
q.QRZComDownloadDate = qrzcomDlDate.String
|
||||||
q.QRZComDownloadStatus = qrzcomDlStatus.String
|
q.QRZComDownloadStatus = qrzcomDlStatus.String
|
||||||
|
q.ClublogDownloadDate = clublogDlDate.String
|
||||||
|
q.ClublogDownloadStatus = clublogDlStatus.String
|
||||||
q.ContestID = contestID.String
|
q.ContestID = contestID.String
|
||||||
if srx.Valid {
|
if srx.Valid {
|
||||||
v := int(srx.Int64)
|
v := int(srx.Int64)
|
||||||
|
|||||||
+74
-6
@@ -13,6 +13,7 @@ package scp
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +31,14 @@ import (
|
|||||||
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
|
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
|
||||||
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
|
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 cacheFile = "MASTER.SCP"
|
||||||
|
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed
|
||||||
|
|
||||||
// Result is the two suggestion lists for a typed fragment.
|
// Result is the two suggestion lists for a typed fragment.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -44,6 +53,7 @@ type Manager struct {
|
|||||||
updated time.Time // when the cache was last refreshed
|
updated time.Time // when the cache was last refreshed
|
||||||
dir string
|
dir string
|
||||||
client *http.Client
|
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.
|
// 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) 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() {
|
func (m *Manager) loadCache() {
|
||||||
data, err := os.ReadFile(m.path())
|
data, _ := os.ReadFile(m.path())
|
||||||
if err != nil {
|
var extra []byte
|
||||||
|
if m.clublog.Load() {
|
||||||
|
extra, _ = os.ReadFile(m.clublogPath())
|
||||||
|
}
|
||||||
|
if data == nil && extra == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.parse(data)
|
m.parse(data, extra)
|
||||||
if fi, e := os.Stat(m.path()); e == nil {
|
if fi, e := os.Stat(m.path()); e == nil {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.updated = fi.ModTime()
|
m.updated = fi.ModTime()
|
||||||
@@ -91,7 +117,17 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("scp: read: %w", err)
|
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 {
|
if n == 0 {
|
||||||
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
|
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
|
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.
|
// 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)
|
seen := make(map[string]struct{}, 1<<17)
|
||||||
|
for _, data := range datasets {
|
||||||
|
if len(data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
@@ -112,7 +179,7 @@ func (m *Manager) parse(data []byte) int {
|
|||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue // blank / header comment
|
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).
|
// against stray trailing fields).
|
||||||
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
|
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
|
||||||
line = line[:i]
|
line = line[:i]
|
||||||
@@ -122,6 +189,7 @@ func (m *Manager) parse(data []byte) int {
|
|||||||
}
|
}
|
||||||
seen[line] = struct{}{}
|
seen[line] = struct{}{}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if len(seen) == 0 {
|
if len(seen) == 0 {
|
||||||
return 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 }},
|
{"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_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_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_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 }},
|
{"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 }},
|
{"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 (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.27.3"
|
appVersion = "0.27.5"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user