diff --git a/app.go b/app.go index e8939f5..b9cca23 100644 --- a/app.go +++ b/app.go @@ -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 @@ -421,6 +422,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 @@ -1395,6 +1398,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 +3152,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 +3164,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 +3172,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 +6925,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 +6942,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", @@ -12935,6 +12973,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)) } diff --git a/changelog.json b/changelog.json index 2499344..f11a0d0 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,9 @@ "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." + "Watchlist: fixed columns in the spot rows, so band, mode and frequency line up instead of drifting with the country name.", + "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." ], "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.", @@ -18,7 +20,9 @@ "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." + "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.", + "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." ] }, { diff --git a/frontend/src/components/BulkEditModal.tsx b/frontend/src/components/BulkEditModal.tsx index 8cd41fd..43b87c7 100644 --- a/frontend/src/components/BulkEditModal.tsx +++ b/frontend/src/components/BulkEditModal.tsx @@ -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 diff --git a/frontend/src/components/FilterBuilder.tsx b/frontend/src/components/FilterBuilder.tsx index da7d339..04f0ff1 100644 --- a/frontend/src/components/FilterBuilder.tsx +++ b/frontend/src/components/FilterBuilder.tsx @@ -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 diff --git a/frontend/src/components/QSOEditModal.tsx b/frontend/src/components/QSOEditModal.tsx index 4178a0e..7a78a9b 100644 --- a/frontend/src/components/QSOEditModal.tsx +++ b/frontend/src/components/QSOEditModal.tsx @@ -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 diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 64f0543..8d6170c 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -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 }, diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index ac1d4bd..90143a0 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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, @@ -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 {} @@ -7306,6 +7307,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged toggleScp(!!c)} /> {t('scp.enable')} + {scp.enabled && ( + + )} {scp.enabled && (