Files
OpsLog/internal/qso/qso.go
T
rouggy 7ace2cc602 Initial codebase: Go + Wails amateur radio logbook
Backend (Go 1.25 / Wails v2):
- QSO storage on SQLite (modernc) with embedded migrations (0001..0005)
- Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC
- Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing)
  and SQLite-backed TTL cache
- DXCC resolver from cty.dat (auto-download, longest-prefix-match)
- Multi-profile operator identities (home/portable/SOTA/contest) — every
  QSO stamps MY_* from the active profile
- CAT control via OmniRig COM on a single OS-locked goroutine, with
  bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap
- Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log

Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style):
- Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End
  UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs
- Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges,
  CAT pill with rig selector and clickable Azimuth pill (rotor TODO)
- Settings tree: Profiles (Log4OM-style manager), Station Information
  (edits the active profile), unified Callsign Lookup with Test buttons,
  Bands/Modes lists, CAT
- Worked-before matrix (band × mode × class) with new-DXCC highlighting
- ADIF import from menu + Maintenance > Refresh cty.dat

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:16:45 +02:00

1041 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package qso models a radio contact (QSO) and provides its repository.
package qso
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
// QSO represents a contact. Fields are aligned on ADIF naming for
// import/export. Pointers are used to distinguish "absent" from "zero".
// Anything in ADIF that is not a promoted column lands in Extras.
type QSO struct {
ID int64 `json:"id"`
Callsign string `json:"callsign"`
QSODate time.Time `json:"qso_date"` // start, UTC
QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC
Band string `json:"band"`
BandRX string `json:"band_rx,omitempty"`
Mode string `json:"mode"`
Submode string `json:"submode,omitempty"`
FreqHz *int64 `json:"freq_hz,omitempty"`
FreqRXHz *int64 `json:"freq_rx_hz,omitempty"`
RSTSent string `json:"rst_sent,omitempty"`
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"`
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
QSLSent string `json:"qsl_sent,omitempty"`
QSLRcvd string `json:"qsl_rcvd,omitempty"`
QSLSentDate string `json:"qsl_sent_date,omitempty"`
QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"`
QSLVia string `json:"qsl_via,omitempty"`
QSLMsg string `json:"qsl_msg,omitempty"`
QSLMsgRcvd string `json:"qslmsg_rcvd,omitempty"`
LOTWSent string `json:"lotw_sent,omitempty"`
LOTWRcvd string `json:"lotw_rcvd,omitempty"`
LOTWSentDate string `json:"lotw_sent_date,omitempty"`
LOTWRcvdDate string `json:"lotw_rcvd_date,omitempty"`
EQSLSent string `json:"eqsl_sent,omitempty"`
EQSLRcvd string `json:"eqsl_rcvd,omitempty"`
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"`
// --- Contest ---
ContestID string `json:"contest_id,omitempty"`
SRX *int `json:"srx,omitempty"`
STX *int `json:"stx,omitempty"`
SRXString string `json:"srx_string,omitempty"`
STXString string `json:"stx_string,omitempty"`
Check string `json:"check,omitempty"`
Precedence string `json:"precedence,omitempty"`
ARRLSect string `json:"arrl_sect,omitempty"`
// --- Satellite / propagation ---
PropMode string `json:"prop_mode,omitempty"`
SatName string `json:"sat_name,omitempty"`
SatMode string `json:"sat_mode,omitempty"`
AntAz *float64 `json:"ant_az,omitempty"`
AntEl *float64 `json:"ant_el,omitempty"`
AntPath string `json:"ant_path,omitempty"`
// --- My station / operator ---
StationCallsign string `json:"station_callsign,omitempty"`
Operator string `json:"operator,omitempty"`
MyGrid string `json:"my_grid,omitempty"`
MyGridExt string `json:"my_gridsquare_ext,omitempty"`
MyCountry string `json:"my_country,omitempty"`
MyState string `json:"my_state,omitempty"`
MyCounty string `json:"my_cnty,omitempty"`
MyIOTA string `json:"my_iota,omitempty"`
MySOTARef string `json:"my_sota_ref,omitempty"`
MyPOTARef string `json:"my_pota_ref,omitempty"`
MyDXCC *int `json:"my_dxcc,omitempty"`
MyCQZone *int `json:"my_cq_zone,omitempty"`
MyITUZone *int `json:"my_itu_zone,omitempty"`
MyLat *float64 `json:"my_lat,omitempty"`
MyLon *float64 `json:"my_lon,omitempty"`
MyStreet string `json:"my_street,omitempty"`
MyCity string `json:"my_city,omitempty"`
MyPostalCode string `json:"my_postal_code,omitempty"`
MyRig string `json:"my_rig,omitempty"`
MyAntenna string `json:"my_antenna,omitempty"`
// --- Misc ---
TXPower *float64 `json:"tx_pwr,omitempty"`
Comment string `json:"comment,omitempty"`
Notes string `json:"notes,omitempty"`
// Extras holds ADIF fields not promoted to columns. Keys are uppercase
// ADIF field names (e.g. "DARC_DOK"); values are the raw string content.
Extras map[string]string `json:"extras,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListFilter filters a search in the logbook.
// Any zero/nil field is ignored.
type ListFilter struct {
Callsign string `json:"callsign,omitempty"`
Band string `json:"band,omitempty"`
Mode string `json:"mode,omitempty"`
StationCallsign string `json:"station_callsign,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
// Repo accesses the qso table.
type Repo struct {
db *sql.DB
}
// NewRepo builds a QSO repo on the given connection.
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
const isoMillis = "2006-01-02T15:04:05.000Z"
// columnList is the canonical list of columns in declaration order. It is
// used by both read and write queries so they stay in sync.
const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submode, freq_hz, freq_rx_hz,
rst_sent, rst_rcvd,
name, qth, address, email, web,
grid, gridsquare_ext, vucc_grids,
country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref,
age, lat, lon, rig, ant,
qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_msg, qslmsg_rcvd,
lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date,
eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
clublog_qso_upload_date, clublog_qso_upload_status,
hrdlog_qso_upload_date, hrdlog_qso_upload_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,
my_sota_ref, my_pota_ref, my_dxcc, my_cq_zone, my_itu_zone, my_lat, my_lon,
my_street, my_city, my_postal_code, my_rig, my_antenna,
tx_pwr, comment, notes, extras_json`
const selectCols = `id, ` + columnList + `, created_at, updated_at`
// columnCount is derived from columnList at init so they can never drift.
var columnCount = countColumns(columnList)
// insertPlaceholders returns "?,?,?,..." matching columnCount.
var insertPlaceholders = buildInsertPlaceholders()
func countColumns(s string) int {
n := 1
for i := 0; i < len(s); i++ {
if s[i] == ',' {
n++
}
}
return n
}
func buildInsertPlaceholders() string {
out := make([]byte, 0, 2*columnCount)
for i := 0; i < columnCount; i++ {
if i > 0 {
out = append(out, ',')
}
out = append(out, '?')
}
return string(out)
}
// args returns the QSO field values in the same order as columnList.
func (q *QSO) args() []any {
extras := encodeExtras(q.Extras)
return []any{
q.Callsign, q.QSODate.UTC().Format(isoMillis), nullDateTime(q.QSODateOff), q.Band, q.BandRX, q.Mode, q.Submode, q.FreqHz, q.FreqRXHz,
q.RSTSent, q.RSTRcvd,
q.Name, q.QTH, q.Address, q.Email, q.Web,
q.Grid, q.GridExt, q.VUCCGrids,
q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef,
q.Age, q.Lat, q.Lon, q.Rig, q.Ant,
q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLMsg, q.QSLMsgRcvd,
q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate,
q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
q.ClublogUploadDate, q.ClublogUploadStatus,
q.HRDLogUploadDate, q.HRDLogUploadStatus,
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,
q.MySOTARef, q.MyPOTARef, q.MyDXCC, q.MyCQZone, q.MyITUZone, q.MyLat, q.MyLon,
q.MyStreet, q.MyCity, q.MyPostalCode, q.MyRig, q.MyAntenna,
q.TXPower, q.Comment, q.Notes, extras,
}
}
// nullDateTime renders a time.Time as ISO UTC or nil if zero.
func nullDateTime(t time.Time) any {
if t.IsZero() {
return nil
}
return t.UTC().Format(isoMillis)
}
// encodeExtras returns a JSON-encoded string or nil if empty.
func encodeExtras(m map[string]string) any {
if len(m) == 0 {
return nil
}
b, err := json.Marshal(m)
if err != nil {
return nil
}
return string(b)
}
func decodeExtras(s string) map[string]string {
if s == "" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil
}
return m
}
// Add inserts a QSO and returns its ID.
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
if q.Callsign == "" {
return 0, fmt.Errorf("empty callsign")
}
if q.QSODate.IsZero() {
q.QSODate = time.Now().UTC()
}
res, err := r.db.ExecContext(ctx,
`INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`,
q.args()...)
if err != nil {
return 0, fmt.Errorf("insert qso: %w", err)
}
return res.LastInsertId()
}
// AddBatch inserts many QSOs inside a single transaction using a prepared
// statement. Empty-callsign records are skipped. Returns rows inserted.
func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
if len(qsos) == 0 {
return 0, nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO qso (`+columnList+`) VALUES (`+insertPlaceholders+`)`)
if err != nil {
return 0, fmt.Errorf("prepare batch insert: %w", err)
}
defer stmt.Close()
var inserted int64
for _, q := range qsos {
if q.Callsign == "" {
continue
}
if q.QSODate.IsZero() {
q.QSODate = time.Now().UTC()
}
if _, err := stmt.ExecContext(ctx, q.args()...); err != nil {
return inserted, fmt.Errorf("insert qso %q: %w", q.Callsign, err)
}
inserted++
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit batch: %w", err)
}
return inserted, nil
}
// GetByID fetches a single QSO by primary key.
func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
row := r.db.QueryRowContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE id = ?`, id)
return scanQSO(row)
}
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
func (r *Repo) Update(ctx context.Context, q QSO) error {
if q.ID == 0 {
return fmt.Errorf("missing id")
}
if q.Callsign == "" {
return fmt.Errorf("empty callsign")
}
if q.QSODate.IsZero() {
q.QSODate = time.Now().UTC()
}
setClause := buildUpdateSetClause()
args := append(q.args(), q.ID)
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+setClause+`, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
args...)
if err != nil {
return fmt.Errorf("update qso %d: %w", q.ID, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// buildUpdateSetClause turns "col1, col2, …" into "col1 = ?, col2 = ?, …".
func buildUpdateSetClause() string {
// Quick split — columnList has no escaped commas.
var out []byte
col := []byte{}
flush := func() {
if len(col) == 0 {
return
}
if len(out) > 0 {
out = append(out, ',', ' ')
}
// trim leading whitespace
i := 0
for i < len(col) && (col[i] == ' ' || col[i] == '\n' || col[i] == '\t') {
i++
}
out = append(out, col[i:]...)
out = append(out, ' ', '=', ' ', '?')
col = col[:0]
}
for i := 0; i < len(columnList); i++ {
c := columnList[i]
if c == ',' {
flush()
continue
}
col = append(col, c)
}
flush()
return string(out)
}
// DeleteAll wipes every QSO from the logbook. Returns the number of rows
// removed. Use with care — there is no recovery short of a DB backup.
// Runs in a transaction and VACUUMs afterwards so the file actually shrinks.
func (r *Repo) DeleteAll(ctx context.Context) (int64, error) {
res, err := r.db.ExecContext(ctx, `DELETE FROM qso`)
if err != nil {
return 0, fmt.Errorf("delete all qso: %w", err)
}
n, _ := res.RowsAffected()
// VACUUM is run outside any transaction; reclaims pages so the .db file
// stops being a 200 MB ghost after a 25k-row wipe.
if _, err := r.db.ExecContext(ctx, `VACUUM`); err != nil {
// Non-fatal: rows are gone, just file isn't shrunk. Surface the err.
return n, fmt.Errorf("delete ok (%d rows) but vacuum failed: %w", n, err)
}
return n, nil
}
// Delete removes a QSO by id.
func (r *Repo) Delete(ctx context.Context, id int64) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete qso %d: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// List returns QSOs matching the filter, sorted by date desc.
func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
q := `SELECT ` + selectCols + ` FROM qso WHERE 1=1`
args := []any{}
if f.Callsign != "" {
q += " AND callsign LIKE ?"
args = append(args, f.Callsign+"%")
}
if f.Band != "" {
q += " AND band = ?"
args = append(args, f.Band)
}
if f.Mode != "" {
q += " AND mode = ?"
args = append(args, f.Mode)
}
if f.StationCallsign != "" {
q += " AND station_callsign = ?"
args = append(args, f.StationCallsign)
}
q += " ORDER BY qso_date DESC, id DESC"
if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 200
}
q += " LIMIT ? OFFSET ?"
args = append(args, f.Limit, f.Offset)
rows, err := r.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
out := make([]QSO, 0, 64)
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
// WorkedBefore summarises prior contacts at two granularities:
// - by exact callsign → shown as "this call worked N×"
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
//
// In ham radio the meaningful "new one" is a new DXCC entity, not a brand-new
// callsign — most QSOs to known countries aren't worth flagging.
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 []WorkedEntry `json:"entries"` // up to maxWorkedEntries most recent
// --- Per-DXCC entity (populated when DXCC is known) ---
DXCC int `json:"dxcc,omitempty"`
DXCCName string `json:"dxcc_name,omitempty"` // human country name (best effort)
DXCCCount int `json:"dxcc_count"`
DXCCFirst time.Time `json:"dxcc_first,omitempty"`
DXCCLast time.Time `json:"dxcc_last,omitempty"`
DXCCBands []string `json:"dxcc_bands"`
DXCCModes []string `json:"dxcc_modes"`
DXCCBandModes []BandMode `json:"dxcc_band_modes"`
// Status grid driving the band×class matrix in the UI. One entry per
// (band, class) where ANY QSO exists in this DXCC. Only the highest
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
BandStatus []BandStatus `json:"band_status"`
}
// BandStatus is one cell in the worked-before grid.
type BandStatus struct {
Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
Class string `json:"class"` // "PH" | "CW" | "DIG"
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
}
// modeClass collapses ADIF modes into the three buckets DXers care about.
// Anything not voice and not CW is treated as digital.
func modeClass(mode string) string {
switch strings.ToUpper(mode) {
case "SSB", "USB", "LSB", "AM", "FM", "DIGITALVOICE", "PHONE":
return "PH"
case "CW":
return "CW"
default:
return "DIG"
}
}
// BandMode is a (band, mode) pair used for the NEW SLOT check.
type BandMode struct {
Band string `json:"band"`
Mode string `json:"mode"`
}
// WorkedEntry is one prior contact row, lean enough to ship to the UI for
// rendering a recent-contacts mini-list.
type WorkedEntry struct {
ID int64 `json:"id"`
QSODate time.Time `json:"qso_date"`
Band string `json:"band"`
Mode string `json:"mode"`
RSTSent string `json:"rst_sent,omitempty"`
RSTRcvd string `json:"rst_rcvd,omitempty"`
QSLSent string `json:"qsl_sent,omitempty"`
QSLRcvd string `json:"qsl_rcvd,omitempty"`
LOTWSent string `json:"lotw_sent,omitempty"`
LOTWRcvd string `json:"lotw_rcvd,omitempty"`
}
const maxWorkedEntries = 50
// WorkedBefore returns aggregated history at both callsign and DXCC level.
// dxccHint lets the caller pass a known DXCC number (e.g. from a fresh QRZ
// lookup) when the call has never been worked. If 0, the DXCC is inferred
// from the most recent prior QSO with the same callsign.
func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int) (WorkedBefore, error) {
wb := WorkedBefore{
Callsign: upperTrim(callsign),
Bands: []string{},
Modes: []string{},
BandModes: []BandMode{},
Entries: []WorkedEntry{},
DXCCBands: []string{},
DXCCModes: []string{},
DXCCBandModes: []BandMode{},
}
if wb.Callsign == "" {
return wb, nil
}
// ---- Per-callsign stats ----
if err := r.db.QueryRowContext(ctx,
`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 {
rows, err := r.db.QueryContext(ctx, `
SELECT id, qso_date, band, mode, rst_sent, rst_rcvd,
qsl_sent, qsl_rcvd, lotw_sent, lotw_rcvd
FROM qso WHERE callsign = ?
ORDER BY qso_date DESC, id DESC
LIMIT ?`, wb.Callsign, maxWorkedEntries)
if err != nil {
return wb, fmt.Errorf("query worked: %w", err)
}
bandsSet := map[string]struct{}{}
modesSet := map[string]struct{}{}
bmSet := map[string]BandMode{}
for rows.Next() {
var (
e WorkedEntry
dateStr string
rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString
)
if err := rows.Scan(&e.ID, &dateStr, &e.Band, &e.Mode,
&rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil {
rows.Close()
return wb, fmt.Errorf("scan worked: %w", err)
}
e.QSODate = parseTimeLoose(dateStr)
e.RSTSent = rstS.String
e.RSTRcvd = rstR.String
e.QSLSent = qslS.String
e.QSLRcvd = qslR.String
e.LOTWSent = lotwS.String
e.LOTWRcvd = lotwR.String
wb.Entries = append(wb.Entries, e)
bandsSet[e.Band] = struct{}{}
modesSet[e.Mode] = struct{}{}
bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode}
if wb.Last.IsZero() {
wb.Last = e.QSODate
}
wb.First = e.QSODate
}
rows.Close()
if wb.Count > maxWorkedEntries {
var firstStr sql.NullString
_ = r.db.QueryRowContext(ctx,
`SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
if firstStr.Valid {
wb.First = parseTimeLoose(firstStr.String)
}
}
for b := range bandsSet {
wb.Bands = append(wb.Bands, b)
}
for m := range modesSet {
wb.Modes = append(wb.Modes, m)
}
for _, bm := range bmSet {
wb.BandModes = append(wb.BandModes, bm)
}
sortStrings(wb.Bands)
sortStrings(wb.Modes)
}
// ---- Resolve DXCC ----
dxcc := dxccHint
if dxcc == 0 && wb.Count > 0 {
// Take the most recent non-null DXCC from past QSOs with this call.
var d sql.NullInt64
_ = r.db.QueryRowContext(ctx, `
SELECT dxcc FROM qso
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)
}
}
if dxcc <= 0 {
return wb, nil
}
wb.DXCC = dxcc
// Best-effort country name: latest non-null country for this DXCC.
var name sql.NullString
_ = r.db.QueryRowContext(ctx, `
SELECT country FROM qso
WHERE dxcc = ? AND country IS NOT NULL AND country != ''
ORDER BY qso_date DESC LIMIT 1`, dxcc).Scan(&name)
wb.DXCCName = name.String
// ---- Per-DXCC stats ----
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*), MIN(qso_date), MAX(qso_date)
FROM qso WHERE dxcc = ?`, dxcc).Scan(
&wb.DXCCCount,
&nullableTimeScan{&wb.DXCCFirst},
&nullableTimeScan{&wb.DXCCLast},
); err != nil {
return wb, fmt.Errorf("count dxcc: %w", err)
}
if wb.DXCCCount == 0 {
return wb, nil
}
if err := r.collectDistinct(ctx, &wb.DXCCBands,
`SELECT DISTINCT band FROM qso WHERE dxcc = ?`, dxcc); err != nil {
return wb, err
}
if err := r.collectDistinct(ctx, &wb.DXCCModes,
`SELECT DISTINCT mode FROM qso WHERE dxcc = ?`, dxcc); err != nil {
return wb, err
}
bmRows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT band, mode FROM qso WHERE dxcc = ?`, dxcc)
if err != nil {
return wb, fmt.Errorf("dxcc band_modes: %w", err)
}
for bmRows.Next() {
var b, m string
if err := bmRows.Scan(&b, &m); err != nil {
bmRows.Close()
return wb, err
}
wb.DXCCBandModes = append(wb.DXCCBandModes, BandMode{Band: b, Mode: m})
}
bmRows.Close()
sortStrings(wb.DXCCBands)
sortStrings(wb.DXCCModes)
// ---- Per-(band, class) status grid ----
// One pass over every distinct (band, mode) in the DXCC, aggregating
// "did this call work it?" and "was anything confirmed?" via MAX.
// Status precedence: call_c > call_w > dxcc_c > dxcc_w.
statusRows, err := r.db.QueryContext(ctx, `
SELECT band, mode,
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'
THEN 1 ELSE 0 END)
FROM qso WHERE dxcc = ?
GROUP BY band, mode`, wb.Callsign, wb.Callsign, dxcc)
if err != nil {
return wb, fmt.Errorf("band status: %w", err)
}
type cellKey struct{ band, class string }
const (
stDxccW = 0
stDxccC = 1
stCallW = 2
stCallC = 3
)
best := map[cellKey]int{}
for statusRows.Next() {
var band, mode string
var callW, callC, dxccConfirmed int
if err := statusRows.Scan(&band, &mode, &callW, &callC, &dxccConfirmed); err != nil {
statusRows.Close()
return wb, fmt.Errorf("scan band status: %w", err)
}
code := stDxccW // row exists ⇒ entity worked at minimum
if dxccConfirmed == 1 {
code = stDxccC
}
if callW == 1 {
code = stCallW
}
if callC == 1 {
code = stCallC
}
k := cellKey{band: band, class: modeClass(mode)}
if cur, ok := best[k]; !ok || code > cur {
best[k] = code
}
}
statusRows.Close()
codeStr := [...]string{"dxcc_w", "dxcc_c", "call_w", "call_c"}
for k, code := range best {
wb.BandStatus = append(wb.BandStatus, BandStatus{
Band: k.band, Class: k.class, Status: codeStr[code],
})
}
return wb, nil
}
// collectDistinct fills *out with the first column of every row.
func (r *Repo) collectDistinct(ctx context.Context, out *[]string, query string, args ...any) error {
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("distinct: %w", err)
}
defer rows.Close()
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return err
}
*out = append(*out, s)
}
return rows.Err()
}
// nullableTimeScan adapts a *time.Time to sql.Scanner for fields that may
// be NULL in aggregate queries (MIN/MAX over an empty subset returns NULL).
type nullableTimeScan struct {
dst *time.Time
}
func (n *nullableTimeScan) Scan(src any) error {
if src == nil {
return nil
}
switch v := src.(type) {
case string:
*n.dst = parseTimeLoose(v)
case []byte:
*n.dst = parseTimeLoose(string(v))
}
return nil
}
func upperTrim(s string) string {
out := []byte(s)
// strings.TrimSpace + ToUpper without importing strings for this micro-helper.
start, end := 0, len(out)
for start < end && out[start] <= ' ' {
start++
}
for end > start && out[end-1] <= ' ' {
end--
}
for i := start; i < end; i++ {
if out[i] >= 'a' && out[i] <= 'z' {
out[i] -= 32
}
}
return string(out[start:end])
}
func sortStrings(s []string) {
// Tiny insertion sort: typical worked-before slices are < 20 items.
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j-1] > s[j]; j-- {
s[j-1], s[j] = s[j], s[j-1]
}
}
}
// Count returns the total number of QSOs in the database.
func (r *Repo) Count(ctx context.Context) (int64, error) {
var n int64
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM qso`).Scan(&n)
return n, err
}
// scanner is what both *sql.Row and *sql.Rows satisfy for our needs.
type scanner interface {
Scan(dest ...any) error
}
// scanQSO reads one row produced by selectCols into a QSO.
// Long but flat — kept in field declaration order to make audits easy.
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
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
extrasJSON sql.NullString
createdStr, updatedStr string
)
if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &qsoDateOffStr, &q.Band, &bandRx, &q.Mode, &submode, &freqHz, &freqRX,
&rstS, &rstR,
&name, &qth, &addr, &email, &web,
&grid, &gridExt, &vucc,
&country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota,
&age, &lat, &lon, &rig, &ant,
&qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslMsg, &qslMsgRcvd,
&lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate,
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
&clublogDate, &clublogStatus,
&hrdlogDate, &hrdlogStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
&mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon,
&myStreet, &myCity, &myPostal, &myRig, &myAntenna,
&txp, &comment, &notes, &extrasJSON, &createdStr, &updatedStr,
); err != nil {
return QSO{}, fmt.Errorf("scan qso: %w", err)
}
q.QSODate = parseTimeLoose(qsoDateStr)
q.QSODateOff = parseTimeLoose(qsoDateOffStr.String)
q.CreatedAt = parseTimeLoose(createdStr)
q.UpdatedAt = parseTimeLoose(updatedStr)
q.BandRX = bandRx.String
q.Submode = submode.String
if freqHz.Valid {
v := freqHz.Int64
q.FreqHz = &v
}
if freqRX.Valid {
v := freqRX.Int64
q.FreqRXHz = &v
}
q.RSTSent = rstS.String
q.RSTRcvd = rstR.String
q.Name = name.String
q.QTH = qth.String
q.Address = addr.String
q.Email = email.String
q.Web = web.String
q.Grid = grid.String
q.GridExt = gridExt.String
q.VUCCGrids = vucc.String
q.Country = country.String
q.State = state.String
q.County = cnty.String
if dxcc.Valid {
v := int(dxcc.Int64)
q.DXCC = &v
}
q.Continent = cont.String
if cqz.Valid {
v := int(cqz.Int64)
q.CQZ = &v
}
if ituz.Valid {
v := int(ituz.Int64)
q.ITUZ = &v
}
q.IOTA = iota.String
q.SOTARef = sota.String
q.POTARef = pota.String
if age.Valid {
v := int(age.Int64)
q.Age = &v
}
if lat.Valid {
v := lat.Float64
q.Lat = &v
}
if lon.Valid {
v := lon.Float64
q.Lon = &v
}
q.Rig = rig.String
q.Ant = ant.String
q.QSLSent = qslSent.String
q.QSLRcvd = qslRcvd.String
q.QSLSentDate = qslSentDate.String
q.QSLRcvdDate = qslRcvdDate.String
q.QSLVia = qslVia.String
q.QSLMsg = qslMsg.String
q.QSLMsgRcvd = qslMsgRcvd.String
q.LOTWSent = lotwSent.String
q.LOTWRcvd = lotwRcvd.String
q.LOTWSentDate = lotwSentDate.String
q.LOTWRcvdDate = lotwRcvdDate.String
q.EQSLSent = eqslSent.String
q.EQSLRcvd = eqslRcvd.String
q.EQSLSentDate = eqslSentDate.String
q.EQSLRcvdDate = eqslRcvdDate.String
q.ClublogUploadDate = clublogDate.String
q.ClublogUploadStatus = clublogStatus.String
q.HRDLogUploadDate = hrdlogDate.String
q.HRDLogUploadStatus = hrdlogStatus.String
q.ContestID = contestID.String
if srx.Valid {
v := int(srx.Int64)
q.SRX = &v
}
if stx.Valid {
v := int(stx.Int64)
q.STX = &v
}
q.SRXString = srxStr.String
q.STXString = stxStr.String
q.Check = checkField.String
q.Precedence = precedence.String
q.ARRLSect = arrlSect.String
q.PropMode = propMode.String
q.SatName = satName.String
q.SatMode = satMode.String
if antAz.Valid {
v := antAz.Float64
q.AntAz = &v
}
if antEl.Valid {
v := antEl.Float64
q.AntEl = &v
}
q.AntPath = antPath.String
q.StationCallsign = stCall.String
q.Operator = op.String
q.MyGrid = myGrid.String
q.MyGridExt = myGridExt.String
q.MyCountry = myCountry.String
q.MyState = myState.String
q.MyCounty = myCnty.String
q.MyIOTA = myIOTA.String
q.MySOTARef = mySOTA.String
q.MyPOTARef = myPOTA.String
if myDXCC.Valid {
v := int(myDXCC.Int64)
q.MyDXCC = &v
}
if myCQZ.Valid {
v := int(myCQZ.Int64)
q.MyCQZone = &v
}
if myITUZ.Valid {
v := int(myITUZ.Int64)
q.MyITUZone = &v
}
if myLat.Valid {
v := myLat.Float64
q.MyLat = &v
}
if myLon.Valid {
v := myLon.Float64
q.MyLon = &v
}
q.MyStreet = myStreet.String
q.MyCity = myCity.String
q.MyPostalCode = myPostal.String
q.MyRig = myRig.String
q.MyAntenna = myAntenna.String
if txp.Valid {
v := txp.Float64
q.TXPower = &v
}
q.Comment = comment.String
q.Notes = notes.String
q.Extras = decodeExtras(extrasJSON.String)
return q, nil
}
// parseTimeLoose tries several common ISO layouts returned by SQLite.
func parseTimeLoose(s string) time.Time {
if s == "" {
return time.Time{}
}
for _, layout := range []string{isoMillis, "2006-01-02T15:04:05Z", "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}