diff --git a/changelog.json b/changelog.json index 5f944b2..0eda0dc 100644 --- a/changelog.json +++ b/changelog.json @@ -8,7 +8,8 @@ "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.", "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": [ "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.", "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.", - "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." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7b7826e..b549935 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,7 +56,7 @@ import { QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair, } from '../wailsjs/go/main/App'; 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 type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -3867,6 +3867,18 @@ export default function App() { email: d.email || last.email || '', web: d.web || last.web || '', 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 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); diff --git a/frontend/src/lib/awardRefs.ts b/frontend/src/lib/awardRefs.ts index 30bdc49..aaafb29 100644 --- a/frontend/src/lib/awardRefs.ts +++ b/frontend/src/lib/awardRefs.ts @@ -192,3 +192,27 @@ export function spotRefList(byCode: Record, fieldOf: Record 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 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; +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 0040b79..13448c2 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1444,6 +1444,7 @@ export namespace lookup { email?: string; qsl_via?: string; web?: string; + iota?: string; zip?: string; image_url?: string; source: string; @@ -1473,6 +1474,7 @@ export namespace lookup { this.email = source["email"]; this.qsl_via = source["qsl_via"]; this.web = source["web"]; + this.iota = source["iota"]; this.zip = source["zip"]; this.image_url = source["image_url"]; this.source = source["source"]; @@ -4546,6 +4548,7 @@ export namespace qso { my_vucc_grids?: string; extras?: Record; award_refs?: string; + sync_uid?: string; // Go type: time created_at: any; // Go type: time @@ -4687,6 +4690,7 @@ export namespace qso { this.my_vucc_grids = source["my_vucc_grids"]; this.extras = source["extras"]; this.award_refs = source["award_refs"]; + this.sync_uid = source["sync_uid"]; this.created_at = this.convertValues(source["created_at"], null); this.updated_at = this.convertValues(source["updated_at"], null); } diff --git a/internal/db/migrations/0030_lookup_iota.sql b/internal/db/migrations/0030_lookup_iota.sql new file mode 100644 index 0000000..27bc5b3 --- /dev/null +++ b/internal/db/migrations/0030_lookup_iota.sql @@ -0,0 +1,8 @@ +-- The lookup cache gains the IOTA reference QRZ was already sending. +-- +-- QRZ's XML carries EU-048 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; diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index dc62015..0b10a8f 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -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 diff --git a/internal/lookup/qrz.go b/internal/lookup/qrz.go index 74a823e..ed0a297 100644 --- a/internal/lookup/qrz.go +++ b/internal/lookup/qrz.go @@ -134,6 +134,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e 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) @@ -193,6 +194,7 @@ type qrzCallsign struct { 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 diff --git a/internal/lookup/qrz_iota_test.go b/internal/lookup/qrz_iota_test.go new file mode 100644 index 0000000..cea4d67 --- /dev/null +++ b/internal/lookup/qrz_iota_test.go @@ -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 = ` + + +F5IRH +Max +France +IN87ki +14 +EU-048 + +abc +` + + 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 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, "EU-048", "", 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) + } +}