Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8790b98766 | ||
|
|
8846cba40c | ||
|
|
0cc6ad686a | ||
|
|
612cb67438 | ||
|
|
cd5d8b503b | ||
|
|
aa59431403 | ||
|
|
269701410f | ||
|
|
3cb8096141 | ||
|
|
1f667e4a4b | ||
|
|
d5f7e290ab |
@@ -329,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
|
||||||
@@ -356,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"
|
||||||
@@ -421,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
|
||||||
@@ -436,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
|
||||||
@@ -1129,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
|
||||||
@@ -1395,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
|
||||||
@@ -3146,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.
|
||||||
@@ -3157,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)
|
||||||
}
|
}
|
||||||
@@ -3164,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 {
|
||||||
@@ -6889,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",
|
||||||
@@ -6905,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",
|
||||||
@@ -10898,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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10917,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,
|
||||||
)
|
)
|
||||||
@@ -10931,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]
|
||||||
@@ -10954,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)),
|
||||||
@@ -11006,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)
|
||||||
@@ -12935,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))
|
||||||
}
|
}
|
||||||
@@ -16098,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
|
||||||
@@ -18549,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"
|
||||||
|
|||||||
+32
-2
@@ -1,16 +1,46 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.27.4",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"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 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.",
|
"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."
|
"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": [
|
"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 : 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.",
|
"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."
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
|
|||||||
|
|
||||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full min-h-0">
|
// 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" />
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||||
{/* Basemap picker, MainMap's own vocabulary. */}
|
{/* 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">
|
<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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -49,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,
|
||||||
@@ -1822,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;
|
||||||
};
|
};
|
||||||
@@ -1830,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: '',
|
||||||
});
|
});
|
||||||
@@ -1884,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 {}
|
||||||
@@ -5708,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">
|
||||||
@@ -7306,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}>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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
+2
@@ -1202,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>;
|
||||||
|
|||||||
@@ -2342,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3294,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;
|
||||||
@@ -3313,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"];
|
||||||
@@ -3852,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);
|
||||||
@@ -3862,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 {
|
||||||
@@ -5269,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;
|
||||||
@@ -5411,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
|
||||||
|
}
|
||||||
+43
-3
@@ -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,
|
||||||
@@ -3264,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
|
||||||
@@ -3299,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 != "" {
|
||||||
@@ -3327,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
|
||||||
@@ -3342,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
|
||||||
@@ -3504,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
|
||||||
@@ -3560,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,
|
||||||
@@ -3638,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
|
||||||
@@ -3682,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,
|
||||||
@@ -3779,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