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
+24 -24
View File
@@ -2367,7 +2367,7 @@ export default function App() {
if (n <= 0) return;
await refresh();
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?.(); };
// 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',
)}>
<div className="flex items-center gap-3 flex-wrap">
<strong>Import complete.</strong>
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge>
<strong>{t('imp.complete')}</strong>
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{t('imp.imported', { n: importResult.imported })}</Badge>
{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 && (
<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">{importResult.total} total</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">{t('imp.total', { n: importResult.total })}</Badge>
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
<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
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
? t('imp.dupsFirst', { shown: importResult.duplicate_samples.length, total: importResult.duplicates })
: ''}
</button>
)}
{importResult.errors && importResult.errors.length > 0 && (
<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 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); }}>
<DialogContent className="max-w-lg px-6">
<DialogHeader className="px-2">
<DialogTitle>Import ADIF</DialogTitle>
<DialogTitle>{t('imp.dialogTitle')}</DialogTitle>
<DialogDescription className="text-xs break-all">
{pendingImportPath}
</DialogDescription>
</DialogHeader>
<div className="py-2 px-2 space-y-2">
<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>
{([
{ id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' },
{ 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: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' },
{ id: 'skip', title: t('imp.skipTitle'), desc: t('imp.skipDesc') },
{ id: 'update', title: t('imp.updateTitle'), desc: t('imp.updateDesc') },
{ id: 'all', title: t('imp.allTitle'), desc: t('imp.allDesc') },
] as const).map((o) => (
<button
key={o.id}
@@ -6153,9 +6153,9 @@ export default function App() {
className="mt-0.5"
/>
<span>
Fix country &amp; zones (cty.dat + ClubLog)
{t('imp.ctyTitle')}
<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>
</label>
@@ -6166,16 +6166,16 @@ export default function App() {
className="mt-0.5"
/>
<span>
Fill my station fields from my profile
{t('imp.stationTitle')}
<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>
</label>
</div>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
<Button onClick={runImport}>Import</Button>
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
<Button onClick={runImport}>{t('imp.import')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -6185,7 +6185,7 @@ export default function App() {
<Dialog open>
<DialogContent className="max-w-sm px-6" hideClose>
<DialogHeader className="px-2">
<DialogTitle>Importing ADIF</DialogTitle>
<DialogTitle>{t('imp.progressTitle')}</DialogTitle>
</DialogHeader>
<div className="px-2 pb-4 space-y-2">
{(() => {
@@ -6202,8 +6202,8 @@ export default function App() {
</div>
<div className="text-xs text-muted-foreground text-center font-mono">
{tot > 0
? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%`
: `${done.toLocaleString()} records…`}
? t('imp.progressCount', { done: done.toLocaleString(), tot: tot.toLocaleString(), pct })
: t('imp.progressCountOnly', { done: done.toLocaleString() })}
</div>
</>
);
+32 -7
View File
@@ -26,7 +26,9 @@ export interface QueryFilter {
// Curated field catalog. `value` MUST match a column in the backend whitelist
// (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 }[] = [
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
{ 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: 'rig', label: 'fltb.fRig', 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_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
{ 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: '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_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
{ 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: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', 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: 'srx', label: 'fltb.fSerialRcvd', 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 }[] {
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;
return OPS.filter((o) => allow.includes(o.value));
}
@@ -253,12 +271,19 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
</SelectContent>
</Select>
<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"
disabled={!needsValue}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
value={c.value}
onChange={(e) => setCond(i, { value: e.target.value })}
value={fieldType === 'adifdate' && /^d{8}$/.test(c.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(); }}
/>
<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 { 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 { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -330,19 +330,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
setLooking(true);
setLocalErr('');
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) => ({
...d,
name: r.name ?? d.name,
qth: r.qth ?? d.qth,
address: r.address ?? (d as any).address,
email: r.email ?? (d as any).email,
country: r.country ?? d.country,
grid: r.grid ?? d.grid,
state: r.state ?? d.state,
cnty: r.cnty ?? d.cnty,
cont: r.cont ?? d.cont,
qsl_via: r.qsl_via ?? d.qsl_via,
name: keep(r.name, d.name),
qth: keep(r.qth, d.qth),
address: keep(r.address, (d as any).address),
email: keep(r.email, (d as any).email),
country: keep(r.country, d.country),
grid: keep(r.grid, d.grid),
state: keep(r.state, d.state),
cnty: keep(r.cnty, d.cnty),
cont: keep(r.cont, d.cont),
qsl_via: keep(r.qsl_via, d.qsl_via),
dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz,
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';
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];
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 LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
export function MotorReadElements():Promise<Array<number>>;
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);
}
export function LookupCallsignFresh(arg1) {
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
}
export function MotorReadElements() {
return window['go']['main']['App']['MotorReadElements']();
}