QRZ's XML carries <iota>EU-048</iota> for an operator on an island, and OpsLog read past it. Wired end to end: the provider, the lookup cache (a column, like web and zip before it), and the entry's award references. It matters more here than the same field would for another award. There is no live "who is on an island right now" feed anywhere — POTA has one and that is what OpsLog matches spots against; SOTA has one behind conditions; IOTA publishes only static lists. So the callbook record is the practical source, and it is known BEFORE the contact is logged, which is when a reference is useful. The reference goes in as an IOTA award reference, exactly as one picked by hand, so the existing path carries it to the qso.iota column on save. Two limits, both deliberate. A reference the operator typed or picked WINS: a callbook entry can be years out of date, and the operator in front of the radio has just been told where the station is. And the value must look like an IOTA reference — two letters, a hyphen, three digits — because writing anything else into the award makes a reference no list contains, which counts for nothing and has to be found by hand later.
259 lines
7.2 KiB
Go
259 lines
7.2 KiB
Go
package lookup
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// QRZ is a lookup.Provider for xmldata.qrz.com.
|
|
// A QRZ subscription is required for full data; basic info is free.
|
|
type QRZ struct {
|
|
User string
|
|
Password string
|
|
|
|
HTTP *http.Client
|
|
|
|
// PreferNickname takes QRZ's <nickname> over the composed first+last name
|
|
// when the operator has published one. It is the name they go BY on the air,
|
|
// which is what belongs in a log — HamQTH's <nick> is already used that way,
|
|
// and this brings QRZ into line for operators who want it.
|
|
PreferNickname bool
|
|
|
|
mu sync.Mutex
|
|
session string
|
|
loggedAt time.Time
|
|
}
|
|
|
|
func NewQRZ(user, password string) *QRZ {
|
|
return &QRZ{
|
|
User: user,
|
|
Password: password,
|
|
HTTP: &http.Client{Timeout: 12 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (q *QRZ) Name() string { return "qrz" }
|
|
|
|
// Lookup queries QRZ for the given callsign.
|
|
func (q *QRZ) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|
if q.User == "" || q.Password == "" {
|
|
return Result{}, fmt.Errorf("qrz: credentials not set")
|
|
}
|
|
key, err := q.sessionKey(ctx)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
r, err := q.fetch(ctx, key, callsign)
|
|
if err == errQRZSessionExpired {
|
|
// Force re-login and retry once.
|
|
q.mu.Lock()
|
|
q.session = ""
|
|
q.mu.Unlock()
|
|
key, err = q.sessionKey(ctx)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
r, err = q.fetch(ctx, key, callsign)
|
|
}
|
|
return r, err
|
|
}
|
|
|
|
func (q *QRZ) sessionKey(ctx context.Context) (string, error) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
// QRZ sessions are valid ~24h; re-login defensively after 12h.
|
|
if q.session != "" && time.Since(q.loggedAt) < 12*time.Hour {
|
|
return q.session, nil
|
|
}
|
|
u := fmt.Sprintf("https://xmldata.qrz.com/xml/current/?username=%s;password=%s;agent=HamLog",
|
|
url.QueryEscape(q.User), url.QueryEscape(q.Password))
|
|
body, err := q.get(ctx, u)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var resp qrzDB
|
|
if err := xml.Unmarshal(body, &resp); err != nil {
|
|
return "", fmt.Errorf("qrz: parse session: %w", err)
|
|
}
|
|
if resp.Session.Error != "" {
|
|
return "", fmt.Errorf("qrz login: %s", resp.Session.Error)
|
|
}
|
|
if resp.Session.Key == "" {
|
|
return "", fmt.Errorf("qrz: empty session key")
|
|
}
|
|
q.session = resp.Session.Key
|
|
q.loggedAt = time.Now()
|
|
return q.session, nil
|
|
}
|
|
|
|
var errQRZSessionExpired = fmt.Errorf("qrz: session expired")
|
|
|
|
func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, error) {
|
|
u := fmt.Sprintf("https://xmldata.qrz.com/xml/current/?s=%s;callsign=%s",
|
|
url.QueryEscape(sessionKey), url.QueryEscape(callsign))
|
|
body, err := q.get(ctx, u)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
var resp qrzDB
|
|
if err := xml.Unmarshal(body, &resp); err != nil {
|
|
return Result{}, fmt.Errorf("qrz: parse callsign: %w", err)
|
|
}
|
|
if resp.Session.Error != "" {
|
|
msg := strings.ToLower(resp.Session.Error)
|
|
if strings.Contains(msg, "session") || strings.Contains(msg, "invalid") {
|
|
return Result{}, errQRZSessionExpired
|
|
}
|
|
if strings.Contains(msg, "not found") {
|
|
return Result{}, ErrNotFound
|
|
}
|
|
return Result{}, fmt.Errorf("qrz: %s", resp.Session.Error)
|
|
}
|
|
c := resp.Callsign
|
|
if c.Call == "" {
|
|
return Result{}, ErrNotFound
|
|
}
|
|
r := Result{
|
|
Callsign: strings.ToUpper(c.Call),
|
|
Name: qrzName(q.PreferNickname, c.Nickname, c.FName, c.Name),
|
|
QTH: c.Addr2,
|
|
Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country),
|
|
State: strings.ToUpper(c.State),
|
|
County: c.County,
|
|
Country: c.Country,
|
|
Grid: strings.ToUpper(c.Grid),
|
|
Continent: strings.ToUpper(c.Continent),
|
|
Email: c.Email,
|
|
QSLVia: c.QSLMgr,
|
|
ImageURL: strings.TrimSpace(c.Image),
|
|
IOTA: strings.ToUpper(strings.TrimSpace(c.IOTA)),
|
|
}
|
|
r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
|
|
r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
|
|
r.DXCC, _ = strconv.Atoi(c.DXCC)
|
|
r.CQZ, _ = strconv.Atoi(c.CQZone)
|
|
r.ITUZ, _ = strconv.Atoi(c.ITUZone)
|
|
return r, nil
|
|
}
|
|
|
|
func (q *QRZ) get(ctx context.Context, u string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := q.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qrz http: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("qrz http %d", resp.StatusCode)
|
|
}
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// ----- XML shapes -----
|
|
|
|
type qrzDB struct {
|
|
XMLName xml.Name `xml:"QRZDatabase"`
|
|
Session qrzSession `xml:"Session"`
|
|
Callsign qrzCallsign `xml:"Callsign"`
|
|
}
|
|
|
|
type qrzSession struct {
|
|
Key string `xml:"Key"`
|
|
Error string `xml:"Error"`
|
|
}
|
|
|
|
type qrzCallsign struct {
|
|
Call string `xml:"call"`
|
|
FName string `xml:"fname"`
|
|
Nickname string `xml:"nickname"` // the name the operator goes by on the air
|
|
Name string `xml:"name"`
|
|
Addr1 string `xml:"addr1"`
|
|
Addr2 string `xml:"addr2"`
|
|
Zip string `xml:"zip"`
|
|
State string `xml:"state"`
|
|
County string `xml:"county"`
|
|
Country string `xml:"country"`
|
|
Grid string `xml:"grid"`
|
|
Lat string `xml:"lat"`
|
|
Lon string `xml:"lon"`
|
|
DXCC string `xml:"dxcc"`
|
|
CQZone string `xml:"cqzone"`
|
|
ITUZone string `xml:"ituzone"`
|
|
Continent string `xml:"cont"`
|
|
Email string `xml:"email"`
|
|
QSLMgr string `xml:"qslmgr"`
|
|
Image string `xml:"image"` // direct URL to the profile picture (subscribers only on QRZ)
|
|
IOTA string `xml:"iota"` // island reference for an operator on one, e.g. EU-048
|
|
}
|
|
|
|
// composeQRZAddress builds a multi-line postal address from QRZ's separate
|
|
// fields (street, city, zip, country) the same way Log4OM displays it.
|
|
// addr1 is the street, addr2 is the city — skip addr1 when it duplicates
|
|
// the city (common for users who only filled the city).
|
|
func composeQRZAddress(addr1, addr2, zip, country string) string {
|
|
addr1 = strings.TrimSpace(addr1)
|
|
addr2 = strings.TrimSpace(addr2)
|
|
zip = strings.TrimSpace(zip)
|
|
country = strings.TrimSpace(country)
|
|
var parts []string
|
|
if addr1 != "" && !strings.EqualFold(addr1, addr2) {
|
|
parts = append(parts, addr1)
|
|
}
|
|
if addr2 != "" {
|
|
parts = append(parts, addr2)
|
|
}
|
|
if zip != "" {
|
|
parts = append(parts, zip)
|
|
}
|
|
if country != "" {
|
|
parts = append(parts, country)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func firstNonEmpty(s ...string) string {
|
|
for _, v := range s {
|
|
v = strings.TrimSpace(v)
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// qrzName picks what goes in the log's Name field.
|
|
//
|
|
// The FIRST name only. A log greets an operator, it does not address an
|
|
// envelope: "Robert" is what goes out on the air, and "Robert Smith" filled the
|
|
// field with something no one would ever send. The surname stays available in
|
|
// the record; it simply is not the name of the contact.
|
|
//
|
|
// The nickname is taken ahead of it when the operator asked for that AND QRZ
|
|
// has one — a blank nickname must never blank the name, which is the whole
|
|
// reason this is a fallback rather than a swap.
|
|
//
|
|
// The surname is the last resort: a record with no first name at all is better
|
|
// answered with a surname than with nothing.
|
|
func qrzName(preferNickname bool, nickname, fname, name string) string {
|
|
if preferNickname {
|
|
if n := strings.TrimSpace(nickname); n != "" {
|
|
return n
|
|
}
|
|
}
|
|
if f := strings.TrimSpace(fname); f != "" {
|
|
return f
|
|
}
|
|
return strings.TrimSpace(name)
|
|
}
|