feat(lookup): read QRZ's island reference and fill the IOTA award

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.
This commit is contained in:
2026-08-16 23:22:37 +02:00
parent 724e68b38d
commit 747c2b9105
8 changed files with 111 additions and 10 deletions
+12 -7
View File
@@ -41,6 +41,10 @@ type Result struct {
// along and nothing ever filled it, because no provider mapping read the
// field.
Web string `json:"web,omitempty"`
// IOTA is the island reference (EU-048) for an operator on one. QRZ sends it
// and nothing used to read it — and since no live activation feed exists for
// IOTA the way it does for POTA, the callbook record is the practical source.
IOTA string `json:"iota,omitempty"`
// Zip is the postal code. HamQTH and QRZ both send one.
Zip string `json:"zip,omitempty"`
ImageURL string `json:"image_url,omitempty"` // profile picture URL
@@ -452,13 +456,13 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
row := c.db.QueryRowContext(ctx, `
SELECT callsign, name, qth, address, state, cnty, country, grid,
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
web, zip, source, fetched_at
web, zip, iota, source, fetched_at
FROM callsign_cache WHERE callsign = ?`, callsign)
var (
r Result
name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia, image sql.NullString
web, zip sql.NullString
web, zip, iotaRef sql.NullString
src string
dxcc, cqz, ituz sql.NullInt64
lat, lon sql.NullFloat64
@@ -466,7 +470,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
)
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
&country, &grid, &lat, &lon,
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip,
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip, &iotaRef,
&src, &fetched); err != nil {
return Result{}, false
}
@@ -490,6 +494,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
r.Email = email.String
r.Web = web.String
r.Zip = zip.String
r.IOTA = strings.ToUpper(iotaRef.String)
r.QSLVia = qslVia.String
r.ImageURL = image.String
r.DXCC = int(dxcc.Int64)
@@ -506,7 +511,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
updateCols := []string{
"name", "qth", "address", "state", "cnty",
"country", "grid", "lat", "lon",
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip",
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip", "iota",
"source", "fetched_at",
}
// The lookup cache always lives in the local SQLite database, so SQLite
@@ -519,8 +524,8 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
country, grid, lat, lon,
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
web, zip, source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?, ?,?)
web, zip, iota, source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?,?, ?,?)
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
_, err := c.db.ExecContext(ctx, q,
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
@@ -529,7 +534,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
nullableFloat(r.Lat), nullableFloat(r.Lon),
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip),
nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip), nullable(r.IOTA),
r.Source, db.NowISO(),
)
return err