fix: keep the precise locator on digital QSOs; make the manual lookup real

Grid on the UDP path — WSJT-X and MSHV can only send a FOUR-character grid,
that is all the FT8 protocol carries. The enrichment rule there is "fill only
what is empty", so the coarse JN05 always won and QRZ's JN05JG was thrown away:
roughly 100 km of accuracy discarded on every digital QSO. The lookup grid is
now taken when it EXTENDS the received square. A lookup that disagrees outright
(JN18 against JN05) is not a refinement — the station may be portable and what
came over the air is then the better record. Both rules are pinned by tests.

Manual fetch in the QSO editor — it read the CACHE, valid for 30 days. An
operator who upgraded their QRZ subscription went on getting the thin
free-account record and clearing the cached row by hand was the only way out.
The button now forces a lookup that bypasses the cache, reaches the provider and
REFRESHES the stored row, with a 15 s budget instead of 2 s: a deliberate click
must reach the network, where giving up early would fall back to cty.dat and
look like nothing happened. The automatic type-ahead lookup keeps the cache,
which is where it earns its keep.

Same fetch: it used `??`, which only guards against null. Go marshals an unset
string as "", so a QRZ record with no grid BLANKED the grid already in the QSO.
The lookup still wins — that is the point of asking for it — but an empty
result no longer erases a good value.
This commit is contained in:
2026-07-27 22:10:00 +02:00
parent 4dd15b09e9
commit cc1ad06a9d
13 changed files with 261 additions and 63 deletions
+52 -3
View File
@@ -6266,6 +6266,22 @@ func (a *App) SaveCabrilloFile() (string, error) {
// Errors are returned as-is to the frontend; ErrNotFound surfaces as // Errors are returned as-is to the frontend; ErrNotFound surfaces as
// "callsign not found". // "callsign not found".
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) { func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
return a.lookupCallsign(callsign, false)
}
// LookupCallsignFresh is the same, but SKIPS the cache and refreshes it.
//
// For a lookup the operator asked for by clicking, in the QSO editor. The cache
// is right for the automatic lookup that fires while typing, but it also freezes
// a wrong answer for its whole 30-day life: an operator who upgraded their QRZ
// subscription went on getting the thin free-account record, and deleting the
// cached row by hand was the only way out. A deliberate click must reach the
// provider and overwrite what was stored.
func (a *App) LookupCallsignFresh(callsign string) (lookup.Result, error) {
return a.lookupCallsign(callsign, true)
}
func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error) {
if a.lookup == nil { if a.lookup == nil {
return lookup.Result{}, fmt.Errorf("lookup not initialized") return lookup.Result{}, fmt.Errorf("lookup not initialized")
} }
@@ -6287,6 +6303,16 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
} }
ctx, cancel := context.WithTimeout(a.ctx, budget) ctx, cancel := context.WithTimeout(a.ctx, budget)
defer cancel() defer cancel()
if force {
// A forced lookup has to reach the network, so it gets a longer leash than
// the type-ahead one: giving up at 2 s would just fall back to cty.dat and
// look like the click did nothing.
cancel()
ctx, cancel = context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
ctx = lookup.WithForce(ctx)
applog.Printf("lookup: FORCED (cache bypassed) for %s", strings.ToUpper(strings.TrimSpace(callsign)))
}
r, err := a.lookup.Lookup(ctx, callsign) r, err := a.lookup.Lookup(ctx, callsign)
if errors.Is(err, lookup.ErrNotFound) { if errors.Is(err, lookup.ErrNotFound) {
return lookup.Result{}, fmt.Errorf("callsign not found") return lookup.Result{}, fmt.Errorf("callsign not found")
@@ -10290,6 +10316,31 @@ func (a *App) ReloadUDPIntegrations() []string {
return a.udp.Reload(a.ctx) return a.udp.Reload(a.ctx)
} }
// refineGrid picks between the locator a QSO arrived with and the one the
// callsign lookup returned.
//
// WSJT-X and MSHV always send a FOUR-character grid — that is all the FT8
// protocol carries — so a QSO logged from them landed with JN05 while QRZ knew
// JN05JG. The enrichment rule everywhere else on this path is "fill only what is
// empty", which meant the coarse grid always won and the precise one was thrown
// away, silently costing the operator ~100 km of accuracy on every digital QSO.
//
// The upgrade is only taken when the lookup grid EXTENDS the received one (same
// first four characters). A lookup that disagrees outright — JN18 against JN05 —
// is not a refinement: the station may be portable, and what came over the air
// is then the better record. Case-insensitive, since ADIF grids arrive in both.
func refineGrid(have, found string) string {
h := strings.ToUpper(strings.TrimSpace(have))
f := strings.ToUpper(strings.TrimSpace(found))
if h == "" {
return f
}
if len(f) > len(h) && strings.HasPrefix(f, h) {
return f
}
return h
}
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the // LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
// first record into the local logbook. Returns the ID of the inserted // first record into the local logbook. Returns the ID of the inserted
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert / // row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
@@ -10357,9 +10408,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if q.Country == "" { if q.Country == "" {
q.Country = lr.Country q.Country = lr.Country
} }
if q.Grid == "" { q.Grid = refineGrid(q.Grid, lr.Grid)
q.Grid = lr.Grid
}
if q.Continent == "" { if q.Continent == "" {
q.Continent = lr.Continent q.Continent = lr.Continent
} }
+8 -2
View File
@@ -13,7 +13,10 @@
"Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.", "Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.",
"Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).", "Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).",
"CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly.", "CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly.",
"Bulk edit: the confirmation fields are renamed \"sent/received status\" instead of \"upload\", the missing QRZ.com received status is added, and every channel now offers its sent and received DATE with a calendar." "Bulk edit and the filter builder: the confirmation fields are renamed \"sent/received status\" instead of \"upload\", the missing QRZ.com received status is added, and every channel now offers its sent and received DATE with a calendar in bulk edit, and comparable (before/after) in filters.",
"A fresh install now starts on the dark theme. Existing installs keep the theme they are on.",
"A QSO logged from WSJT-X or MSHV now keeps the precise 6-character locator from QRZ/HamQTH instead of the 4-character one the digital protocol carries — about 100 km of accuracy that was being discarded on every digital QSO. A lookup that disagrees with the received square is not applied.",
"Edit QSO: the QRZ/HamQTH fetch button now bypasses the cache, so it really re-reads the callsign — upgrading a QRZ subscription used to change nothing until the cached entry expired a month later. It also no longer blanks an existing value when the lookup comes back with that field empty."
], ],
"fr": [ "fr": [
"Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.", "Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.",
@@ -26,7 +29,10 @@
"Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.", "Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.",
"Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).", "Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).",
"Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal.", "Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal.",
"Édition en lot : les champs de confirmation s'appellent désormais « envoi/réception (statut) » au lieu d'« upload », le statut de réception QRZ.com manquant a été ajouté, et chaque canal propose ses DATES d'envoi et de réception avec un calendrier." "Édition en lot et constructeur de filtres : les champs de confirmation s'appellent désormais « sent/received status » au lieu d'« upload », le statut de réception QRZ.com manquant a été ajouté, et chaque canal propose ses DATES d'envoi et de réception avec un calendrier en édition en lot, et comparables (avant/après) dans les filtres.",
"Une nouvelle installation démarre désormais sur le thème sombre. Les installations existantes conservent le leur.",
"Un QSO enregistré depuis WSJT-X ou MSHV conserve désormais le locator précis à 6 caractères de QRZ/HamQTH au lieu de celui à 4 caractères transporté par le protocole numérique — une centaine de kilomètres de précision perdus jusqu'ici à chaque QSO numérique. Une réponse qui contredit le carré reçu n'est pas appliquée.",
"Édition QSO : le bouton de récupération QRZ/HamQTH contourne désormais le cache et relit donc réellement l'indicatif — passer à un abonnement QRZ payant ne changeait rien tant que l'entrée en cache n'avait pas expiré un mois plus tard. Il n'efface plus non plus une valeur existante lorsque la recherche revient sans ce champ."
] ]
}, },
{ {
+24 -24
View File
@@ -2367,7 +2367,7 @@ export default function App() {
if (n <= 0) return; if (n <= 0) return;
await refresh(); await refresh();
const file = String(p?.file ?? '').replace(/^.*[\\/]/, ''); const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`); showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
}); });
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); }; return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -5368,27 +5368,27 @@ export default function App() {
: 'bg-success-muted border-success-border text-success-muted-foreground', : 'bg-success-muted border-success-border text-success-muted-foreground',
)}> )}>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<strong>Import complete.</strong> <strong>{t('imp.complete')}</strong>
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{t('imp.imported', { n: importResult.imported })}</Badge>
{importResult.updated > 0 && ( {importResult.updated > 0 && (
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.updated} updated</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.updated', { n: importResult.updated })}</Badge>
)} )}
{importResult.duplicates > 0 && ( {importResult.duplicates > 0 && (
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.duplicates} duplicates</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.duplicates', { n: importResult.duplicates })}</Badge>
)} )}
<Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{importResult.skipped} skipped</Badge> <Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{t('imp.skipped', { n: importResult.skipped })}</Badge>
<Badge variant="outline" className="bg-card/60 font-mono">{importResult.total} total</Badge> <Badge variant="outline" className="bg-card/60 font-mono">{t('imp.total', { n: importResult.total })}</Badge>
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && ( {importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}> <button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
{importDupsOpen ? 'Hide' : 'Show'} duplicates {importDupsOpen ? t('imp.hideDups') : t('imp.showDups')}
{importResult.duplicates > importResult.duplicate_samples.length {importResult.duplicates > importResult.duplicate_samples.length
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})` ? t('imp.dupsFirst', { shown: importResult.duplicate_samples.length, total: importResult.duplicates })
: ''} : ''}
</button> </button>
)} )}
{importResult.errors && importResult.errors.length > 0 && ( {importResult.errors && importResult.errors.length > 0 && (
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}> <button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''} {importErrorsOpen ? t('imp.hideErrors', { n: importResult.errors.length }) : t('imp.showErrors', { n: importResult.errors.length })}
</button> </button>
)} )}
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button> <button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
@@ -6113,19 +6113,19 @@ export default function App() {
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}> <Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
<DialogContent className="max-w-lg px-6"> <DialogContent className="max-w-lg px-6">
<DialogHeader className="px-2"> <DialogHeader className="px-2">
<DialogTitle>Import ADIF</DialogTitle> <DialogTitle>{t('imp.dialogTitle')}</DialogTitle>
<DialogDescription className="text-xs break-all"> <DialogDescription className="text-xs break-all">
{pendingImportPath} {pendingImportPath}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-2 px-2 space-y-2"> <div className="py-2 px-2 space-y-2">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Duplicate = same <span className="font-medium text-foreground">callsign + UTC minute + band + mode</span> as a QSO already in the log. {t('imp.dupHintPre')}<span className="font-medium text-foreground">{t('imp.dupHintKey')}</span>{t('imp.dupHintPost')}
</div> </div>
{([ {([
{ id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' }, { id: 'skip', title: t('imp.skipTitle'), desc: t('imp.skipDesc') },
{ id: 'update', title: 'Update duplicates', desc: 'Refresh existing QSOs with this file — merges its non-empty fields (QSL/LoTW/eQSL/QRZ statuses & dates, etc.) onto the matching QSO. Use this to re-sync from Log4OM or LoTW. Fields the file omits are kept.' }, { id: 'update', title: t('imp.updateTitle'), desc: t('imp.updateDesc') },
{ id: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' }, { id: 'all', title: t('imp.allTitle'), desc: t('imp.allDesc') },
] as const).map((o) => ( ] as const).map((o) => (
<button <button
key={o.id} key={o.id}
@@ -6153,9 +6153,9 @@ export default function App() {
className="mt-0.5" className="mt-0.5"
/> />
<span> <span>
Fix country &amp; zones (cty.dat + ClubLog) {t('imp.ctyTitle')}
<span className="block text-xs text-muted-foreground mt-0.5"> <span className="block text-xs text-muted-foreground mt-0.5">
Recompute Country, DXCC &amp; CQ/ITU zones from cty.dat, overriding the file corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF Reunion, TO2A 2012 French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use <strong>Update duplicates</strong> to re-fix QSOs already in your log. {t('imp.ctyDesc')}
</span> </span>
</span> </span>
</label> </label>
@@ -6166,16 +6166,16 @@ export default function App() {
className="mt-0.5" className="mt-0.5"
/> />
<span> <span>
Fill my station fields from my profile {t('imp.stationTitle')}
<span className="block text-xs text-muted-foreground mt-0.5"> <span className="block text-xs text-muted-foreground mt-0.5">
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log. {t('imp.stationDesc')}
</span> </span>
</span> </span>
</label> </label>
</div> </div>
<DialogFooter className="px-2 bg-transparent border-t-0"> <DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button> <Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
<Button onClick={runImport}>Import</Button> <Button onClick={runImport}>{t('imp.import')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -6185,7 +6185,7 @@ export default function App() {
<Dialog open> <Dialog open>
<DialogContent className="max-w-sm px-6" hideClose> <DialogContent className="max-w-sm px-6" hideClose>
<DialogHeader className="px-2"> <DialogHeader className="px-2">
<DialogTitle>Importing ADIF</DialogTitle> <DialogTitle>{t('imp.progressTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="px-2 pb-4 space-y-2"> <div className="px-2 pb-4 space-y-2">
{(() => { {(() => {
@@ -6202,8 +6202,8 @@ export default function App() {
</div> </div>
<div className="text-xs text-muted-foreground text-center font-mono"> <div className="text-xs text-muted-foreground text-center font-mono">
{tot > 0 {tot > 0
? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%` ? t('imp.progressCount', { done: done.toLocaleString(), tot: tot.toLocaleString(), pct })
: `${done.toLocaleString()} records…`} : t('imp.progressCountOnly', { done: done.toLocaleString() })}
</div> </div>
</> </>
); );
+32 -7
View File
@@ -26,7 +26,9 @@ export interface QueryFilter {
// Curated field catalog. `value` MUST match a column in the backend whitelist // Curated field catalog. `value` MUST match a column in the backend whitelist
// (qso.FilterableFields); `type` only drives which operators/value input we show. // (qso.FilterableFields); `type` only drives which operators/value input we show.
type FieldType = 'text' | 'number' | 'date'; // 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
const FIELDS: { value: string; label: string; type: FieldType }[] = [ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' }, { value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' }, { value: 'qso_date', label: 'fltb.fDate', type: 'date' },
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' }, { value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
{ value: 'rig', label: 'fltb.fRig', type: 'text' }, { value: 'rig', label: 'fltb.fRig', type: 'text' },
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' }, { value: 'ant', label: 'fltb.fAntenna', type: 'text' },
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
// of them — a paper QSL is not uploaded.
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' }, { value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' }, { value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' }, { value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' }, { value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' }, { value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' }, { value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' }, { value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' }, { value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' }, { value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' }, { value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' }, { value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' }, { value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' }, { value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
function opsFor(field: string): { value: FilterOp; label: string }[] { function opsFor(field: string): { value: FilterOp; label: string }[] {
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text'; const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
// comparison operators (before/after), not the text ones.
const allow = t === 'text' ? TEXT_OPS : NUM_OPS; const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
return OPS.filter((o) => allow.includes(o.value)); return OPS.filter((o) => allow.includes(o.value));
} }
@@ -253,12 +271,19 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
<Input <Input
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'} // An ADIF date is picked with a calendar but STORED as the
// 8-digit form the column holds, so the comparison stays a
// plain string one on both sides.
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
className="h-8 flex-1 text-xs" className="h-8 flex-1 text-xs"
disabled={!needsValue} disabled={!needsValue}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'} placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
value={c.value} value={fieldType === 'adifdate' && /^d{8}$/.test(c.value)
onChange={(e) => setCond(i, { value: e.target.value })} ? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
: c.value}
onChange={(e) => setCond(i, {
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
})}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }} onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
/> />
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}> <button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
+21 -12
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react'; import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App'; import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst'; import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -330,19 +330,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
setLooking(true); setLooking(true);
setLocalErr(''); setLocalErr('');
try { try {
const r: any = await LookupCallsign(call); // FRESH: this fetch is a deliberate click, so it bypasses the cache and
// refreshes it. A cached answer from a thinner QRZ subscription (or any
// stale row) otherwise stayed for its whole 30-day life and the button
// appeared to do nothing.
const r: any = await LookupCallsignFresh(call);
// The lookup WINS over what is in the record — that is the point of asking
// for it. But an EMPTY result must never blank a good value: `??` only
// guards against null, and Go marshals an unset string as "", so a QRZ
// record with no grid used to wipe the grid that was already there.
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
setDraft((d) => ({ setDraft((d) => ({
...d, ...d,
name: r.name ?? d.name, name: keep(r.name, d.name),
qth: r.qth ?? d.qth, qth: keep(r.qth, d.qth),
address: r.address ?? (d as any).address, address: keep(r.address, (d as any).address),
email: r.email ?? (d as any).email, email: keep(r.email, (d as any).email),
country: r.country ?? d.country, country: keep(r.country, d.country),
grid: r.grid ?? d.grid, grid: keep(r.grid, d.grid),
state: r.state ?? d.state, state: keep(r.state, d.state),
cnty: r.cnty ?? d.cnty, cnty: keep(r.cnty, d.cnty),
cont: r.cont ?? d.cont, cont: keep(r.cont, d.cont),
qsl_via: r.qsl_via ?? d.qsl_via, qsl_via: keep(r.qsl_via, d.qsl_via),
dxcc: r.dxcc || d.dxcc, dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz, cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz, ituz: r.ituz || d.ituz,
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -14,7 +14,11 @@ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
]; ];
export const LS_KEY = 'opslog.theme'; export const LS_KEY = 'opslog.theme';
const DEFAULT: ThemeChoice = 'light-warm'; // A fresh install starts DARK. A shack is usually a dim room and the screen is
// looked at for hours; every other logger defaults the same way. Graphite
// specifically, because that is what 'auto' already resolves to for a dark
// system — so the two paths agree instead of landing on different darks.
const DEFAULT: ThemeChoice = 'dark-graphite';
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES]; const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
function systemDark(): boolean { function systemDark(): boolean {
+2
View File
@@ -637,6 +637,8 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>; export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
export function MotorReadElements():Promise<Array<number>>; export function MotorReadElements():Promise<Array<number>>;
export function MotorSetElement(arg1:number,arg2:number):Promise<void>; export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
+4
View File
@@ -1222,6 +1222,10 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1); return window['go']['main']['App']['LookupCallsign'](arg1);
} }
export function LookupCallsignFresh(arg1) {
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
}
export function MotorReadElements() { export function MotorReadElements() {
return window['go']['main']['App']['MotorReadElements'](); return window['go']['main']['App']['MotorReadElements']();
} }
+33 -9
View File
@@ -86,6 +86,22 @@ func (m *Manager) SetProviders(p ...Provider) {
// Lookup returns a Result for the callsign. Falls back through providers // Lookup returns a Result for the callsign. Falls back through providers
// when one returns ErrNotFound or fails. // when one returns ErrNotFound or fails.
// forceKey marks a context as a FORCED (operator-requested) lookup, which
// bypasses the cache on the way in and refreshes it on the way out. Carried on
// the context rather than as a parameter so every existing caller — and the
// Provider interface — stays untouched.
type forceKey struct{}
// WithForce returns a context that makes Lookup skip the cache.
func WithForce(ctx context.Context) context.Context {
return context.WithValue(ctx, forceKey{}, true)
}
func isForced(ctx context.Context) bool {
v, _ := ctx.Value(forceKey{}).(bool)
return v
}
func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
call := strings.ToUpper(strings.TrimSpace(callsign)) call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" { if call == "" {
@@ -97,15 +113,23 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
dxcc := m.dxcc dxcc := m.dxcc
m.mu.RUnlock() m.mu.RUnlock()
if r, ok := m.cache.Get(ctx, call); ok { // A FORCED lookup skips the cache. The cache is right for the automatic
r.Source = "cache" // lookup that fires as you type, but it also freezes a wrong answer for the
// Re-assert the authoritative DXCC fields (country/zones/continent) // whole TTL: an operator who upgraded their QRZ subscription kept getting the
// from cty.dat on every cache hit — cheap (in-memory) and lets a // thin free-account record for a month, and clearing the cache by hand was
// corrected entity mapping (e.g. Sicily → Italy) heal stale cached // the only way out. A lookup the operator asked for by clicking is a
// rows without waiting for the TTL to expire. // deliberate act and must reach the provider.
fillFromDXCC(&r, dxcc) if !isForced(ctx) {
normalizeNames(&r) if r, ok := m.cache.Get(ctx, call); ok {
return r, nil r.Source = "cache"
// Re-assert the authoritative DXCC fields (country/zones/continent)
// from cty.dat on every cache hit — cheap (in-memory) and lets a
// corrected entity mapping (e.g. Sicily → Italy) heal stale cached
// rows without waiting for the TTL to expire.
fillFromDXCC(&r, dxcc)
normalizeNames(&r)
return r, nil
}
} }
var lastErr error var lastErr error
+9 -1
View File
@@ -1185,7 +1185,15 @@ var filterableColumns = map[string]bool{
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true, "iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true, "lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true, "qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
// also chronological — "before 20240101" works with no date parsing.
"qsl_sent_date": true, "qsl_rcvd_date": true,
"lotw_sent_date": true, "lotw_rcvd_date": true,
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true, "hrdlog_qso_upload_date": true,
"contest_id": true, "srx": true, "stx": true, "contest_id": true, "srx": true, "stx": true,
"prop_mode": true, "sat_name": true, "prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true, "station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
+4 -1
View File
@@ -125,7 +125,10 @@ func main() {
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1}, // The colour the window is painted before the WebView draws. Matches the
// graphite theme's background (#16181d), which is the default on a fresh
// install — otherwise every launch flashes white first.
BackgroundColour: &options.RGBA{R: 0x16, G: 0x18, B: 0x1d, A: 1},
OnStartup: app.startup, OnStartup: app.startup,
OnDomReady: app.domReady, OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose, OnBeforeClose: app.beforeClose,
+28
View File
@@ -0,0 +1,28 @@
package main
import "testing"
// The case that started this: WSJT-X / MSHV can only send a 4-character grid, so
// a digital QSO arrived with JN05 while QRZ knew JN05JG — and the fill-if-empty
// enrichment rule kept the coarse one. Roughly 100 km of accuracy, silently lost
// on every digital QSO.
func TestRefineGrid(t *testing.T) {
cases := []struct{ have, found, want, why string }{
{"JN05", "JN05JG", "JN05JG", "the lookup EXTENDS the received square — take the precise one"},
{"jn05", "JN05JG", "JN05JG", "grids arrive in both cases"},
{"", "JN05JG", "JN05JG", "nothing received — take whatever was found"},
{"JN05JG", "", "JN05JG", "nothing found — keep what was received"},
{"JN05JG", "JN05", "JN05JG", "never trade a precise grid for a coarse one"},
{"JN05JG", "JN05JG", "JN05JG", "same grid"},
// A DISAGREEMENT is not a refinement: the station may be portable, and
// what came over the air is then the better record.
{"JN05", "JN18AA", "JN05", "different square — keep what was actually received"},
{"JN18AA", "JN05", "JN18AA", "different square, coarse lookup — keep the received one"},
{"", "", "", "nothing either way"},
}
for _, c := range cases {
if got := refineGrid(c.have, c.found); got != c.want {
t.Errorf("refineGrid(%q, %q) = %q, want %q — %s", c.have, c.found, got, c.want, c.why)
}
}
}