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:
+4
-2
@@ -8,7 +8,8 @@
|
|||||||
"Worked before: a prefixed call like ZA/OE8NDR matched every other visitor to that country instead of that one operator.",
|
"Worked before: a prefixed call like ZA/OE8NDR matched every other visitor to that country instead of that one operator.",
|
||||||
"French: seventeen strings were still in English, the whole update panel among them, plus Spot lifetime and Chase new grids.",
|
"French: seventeen strings were still in English, the whole update panel among them, plus Spot lifetime and Chase new grids.",
|
||||||
"Selecting a QSO shows the entity the QSO records, not one re-derived from its callsign — a 3Y0K contact logged as Bouvet showed the Antarctica matrix.",
|
"Selecting a QSO shows the entity the QSO records, not one re-derived from its callsign — a 3Y0K contact logged as Bouvet showed the Antarctica matrix.",
|
||||||
"Back-entering a QSO resolves the ClubLog exception at the CONTACT’S date, so a DXpedition entered months later gets the entity it had then."
|
"Back-entering a QSO resolves the ClubLog exception at the CONTACT’S date, so a DXpedition entered months later gets the entity it had then.",
|
||||||
|
"QRZ.com sends an island reference for an operator on one, and OpsLog read past it — it now fills the IOTA award reference before the QSO is logged."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.",
|
"Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.",
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
"Déjà contacté : un indicatif préfixé comme ZA/OE8NDR rapprochait tous les autres visiteurs du pays au lieu de ce seul opérateur.",
|
"Déjà contacté : un indicatif préfixé comme ZA/OE8NDR rapprochait tous les autres visiteurs du pays au lieu de ce seul opérateur.",
|
||||||
"Français : dix-sept textes étaient restés en anglais, dont tout le panneau de mise à jour, la durée de vie des spots et Chasser les nouveaux locators.",
|
"Français : dix-sept textes étaient restés en anglais, dont tout le panneau de mise à jour, la durée de vie des spots et Chasser les nouveaux locators.",
|
||||||
"Sélectionner un QSO affiche l’entité que le QSO enregistre, pas une recalculée depuis l’indicatif — un 3Y0K logué Bouvet montrait la matrice Antarctique.",
|
"Sélectionner un QSO affiche l’entité que le QSO enregistre, pas une recalculée depuis l’indicatif — un 3Y0K logué Bouvet montrait la matrice Antarctique.",
|
||||||
"Saisir un QSO a posteriori résout l’exception ClubLog à la date DU CONTACT : une DXpedition entrée des mois après retrouve l’entité qu’elle avait alors."
|
"Saisir un QSO a posteriori résout l’exception ClubLog à la date DU CONTACT : une DXpedition entrée des mois après retrouve l’entité qu’elle avait alors.",
|
||||||
|
"QRZ.com envoie la référence d’île d’un opérateur sur une île, et OpsLog l’ignorait — elle remplit désormais la référence IOTA avant l’enregistrement du QSO."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+13
-1
@@ -56,7 +56,7 @@ import {
|
|||||||
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList } from '@/lib/awardRefs';
|
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef } from '@/lib/awardRefs';
|
||||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -3867,6 +3867,18 @@ export default function App() {
|
|||||||
email: d.email || last.email || '',
|
email: d.email || last.email || '',
|
||||||
web: d.web || last.web || '',
|
web: d.web || last.web || '',
|
||||||
qsl_via: d.qsl_via || last.qsl_via || '',
|
qsl_via: d.qsl_via || last.qsl_via || '',
|
||||||
|
// An island reference from the callbook becomes an IOTA award reference
|
||||||
|
// on the entry, exactly like one picked by hand.
|
||||||
|
//
|
||||||
|
// QRZ carries <iota> for an operator on an island and OpsLog read past
|
||||||
|
// it. That matters more for IOTA than it would for another award:
|
||||||
|
// unlike POTA there is no live "who is on an island right now" feed
|
||||||
|
// anywhere, so the callbook record is the practical source — and it is
|
||||||
|
// known BEFORE the contact is logged, which is when it is useful.
|
||||||
|
//
|
||||||
|
// Only when the operator has not set one already: a reference typed or
|
||||||
|
// picked by hand outranks a callbook that may be years out of date.
|
||||||
|
award_refs: withIOTARef(d.award_refs ?? '', String((r as any)?.iota ?? '')),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (adoptLastGrid || last.grid || last.lat) setMapZoomSignal((n) => n + 1);
|
if (adoptLastGrid || last.grid || last.lat) setMapZoomSignal((n) => n + 1);
|
||||||
|
|||||||
@@ -192,3 +192,27 @@ export function spotRefList(byCode: Record<string, string>, fieldOf: Record<stri
|
|||||||
}
|
}
|
||||||
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withIOTARef adds an island reference from the callbook to an entry's award
|
||||||
|
// references, unless the operator has already set one.
|
||||||
|
//
|
||||||
|
// QRZ sends <iota> for an operator on an island and OpsLog used to read past it.
|
||||||
|
// It matters more here than it would for another award: unlike POTA there is no
|
||||||
|
// live "who is on an island right now" feed anywhere, so the callbook record is
|
||||||
|
// the practical source — and it is known before the contact is logged, which is
|
||||||
|
// when it is useful.
|
||||||
|
//
|
||||||
|
// A reference the operator typed or picked wins. A callbook entry can be years
|
||||||
|
// old, and the operator in front of the radio has just been told where the
|
||||||
|
// station is.
|
||||||
|
export function withIOTARef(awardRefs: string, iota: string): string {
|
||||||
|
const ref = (iota || '').trim().toUpperCase();
|
||||||
|
// EU-048: two letters, a hyphen, three digits. Anything else is not an IOTA
|
||||||
|
// reference, and writing it into the award would make a reference that no
|
||||||
|
// list contains — which counts for nothing and has to be found by hand later.
|
||||||
|
if (!/^[A-Z]{2}-\d{3}$/.test(ref)) return awardRefs;
|
||||||
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
|
if ((byCode['IOTA'] ?? '').trim() !== '') return awardRefs; // already set: leave it
|
||||||
|
const sep = awardRefs.trim() === '' ? '' : ';';
|
||||||
|
return awardRefs + sep + 'IOTA@' + ref;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1444,6 +1444,7 @@ export namespace lookup {
|
|||||||
email?: string;
|
email?: string;
|
||||||
qsl_via?: string;
|
qsl_via?: string;
|
||||||
web?: string;
|
web?: string;
|
||||||
|
iota?: string;
|
||||||
zip?: string;
|
zip?: string;
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -1473,6 +1474,7 @@ export namespace lookup {
|
|||||||
this.email = source["email"];
|
this.email = source["email"];
|
||||||
this.qsl_via = source["qsl_via"];
|
this.qsl_via = source["qsl_via"];
|
||||||
this.web = source["web"];
|
this.web = source["web"];
|
||||||
|
this.iota = source["iota"];
|
||||||
this.zip = source["zip"];
|
this.zip = source["zip"];
|
||||||
this.image_url = source["image_url"];
|
this.image_url = source["image_url"];
|
||||||
this.source = source["source"];
|
this.source = source["source"];
|
||||||
@@ -4546,6 +4548,7 @@ export namespace qso {
|
|||||||
my_vucc_grids?: string;
|
my_vucc_grids?: string;
|
||||||
extras?: Record<string, string>;
|
extras?: Record<string, string>;
|
||||||
award_refs?: string;
|
award_refs?: string;
|
||||||
|
sync_uid?: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -4687,6 +4690,7 @@ export namespace qso {
|
|||||||
this.my_vucc_grids = source["my_vucc_grids"];
|
this.my_vucc_grids = source["my_vucc_grids"];
|
||||||
this.extras = source["extras"];
|
this.extras = source["extras"];
|
||||||
this.award_refs = source["award_refs"];
|
this.award_refs = source["award_refs"];
|
||||||
|
this.sync_uid = source["sync_uid"];
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- The lookup cache gains the IOTA reference QRZ was already sending.
|
||||||
|
--
|
||||||
|
-- QRZ's XML carries <iota>EU-048</iota> for an operator on an island, and
|
||||||
|
-- nothing read it — so the one place an IOTA reference can be known BEFORE the
|
||||||
|
-- contact is logged was thrown away on every lookup. There is no live "who is
|
||||||
|
-- on an island right now" feed anywhere (unlike POTA), which makes the callbook
|
||||||
|
-- record the practical source.
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN iota TEXT;
|
||||||
@@ -41,6 +41,10 @@ type Result struct {
|
|||||||
// along and nothing ever filled it, because no provider mapping read the
|
// along and nothing ever filled it, because no provider mapping read the
|
||||||
// field.
|
// field.
|
||||||
Web string `json:"web,omitempty"`
|
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 is the postal code. HamQTH and QRZ both send one.
|
||||||
Zip string `json:"zip,omitempty"`
|
Zip string `json:"zip,omitempty"`
|
||||||
ImageURL string `json:"image_url,omitempty"` // profile picture URL
|
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, `
|
row := c.db.QueryRowContext(ctx, `
|
||||||
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
||||||
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
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)
|
FROM callsign_cache WHERE callsign = ?`, callsign)
|
||||||
var (
|
var (
|
||||||
r Result
|
r Result
|
||||||
name, qth, addr, state, cnty sql.NullString
|
name, qth, addr, state, cnty sql.NullString
|
||||||
country, grid, cont, email, qslVia, image sql.NullString
|
country, grid, cont, email, qslVia, image sql.NullString
|
||||||
web, zip sql.NullString
|
web, zip, iotaRef sql.NullString
|
||||||
src string
|
src string
|
||||||
dxcc, cqz, ituz sql.NullInt64
|
dxcc, cqz, ituz sql.NullInt64
|
||||||
lat, lon sql.NullFloat64
|
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,
|
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
|
||||||
&country, &grid, &lat, &lon,
|
&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 {
|
&src, &fetched); err != nil {
|
||||||
return Result{}, false
|
return Result{}, false
|
||||||
}
|
}
|
||||||
@@ -490,6 +494,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
r.Email = email.String
|
r.Email = email.String
|
||||||
r.Web = web.String
|
r.Web = web.String
|
||||||
r.Zip = zip.String
|
r.Zip = zip.String
|
||||||
|
r.IOTA = strings.ToUpper(iotaRef.String)
|
||||||
r.QSLVia = qslVia.String
|
r.QSLVia = qslVia.String
|
||||||
r.ImageURL = image.String
|
r.ImageURL = image.String
|
||||||
r.DXCC = int(dxcc.Int64)
|
r.DXCC = int(dxcc.Int64)
|
||||||
@@ -506,7 +511,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
updateCols := []string{
|
updateCols := []string{
|
||||||
"name", "qth", "address", "state", "cnty",
|
"name", "qth", "address", "state", "cnty",
|
||||||
"country", "grid", "lat", "lon",
|
"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",
|
"source", "fetched_at",
|
||||||
}
|
}
|
||||||
// The lookup cache always lives in the local SQLite database, so SQLite
|
// 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,
|
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
||||||
country, grid, lat, lon,
|
country, grid, lat, lon,
|
||||||
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||||
web, zip, source, fetched_at)
|
web, zip, iota, source, fetched_at)
|
||||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?, ?,?)
|
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?,?, ?,?)
|
||||||
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
||||||
_, err := c.db.ExecContext(ctx, q,
|
_, err := c.db.ExecContext(ctx, q,
|
||||||
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
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),
|
nullableFloat(r.Lat), nullableFloat(r.Lon),
|
||||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
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(),
|
r.Source, db.NowISO(),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e
|
|||||||
Email: c.Email,
|
Email: c.Email,
|
||||||
QSLVia: c.QSLMgr,
|
QSLVia: c.QSLMgr,
|
||||||
ImageURL: strings.TrimSpace(c.Image),
|
ImageURL: strings.TrimSpace(c.Image),
|
||||||
|
IOTA: strings.ToUpper(strings.TrimSpace(c.IOTA)),
|
||||||
}
|
}
|
||||||
r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
|
r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
|
||||||
r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
|
r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
|
||||||
@@ -193,6 +194,7 @@ type qrzCallsign struct {
|
|||||||
Email string `xml:"email"`
|
Email string `xml:"email"`
|
||||||
QSLMgr string `xml:"qslmgr"`
|
QSLMgr string `xml:"qslmgr"`
|
||||||
Image string `xml:"image"` // direct URL to the profile picture (subscribers only on QRZ)
|
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
|
// composeQRZAddress builds a multi-line postal address from QRZ's separate
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QRZ sends the island reference and OpsLog read past it.
|
||||||
|
//
|
||||||
|
// It matters more for IOTA than it would for another award: unlike POTA there
|
||||||
|
// is no live "who is on an island right now" feed anywhere, so the callbook
|
||||||
|
// record is the practical source — and it is known BEFORE the contact is
|
||||||
|
// logged, which is when it is useful.
|
||||||
|
func TestQRZCallsignCarriesIOTA(t *testing.T) {
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<QRZDatabase version="1.34">
|
||||||
|
<Callsign>
|
||||||
|
<call>F5IRH</call>
|
||||||
|
<fname>Max</fname>
|
||||||
|
<country>France</country>
|
||||||
|
<grid>IN87ki</grid>
|
||||||
|
<cqzone>14</cqzone>
|
||||||
|
<iota>EU-048</iota>
|
||||||
|
</Callsign>
|
||||||
|
<Session><Key>abc</Key></Session>
|
||||||
|
</QRZDatabase>`
|
||||||
|
|
||||||
|
var resp qrzDB
|
||||||
|
if err := xml.Unmarshal([]byte(body), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Callsign.IOTA != "EU-048" {
|
||||||
|
t.Fatalf("the <iota> tag decoded as %q — the field is not being read", resp.Callsign.IOTA)
|
||||||
|
}
|
||||||
|
// And a record with no island must not invent one.
|
||||||
|
var none qrzDB
|
||||||
|
if err := xml.Unmarshal([]byte(strings.Replace(body, "<iota>EU-048</iota>", "", 1)), &none); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if none.Callsign.IOTA != "" {
|
||||||
|
t.Errorf("IOTA = %q for a record without one", none.Callsign.IOTA)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user