diff --git a/app.go b/app.go index 1ba15dc..5676c3b 100644 --- a/app.go +++ b/app.go @@ -6266,6 +6266,22 @@ func (a *App) SaveCabrilloFile() (string, error) { // Errors are returned as-is to the frontend; ErrNotFound surfaces as // "callsign not found". 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 { 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) 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) if errors.Is(err, lookup.ErrNotFound) { return lookup.Result{}, fmt.Errorf("callsign not found") @@ -10290,6 +10316,31 @@ func (a *App) ReloadUDPIntegrations() []string { 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 // first record into the local logbook. Returns the ID of the inserted // 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 == "" { q.Country = lr.Country } - if q.Grid == "" { - q.Grid = lr.Grid - } + q.Grid = refineGrid(q.Grid, lr.Grid) if q.Continent == "" { q.Continent = lr.Continent } diff --git a/changelog.json b/changelog.json index 26d5397..e538426 100644 --- a/changelog.json +++ b/changelog.json @@ -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.", "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.", - "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": [ "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.", "É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.", - "É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." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f5005af..e7c69cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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', )}>
- Import complete. - {importResult.imported} imported + {t('imp.complete')} + {t('imp.imported', { n: importResult.imported })} {importResult.updated > 0 && ( - {importResult.updated} updated + {t('imp.updated', { n: importResult.updated })} )} {importResult.duplicates > 0 && ( - {importResult.duplicates} duplicates + {t('imp.duplicates', { n: importResult.duplicates })} )} - {importResult.skipped} skipped - {importResult.total} total + {t('imp.skipped', { n: importResult.skipped })} + {t('imp.total', { n: importResult.total })} {importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && ( )} {importResult.errors && importResult.errors.length > 0 && ( )} @@ -6113,19 +6113,19 @@ export default function App() { { if (!o) setPendingImportPath(null); }}> - Import ADIF + {t('imp.dialogTitle')} {pendingImportPath}
- Duplicate = same callsign + UTC minute + band + mode as a QSO already in the log. + {t('imp.dupHintPre')}{t('imp.dupHintKey')}{t('imp.dupHintPost')}
{([ - { 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) => (
- - + +
@@ -6185,7 +6185,7 @@ export default function App() { - Importing ADIF… + {t('imp.progressTitle')}
{(() => { @@ -6202,8 +6202,8 @@ export default function App() {
{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() })}
); diff --git a/frontend/src/components/FilterBuilder.tsx b/frontend/src/components/FilterBuilder.tsx index 71effcf..4f5edd6 100644 --- a/frontend/src/components/FilterBuilder.tsx +++ b/frontend/src/components/FilterBuilder.tsx @@ -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) { 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(); }} />