Files
OpsLog/internal/qso/qso.go
T
2026-05-29 00:52:10 +02:00

1378 lines
44 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"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_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,
qrzcom_qso_upload_date, qrzcom_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.QRZComUploadDate, q.QRZComUploadStatus,
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) {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
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 {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
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)
}
// UploadRow is a lightweight QSO projection for the QSL Manager grid.
type UploadRow struct {
ID int64 `json:"id"`
QSODate string `json:"qso_date"` // ISO UTC; the UI formats it
Callsign string `json:"callsign"`
Band string `json:"band"`
Mode string `json:"mode"`
Country string `json:"country"`
Status string `json:"status"` // the matched per-service sent status
}
// uploadStatusCols whitelists the per-service sent-status columns the QSL
// Manager may filter on (guards the dynamic column name in the query).
var uploadStatusCols = map[string]bool{
"lotw_sent": true,
"qrzcom_qso_upload_status": true,
"clublog_qso_upload_status": true,
}
// ListForUpload returns QSOs whose per-service sent-status column equals
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column)
}
rows, err := r.db.QueryContext(ctx,
`SELECT id, qso_date, callsign, COALESCE(band,''), COALESCE(mode,''),
COALESCE(country,''), COALESCE(`+column+`,'')
FROM qso WHERE COALESCE(`+column+`,'') = ?
ORDER BY qso_date DESC`, value)
if err != nil {
return nil, fmt.Errorf("list for upload: %w", err)
}
defer rows.Close()
var out []UploadRow
for rows.Next() {
var u UploadRow
if err := rows.Scan(&u.ID, &u.QSODate, &u.Callsign, &u.Band, &u.Mode, &u.Country, &u.Status); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
// rewrite — so it's safe to call from the async upload path.
func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark qrz uploaded %d: %w", id, err)
}
return nil
}
// MarkClublogUploaded stamps CLUBLOG_QSO_UPLOAD_STATUS=Y and the upload
// date after a successful Club Log push. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark clublog uploaded %d: %w", id, err)
}
return nil
}
// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a
// successful TQSL upload. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark lotw uploaded %d: %w", id, err)
}
return nil
}
// 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")
}
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
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 != "" {
// Contains-match so a search for "XYZ" finds F4XYZ, F4XYZ/P, etc.
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 = 500
}
// Hard upper bound: 1M is enough to fit any realistic personal log.
if f.Limit > 1_000_000 {
f.Limit = 1_000_000
}
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 upper(trim(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 upper(trim(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
band, mode sql.NullString
rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString
)
if err := rows.Scan(&e.ID, &dateStr, &band, &mode,
&rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil {
rows.Close()
return wb, fmt.Errorf("scan worked: %w", err)
}
e.QSODate = parseTimeLoose(dateStr)
e.Band = band.String
e.Mode = mode.String
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)
if e.Band != "" {
bandsSet[e.Band] = struct{}{}
}
if e.Mode != "" {
modesSet[e.Mode] = struct{}{}
}
if e.Band != "" && e.Mode != "" {
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 upper(trim(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 upper(trim(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 = ? AND band IS NOT NULL AND band != ''`, dxcc); err != nil {
return wb, err
}
if err := r.collectDistinct(ctx, &wb.DXCCModes,
`SELECT DISTINCT mode FROM qso WHERE dxcc = ? AND mode IS NOT NULL AND mode != ''`, dxcc); err != nil {
return wb, err
}
bmRows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT band, mode FROM qso WHERE dxcc = ?
AND band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''`, 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.
// Filter NULL/empty band+mode rows — they'd create a NULL group key
// that Scan into *string can't handle and would error out the whole
// 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)) = ?
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 = ?
AND band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''
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]
}
}
}
// IterateAll streams every QSO in the database through fn, ordered by
// qso_date ascending so an ADIF export is chronological. Constant memory
// regardless of table size — the alternative (loading all 25k+ rows into
// a slice) wastes ~20MB for no good reason.
func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
if err != nil {
return fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// EntitySlot bundles every (band, mode) tuple ever worked for a given
// DXCC entity name. Used by the cluster spot colouring code to decide
// NEW / NEW SLOT / WORKED in constant time after one batched query.
type EntitySlot struct {
Country string
Bands map[string]struct{} // bands worked, any mode
Slots map[string]map[string]struct{} // band → modes worked
}
// EntitySlotMap returns slot data for every QSO, grouping by entity.
//
// `resolveEntity` maps a callsign to its canonical entity name (we use
// cty.dat for this). When non-nil, the resolved name wins over the
// stored `country` column — that's important because QRZ's "Turkey"
// disagrees with cty.dat's "Asiatic Turkey" and the cluster status
// comparison would otherwise miss past QSOs. When nil, we fall back to
// the stored country (useful for tests).
//
// One DB scan regardless of input size. Cheap to call per cluster batch.
func (r *Repo) EntitySlotMap(ctx context.Context, resolveEntity func(callsign string) string) (map[string]*EntitySlot, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT callsign, lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
WHERE band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]*EntitySlot, 256)
for rows.Next() {
var call, country, band, mode string
if err := rows.Scan(&call, &country, &band, &mode); err != nil {
return nil, err
}
key := country
if resolveEntity != nil {
if name := strings.ToLower(strings.TrimSpace(resolveEntity(call))); name != "" {
key = name
}
}
if key == "" {
continue
}
e, ok := out[key]
if !ok {
e = &EntitySlot{
Country: key,
Bands: make(map[string]struct{}),
Slots: make(map[string]map[string]struct{}),
}
out[key] = e
}
e.Bands[band] = struct{}{}
bandSlots, ok := e.Slots[band]
if !ok {
bandSlots = make(map[string]struct{})
e.Slots[band] = bandSlots
}
bandSlots[mode] = struct{}{}
}
return out, rows.Err()
}
// WorkedCallsigns returns the set of every callsign ever logged (uppercased).
// One pass, used by the cluster spot colouring to flag "already worked this
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 1024)
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out[c] = struct{}{}
}
return out, rows.Err()
}
// 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
}
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
// used by the ADIF importer to skip records that would re-create the
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
// precision so two loggers that wrote a few seconds apart still match.
//
// On a 25k-row table this returns ~25k strings (~2MB RAM) in one pass —
// far cheaper than N exists-queries during the import loop.
func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 1024)
for rows.Next() {
var call, when, band, mode string
if err := rows.Scan(&call, &when, &band, &mode); err != nil {
return nil, err
}
out[DedupeKey(call, when, band, mode)] = struct{}{}
}
return out, rows.Err()
}
// DedupeKey is the canonical dedupe identity for a QSO. Exposed so the
// importer can compute the key from in-flight records and check against
// the same map ExistingDedupeKeys returns.
func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
}
// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
out[DedupeKey(call, when, band, mode)] = id
}
return out, rows.Err()
}
// ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
type ConfirmedSets struct {
DXCC map[int]bool // dxcc entity confirmed
Band map[string]bool // "dxcc|band"
Slot map[string]bool // "dxcc|band|mode"
}
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
func SlotKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
}
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos. A QSO
// counts as confirmed when any received flag (LoTW, paper, eQSL) is "Y".
func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,''))
FROM qso
WHERE lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'`)
if err != nil {
return sets, err
}
defer rows.Close()
for rows.Next() {
var dxcc int
var band, mode string
if err := rows.Scan(&dxcc, &band, &mode); err != nil {
return sets, err
}
if dxcc == 0 {
continue
}
sets.DXCC[dxcc] = true
sets.Band[BandKey(dxcc, band)] = true
sets.Slot[SlotKey(dxcc, band, mode)] = true
}
return sets, rows.Err()
}
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
}
return nil
}
// 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
qrzcomDate, qrzcomStatus 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,
&qrzcomDate, &qrzcomStatus,
&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.QRZComUploadDate = qrzcomDate.String
q.QRZComUploadStatus = qrzcomStatus.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{}
}