1146 lines
36 KiB
Go
1146 lines
36 KiB
Go
// 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]
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 grouped by lowercase
|
||
// country name (cty.dat-style key). Cheap on a 25k-row table: one
|
||
// scan, no joins. Callers can compare a spot's entity to this map to
|
||
// decide if it's NEW / NEW SLOT / WORKED.
|
||
func (r *Repo) EntitySlotMap(ctx context.Context) (map[string]*EntitySlot, error) {
|
||
rows, err := r.db.QueryContext(ctx,
|
||
`SELECT lower(country), lower(band), upper(mode) FROM qso
|
||
WHERE country IS NOT NULL AND country != ''
|
||
AND 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 country, band, mode string
|
||
if err := rows.Scan(&country, &band, &mode); err != nil {
|
||
return nil, err
|
||
}
|
||
e, ok := out[country]
|
||
if !ok {
|
||
e = &EntitySlot{
|
||
Country: country,
|
||
Bands: make(map[string]struct{}),
|
||
Slots: make(map[string]map[string]struct{}),
|
||
}
|
||
out[country] = 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()
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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, ¬es, &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{}
|
||
}
|