Compare commits

...
10 Commits
Author SHA1 Message Date
rouggy 8790b98766 chore: release v0.27.5 2026-08-31 13:38:18 +02:00
rouggy 8846cba40c feat(confirmations): a Club Log received default, No out of the box
The Confirmations page's Club Log row gains its received side, applied to
every new QSO the way QRZ.com's already is — so the match download's Y
stands out against a ledger of explicit Ns.
2026-08-31 11:59:14 +02:00
rouggy 0cc6ad686a style(columns): the picker lists each group alphabetically
The catalog's order is the grid's column order, which appends new columns
at the end — so the two ClubLog rows sat at opposite ends of the Uploads
box. Sorted for display only; the grid keeps its own order.
2026-08-31 11:39:54 +02:00
rouggy 612cb67438 fix(kpa): a settings save must not flick the KPA500's power switch — and the maps stay under the menus
startAmps tore down and rebuilt every amplifier client on any settings
save, and closing a COM port makes the Windows driver drop DTR/RTS. On a
KPA500 those lines ARE the power switch — so 'save' meant 'amplifier off
twenty seconds later', exactly F1TPL's report, and the fix that held the
lines while OPEN couldn't survive a close. An amplifier whose config is
unchanged now keeps its running client across saves; the others still
rebuild.

Also: isolate on the FT Map panel — Leaflet's z-1000 panes were painting
over the menu bar and the Preferences dialog, as the grid map's own
comment warned.
2026-08-31 11:39:02 +02:00
rouggy cd5d8b503b fix(db): a migration aimed at a table a database no longer holds must not kill the startup
The 0031 clublog ALTER hit split settings databases (their qso table
moved to the logbook years of QSOs ago) and the whole open failed —
silently, because the error went to a println the GUI subsystem
discards. The app then ran with no settings store: every panel showed
defaults, 'db not initialized' in Preferences, and operators read it as
their database being lost. Nothing was ever touched: the failed
migration rolled back on every attempt.

The SQLite migration path now tolerates what the MySQL path always has —
plus the one case it never meets: ALTER/CREATE INDEX/DROP on a table
this database legitimately does not hold. And a failed open is written
to the rotating log, where the next such morning can actually be
diagnosed.
2026-08-31 11:17:45 +02:00
rouggy aa59431403 fix(changelog): scrub the stash-pop conflict and move the Club Log work to 0.27.5
A stash pop during a test-on-parent check left conflict markers inside
changelog.json and the next commit carried them. The 0.27.4 release had
already shipped with its seven entries, so the two Club Log entries that
landed after it open the 0.27.5 block, per the released-block rule.
2026-08-31 11:13:11 +02:00
rouggy 269701410f fix(profiles): the watchlist's Club Log enrichment follows a profile switch
The guard test said so: startWatchlistClubLog ran at startup only, so
switching profiles kept the previous profile's enrichment settings alive.
2026-08-31 10:16:16 +02:00
rouggy 3cb8096141 feat(clublog): the matches come home, and Club Log lends its call list
Two new QSO columns, clublog_qso_download_status/date — a Club Log MATCH,
the service's own confirmation (both stations uploaded the QSO, paired
within 15 minutes). Full promoted-column lockstep: migration 0031, repo
insert/scan, ADIF dictionary + import + export (app-defined
CLUBLOG_QSO_DOWNLOAD_*), table columns, filter builder, bulk edit and the
QSO editor's Club Log row.

The QSL Manager's Club Log entry now actually downloads: getmatches.php
with the existing account settings and the embedded application key,
incremental via the match-completion date filter, matched call+band+mode
±15 min with a mode-blind fallback because Club Log reports 'false' for
modes it cannot infer. Matches always exist on both sides, so unmatched
ones are listed rather than skeleton-added.

