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:
+71
-31
@@ -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,
|
||||
@@ -771,30 +777,32 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
||||
// zones, lat/lon) that are meaningless shared.
|
||||
var bulkEditableCols = map[string]bool{
|
||||
// QSL / upload status
|
||||
"lotw_sent": true,
|
||||
"lotw_rcvd": true,
|
||||
"eqsl_sent": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qsl_sent_via": true,
|
||||
"qsl_rcvd_via": true,
|
||||
"qrzcom_qso_upload_status": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"clublog_qso_upload_status": true,
|
||||
"hrdlog_qso_upload_status": true,
|
||||
"lotw_sent": true,
|
||||
"lotw_rcvd": true,
|
||||
"eqsl_sent": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qsl_sent": true,
|
||||
"qsl_rcvd": true,
|
||||
"qsl_via": true,
|
||||
"qsl_sent_via": true,
|
||||
"qsl_rcvd_via": true,
|
||||
"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,
|
||||
"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,
|
||||
"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,
|
||||
"clublog_qso_download_date": true,
|
||||
"hrdlog_qso_upload_date": true,
|
||||
// My station / operator
|
||||
"station_callsign": true,
|
||||
"operator": true,
|
||||
@@ -1339,15 +1347,17 @@ 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,
|
||||
"contest_id": true, "srx": true, "stx": 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,
|
||||
"my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": 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
|
||||
@@ -3500,10 +3522,11 @@ func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
|
||||
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||
// OR together (guards the dynamic SQL).
|
||||
var confirmedCols = map[string]bool{
|
||||
"lotw_rcvd": true,
|
||||
"qsl_rcvd": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
"lotw_rcvd": true,
|
||||
"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)
|
||||
|
||||
Reference in New Issue
Block a user