chore: release v0.21.3
This commit is contained in:
+139
-89
@@ -62,29 +62,29 @@ type QSO struct {
|
||||
RSTRcvd string `json:"rst_rcvd,omitempty"`
|
||||
|
||||
// --- Contacted station ---
|
||||
Name string `json:"name,omitempty"`
|
||||
QTH string `json:"qth,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Web string `json:"web,omitempty"`
|
||||
Grid string `json:"grid,omitempty"`
|
||||
GridExt string `json:"gridsquare_ext,omitempty"`
|
||||
VUCCGrids string `json:"vucc_grids,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
County string `json:"cnty,omitempty"`
|
||||
DXCC *int `json:"dxcc,omitempty"`
|
||||
Continent string `json:"cont,omitempty"`
|
||||
CQZ *int `json:"cqz,omitempty"`
|
||||
ITUZ *int `json:"ituz,omitempty"`
|
||||
IOTA string `json:"iota,omitempty"`
|
||||
SOTARef string `json:"sota_ref,omitempty"`
|
||||
POTARef string `json:"pota_ref,omitempty"`
|
||||
Age *int `json:"age,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Rig string `json:"rig,omitempty"`
|
||||
Ant string `json:"ant,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
QTH string `json:"qth,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Web string `json:"web,omitempty"`
|
||||
Grid string `json:"grid,omitempty"`
|
||||
GridExt string `json:"gridsquare_ext,omitempty"`
|
||||
VUCCGrids string `json:"vucc_grids,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
County string `json:"cnty,omitempty"`
|
||||
DXCC *int `json:"dxcc,omitempty"`
|
||||
Continent string `json:"cont,omitempty"`
|
||||
CQZ *int `json:"cqz,omitempty"`
|
||||
ITUZ *int `json:"ituz,omitempty"`
|
||||
IOTA string `json:"iota,omitempty"`
|
||||
SOTARef string `json:"sota_ref,omitempty"`
|
||||
POTARef string `json:"pota_ref,omitempty"`
|
||||
Age *int `json:"age,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Rig string `json:"rig,omitempty"`
|
||||
Ant string `json:"ant,omitempty"`
|
||||
|
||||
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
||||
QSLSent string `json:"qsl_sent,omitempty"`
|
||||
@@ -105,12 +105,12 @@ type QSO struct {
|
||||
EQSLSentDate string `json:"eqsl_sent_date,omitempty"`
|
||||
EQSLRcvdDate string `json:"eqsl_rcvd_date,omitempty"`
|
||||
|
||||
ClublogUploadDate string `json:"clublog_qso_upload_date,omitempty"`
|
||||
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
|
||||
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"`
|
||||
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
|
||||
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
|
||||
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
|
||||
ClublogUploadDate string `json:"clublog_qso_upload_date,omitempty"`
|
||||
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
|
||||
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"`
|
||||
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
|
||||
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
|
||||
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
|
||||
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
|
||||
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
|
||||
|
||||
@@ -599,7 +599,7 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
|
||||
// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not
|
||||
// all be signed under one cert).
|
||||
type UploadCandidate struct {
|
||||
ID int64
|
||||
ID int64
|
||||
StationCallsign string
|
||||
}
|
||||
|
||||
@@ -828,6 +828,56 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// bulkEditableExtras whitelists ADIF fields that are bulk-editable but live in
|
||||
// extras_json rather than in a promoted column. Key = the frontend's field id,
|
||||
// value = the uppercase ADIF key inside the JSON object.
|
||||
//
|
||||
// OWNER_CALLSIGN is the case that prompted this: it was already filterable (see
|
||||
// filterableExtras) but could not be bulk-edited, because BulkSetField writes a
|
||||
// column and there is no owner_callsign column.
|
||||
var bulkEditableExtras = map[string]string{
|
||||
"owner_callsign": "OWNER_CALLSIGN",
|
||||
}
|
||||
|
||||
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
||||
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
|
||||
|
||||
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
|
||||
// leaving the other extras untouched. An empty value REMOVES the key rather than
|
||||
// storing a blank — an empty extra would otherwise be carried into every export.
|
||||
//
|
||||
// json_set / json_remove exist under those names in both SQLite and MySQL and
|
||||
// take the same '$.KEY' path syntax, so one statement serves both backends.
|
||||
func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value string) (int64, error) {
|
||||
if adifKey == "" {
|
||||
return 0, fmt.Errorf("empty extras key")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
|
||||
if value == "" {
|
||||
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
|
||||
} else {
|
||||
args = append(args, value)
|
||||
}
|
||||
args = append(args, db.NowISO())
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept
|
||||
// separate from BulkSetField because frequency is numeric and must keep the band
|
||||
// consistent — the main use is fixing a batch that was logged on a stale/default
|
||||
@@ -1388,13 +1438,13 @@ type WorkedBefore struct {
|
||||
Callsign string `json:"callsign"`
|
||||
|
||||
// --- Per-callsign ---
|
||||
Count int `json:"count"` // total prior QSOs with this call
|
||||
First time.Time `json:"first,omitempty"` // oldest call QSO date
|
||||
Last time.Time `json:"last,omitempty"` // most recent call QSO date
|
||||
Bands []string `json:"bands"` // distinct bands for this call
|
||||
Modes []string `json:"modes"` // distinct modes for this call
|
||||
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
|
||||
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
|
||||
Count int `json:"count"` // total prior QSOs with this call
|
||||
First time.Time `json:"first,omitempty"` // oldest call QSO date
|
||||
Last time.Time `json:"last,omitempty"` // most recent call QSO date
|
||||
Bands []string `json:"bands"` // distinct bands for this call
|
||||
Modes []string `json:"modes"` // distinct modes for this call
|
||||
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
|
||||
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
|
||||
|
||||
// --- Per-DXCC entity (populated when DXCC is known) ---
|
||||
DXCC int `json:"dxcc,omitempty"`
|
||||
@@ -1478,14 +1528,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
|
||||
// ---- Per-callsign stats ----
|
||||
if err := r.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
|
||||
`SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
|
||||
return wb, fmt.Errorf("count worked: %w", err)
|
||||
}
|
||||
if wb.Count > 0 {
|
||||
// Pull the full QSO records (same columns as the Recent QSOs list) so
|
||||
// the Worked-before grid can offer the same rich column picker.
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
|
||||
FROM qso WHERE upper(trim(callsign)) = ?
|
||||
FROM qso WHERE callsign = ?
|
||||
ORDER BY qso_date DESC, id DESC
|
||||
LIMIT ?`, wb.Callsign, maxWorkedEntries)
|
||||
if err != nil {
|
||||
@@ -1520,7 +1570,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
if wb.Count > maxWorkedEntries {
|
||||
var firstStr sql.NullString
|
||||
_ = r.db.QueryRowContext(ctx,
|
||||
`SELECT MIN(qso_date) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&firstStr)
|
||||
`SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
|
||||
if firstStr.Valid {
|
||||
wb.First = parseTimeLoose(firstStr.String)
|
||||
}
|
||||
@@ -1545,7 +1595,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
var d sql.NullInt64
|
||||
_ = r.db.QueryRowContext(ctx, `
|
||||
SELECT dxcc FROM qso
|
||||
WHERE upper(trim(callsign)) = ? AND dxcc IS NOT NULL
|
||||
WHERE callsign = ? AND dxcc IS NOT NULL
|
||||
ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
|
||||
if d.Valid {
|
||||
dxcc = int(d.Int64)
|
||||
@@ -1614,8 +1664,8 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
|
||||
// WorkedBefore call, blanking the matrix in the UI.
|
||||
statusRows, err := r.db.QueryContext(ctx, `
|
||||
SELECT band, mode,
|
||||
MAX(CASE WHEN upper(trim(callsign)) = ? THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN upper(trim(callsign)) = ?
|
||||
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN callsign = ?
|
||||
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
|
||||
THEN 1 ELSE 0 END),
|
||||
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
|
||||
@@ -2459,58 +2509,58 @@ type scanner interface {
|
||||
func scanQSO(s scanner) (QSO, error) {
|
||||
var q QSO
|
||||
var (
|
||||
qsoDateStr string
|
||||
qsoDateOffStr sql.NullString
|
||||
bandRx, submode sql.NullString
|
||||
freqHz, freqRX sql.NullInt64
|
||||
rstS, rstR sql.NullString
|
||||
name, qth, addr, email, web sql.NullString
|
||||
grid, gridExt, vucc sql.NullString
|
||||
country, state, cnty sql.NullString
|
||||
dxcc, cqz, ituz sql.NullInt64
|
||||
cont, iota, sota, pota sql.NullString
|
||||
age sql.NullInt64
|
||||
lat, lon sql.NullFloat64
|
||||
rig, ant sql.NullString
|
||||
qslSent, qslRcvd sql.NullString
|
||||
qslSentDate, qslRcvdDate sql.NullString
|
||||
qslVia, qslMsg, qslMsgRcvd sql.NullString
|
||||
lotwSent, lotwRcvd sql.NullString
|
||||
lotwSentDate, lotwRcvdDate sql.NullString
|
||||
eqslSent, eqslRcvd sql.NullString
|
||||
eqslSentDate, eqslRcvdDate sql.NullString
|
||||
clublogDate, clublogStatus sql.NullString
|
||||
hrdlogDate, hrdlogStatus sql.NullString
|
||||
qrzcomDate, qrzcomStatus sql.NullString
|
||||
qrzcomDlDate, qrzcomDlStatus sql.NullString
|
||||
contestID sql.NullString
|
||||
srx, stx sql.NullInt64
|
||||
srxStr, stxStr sql.NullString
|
||||
checkField, precedence, arrlSect sql.NullString
|
||||
propMode, satName, satMode sql.NullString
|
||||
antAz, antEl sql.NullFloat64
|
||||
antPath sql.NullString
|
||||
stCall, op, myGrid, myGridExt sql.NullString
|
||||
myCountry, myState, myCnty, myIOTA sql.NullString
|
||||
mySOTA, myPOTA sql.NullString
|
||||
myDXCC, myCQZ, myITUZ sql.NullInt64
|
||||
myLat, myLon sql.NullFloat64
|
||||
myStreet, myCity, myPostal sql.NullString
|
||||
myRig, myAntenna sql.NullString
|
||||
txp sql.NullFloat64
|
||||
comment, notes sql.NullString
|
||||
sig, sigInfo, mySig, mySigInfo sql.NullString
|
||||
qsoDateStr string
|
||||
qsoDateOffStr sql.NullString
|
||||
bandRx, submode sql.NullString
|
||||
freqHz, freqRX sql.NullInt64
|
||||
rstS, rstR sql.NullString
|
||||
name, qth, addr, email, web sql.NullString
|
||||
grid, gridExt, vucc sql.NullString
|
||||
country, state, cnty sql.NullString
|
||||
dxcc, cqz, ituz sql.NullInt64
|
||||
cont, iota, sota, pota sql.NullString
|
||||
age sql.NullInt64
|
||||
lat, lon sql.NullFloat64
|
||||
rig, ant sql.NullString
|
||||
qslSent, qslRcvd sql.NullString
|
||||
qslSentDate, qslRcvdDate sql.NullString
|
||||
qslVia, qslMsg, qslMsgRcvd sql.NullString
|
||||
lotwSent, lotwRcvd sql.NullString
|
||||
lotwSentDate, lotwRcvdDate sql.NullString
|
||||
eqslSent, eqslRcvd sql.NullString
|
||||
eqslSentDate, eqslRcvdDate sql.NullString
|
||||
clublogDate, clublogStatus sql.NullString
|
||||
hrdlogDate, hrdlogStatus sql.NullString
|
||||
qrzcomDate, qrzcomStatus sql.NullString
|
||||
qrzcomDlDate, qrzcomDlStatus sql.NullString
|
||||
contestID sql.NullString
|
||||
srx, stx sql.NullInt64
|
||||
srxStr, stxStr sql.NullString
|
||||
checkField, precedence, arrlSect sql.NullString
|
||||
propMode, satName, satMode sql.NullString
|
||||
antAz, antEl sql.NullFloat64
|
||||
antPath sql.NullString
|
||||
stCall, op, myGrid, myGridExt sql.NullString
|
||||
myCountry, myState, myCnty, myIOTA sql.NullString
|
||||
mySOTA, myPOTA sql.NullString
|
||||
myDXCC, myCQZ, myITUZ sql.NullInt64
|
||||
myLat, myLon sql.NullFloat64
|
||||
myStreet, myCity, myPostal sql.NullString
|
||||
myRig, myAntenna sql.NullString
|
||||
txp sql.NullFloat64
|
||||
comment, notes sql.NullString
|
||||
sig, sigInfo, mySig, mySigInfo sql.NullString
|
||||
wwffRef, myWWFFRef sql.NullString
|
||||
distance, rxPwr, aIndex, kIndex, sfi sql.NullFloat64
|
||||
distance, rxPwr, aIndex, kIndex, sfi sql.NullFloat64
|
||||
skcc, fists, tenTen sql.NullString
|
||||
contactedOp, eqCall, pfx, myName sql.NullString
|
||||
class, darcDOK, myDarcDOK, region sql.NullString
|
||||
silentKey, swl, qsoComplete, qsoRandom sql.NullString
|
||||
creditGranted, creditSubmitted sql.NullString
|
||||
myARRLSect, myVUCCGrids sql.NullString
|
||||
extrasJSON sql.NullString
|
||||
awardRefs sql.NullString
|
||||
createdStr, updatedStr string
|
||||
myARRLSect, myVUCCGrids sql.NullString
|
||||
extrasJSON sql.NullString
|
||||
awardRefs sql.NullString
|
||||
createdStr, updatedStr string
|
||||
)
|
||||
if err := s.Scan(
|
||||
&q.ID, &q.Callsign, &qsoDateStr, &qsoDateOffStr, &q.Band, &bandRx, &q.Mode, &submode, &freqHz, &freqRX,
|
||||
|
||||
Reference in New Issue
Block a user