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.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
+6
-2
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,7 +71,7 @@ const CONFIRMATIONS: ConfDef[] = [
|
||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
|
||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||
];
|
||||
// i18n label keys for confirmation channels whose label has translatable words
|
||||
|
||||
@@ -235,6 +235,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||
|
||||
@@ -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
|
||||
<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
Vendored
+2
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3852,6 +3852,7 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
updated?: string;
|
||||
clublog: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScpStatus(source);
|
||||
@@ -3862,6 +3863,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 +5271,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 +5415,8 @@ export namespace qso {
|
||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
||||
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
||||
this.clublog_qso_download_date = source["clublog_qso_download_date"];
|
||||
this.clublog_qso_download_status = source["clublog_qso_download_status"];
|
||||
this.contest_id = source["contest_id"];
|
||||
this.srx = source["srx"];
|
||||
this.stx = source["stx"];
|
||||
|
||||
@@ -267,6 +267,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
||||
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
|
||||
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
|
||||
|
||||
// --- Contest ---
|
||||
w("CONTEST_ID", q.ContestID)
|
||||
|
||||
@@ -161,6 +161,9 @@ var Fields = []FieldDef{
|
||||
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
// App-defined pair (no standard ADIF field): Club Log's log-match download.
|
||||
{Name: "CLUBLOG_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||
{Name: "CLUBLOG_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||
|
||||
@@ -287,6 +287,7 @@ var adifPromoted = stringSet(
|
||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||
"clublog_qso_download_date", "clublog_qso_download_status",
|
||||
// Contest
|
||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||
"check", "precedence", "arrl_sect",
|
||||
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
|
||||
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
|
||||
|
||||
// Contest
|
||||
q.ContestID = rec["contest_id"]
|
||||
|
||||
@@ -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"`
|
||||
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
@@ -13,6 +13,7 @@ package scp
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -29,7 +31,14 @@ import (
|
||||
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
|
||||
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
|
||||
|
||||
// clublogURL is Club Log's own SCP list, rebuilt weekly from real DX logs:
|
||||
// every call from a current entity with 40+ QSOs in the last 3 years (~180k).
|
||||
// Broader than MASTER.SCP (which leans contest), so the manager merges the two
|
||||
// when the Club Log source is enabled.
|
||||
const clublogURL = "https://cdn.clublog.org/clublog.scp.gz"
|
||||
|
||||
const cacheFile = "MASTER.SCP"
|
||||
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed
|
||||
|
||||
// Result is the two suggestion lists for a typed fragment.
|
||||
type Result struct {
|
||||
@@ -44,6 +53,7 @@ type Manager struct {
|
||||
updated time.Time // when the cache was last refreshed
|
||||
dir string
|
||||
client *http.Client
|
||||
clublog atomic.Bool // merge Club Log's list into the master list
|
||||
}
|
||||
|
||||
// NewManager loads any on-disk cache and returns a ready manager.
|
||||
@@ -57,13 +67,29 @@ func NewManager(dataDir string) *Manager {
|
||||
}
|
||||
|
||||
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
|
||||
func (m *Manager) clublogPath() string { return filepath.Join(m.dir, clublogCacheFile) }
|
||||
|
||||
// SetClublogEnabled turns the Club Log source on/off and reparses the on-disk
|
||||
// caches so the in-memory list reflects the choice immediately.
|
||||
func (m *Manager) SetClublogEnabled(on bool) {
|
||||
if m.clublog.Swap(on) != on {
|
||||
m.loadCache()
|
||||
}
|
||||
}
|
||||
|
||||
// ClublogEnabled reports whether the Club Log source is merged in.
|
||||
func (m *Manager) ClublogEnabled() bool { return m.clublog.Load() }
|
||||
|
||||
func (m *Manager) loadCache() {
|
||||
data, err := os.ReadFile(m.path())
|
||||
if err != nil {
|
||||
data, _ := os.ReadFile(m.path())
|
||||
var extra []byte
|
||||
if m.clublog.Load() {
|
||||
extra, _ = os.ReadFile(m.clublogPath())
|
||||
}
|
||||
if data == nil && extra == nil {
|
||||
return
|
||||
}
|
||||
m.parse(data)
|
||||
m.parse(data, extra)
|
||||
if fi, e := os.Stat(m.path()); e == nil {
|
||||
m.mu.Lock()
|
||||
m.updated = fi.ModTime()
|
||||
@@ -91,7 +117,17 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scp: read: %w", err)
|
||||
}
|
||||
n := m.parse(body)
|
||||
var extra []byte
|
||||
if m.clublog.Load() {
|
||||
if cl, cerr := m.downloadClublog(ctx); cerr == nil {
|
||||
extra = cl
|
||||
} else {
|
||||
// The master list alone is still worth having; fall back to any
|
||||
// cached Club Log list rather than dropping the source silently.
|
||||
extra, _ = os.ReadFile(m.clublogPath())
|
||||
}
|
||||
}
|
||||
n := m.parse(body, extra)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
|
||||
}
|
||||
@@ -102,9 +138,40 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// downloadClublog fetches Club Log's gzipped SCP list and caches it decompressed.
|
||||
func (m *Manager) downloadClublog(ctx context.Context) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpsLog")
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("scp: clublog http %d", resp.StatusCode)
|
||||
}
|
||||
gz, err := gzip.NewReader(io.LimitReader(resp.Body, 32*1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog gunzip: %w", err)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(gz, 64*1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scp: clublog read: %w", err)
|
||||
}
|
||||
_ = os.WriteFile(m.clublogPath(), body, 0o644)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// parse loads the SCP bytes into the sorted call slice and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
func (m *Manager) parse(datasets ...[]byte) int {
|
||||
seen := make(map[string]struct{}, 1<<17)
|
||||
for _, data := range datasets {
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
@@ -112,7 +179,7 @@ func (m *Manager) parse(data []byte) int {
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue // blank / header comment
|
||||
}
|
||||
// A call token only (the master file is one call per line, but guard
|
||||
// A call token only (the files are one call per line, but guard
|
||||
// against stray trailing fields).
|
||||
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
|
||||
line = line[:i]
|
||||
@@ -122,6 +189,7 @@ func (m *Manager) parse(data []byte) int {
|
||||
}
|
||||
seen[line] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -190,6 +190,8 @@ var Columns = []Column{
|
||||
{"eqsl_rcvd_date", "Eqsl rcvd date", "QSL", func(q *qso.QSO) string { return q.EQSLRcvdDate }},
|
||||
{"clublog_qso_upload_date", "Clublog qso upload date", "QSL", func(q *qso.QSO) string { return q.ClublogUploadDate }},
|
||||
{"clublog_qso_upload_status", "Clublog qso upload status", "QSL", func(q *qso.QSO) string { return q.ClublogUploadStatus }},
|
||||
{"clublog_qso_download_date", "Clublog match date", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadDate }},
|
||||
{"clublog_qso_download_status", "Clublog match status", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadStatus }},
|
||||
{"hrdlog_qso_upload_date", "Hrdlog qso upload date", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadDate }},
|
||||
{"hrdlog_qso_upload_status", "Hrdlog qso upload status", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadStatus }},
|
||||
{"qrzcom_qso_upload_date", "Qrzcom qso upload date", "QSL", func(q *qso.QSO) string { return q.QRZComUploadDate }},
|
||||
|
||||
Reference in New Issue
Block a user