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:
2026-08-23 15:43:57 +02:00
parent b952e074f4
commit ab89611eca
7 changed files with 128 additions and 6 deletions
+87 -1
View File
@@ -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.