chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+139 -89
View File
@@ -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,