feat(hamlog): show what the confirmation import did, and export what it could not place
The import printed four numbers and left nothing behind. 1106 matched and 395 unmatched out of 1501 is not a report: the operator cannot see which contacts were confirmed, nor work through the discrepancies. Two additions. The Results view is now filled with the confirmed contacts, flagged new entity/band/mode/slot against what LoTW and paper QSL already confirm -- the only sense in which a HAMLOG confirmation changes anything. And the unmatched ones can be written out as ADIF, because a few hundred of them are a list to open and compare, not something to read in a log window.
This commit is contained in:
@@ -782,6 +782,13 @@ type App struct {
|
||||
// a still-running QRZ sync bleed its log into a freshly started LoTW download).
|
||||
confDLMu sync.Mutex
|
||||
confDLCancel context.CancelFunc
|
||||
|
||||
// hamlogUnmatched holds the confirmations the last HAMLOG.online import
|
||||
// could not place onto a QSO, kept so they can be exported and worked
|
||||
// through — see ExportHamlogUnmatched. Replaced by each import, never
|
||||
// accumulated: it describes one run, not a history.
|
||||
hamlogUnmatchedMu sync.Mutex
|
||||
hamlogUnmatched []qso.QSO
|
||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||
syncMu sync.Mutex // serialises folder synchronisation: config, the seq counter, and the append to our own file
|
||||
|
||||
+4
-2
@@ -7,14 +7,16 @@
|
||||
"The filter can now match on when a QSO was added to the log ('Added to the log on'), which is the only way to isolate what an import brought in — an import of old contacts carries old QSO dates and otherwise hides inside the log.",
|
||||
"QSL Manager: HAMLOG.online gains 'Import confirmations (ADIF)…'. Their site exports a log, this reads it back and stamps the confirmations on QSOs already present — it never inserts. Importing that same file through the ordinary ADIF import does insert, because it matches on the UTC minute and their export rarely agrees to the minute.",
|
||||
"Deleting QSOs now says how many rows were actually removed, and writes it to the log. A delete that removes nothing used to look exactly like one that worked.",
|
||||
"Deleting QSOs is fast again when 'delete from the remote services' is on. Club Log is only asked about contacts that were actually uploaded to it — asking about the others earned a 403 each time, one network round trip per QSO — the withdrawals now run in the background instead of holding the window, the rows behind a selection are read in one query rather than one per QSO, and repeated refusals stop the run rather than earning a longer block."
|
||||
"Deleting QSOs is fast again when 'delete from the remote services' is on. Club Log is only asked about contacts that were actually uploaded to it — asking about the others earned a 403 each time, one network round trip per QSO — the withdrawals now run in the background instead of holding the window, the rows behind a selection are read in one query rather than one per QSO, and repeated refusals stop the run rather than earning a longer block.",
|
||||
"The HAMLOG.online confirmation import now fills the Results view with the contacts it confirmed, flagged new entity / band / mode / slot, and can export the unmatched ones as ADIF. Those are the interesting half: each is a contact their site holds and the log does not confirm — a minute of drift, a portable call, or a QSO genuinely missing."
|
||||
],
|
||||
"fr": [
|
||||
"Édition de QSO : HAMLOG.online rejoint l'onglet QSL Info — son propre canal dans la liste, avec envoyé/reçu et les deux dates, et une ligne dans le tableau de statut. Ses quatre colonnes passent aussi du groupe QSL au groupe Uploads, à côté de Club Log et QRZ.com.",
|
||||
"Le filtre peut maintenant porter sur la date d'ajout au journal (« Ajouté au journal le »), seul moyen d'isoler ce qu'un import a apporté : un import de vieux contacts porte de vieilles dates de QSO et se fond sinon dans le journal.",
|
||||
"Gestionnaire QSL : HAMLOG.online reçoit « Importer les confirmations (ADIF)… ». Leur site exporte un log, cette fonction le relit et appose les confirmations sur les QSO déjà présents — elle n'ajoute jamais rien. Le même fichier passé par l'import ADIF ordinaire, lui, ajoute : il compare à la minute UTC près et leur export s'accorde rarement à la minute.",
|
||||
"La suppression de QSO indique maintenant combien de lignes ont réellement été supprimées, et l'écrit dans le journal. Une suppression sans effet ressemblait exactement à une suppression réussie.",
|
||||
"La suppression de QSO redevient rapide quand « supprimer aussi des services externes » est activé. Club Log n'est plus interrogé que pour les contacts qui y ont réellement été envoyés — pour les autres il répondait 403, un aller-retour réseau par QSO — les retraits se font désormais en arrière-plan au lieu de bloquer la fenêtre, les lignes d'une sélection sont lues en une seule requête au lieu d'une par QSO, et une série de refus interrompt le traitement au lieu d'aggraver le blocage."
|
||||
"La suppression de QSO redevient rapide quand « supprimer aussi des services externes » est activé. Club Log n'est plus interrogé que pour les contacts qui y ont réellement été envoyés — pour les autres il répondait 403, un aller-retour réseau par QSO — les retraits se font désormais en arrière-plan au lieu de bloquer la fenêtre, les lignes d'une sélection sont lues en une seule requête au lieu d'une par QSO, et une série de refus interrompt le traitement au lieu d'aggraver le blocage.",
|
||||
"L'import des confirmations HAMLOG.online alimente maintenant la vue Résultats avec les contacts confirmés, marqués nouvelle entité / bande / mode / slot, et peut exporter les non-rapprochés en ADIF. C'est la moitié intéressante : chacun est un contact que leur site détient et que le journal ne confirme pas — minute décalée, indicatif portable, ou QSO réellement absent."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, OpenADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -363,6 +363,21 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
// The unmatched half of an import. Offered as a file because a few hundred
|
||||
// discrepancies are a list to work through, not something to read in a log
|
||||
// window.
|
||||
async function exportHamlogUnmatched() {
|
||||
try {
|
||||
const path = await SaveADIFFile();
|
||||
if (!path) return;
|
||||
const n = await ExportHamlogUnmatched(path);
|
||||
setLogLines((p) => [...p, `Exported ${n} unmatched confirmation(s) → ${path}`]);
|
||||
setShowLog(true);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setShowLog(true);
|
||||
}
|
||||
}
|
||||
|
||||
function viewResults() {
|
||||
setShowLog(false);
|
||||
if (logAction === 'upload') selectRequired();
|
||||
@@ -680,10 +695,16 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{service === 'hamlog' ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||
title={t('qslm.hamlogImportTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.hamlogImportCfm')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportHamlogUnmatched} disabled={busy}
|
||||
title={t('qslm.hamlogUnmatchedTitle')}>
|
||||
<UploadCloud className="size-3.5 rotate-180" /> {t('qslm.hamlogUnmatched')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title={t('qslm.downloadTitle')}>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -233,6 +233,8 @@ export function ExportCabrilloFiltered(arg1:string,arg2:qso.QueryFilter):Promise
|
||||
|
||||
export function ExportCabrilloSelected(arg1:string,arg2:Array<number>):Promise<main.CabrilloResult>;
|
||||
|
||||
export function ExportHamlogUnmatched(arg1:string):Promise<number>;
|
||||
|
||||
export function FilterFields():Promise<Array<string>>;
|
||||
|
||||
export function FindDuplicates(arg1:number):Promise<Array<main.DuplicateGroup>>;
|
||||
|
||||
@@ -406,6 +406,10 @@ export function ExportCabrilloSelected(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportHamlogUnmatched(arg1) {
|
||||
return window['go']['main']['App']['ExportHamlogUnmatched'](arg1);
|
||||
}
|
||||
|
||||
export function FilterFields() {
|
||||
return window['go']['main']['App']['FilterFields']();
|
||||
}
|
||||
|
||||
+87
-1
@@ -34,6 +34,11 @@ import (
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// hamlogUnmatchedMax bounds what is kept for export. A whole log's worth of
|
||||
// unmatched records means the file belongs to another station, not that the
|
||||
// operator wants 50 000 of them written back out.
|
||||
const hamlogUnmatchedMax = 20000
|
||||
|
||||
// HamlogCfmResult is what the import did.
|
||||
type HamlogCfmResult struct {
|
||||
Total int `json:"total"` // records read from the file
|
||||
@@ -42,7 +47,9 @@ type HamlogCfmResult struct {
|
||||
ByClass int `json:"by_class"` // matched on mode CLASS rather than exact mode
|
||||
Unmatched int `json:"unmatched"` // confirmations with no local QSO
|
||||
// Samples names a few unmatched contacts, so "12 unmatched" can be looked
|
||||
// into rather than merely worried about.
|
||||
// into rather than merely worried about. The full list is kept for export —
|
||||
// see ExportHamlogUnmatched — because 395 of them is not a sample-sized
|
||||
// problem: it is a list to work through.
|
||||
Samples []string `json:"samples"`
|
||||
}
|
||||
|
||||
@@ -86,6 +93,14 @@ func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
|
||||
}
|
||||
emit("Reading " + path + "…")
|
||||
|
||||
// What already counts towards an award, so each confirmation can be flagged
|
||||
// NEW. LoTW and paper QSL are the two award-valid sources; a HAMLOG
|
||||
// confirmation is "new" when it lands on a slot neither of them holds — which
|
||||
// is the only sense in which it changes anything.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
||||
var items []ConfirmationItem
|
||||
var unmatched []qso.QSO
|
||||
|
||||
perr := adif.Parse(f, func(rec adif.Record) error {
|
||||
q, ok := adif.RecordToQSO(rec)
|
||||
if !ok {
|
||||
@@ -116,6 +131,9 @@ func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
|
||||
res.Samples = append(res.Samples, fmt.Sprintf("%s · %s · %s · %s",
|
||||
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
|
||||
}
|
||||
if len(unmatched) < hamlogUnmatchedMax {
|
||||
unmatched = append(unmatched, q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
date := hamlogCfmDate(rec)
|
||||
@@ -128,12 +146,45 @@ func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
|
||||
// from their website would otherwise read "never sent, yet confirmed".
|
||||
_ = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y")
|
||||
res.Matched++
|
||||
|
||||
// Feed the Results view, the same rows the LoTW download produces: an
|
||||
// import that only prints counts leaves the operator with no way to see
|
||||
// WHICH contacts were confirmed, which is the reason they ran it.
|
||||
a.enrichContactedFromCty(&q) // country/dxcc, for the entity flags
|
||||
it := ConfirmationItem{
|
||||
Callsign: q.Callsign,
|
||||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||||
Band: q.Band,
|
||||
Mode: q.Mode,
|
||||
Country: q.Country,
|
||||
}
|
||||
if q.DXCC != nil && *q.DXCC != 0 {
|
||||
n := *q.DXCC
|
||||
it.NewDXCC = !sets.DXCC[n]
|
||||
it.NewBand = !sets.Band[qso.BandKey(n, q.Band)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(n, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(n, q.Band, q.Mode)]
|
||||
// Fold it in, so a repeat inside the same file isn't flagged twice.
|
||||
sets.DXCC[n] = true
|
||||
sets.Band[qso.BandKey(n, q.Band)] = true
|
||||
sets.Mode[qso.ModeClassKey(n, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(n, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
})
|
||||
if perr != nil {
|
||||
return res, perr
|
||||
}
|
||||
a.invalidateAwardStats() // confirmations move award counts
|
||||
// Kept for ExportHamlogUnmatched. Held rather than written now: the operator
|
||||
// decides whether a list of 395 is worth a file.
|
||||
a.hamlogUnmatchedMu.Lock()
|
||||
a.hamlogUnmatched = unmatched
|
||||
a.hamlogUnmatchedMu.Unlock()
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||||
}
|
||||
applog.Printf("hamlog cfm import: %d records, %d confirmed, %d matched (%d by mode class), %d unmatched",
|
||||
res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched)
|
||||
emit(fmt.Sprintf("%d records read, %d confirmed by HAMLOG.online, %d matched in the log (%d by mode class), %d unmatched",
|
||||
@@ -141,12 +192,47 @@ func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
|
||||
for _, s := range res.Samples {
|
||||
emit(" unmatched: " + s)
|
||||
}
|
||||
if res.Unmatched > len(res.Samples) {
|
||||
emit(fmt.Sprintf(" …and %d more — use \"Export unmatched (ADIF)…\" to get the whole list.",
|
||||
res.Unmatched-len(res.Samples)))
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": res.Matched, "total": res.Confirmed})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ExportHamlogUnmatched writes the confirmations the last import could not
|
||||
// place onto a QSO.
|
||||
//
|
||||
// They are the interesting half of the result: each one is a contact HAMLOG
|
||||
// believes it holds and this log does not agree about — a minute of drift, a
|
||||
// portable call, a band written differently, or a QSO genuinely missing. A
|
||||
// count cannot be worked through; a file can be opened, sorted and compared.
|
||||
//
|
||||
// Written as ADIF because that is what every other tool reads, and because it
|
||||
// can be handed straight back to an import once the discrepancies are settled.
|
||||
func (a *App) ExportHamlogUnmatched(path string) (int, error) {
|
||||
a.hamlogUnmatchedMu.Lock()
|
||||
rows := a.hamlogUnmatched
|
||||
a.hamlogUnmatchedMu.Unlock()
|
||||
if len(rows) == 0 {
|
||||
return 0, fmt.Errorf("nothing to export: the last import left no unmatched confirmations")
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return 0, fmt.Errorf("empty path")
|
||||
}
|
||||
recs := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
recs = append(recs, adif.FullRecordADIF(rows[i]))
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(adif.BatchRecordsADIF(recs)), 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applog.Printf("hamlog cfm import: exported %d unmatched confirmation(s) to %s", len(rows), path)
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// hamlogQSLDateKey stamps WHEN the confirmation was read back. Their export
|
||||
// carries no confirmation date of its own, so this is the import date — which
|
||||
// is honest about what it knows, unlike borrowing the QSO date.
|
||||
|
||||
Reference in New Issue
Block a user