feat: progress bar for bulk QSO updates from QRZ.com

Selecting the 102 contacts of a freshly imported contest log and updating them
from QRZ.com is 102 network round trips. Nothing moved on screen while it ran,
so the only honest reading was that the application had frozen — and the natural
response to that is to kill it, halfway through.

The backend now emits a progress event per QSO and the frontend shows the same
overlay the ADIF import uses: a bar, the count, and the callsign being queried.

The callsign is reported BEFORE the lookup rather than after. With a slow
provider that is the difference between "waiting on F4BPO" and a name that lags
one QSO behind whatever is actually taking the time — which would be the field
someone stares at while deciding whether it is stuck.

The overlay is cleared by the closing event and again in a finally, so neither
a completed run nor a failed one can leave it on screen.
This commit is contained in:
2026-07-30 17:54:07 +02:00
parent eb271e8f20
commit c931d8a762
4 changed files with 77 additions and 7 deletions
+29 -1
View File
@@ -10262,6 +10262,23 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
return changed, nil return changed, nil
} }
// emitBulkProgress reports how far a bulk update has got.
//
// The QRZ pass is the one that needs it: it is a NETWORK round trip per QSO, so
// 102 contacts imported from a contest take a couple of minutes during which
// nothing moved on screen and the only honest reading was "it has frozen". The
// cty.dat and ClubLog passes are local and usually instant, but they report too
// — a progress bar that appears only when things are slow teaches people that
// its absence means trouble.
func (a *App) emitBulkProgress(op string, processed, total int, call string) {
wruntime.EventsEmit(a.ctx, "bulkupdate:progress", map[string]any{
"op": op,
"processed": processed,
"total": total,
"call": call,
})
}
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per // UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
// the configured providers) for each QSO id and overwrites the geographic // the configured providers) for each QSO id and overwrites the geographic
// + entity fields (country, continent, DXCC, zones, grid, state, county) // + entity fields (country, continent, DXCC, zones, grid, state, county)
@@ -10272,13 +10289,23 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
return 0, fmt.Errorf("not initialized") return 0, fmt.Errorf("not initialized")
} }
changed := 0 changed := 0
for _, id := range ids { total := len(ids)
a.emitBulkProgress("qrz", 0, total, "")
defer a.emitBulkProgress("qrz", total, total, "")
for i, id := range ids {
q, err := a.qso.GetByID(a.ctx, id) q, err := a.qso.GetByID(a.ctx, id)
if err != nil || q.Callsign == "" { if err != nil || q.Callsign == "" {
a.emitBulkProgress("qrz", i+1, total, "")
continue continue
} }
// Reported BEFORE the lookup, so the callsign on screen is the one being
// waited on rather than the one already finished — with a slow provider
// that is the difference between "working on F4BPO" and a name that
// lags a QSO behind whatever is actually taking the time.
a.emitBulkProgress("qrz", i, total, q.Callsign)
r, err := a.lookup.Lookup(a.ctx, q.Callsign) r, err := a.lookup.Lookup(a.ctx, q.Callsign)
if err != nil { if err != nil {
a.emitBulkProgress("qrz", i+1, total, "")
continue continue
} }
if r.Country != "" { if r.Country != "" {
@@ -10330,6 +10357,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
if err := a.qso.Update(a.ctx, q); err == nil { if err := a.qso.Update(a.ctx, q); err == nil {
changed++ changed++
} }
a.emitBulkProgress("qrz", i+1, total, "")
} }
if changed > 0 { if changed > 0 {
a.invalidateAwardStats() a.invalidateAwardStats()
+4 -2
View File
@@ -5,12 +5,14 @@
"en": [ "en": [
"The Kenwood backend is now exercised against a rig that answers: OpsLog talks to the TS-2000 emulator it already carries for ACOM amplifiers, so frequency, mode, VFO, split and PTT are checked end to end. Testing on a real Kenwood is still needed, but the dialogue itself is no longer untried.", "The Kenwood backend is now exercised against a rig that answers: OpsLog talks to the TS-2000 emulator it already carries for ACOM amplifiers, so frequency, mode, VFO, split and PTT are checked end to end. Testing on a real Kenwood is still needed, but the dialogue itself is no longer untried.",
"Kenwood: split is now detected from the transmit and receive VFO (FR/FT) when the radio leaves it out of its status frame — the case seen on a Flex in Kenwood CAT mode, where the frequency read correctly but split never appeared. Radios that report it in the status frame are unaffected.", "Kenwood: split is now detected from the transmit and receive VFO (FR/FT) when the radio leaves it out of its status frame — the case seen on a Flex in Kenwood CAT mode, where the frequency read correctly but split never appeared. Radios that report it in the status frame are unaffected.",
"The CAT protocol trace covers the Kenwood backend as well, not just CI-V. Settings → CAT." "The CAT protocol trace covers the Kenwood backend as well, not just CI-V. Settings → CAT.",
"Updating selected QSOs from QRZ.com now shows a progress bar with the callsign being queried. On a contest log freshly imported, a hundred contacts mean a hundred network round trips, and until now nothing moved on screen while it worked."
], ],
"fr": [ "fr": [
"Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.", "Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.",
"Kenwood : le split est désormais détecté à partir des VFO d'émission et de réception (FR/FT) quand la radio ne le renseigne pas dans sa trame d'état — le cas observé sur un Flex en mode CAT Kenwood, où la fréquence était juste mais le split n'apparaissait jamais. Les radios qui le signalent normalement ne changent pas.", "Kenwood : le split est désormais détecté à partir des VFO d'émission et de réception (FR/FT) quand la radio ne le renseigne pas dans sa trame d'état — le cas observé sur un Flex en mode CAT Kenwood, où la fréquence était juste mais le split n'apparaissait jamais. Les radios qui le signalent normalement ne changent pas.",
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V. Réglages → CAT." "La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V. Réglages → CAT.",
"La mise à jour des QSO sélectionnés depuis QRZ.com affiche désormais une barre de progression avec l'indicatif en cours d'interrogation. Sur un log de concours fraîchement importé, cent contacts font cent allers-retours réseau, et jusqu'ici rien ne bougeait à l'écran pendant ce temps."
] ]
}, },
{ {
+42 -2
View File
@@ -1518,6 +1518,8 @@ export default function App() {
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip'); const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
const [importApplyCty, setImportApplyCty] = useState(true); const [importApplyCty, setImportApplyCty] = useState(true);
const [importApplyStation, setImportApplyStation] = useState(false); const [importApplyStation, setImportApplyStation] = useState(false);
// Progress of a bulk update (QRZ.com / cty.dat / ClubLog) over selected QSOs.
const [bulkProgress, setBulkProgress] = useState<{ op: string; processed: number; total: number; call: string } | null>(null);
// Field remapping, off unless the operator adds a row. Contest software puts // Field remapping, off unless the operator adds a row. Contest software puts
// the exchange where its own module keeps it rather than where ADIF says: the // the exchange where its own module keeps it rather than where ADIF says: the
// RSGB IOTA contest exports the island reference in STATE, which imports as a // RSGB IOTA contest exports the island reference in STATE, which imports as a
@@ -2402,6 +2404,14 @@ export default function App() {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call); if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
}); });
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
const total = Number(p?.total ?? 0);
const processed = Number(p?.processed ?? 0);
// The closing event (processed === total) clears the overlay: the work is
// done, and a bar left at 100% would have to be dismissed by hand.
if (total > 0 && processed >= total) { setBulkProgress(null); return; }
setBulkProgress({ op: String(p?.op ?? ''), processed, total, call: String(p?.call ?? '') });
});
const unsubProg = EventsOn('import:progress', (p: any) => { const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
}); });
@@ -2428,7 +2438,7 @@ export default function App() {
const file = String(p?.file ?? '').replace(/^.*[\\/]/, ''); const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n })); 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?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -2935,9 +2945,9 @@ export default function App() {
} }
async function bulkUpdateFromQRZ(ids: number[]) { async function bulkUpdateFromQRZ(ids: number[]) {
if (ids.length === 0) return; if (ids.length === 0) return;
showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}`);
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); } try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
catch (e: any) { setError(String(e?.message ?? e)); } catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBulkProgress(null); }
} }
async function bulkUpdateFromClublog(ids: number[]) { async function bulkUpdateFromClublog(ids: number[]) {
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -6384,6 +6394,36 @@ export default function App() {
</Dialog> </Dialog>
)} )}
{bulkProgress && (
<Dialog open>
<DialogContent className="max-w-sm px-6" hideClose>
<DialogHeader className="px-2">
<DialogTitle>{t('bulk.progressTitle')}</DialogTitle>
</DialogHeader>
<div className="px-2 pb-4 space-y-2">
{(() => {
const { processed, total, call } = bulkProgress;
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
return (
<>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full bg-primary rounded-full transition-[width] duration-150', total === 0 && 'w-1/3 animate-pulse')}
style={total > 0 ? { width: `${pct}%` } : undefined}
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-mono">{call || '\u00a0'}</span>
<span>{total > 0 ? `${processed} / ${total}` : ''}</span>
</div>
</>
);
})()}
</div>
</DialogContent>
</Dialog>
)}
{importing && ( {importing && (
<Dialog open> <Dialog open>
<DialogContent className="max-w-sm px-6" hideClose> <DialogContent className="max-w-sm px-6" hideClose>
+2 -2
View File
@@ -136,7 +136,7 @@ const en: Dict = {
'imp.stationTitle': 'Fill my station fields from my profile', 'imp.stationTitle': 'Fill my station fields from my profile',
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.", 'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
'imp.cancel': 'Cancel', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import', 'imp.cancel': 'Cancel', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
'imp.progressTitle': 'Importing ADIF…', 'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…', 'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
'imp.complete': 'Import complete.', 'imp.complete': 'Import complete.',
'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total', 'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total',
@@ -547,7 +547,7 @@ const fr: Dict = {
'imp.stationTitle': 'Remplir mes champs station depuis mon profil', 'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.", 'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
'imp.cancel': 'Annuler', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer', 'imp.cancel': 'Annuler', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
'imp.progressTitle': 'Import ADIF en cours…', 'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…', 'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
'imp.complete': 'Import terminé.', 'imp.complete': 'Import terminé.',
'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total', 'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total',