chore: release v0.19.3
This commit is contained in:
+70
-6
@@ -61,9 +61,12 @@ const (
|
||||
eqslBaseURL = "https://www.eQSL.cc"
|
||||
)
|
||||
|
||||
// eqslAdiHrefRe pulls the generated .adi path out of the DownloadInBox reply,
|
||||
// e.g. `<A HREF="/qslcard/downloadedfiles/xxxx.adi">`.
|
||||
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href="(/[^"]+\.adi)"`)
|
||||
// eqslAdiHrefRe pulls the generated .adi link out of the DownloadInBox reply.
|
||||
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
|
||||
// e.g. `<A HREF=/downloadedFiles/xxxx.adi>` or `<a href="…/xxxx.adi">`, and the
|
||||
// path may be root-relative or a bare filename. Match case-insensitively with
|
||||
// optional quoting; resolve to an absolute URL afterwards.
|
||||
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.adi)`)
|
||||
|
||||
// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
|
||||
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
|
||||
@@ -97,18 +100,79 @@ func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg Ser
|
||||
}
|
||||
m := eqslAdiHrefRe.FindStringSubmatch(page)
|
||||
if m == nil {
|
||||
// eQSL reports problems inline ("Error: …", or "no QSLs" wording).
|
||||
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslReason(page))
|
||||
// eQSL reports problems inline ("Error: …", "You have no … confirmations").
|
||||
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
|
||||
}
|
||||
|
||||
// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
|
||||
// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
|
||||
// xxx.adi`); normalise those. It may also be absolute or a bare filename.
|
||||
link := strings.TrimSpace(m[1])
|
||||
link = strings.ReplaceAll(link, `\`, "/")
|
||||
link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
|
||||
var fileURL string
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToLower(link), "http"):
|
||||
fileURL = link
|
||||
case strings.HasPrefix(link, "/"):
|
||||
fileURL = eqslBaseURL + link
|
||||
default:
|
||||
fileURL = eqslBaseURL + "/" + link
|
||||
}
|
||||
|
||||
// Step 2: fetch the generated .adi file.
|
||||
adif, err := eqslGet(ctx, client, eqslBaseURL+m[1])
|
||||
adif, err := eqslGet(ctx, client, fileURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
|
||||
}
|
||||
return adif, nil
|
||||
}
|
||||
|
||||
// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
|
||||
// own error/notice text if present, else a short tag-stripped snippet of the page
|
||||
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
|
||||
func eqslNoLinkReason(page string) string {
|
||||
low := strings.ToLower(page)
|
||||
for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
|
||||
if i := strings.Index(low, marker); i >= 0 {
|
||||
seg := page[i:]
|
||||
if len(seg) > 160 {
|
||||
seg = seg[:160]
|
||||
}
|
||||
return strings.Join(strings.Fields(stripTags(seg)), " ")
|
||||
}
|
||||
}
|
||||
txt := strings.Join(strings.Fields(stripTags(page)), " ")
|
||||
if len(txt) > 200 {
|
||||
txt = txt[:200]
|
||||
}
|
||||
if txt == "" {
|
||||
txt = "empty response"
|
||||
}
|
||||
return txt
|
||||
}
|
||||
|
||||
// stripTags removes HTML tags for a readable one-line diagnostic.
|
||||
func stripTags(s string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '<':
|
||||
depth++
|
||||
case '>':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
default:
|
||||
if depth == 0 {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
|
||||
// a non-200 status.
|
||||
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package solar fetches live space-weather / propagation numbers (solar flux,
|
||||
// sunspot number, A/K indices, X-ray, geomagnetic field) from N0NBH's public
|
||||
// hamqsl.com XML feed, caches them, and refreshes periodically. OpsLog shows the
|
||||
// numbers in a header strip and stamps SFI / A_INDEX / K_INDEX onto each logged
|
||||
// QSO (SSN has no standard ADIF field — the app stores it as an extra).
|
||||
package solar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// feedURL is N0NBH's solar data as XML. Free, no key.
|
||||
const feedURL = "https://www.hamqsl.com/solarxml.php"
|
||||
|
||||
// Data is the parsed snapshot exposed to the UI (strings — some fields read
|
||||
// "No Report" or carry units, so we keep them verbatim and parse to numbers only
|
||||
// when stamping a QSO).
|
||||
type Data struct {
|
||||
SFI string `json:"sfi"` // solar flux index
|
||||
SSN string `json:"ssn"` // sunspot number
|
||||
AIndex string `json:"a_index"` // geomagnetic A index
|
||||
KIndex string `json:"k_index"` // geomagnetic K index
|
||||
XRay string `json:"xray"` // X-ray flux class (e.g. C1.5)
|
||||
GeomagField string `json:"geomag_field"` // QUIET / UNSETTLED / STORM…
|
||||
Aurora string `json:"aurora"`
|
||||
MUF string `json:"muf"`
|
||||
Updated string `json:"updated"` // source timestamp text
|
||||
Source string `json:"source"` // e.g. N0NBH
|
||||
OK bool `json:"ok"` // last fetch succeeded
|
||||
Error string `json:"error,omitempty"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// xmlSolar mirrors the hamqsl solarxml.php document.
|
||||
type xmlSolar struct {
|
||||
XMLName xml.Name `xml:"solar"`
|
||||
SolarData struct {
|
||||
Source string `xml:"source"`
|
||||
Updated string `xml:"updated"`
|
||||
SolarFlux string `xml:"solarflux"`
|
||||
AIndex string `xml:"aindex"`
|
||||
KIndex string `xml:"kindex"`
|
||||
XRay string `xml:"xray"`
|
||||
Sunspots string `xml:"sunspots"`
|
||||
Aurora string `xml:"aurora"`
|
||||
GeomagField string `xml:"geomagfield"`
|
||||
MUF string `xml:"muf"`
|
||||
} `xml:"solardata"`
|
||||
}
|
||||
|
||||
// Fetch performs one GET + parse of the feed.
|
||||
func Fetch(ctx context.Context, client *http.Client) (Data, error) {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||
if err != nil {
|
||||
return Data{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpsLog")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Data{}, fmt.Errorf("solar: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return Data{}, fmt.Errorf("solar: read: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Data{}, fmt.Errorf("solar: http %d", resp.StatusCode)
|
||||
}
|
||||
return parseSolar(body)
|
||||
}
|
||||
|
||||
// parseSolar turns the feed's XML bytes into a Data (split out so it can be
|
||||
// unit-tested without a network call).
|
||||
func parseSolar(body []byte) (Data, error) {
|
||||
var x xmlSolar
|
||||
if err := xml.Unmarshal(body, &x); err != nil {
|
||||
return Data{}, fmt.Errorf("solar: parse: %w", err)
|
||||
}
|
||||
s := x.SolarData
|
||||
return Data{
|
||||
SFI: strings.TrimSpace(s.SolarFlux),
|
||||
SSN: strings.TrimSpace(s.Sunspots),
|
||||
AIndex: strings.TrimSpace(s.AIndex),
|
||||
KIndex: strings.TrimSpace(s.KIndex),
|
||||
XRay: strings.TrimSpace(s.XRay),
|
||||
GeomagField: strings.TrimSpace(s.GeomagField),
|
||||
Aurora: strings.TrimSpace(s.Aurora),
|
||||
MUF: strings.TrimSpace(s.MUF),
|
||||
Updated: strings.TrimSpace(s.Updated),
|
||||
Source: strings.TrimSpace(s.Source),
|
||||
OK: true,
|
||||
FetchedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Manager caches the latest Data and refreshes it on a timer.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
data Data
|
||||
client *http.Client
|
||||
onChange func()
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewManager builds a manager. onChange (optional) fires after each refresh so
|
||||
// the host can push a UI event.
|
||||
func NewManager(onChange func()) *Manager {
|
||||
return &Manager{
|
||||
client: &http.Client{Timeout: 20 * time.Second},
|
||||
onChange: onChange,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start fetches now and every hour until Stop (the feed updates roughly hourly).
|
||||
func (m *Manager) Start() {
|
||||
go func() {
|
||||
m.refresh()
|
||||
t := time.NewTicker(1 * time.Hour)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
m.refresh()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop ends the refresh loop.
|
||||
func (m *Manager) Stop() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.once.Do(func() { close(m.stop) })
|
||||
}
|
||||
|
||||
// Get returns the latest snapshot.
|
||||
func (m *Manager) Get() Data {
|
||||
if m == nil {
|
||||
return Data{}
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.data
|
||||
}
|
||||
|
||||
// Refresh forces an immediate fetch (used by a manual "refresh" binding).
|
||||
func (m *Manager) Refresh() { m.refresh() }
|
||||
|
||||
func (m *Manager) refresh() {
|
||||
d, err := Fetch(context.Background(), m.client)
|
||||
m.mu.Lock()
|
||||
if err != nil {
|
||||
m.data.OK = false
|
||||
m.data.Error = err.Error()
|
||||
} else {
|
||||
m.data = d
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if m.onChange != nil {
|
||||
m.onChange()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package solar
|
||||
|
||||
import "testing"
|
||||
|
||||
// A trimmed sample of the real N0NBH hamqsl.com solarxml.php document.
|
||||
const sampleXML = `<?xml version="1.0"?>
|
||||
<solar>
|
||||
<solardata>
|
||||
<source url="http://www.hamqsl.com/solar.html">N0NBH</source>
|
||||
<updated>08 Jul 2025 2100 GMT</updated>
|
||||
<solarflux>170</solarflux>
|
||||
<aindex>5</aindex>
|
||||
<kindex>2</kindex>
|
||||
<xray>C1.5</xray>
|
||||
<sunspots>150</sunspots>
|
||||
<geomagfield>QUIET</geomagfield>
|
||||
<muf>18.5</muf>
|
||||
</solardata>
|
||||
</solar>`
|
||||
|
||||
func TestParseSolar(t *testing.T) {
|
||||
d, err := parseSolar([]byte(sampleXML))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if !d.OK {
|
||||
t.Fatalf("expected OK")
|
||||
}
|
||||
checks := map[string]string{
|
||||
"SFI": d.SFI, "SSN": d.SSN, "A": d.AIndex, "K": d.KIndex,
|
||||
"XRay": d.XRay, "Geomag": d.GeomagField, "Source": d.Source,
|
||||
}
|
||||
want := map[string]string{
|
||||
"SFI": "170", "SSN": "150", "A": "5", "K": "2",
|
||||
"XRay": "C1.5", "Geomag": "QUIET", "Source": "N0NBH",
|
||||
}
|
||||
for k, got := range checks {
|
||||
if got != want[k] {
|
||||
t.Errorf("%s = %q, want %q", k, got, want[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSolarBadXML(t *testing.T) {
|
||||
if _, err := parseSolar([]byte("not xml")); err == nil {
|
||||
t.Fatalf("expected an error for non-XML input")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user