feat: status bar added
This commit is contained in:
@@ -169,6 +169,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
||||
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||
|
||||
// --- Contest ---
|
||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
||||
|
||||
@@ -184,6 +184,7 @@ var adifPromoted = stringSet(
|
||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||
// Contest
|
||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||
"check", "precedence", "arrl_sect",
|
||||
@@ -315,6 +316,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
|
||||
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
|
||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||
|
||||
// Contest
|
||||
q.ContestID = rec["contest_id"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- QRZ.com confirmation (download) tracking. Mirrors the upload columns from
|
||||
-- 0014: QRZCOM_QSO_DOWNLOAD_STATUS = 'Y' when QRZ reports the QSO as
|
||||
-- confirmed (matched by the other op), with the date it was pulled.
|
||||
ALTER TABLE qso ADD COLUMN qrzcom_qso_download_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN qrzcom_qso_download_status TEXT;
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -147,6 +149,30 @@ func (m *Manager) Lookup(callsign string) (Match, bool) {
|
||||
return db.Lookup(callsign)
|
||||
}
|
||||
|
||||
// EntityNames returns the sorted, de-duplicated DXCC entity names from the
|
||||
// loaded cty.dat — the canonical list for a "Country" picker. Empty until
|
||||
// cty.dat has loaded.
|
||||
func (m *Manager) EntityNames() []string {
|
||||
m.mu.RLock()
|
||||
db := m.db
|
||||
m.mu.RUnlock()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range db.Entities() {
|
||||
n := strings.TrimSpace(e.Name)
|
||||
if n == "" || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Info returns metadata about the currently-loaded cty.dat (or zero value
|
||||
// if nothing loaded).
|
||||
func (m *Manager) Info() ctySource {
|
||||
|
||||
@@ -3,6 +3,7 @@ package extsvc
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -70,6 +71,80 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
|
||||
return parseQRZResponse(string(body))
|
||||
}
|
||||
|
||||
// QRZFetchResult is the parsed outcome of a QRZ FETCH.
|
||||
type QRZFetchResult struct {
|
||||
ADIF string // raw ADIF document
|
||||
Result string // RESULT field (OK / FAIL / AUTH)
|
||||
Count string // COUNT field reported by QRZ
|
||||
}
|
||||
|
||||
// FetchQRZ pulls logbook records as ADIF via the QRZ FETCH action. option is
|
||||
// the QRZ OPTION string (e.g. "ALL"). The ADIF document is returned in the
|
||||
// response's ADIF field.
|
||||
func FetchQRZ(ctx context.Context, client *http.Client, apiKey, option string) (QRZFetchResult, error) {
|
||||
var out QRZFetchResult
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return out, fmt.Errorf("qrz: api key not set")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("KEY", apiKey)
|
||||
form.Set("ACTION", "FETCH")
|
||||
if option != "" {
|
||||
form.Set("OPTION", option)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("qrz: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 120 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("qrz: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("qrz: read response: %w", err)
|
||||
}
|
||||
// The response is "RESULT=OK&COUNT=N&ADIF=<adif>". The ADIF blob can
|
||||
// contain '&' and ';', so we can't url.ParseQuery the whole body (Go
|
||||
// caps the number of params). Split off the ADIF value manually and
|
||||
// only query-parse the small status header.
|
||||
full := string(body)
|
||||
head, adifPart := full, ""
|
||||
if i := strings.Index(full, "ADIF="); i >= 0 {
|
||||
head = full[:i]
|
||||
adifPart = full[i+len("ADIF="):]
|
||||
}
|
||||
vals, _ := url.ParseQuery(strings.TrimRight(head, "&"))
|
||||
out.Result = strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
|
||||
out.Count = strings.TrimSpace(vals.Get("COUNT"))
|
||||
if out.Result == "AUTH" || out.Result == "FAIL" {
|
||||
reason := strings.TrimSpace(vals.Get("REASON"))
|
||||
if reason == "" {
|
||||
reason = "fetch rejected"
|
||||
}
|
||||
return out, fmt.Errorf("qrz: %s", reason)
|
||||
}
|
||||
// The ADIF value may be url-encoded (%3C) and/or HTML-entity-encoded
|
||||
// (QRZ returns < > &). Decode both so the ADIF parser sees
|
||||
// real '<' / '>' tags.
|
||||
if strings.Contains(adifPart, "%3C") || strings.Contains(adifPart, "%3c") {
|
||||
if dec, derr := url.QueryUnescape(adifPart); derr == nil {
|
||||
adifPart = dec
|
||||
}
|
||||
}
|
||||
if strings.Contains(adifPart, "<") || strings.Contains(adifPart, ">") || strings.Contains(adifPart, "&") {
|
||||
adifPart = html.UnescapeString(adifPart)
|
||||
}
|
||||
out.ADIF = adifPart
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TestQRZ checks a logbook API key with ACTION=STATUS and returns a short
|
||||
// human-readable summary (callsign + QSO count) for the settings UI. An
|
||||
// invalid key comes back as STATUS=AUTH → returned as an error.
|
||||
|
||||
@@ -50,7 +50,7 @@ type Provider interface {
|
||||
// don't (or when no provider returned anything). Decoupled via interface so
|
||||
// `lookup` doesn't import the dxcc package directly.
|
||||
type DXCCResolver interface {
|
||||
Resolve(callsign string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool)
|
||||
Resolve(callsign string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool)
|
||||
}
|
||||
|
||||
// Manager composes a cache with one or more providers.
|
||||
@@ -210,7 +210,7 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
if dxcc == nil {
|
||||
return false
|
||||
}
|
||||
country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
|
||||
dxccNum, country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
@@ -221,8 +221,15 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
if ituz != 0 { r.ITUZ = ituz; filled = true }
|
||||
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
|
||||
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
|
||||
// Slashed call → drop QRZ's DXCC# (it's the home call's).
|
||||
if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
|
||||
// cty.dat is authoritative for the *operating* entity: it strips benign
|
||||
// suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
|
||||
// Use its DXCC# when known — this overrides the provider's home-call
|
||||
// value AND fixes portable calls like F4BPO/P (same entity, must keep
|
||||
// France's 227). Only when cty.dat can't map a slashed call do we drop
|
||||
// the provider's number rather than mislabel.
|
||||
if dxccNum != 0 {
|
||||
if r.DXCC != dxccNum { r.DXCC = dxccNum; filled = true }
|
||||
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
|
||||
r.DXCC = 0
|
||||
filled = true
|
||||
}
|
||||
|
||||
+46
-4
@@ -78,6 +78,8 @@ type QSO struct {
|
||||
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
|
||||
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
|
||||
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
|
||||
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
|
||||
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
|
||||
|
||||
// --- Contest ---
|
||||
ContestID string `json:"contest_id,omitempty"`
|
||||
@@ -167,6 +169,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
||||
clublog_qso_upload_date, clublog_qso_upload_status,
|
||||
hrdlog_qso_upload_date, hrdlog_qso_upload_status,
|
||||
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
|
||||
qrzcom_qso_download_date, qrzcom_qso_download_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,
|
||||
@@ -219,6 +222,7 @@ func (q *QSO) args() []any {
|
||||
q.ClublogUploadDate, q.ClublogUploadStatus,
|
||||
q.HRDLogUploadDate, q.HRDLogUploadStatus,
|
||||
q.QRZComUploadDate, q.QRZComUploadStatus,
|
||||
q.QRZComDownloadDate, q.QRZComDownloadStatus,
|
||||
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,
|
||||
@@ -1099,14 +1103,35 @@ 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) {
|
||||
// confirmedCols whitelists the received-status columns ConfirmedSlots may
|
||||
// OR together (guards the dynamic SQL).
|
||||
var confirmedCols = map[string]bool{
|
||||
"lotw_rcvd": true,
|
||||
"qsl_rcvd": true,
|
||||
"eqsl_rcvd": true,
|
||||
"qrzcom_qso_download_status": true,
|
||||
}
|
||||
|
||||
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
|
||||
// only the given received-status columns as "confirmed". This lets the caller
|
||||
// scope award-relevant confirmations per service — e.g. LoTW download uses
|
||||
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
|
||||
// {qrzcom_qso_download_status}.
|
||||
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
|
||||
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
|
||||
var conds []string
|
||||
for _, c := range cols {
|
||||
if confirmedCols[c] {
|
||||
conds = append(conds, c+" = 'Y'")
|
||||
}
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return sets, nil
|
||||
}
|
||||
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'`)
|
||||
WHERE `+strings.Join(conds, " OR "))
|
||||
if err != nil {
|
||||
return sets, err
|
||||
}
|
||||
@@ -1127,6 +1152,19 @@ func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
|
||||
return sets, rows.Err()
|
||||
}
|
||||
|
||||
// MarkQRZConfirmed stamps QRZCOM_QSO_DOWNLOAD_STATUS=Y and the date on a QSO
|
||||
// confirmed via a QRZ.com download. date is an ADIF YYYYMMDD string.
|
||||
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
||||
date, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -1173,6 +1211,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
clublogDate, clublogStatus sql.NullString
|
||||
hrdlogDate, hrdlogStatus sql.NullString
|
||||
qrzcomDate, qrzcomStatus sql.NullString
|
||||
qrzcomDlDate, qrzcomDlStatus sql.NullString
|
||||
contestID sql.NullString
|
||||
srx, stx sql.NullInt64
|
||||
srxStr, stxStr sql.NullString
|
||||
@@ -1205,6 +1244,7 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
&clublogDate, &clublogStatus,
|
||||
&hrdlogDate, &hrdlogStatus,
|
||||
&qrzcomDate, &qrzcomStatus,
|
||||
&qrzcomDlDate, &qrzcomDlStatus,
|
||||
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
|
||||
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
|
||||
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
||||
@@ -1292,6 +1332,8 @@ func scanQSO(s scanner) (QSO, error) {
|
||||
q.HRDLogUploadStatus = hrdlogStatus.String
|
||||
q.QRZComUploadDate = qrzcomDate.String
|
||||
q.QRZComUploadStatus = qrzcomStatus.String
|
||||
q.QRZComDownloadDate = qrzcomDlDate.String
|
||||
q.QRZComDownloadStatus = qrzcomDlStatus.String
|
||||
q.ContestID = contestID.String
|
||||
if srx.Valid {
|
||||
v := int(srx.Int64)
|
||||
|
||||
@@ -10,6 +10,7 @@ package pst
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -53,6 +54,59 @@ func (c *Client) Park() error {
|
||||
return c.send("<PST><PARK>1</PARK></PST>")
|
||||
}
|
||||
|
||||
// Heading queries PstRotator for the current azimuth. PstRotator's protocol:
|
||||
// send "<PST>AZ?</PST>" to the command port, and it reports the azimuth back
|
||||
// on UDP port+1. So we bind a listener on port+1 first, send the query, then
|
||||
// read the reply. Returns the raw reply too, for diagnostics. err is non-nil
|
||||
// on timeout (no reply) or an unparseable response.
|
||||
func (c *Client) Heading() (az int, raw string, err error) {
|
||||
// Listen on port+1 where PstRotator sends its position report.
|
||||
pc, err := net.ListenPacket("udp4", fmt.Sprintf(":%d", c.Port+1))
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("listen :%d for PstRotator reply: %w", c.Port+1, err)
|
||||
}
|
||||
defer pc.Close()
|
||||
|
||||
if err := c.send("<PST>AZ?</PST>"); err != nil {
|
||||
return 0, "", fmt.Errorf("query PstRotator: %w", err)
|
||||
}
|
||||
|
||||
_ = pc.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
|
||||
buf := make([]byte, 512)
|
||||
n, _, rerr := pc.ReadFrom(buf)
|
||||
if rerr != nil {
|
||||
return 0, "", fmt.Errorf("no reply on :%d: %w", c.Port+1, rerr)
|
||||
}
|
||||
raw = string(buf[:n])
|
||||
a, ok := parseAzimuth(raw)
|
||||
if !ok {
|
||||
return 0, raw, fmt.Errorf("no azimuth in reply %q", raw)
|
||||
}
|
||||
return a, raw, nil
|
||||
}
|
||||
|
||||
// parseAzimuth extracts the first integer found in a PstRotator reply
|
||||
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
|
||||
// it to [0,360).
|
||||
func parseAzimuth(s string) (int, bool) {
|
||||
i := 0
|
||||
for i < len(s) && (s[i] < '0' || s[i] > '9') {
|
||||
i++
|
||||
}
|
||||
if i >= len(s) {
|
||||
return 0, false
|
||||
}
|
||||
j := i
|
||||
for j < len(s) && s[j] >= '0' && s[j] <= '9' {
|
||||
j++
|
||||
}
|
||||
n, err := strconv.Atoi(s[i:j])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return ((n % 360) + 360) % 360, true
|
||||
}
|
||||
|
||||
func (c *Client) send(payload string) error {
|
||||
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
conn, err := net.DialTimeout("udp", addr, 2*time.Second)
|
||||
|
||||
Reference in New Issue
Block a user