And Super Check Partial can merge Club Log's weekly SCP list (~180k calls
worked on the air in the last 3 years) with MASTER.SCP — an opt-in
checkbox under the SCP setting.
2026-08-31 10:14:52 +02:00
rouggy 1f667e4a4b chore: release v0.27.4 2026-08-30 22:24:05 +02:00
rouggy d5f7e290ab chore(changelog): the FTx menu, the FT Map and the map fixes enter the 0.27.4 block 2026-08-30 22:23:30 +02:00
23 changed files with 591 additions and 81 deletions
+174 -9
View File
@@ -329,6 +329,7 @@ const (
keyClusterSpotMax = "cluster.spot_max" // how many spots the list holds at once
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
keyScpClublog = "scp.clublog" // merge Club Log's weekly SCP list (~180k calls)
// Web publishing: the whole config as one JSON blob. A single key rather than
// twenty: it is read and written as a unit by one settings panel, and the FTP
@@ -356,6 +357,7 @@ const (
keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
keyQSLDefaultClublogStatus = "qsl.clublog_status"
keyQSLDefaultClublogCfm = "qsl.clublog_confirmed"
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
@@ -421,6 +423,8 @@ const (
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull
keyExtEQSLLastDownload = "extsvc.eqsl.last_download" // YYYY-MM-DD of last eQSL inbox pull
keyExtClublogLastDownload = "extsvc.clublog.last_download" // YYYY-MM-DD of last Club Log matches pull
)
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -436,6 +440,7 @@ type QSLDefaults struct {
EQSLSent string `json:"eqsl_sent"`
EQSLRcvd string `json:"eqsl_rcvd"`
ClublogStatus string `json:"clublog_status"`
ClublogCfm string `json:"clublog_confirmed"` // Club Log match/download status
HRDLogStatus string `json:"hrdlog_status"`
QRZComStatus string `json:"qrzcom_status"`
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
@@ -1129,7 +1134,9 @@ func (a *App) startup(ctx context.Context) {
conn, err := db.Open(a.dbPath)
if err != nil {
a.startupErr = "cannot open db: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
// In the rotating log too: the GUI subsystem discards stdout, and this
// exact failure once hid for a whole morning behind a println.
applog.Printf("startup: %s", a.startupErr)
return
}
a.db = conn
@@ -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
// enabled, auto-(re)download it if missing or older than a week.
a.scp = scp.NewManager(dataDir)
if v, _ := a.settings.Get(a.ctx, keyScpClublog); v == "1" {
a.scp.SetClublogEnabled(true)
}
go func() {
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
return
@@ -3146,6 +3156,7 @@ type ScpStatus struct {
Enabled bool `json:"enabled"`
Count int `json:"count"`
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
Clublog bool `json:"clublog"` // Club Log source merged in
}
// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
@@ -3157,6 +3168,7 @@ func (a *App) GetScpStatus() ScpStatus {
}
if a.scp != nil {
st.Count = a.scp.Count()
st.Clublog = a.scp.ClublogEnabled()
if u := a.scp.Updated(); !u.IsZero() {
st.Updated = u.UTC().Format(time.RFC3339)
}
@@ -3164,6 +3176,34 @@ func (a *App) GetScpStatus() ScpStatus {
return st
}
// SetScpClublogEnabled merges (or stops merging) Club Log's weekly SCP list
// into the Super Check Partial call list, and refreshes the download so the
// change is visible without waiting for the weekly cycle.
func (a *App) SetScpClublogEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if err := a.settings.Set(a.ctx, keyScpClublog, boolStr(on)); err != nil {
return err
}
if a.scp != nil {
a.scp.SetClublogEnabled(on)
if on {
go func() {
if n, err := a.scp.Download(context.Background()); err == nil {
applog.Printf("scp: downloaded %d callsigns (with Club Log list)", n)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "scp:updated")
}
} else {
applog.Printf("scp: download failed: %v", err)
}
}()
}
}
return nil
}
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
// download when the list is missing or stale.
func (a *App) SetScpEnabled(on bool) error {
@@ -6889,6 +6929,7 @@ var bulkFieldColumns = map[string]string{
"qrz_sent": "qrzcom_qso_upload_status",
"qrz_rcvd": "qrzcom_qso_download_status",
"clublog_sent": "clublog_qso_upload_status",
"clublog_rcvd": "clublog_qso_download_status",
"hrdlog_sent": "hrdlog_qso_upload_status",
// Old ids kept so anything holding one keeps working.
"qrz_upload": "qrzcom_qso_upload_status",
@@ -6905,6 +6946,7 @@ var bulkFieldColumns = map[string]string{
"qrz_sent_date": "qrzcom_qso_upload_date",
"qrz_rcvd_date": "qrzcom_qso_download_date",
"clublog_sent_date": "clublog_qso_upload_date",
"clublog_rcvd_date": "clublog_qso_download_date",
"hrdlog_sent_date": "hrdlog_qso_upload_date",
// My station / operator
"station_callsign": "station_callsign",
@@ -10898,7 +10940,7 @@ func defaultQSLDefaults() QSLDefaults {
QSLSent: "N", QSLRcvd: "N",
EQSLSent: "R", EQSLRcvd: "N",
LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", HRDLogStatus: "R",
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
QRZComStatus: "R", QRZComCfm: "N",
}
}
@@ -10917,7 +10959,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
)
@@ -10931,6 +10973,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out.EQSLSent = m[keyQSLDefaultEQSLSent]
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
out.ClublogCfm = m[keyQSLDefaultClublogCfm]
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
@@ -10954,6 +10997,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
keyQSLDefaultClublogCfm: strings.ToUpper(strings.TrimSpace(d.ClublogCfm)),
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
@@ -11006,6 +11050,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
fill(&q.EQSLSent, d.EQSLSent)
fill(&q.EQSLRcvd, d.EQSLRcvd)
fill(&q.ClublogUploadStatus, d.ClublogStatus)
fill(&q.ClublogDownloadStatus, d.ClublogCfm)
fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
fill(&q.QRZComUploadStatus, d.QRZComStatus)
fill(&q.QRZComDownloadStatus, d.QRZComCfm)
@@ -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"))
}
case extsvc.ServiceClublog:
sinceDate := resolveSince(keyExtClublogLastDownload)
if sinceDate != "" {
emit("Downloading Club Log matches (matched since " + sinceDate + ")…")
} else {
emit("Downloading ALL Club Log matches (full pull — the date filter is faster next time)…")
}
matches, err := extsvc.DownloadClublogMatches(ctx, nil, cfg.Clublog, sinceDate)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
return
}
mIdx, kerr := a.qso.BuildMatchIndex(ctx, a.uploadOwnerCall(extsvc.ServiceClublog))
if kerr != nil {
emit("Error reading local log: " + kerr.Error())
done(matched, total)
return
}
// Club Log pairs QSOs within ±15 minutes, same as LoTW.
const clublogMatchWindow = 15 * time.Minute
today := time.Now().UTC().Format("20060102")
// A Club Log match is Club Log's own kind of confirmation, so NEW is
// judged only against other Club Log matches.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"clublog_qso_download_status"})
var items []ConfirmationItem
var unmatched []string
for _, m := range matches {
if ctx.Err() != nil {
return
}
total++
id, found := int64(0), false
if m.Band != "" && m.Mode != "" {
id, found = mIdx.Match(m.Callsign, m.Band, m.Mode, m.When, clublogMatchWindow)
}
if !found && m.Band != "" {
// Club Log reports "false" for modes it can't infer — fall back to
// call+band+time, still unambiguous inside a 15-minute window.
id, found = mIdx.MatchBand(m.Callsign, m.Band, m.When, clublogMatchWindow)
}
if found {
if e := a.qso.MarkClublogConfirmed(ctx, id, today); e == nil {
matched++
}
} else {
unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
m.Callsign, m.When.Format("2006-01-02 15:04Z"), m.Band, m.Mode))
}
it := ConfirmationItem{
Callsign: m.Callsign,
QSODate: m.When.Format(time.RFC3339),
Band: m.Band, Mode: m.Mode,
}
if m.DXCC != 0 {
it.Country = dxcc.NameForDXCC(m.DXCC)
it.NewDXCC = !sets.DXCC[m.DXCC]
it.NewBand = !sets.Band[qso.BandKey(m.DXCC, m.Band)]
it.NewMode = !sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)]
it.NewSlot = !sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)]
sets.DXCC[m.DXCC] = true
sets.Band[qso.BandKey(m.DXCC, m.Band)] = true
sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)] = true
sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)] = true
}
items = append(items, it)
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
emit(fmt.Sprintf("Matched %d of %d Club Log match(es)", matched, total))
if addNotFound && len(unmatched) > 0 {
// A match means BOTH sides uploaded this QSO — one Club Log can't find
// locally was deleted or edited here, not missed; adding a skeleton
// row from call/band/time alone would just create a duplicate shadow.
emit("Club Log matches carry too little detail to add missing QSOs — unmatched ones are listed below.")
}
for _, u := range unmatched {
emit(" ⚠ no local QSO for: " + u)
}
if a.settings != nil {
a.setSetting(a.profileScope()+keyExtClublogLastDownload, time.Now().UTC().Format("2006-01-02"))
}
default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
}
@@ -16098,6 +16227,10 @@ func (a *App) reloadAfterProfileSwitch() {
a.restartAsync("tuner", a.startTunerGenius)
a.restartAsync("psu", a.startPSU)
a.startQSORecorderIfEnabled()
// The watchlist's Club Log enrichment (OQRS/QSO-count badges) reads
// per-profile settings too — rebuilt so a switch doesn't keep showing the
// previous profile's view of who is worth chasing.
a.startWatchlistClubLog()
}
// DuplicateProfile clones an existing profile under newName. Useful when
@@ -18549,19 +18682,51 @@ func (a *App) startAmps() {
old := a.ampInsts
a.ampInsts = map[string]*ampInst{}
a.ampsMu.Unlock()
a.pgxl, a.spe, a.acom = nil, nil, nil
list, err := a.GetAmplifiers()
if err != nil {
for _, inst := range old {
go inst.stopAll()
}
return
}
// An amplifier whose configuration did not change keeps its RUNNING client
// across a settings save. Rebuilding closes the serial port, and closing a
// COM port makes the Windows driver drop DTR/RTS — which on a KPA500 is the
// POWER SWITCH: every save of any Settings page was switching the amplifier
// off (F1TPL's report). Reuse also spares the other amps a reconnect blink.
reused := map[string]bool{}
for _, c := range list {
if !c.Enabled {
continue
}
if o, ok := old[c.ID]; ok && o.cfg == c {
reused[c.ID] = true
if o.pgxl != nil && a.pgxl == nil {
a.pgxl = o.pgxl
}
if o.spe != nil && a.spe == nil {
a.spe = o.spe
}
if o.acom != nil && a.acom == nil {
a.acom = o.acom
}
a.ampsMu.Lock()
a.ampInsts[c.ID] = o
a.ampsMu.Unlock()
}
}
for id, inst := range old {
if reused[id] {
continue
}
// Stop() can block up to the dial timeout waiting for an in-progress
// connect; tear down in the background so saving Settings (this runs on
// the Wails RPC goroutine) doesn't freeze the UI.
go inst.stopAll()
}
a.pgxl, a.spe, a.acom = nil, nil, nil
list, err := a.GetAmplifiers()
if err != nil {
return
}
for _, c := range list {
if !c.Enabled {
if !c.Enabled || reused[c.ID] {
continue
}
isPGXL := c.Type == "" || c.Type == "pgxl"
+32 -2
View File
@@ -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 Logs 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 KPA500s 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 lair ces 3 dernières années) avec MASTER.SCP.",
"Corrigé : lapplication 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 sapplique 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 nimporte quelle page des réglages n’éteint plus lampli. Une sauvegarde reconstruisait chaque connexion dampli, et fermer le port COM relâche DTR/RTS — linterrupteur dalimentation 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 grids 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 grids 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é sallume 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 saffichent 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 saffichent 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: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'clublog_rcvd', label: 'bulk.fClublogRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_rcvd_date', label: 'bulk.fClublogRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
// HAMLOG.online: no promoted column, written into extras_json (see
+4 -1
View File
@@ -119,7 +119,10 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
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" />
{/* 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">
@@ -86,6 +86,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
{ value: 'clublog_qso_download_status', label: 'fltb.fClublogRcvd', type: 'text' },
{ value: 'clublog_qso_download_date', label: 'fltb.fClublogRcvdDate', type: 'adifdate' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
// HAMLOG.online: no promoted column, filtered through extras_json (see
+1 -1
View File
@@ -71,7 +71,7 @@ const CONFIRMATIONS: ConfDef[] = [
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
];
// i18n label keys for confirmation channels whose label has translatable words
+7 -1
View File
@@ -235,6 +235,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── Contest ──
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
@@ -784,7 +786,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
</div>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
// Alphabetical within the group: the catalog's order is the
// GRID's column order, which appends newcomers at the end — so
// the two ClubLog rows sat at opposite ends of the Uploads box.
const cols = COL_CATALOG.filter((c) => c.group === group)
.slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
if (cols.length === 0) return null;
return (
<div key={group} className="rounded-md border border-border p-2.5">
+15 -4
View File
@@ -49,7 +49,7 @@ import {
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
GetScpStatus, SetScpEnabled, DownloadScp,
GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp,
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
@@ -1822,7 +1822,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
qsl_sent: string; qsl_rcvd: string;
lotw_sent: string; lotw_rcvd: string;
eqsl_sent: string; eqsl_rcvd: string;
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
qrzcom_confirmed: string;
hamlog_status: string; hamlog_confirmed: string;
};
@@ -1830,7 +1830,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
qsl_sent: '', qsl_rcvd: '',
lotw_sent: '', lotw_rcvd: '',
eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
qrzcom_confirmed: '',
hamlog_status: '', hamlog_confirmed: '',
});
@@ -1884,6 +1884,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
finally { setScpBusy(false); }
};
const refreshScp = async () => { try { const s = await GetScpStatus(); setScp(s as any); } catch {} };
const downloadScp = async () => {
setScpBusy(true);
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
@@ -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>
{renderSelect('clublog_status', FULL_OPTIONS)}
</div>
<div />
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
{renderSelect('clublog_confirmed', FULL_OPTIONS)}
</div>
</div>
{/* HRDLog */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
@@ -7306,6 +7310,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
{t('scp.enable')}
</label>
{scp.enabled && (
<label className="flex items-center gap-2 text-sm cursor-pointer pl-6" title={t('scp.clublogHint')}>
<Checkbox checked={!!(scp as any).clublog} disabled={scpBusy}
onCheckedChange={(c) => { void SetScpClublogEnabled(!!c).then(() => refreshScp()); }} />
{t('scp.clublog')}
</label>
)}
{scp.enabled && (
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.3';
export const APP_VERSION = '0.27.5';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -1202,6 +1202,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
export function SetSpotMax(arg1:number):Promise<void>;
+4
View File
@@ -2342,6 +2342,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
export function SetScpClublogEnabled(arg1) {
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
}
export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1);
}
+8
View File
@@ -3294,6 +3294,7 @@ export namespace main {
eqsl_sent: string;
eqsl_rcvd: string;
clublog_status: string;
clublog_confirmed: string;
hrdlog_status: string;
qrzcom_status: string;
qrzcom_confirmed: string;
@@ -3313,6 +3314,7 @@ export namespace main {
this.eqsl_sent = source["eqsl_sent"];
this.eqsl_rcvd = source["eqsl_rcvd"];
this.clublog_status = source["clublog_status"];
this.clublog_confirmed = source["clublog_confirmed"];
this.hrdlog_status = source["hrdlog_status"];
this.qrzcom_status = source["qrzcom_status"];
this.qrzcom_confirmed = source["qrzcom_confirmed"];
@@ -3852,6 +3854,7 @@ export namespace main {
enabled: boolean;
count: number;
updated?: string;
clublog: boolean;
static createFrom(source: any = {}) {
return new ScpStatus(source);
@@ -3862,6 +3865,7 @@ export namespace main {
this.enabled = source["enabled"];
this.count = source["count"];
this.updated = source["updated"];
this.clublog = source["clublog"];
}
}
export class SecretStatus {
@@ -5269,6 +5273,8 @@ export namespace qso {
qrzcom_qso_upload_status?: string;
qrzcom_qso_download_date?: string;
qrzcom_qso_download_status?: string;
clublog_qso_download_date?: string;
clublog_qso_download_status?: string;
contest_id?: string;
srx?: number;
stx?: number;
@@ -5411,6 +5417,8 @@ export namespace qso {
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
this.clublog_qso_download_date = source["clublog_qso_download_date"];
this.clublog_qso_download_status = source["clublog_qso_download_status"];
this.contest_id = source["contest_id"];
this.srx = source["srx"];
this.stx = source["stx"];
+2
View File
@@ -267,6 +267,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
// --- Contest ---
w("CONTEST_ID", q.ContestID)
+3
View File
@@ -161,6 +161,9 @@ var Fields = []FieldDef{
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
// App-defined pair (no standard ADIF field): Club Log's log-match download.
{Name: "CLUBLOG_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "CLUBLOG_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
+3
View File
@@ -287,6 +287,7 @@ var adifPromoted = stringSet(
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
"clublog_qso_download_date", "clublog_qso_download_status",
// Contest
"contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect",
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
// Contest
q.ContestID = rec["contest_id"]
+29
View File
@@ -240,6 +240,25 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
}
// isIgnorableSQLiteDDLError reports a benign DDL failure: the change is
// already there, or the statement shapes a table this database does not hold.
// Scoped to shaping statements only — a CREATE TABLE or data statement that
// fails must still fail the migration.
func isIgnorableSQLiteDDLError(err error, stmt string) bool {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "duplicate column name") || strings.Contains(msg, "already exists") {
return true
}
if strings.Contains(msg, "no such table") {
head := strings.ToLower(strings.TrimSpace(stmt))
return strings.HasPrefix(head, "alter table") ||
strings.HasPrefix(head, "create index") ||
strings.HasPrefix(head, "create unique index") ||
strings.HasPrefix(head, "drop ")
}
return false
}
// migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system
// (no external dependency). translate, when non-nil, rewrites each statement
@@ -352,6 +371,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string,
continue
}
if _, err := tx.Exec(stmt); err != nil {
// Same self-healing as the MySQL path, plus one case it never
// meets: a table-shaping statement aimed at a table this
// database legitimately does not have. A split settings
// database dropped its qso table when the QSOs moved to the
// logbook, but its role is still RoleAll — so a later
// "ALTER TABLE qso ADD COLUMN" must be a no-op there, not a
// failure that silently kills the whole startup (v0.27.4+).
if isIgnorableSQLiteDDLError(err, stmt) {
continue
}
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
@@ -0,0 +1,4 @@
-- Club Log log-matching (getmatches.php): a QSO both sides uploaded to Club
-- Log is a confirmation in its own right. Mirrors qrzcom_qso_download_*.
ALTER TABLE qso ADD COLUMN clublog_qso_download_date TEXT;
ALTER TABLE qso ADD COLUMN clublog_qso_download_status TEXT;
+126
View File
@@ -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
View File
@@ -123,6 +123,10 @@ type QSO struct {
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
// Club Log match download (getmatches.php) — app-defined, no standard ADIF
// field exists; exported/imported as CLUBLOG_QSO_DOWNLOAD_*.
ClublogDownloadDate string `json:"clublog_qso_download_date,omitempty"`
ClublogDownloadStatus string `json:"clublog_qso_download_status,omitempty"`
// --- Contest ---
ContestID string `json:"contest_id,omitempty"`
@@ -261,6 +265,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
hrdlog_qso_upload_date, hrdlog_qso_upload_status,
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
qrzcom_qso_download_date, qrzcom_qso_download_status,
clublog_qso_download_date, clublog_qso_download_status,
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
@@ -336,6 +341,7 @@ func (q *QSO) args() []any {
q.HRDLogUploadDate, q.HRDLogUploadStatus,
q.QRZComUploadDate, q.QRZComUploadStatus,
q.QRZComDownloadDate, q.QRZComDownloadStatus,
q.ClublogDownloadDate, q.ClublogDownloadStatus,
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
@@ -783,6 +789,7 @@ var bulkEditableCols = map[string]bool{
"qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true,
"clublog_qso_download_status": true,
"hrdlog_qso_upload_status": true,
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
"qsl_sent_date": true,
@@ -794,6 +801,7 @@ var bulkEditableCols = map[string]bool{
"qrzcom_qso_upload_date": true,
"qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true,
"clublog_qso_download_date": true,
"hrdlog_qso_upload_date": true,
// My station / operator
"station_callsign": true,
@@ -1339,14 +1347,16 @@ var filterableColumns = map[string]bool{
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
"clublog_qso_upload_status": true, "clublog_qso_download_status": true,
"hrdlog_qso_upload_status": true,
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
// also chronological — "before 20240101" works with no date parsing.
"qsl_sent_date": true, "qsl_rcvd_date": true,
"lotw_sent_date": true, "lotw_rcvd_date": true,
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true, "hrdlog_qso_upload_date": true,
"clublog_qso_upload_date": true, "clublog_qso_download_date": true,
"hrdlog_qso_upload_date": true,
"contest_id": true, "srx": true, "stx": true,
"prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
@@ -3264,6 +3274,7 @@ type matchRef struct {
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
type MatchIndex struct {
byMode map[string][]matchRef // call|band|canonMode → refs
byBand map[string][]matchRef // call|band → refs (mode-blind fallback)
}
// canonMode folds the phone sidebands into a single "SSB" bucket; every other
@@ -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
// another (e.g. TM2Q).
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
idx := &MatchIndex{byMode: map[string][]matchRef{}}
idx := &MatchIndex{byMode: map[string][]matchRef{}, byBand: map[string][]matchRef{}}
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
var args []any
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
@@ -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) {
mk := matchKeyMode(call, band, mode)
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
if idx.byBand == nil { // tests build the index literally, without byBand
idx.byBand = map[string][]matchRef{}
}
bk := strings.ToUpper(call) + "|" + strings.ToLower(band)
idx.byBand[bk] = append(idx.byBand[bk], matchRef{when: when, id: id})
}
// Add registers a QSO in the index (exported wrapper for callers that inserted a
@@ -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)
}
// MatchBand matches ignoring the mode — for confirmations whose source doesn't
// carry one (Club Log reports "false" for modes it can't infer).
func (idx *MatchIndex) MatchBand(call, band string, when time.Time, window time.Duration) (int64, bool) {
return closestRef(idx.byBand[strings.ToUpper(call)+"|"+strings.ToLower(band)], when, window)
}
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
var best int64
bestD := window + time.Second
@@ -3504,6 +3526,7 @@ var confirmedCols = map[string]bool{
"qsl_rcvd": true,
"eqsl_rcvd": true,
"qrzcom_qso_download_status": true,
"clublog_qso_download_status": true,
}
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
@@ -3560,6 +3583,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
return nil
}
// MarkClublogConfirmed stamps CLUBLOG_QSO_DOWNLOAD_STATUS=Y and the date on a
// QSO Club Log reports as matched. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkClublogConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET clublog_qso_download_status = 'Y', clublog_qso_download_date = ?,
updated_at = ? WHERE id = ?`,
date, db.NowISO(), id)
if err != nil {
return fmt.Errorf("mark clublog confirmed %d: %w", id, err)
}
return nil
}
// ClearQRZConfirmed takes back a QRZ confirmation.
//
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
@@ -3638,6 +3674,7 @@ func scanQSO(s scanner) (QSO, error) {
hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
clublogDlDate, clublogDlStatus sql.NullString
contestID sql.NullString
srx, stx sql.NullInt64
srxStr, stxStr sql.NullString
@@ -3682,6 +3719,7 @@ func scanQSO(s scanner) (QSO, error) {
&hrdlogDate, &hrdlogStatus,
&qrzcomDate, &qrzcomStatus,
&qrzcomDlDate, &qrzcomDlStatus,
&clublogDlDate, &clublogDlStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
@@ -3779,6 +3817,8 @@ func scanQSO(s scanner) (QSO, error) {
q.QRZComUploadStatus = qrzcomStatus.String
q.QRZComDownloadDate = qrzcomDlDate.String
q.QRZComDownloadStatus = qrzcomDlStatus.String
q.ClublogDownloadDate = clublogDlDate.String
q.ClublogDownloadStatus = clublogDlStatus.String
q.ContestID = contestID.String
if srx.Valid {
v := int(srx.Int64)
+74 -6
View File
@@ -13,6 +13,7 @@ package scp
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
@@ -22,6 +23,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -29,7 +31,14 @@ import (
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
// clublogURL is Club Log's own SCP list, rebuilt weekly from real DX logs:
// every call from a current entity with 40+ QSOs in the last 3 years (~180k).
// Broader than MASTER.SCP (which leans contest), so the manager merges the two
// when the Club Log source is enabled.
const clublogURL = "https://cdn.clublog.org/clublog.scp.gz"
const cacheFile = "MASTER.SCP"
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed
// Result is the two suggestion lists for a typed fragment.
type Result struct {
@@ -44,6 +53,7 @@ type Manager struct {
updated time.Time // when the cache was last refreshed
dir string
client *http.Client
clublog atomic.Bool // merge Club Log's list into the master list
}
// NewManager loads any on-disk cache and returns a ready manager.
@@ -57,13 +67,29 @@ func NewManager(dataDir string) *Manager {
}
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
func (m *Manager) clublogPath() string { return filepath.Join(m.dir, clublogCacheFile) }
// SetClublogEnabled turns the Club Log source on/off and reparses the on-disk
// caches so the in-memory list reflects the choice immediately.
func (m *Manager) SetClublogEnabled(on bool) {
if m.clublog.Swap(on) != on {
m.loadCache()
}
}
// ClublogEnabled reports whether the Club Log source is merged in.
func (m *Manager) ClublogEnabled() bool { return m.clublog.Load() }
func (m *Manager) loadCache() {
data, err := os.ReadFile(m.path())
if err != nil {
data, _ := os.ReadFile(m.path())
var extra []byte
if m.clublog.Load() {
extra, _ = os.ReadFile(m.clublogPath())
}
if data == nil && extra == nil {
return
}
m.parse(data)
m.parse(data, extra)
if fi, e := os.Stat(m.path()); e == nil {
m.mu.Lock()
m.updated = fi.ModTime()
@@ -91,7 +117,17 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
if err != nil {
return 0, fmt.Errorf("scp: read: %w", err)
}
n := m.parse(body)
var extra []byte
if m.clublog.Load() {
if cl, cerr := m.downloadClublog(ctx); cerr == nil {
extra = cl
} else {
// The master list alone is still worth having; fall back to any
// cached Club Log list rather than dropping the source silently.
extra, _ = os.ReadFile(m.clublogPath())
}
}
n := m.parse(body, extra)
if n == 0 {
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
}
@@ -102,9 +138,40 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
return n, nil
}
// downloadClublog fetches Club Log's gzipped SCP list and caches it decompressed.
func (m *Manager) downloadClublog(ctx context.Context) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return nil, fmt.Errorf("scp: clublog request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("scp: clublog http %d", resp.StatusCode)
}
gz, err := gzip.NewReader(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return nil, fmt.Errorf("scp: clublog gunzip: %w", err)
}
body, err := io.ReadAll(io.LimitReader(gz, 64*1024*1024))
if err != nil {
return nil, fmt.Errorf("scp: clublog read: %w", err)
}
_ = os.WriteFile(m.clublogPath(), body, 0o644)
return body, nil
}
// parse loads the SCP bytes into the sorted call slice and returns the count.
func (m *Manager) parse(data []byte) int {
func (m *Manager) parse(datasets ...[]byte) int {
seen := make(map[string]struct{}, 1<<17)
for _, data := range datasets {
if len(data) == 0 {
continue
}
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
@@ -112,7 +179,7 @@ func (m *Manager) parse(data []byte) int {
if line == "" || strings.HasPrefix(line, "#") {
continue // blank / header comment
}
// A call token only (the master file is one call per line, but guard
// A call token only (the files are one call per line, but guard
// against stray trailing fields).
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
line = line[:i]
@@ -122,6 +189,7 @@ func (m *Manager) parse(data []byte) int {
}
seen[line] = struct{}{}
}
}
if len(seen) == 0 {
return 0
}
+2
View File
@@ -190,6 +190,8 @@ var Columns = []Column{
{"eqsl_rcvd_date", "Eqsl rcvd date", "QSL", func(q *qso.QSO) string { return q.EQSLRcvdDate }},
{"clublog_qso_upload_date", "Clublog qso upload date", "QSL", func(q *qso.QSO) string { return q.ClublogUploadDate }},
{"clublog_qso_upload_status", "Clublog qso upload status", "QSL", func(q *qso.QSO) string { return q.ClublogUploadStatus }},
{"clublog_qso_download_date", "Clublog match date", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadDate }},
{"clublog_qso_download_status", "Clublog match status", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadStatus }},
{"hrdlog_qso_upload_date", "Hrdlog qso upload date", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadDate }},
{"hrdlog_qso_upload_status", "Hrdlog qso upload status", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadStatus }},
{"qrzcom_qso_upload_date", "Qrzcom qso upload date", "QSL", func(q *qso.QSO) string { return q.QRZComUploadDate }},
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// 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
// to https://us.i.posthog.com for a US project.