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.
265 lines
9.9 KiB
Go
265 lines
9.9 KiB
Go
package main
|
|
|
|
// Reading confirmations back from a HAMLOG.online ADIF export.
|
|
//
|
|
// Their agent protocol only uploads — it has no verb for asking what has been
|
|
// confirmed — so the return path is a file: their site exports an ADIF, and the
|
|
// operator feeds it back here.
|
|
//
|
|
// That file must NOT be imported the ordinary way. A plain import matches a
|
|
// record to a local QSO on callsign + UTC MINUTE + band + mode, and their
|
|
// export is rebuilt from their own database: a minute of rounding, SSB where
|
|
// the log says USB, and the key no longer matches. "Update duplicates" then
|
|
// does what it is told — no duplicate found, so it inserts — and a few hundred
|
|
// copies of contacts the operator already had land in the log. That is exactly
|
|
// what happened once, and it is why this path exists instead.
|
|
//
|
|
// So: match only, never insert. Confirmations are stamped onto the QSOs already
|
|
// in the log, and anything that cannot be matched is REPORTED rather than
|
|
// added, because an unmatched confirmation is a question about the log (a
|
|
// minute off, a portable call) and not a contact to create.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"hamlog/internal/adif"
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/award"
|
|
"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
|
|
Confirmed int `json:"confirmed"` // records carrying HAMLOG's confirmation flag
|
|
Matched int `json:"matched"` // local QSOs stamped
|
|
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. 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"`
|
|
}
|
|
|
|
// hamlogCfmSamples caps the reported list — enough to see the pattern, not so
|
|
// many that the dialog becomes a log file.
|
|
const hamlogCfmSamples = 30
|
|
|
|
// ImportHamlogConfirmations stamps the confirmations from a HAMLOG.online ADIF
|
|
// export onto the matching local QSOs. It inserts nothing.
|
|
func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
|
|
var res HamlogCfmResult
|
|
if a.qso == nil {
|
|
return res, fmt.Errorf("db not initialized")
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return res, fmt.Errorf("empty path")
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
defer f.Close()
|
|
|
|
ctx := a.ctx
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
// The same two indexes the LoTW download uses: the exact key, then the
|
|
// mode-CLASS key for the contacts whose mode was written differently at the
|
|
// other end (FT8 exported as DATA, SSB where the log says USB).
|
|
keyIDs, err := a.qso.DedupeKeyIDs(ctx)
|
|
if err != nil {
|
|
return res, fmt.Errorf("read local log: %w", err)
|
|
}
|
|
classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
|
|
|
|
emit := func(line string) {
|
|
if a.ctx != nil {
|
|
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
|
}
|
|
}
|
|
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 {
|
|
return nil
|
|
}
|
|
res.Total++
|
|
// Only the records they actually confirmed. The export carries the whole
|
|
// log, and stamping "confirmed" on all of it would turn every uploaded
|
|
// contact into a confirmed one.
|
|
if !hamlogRecordConfirmed(rec) {
|
|
return nil
|
|
}
|
|
res.Confirmed++
|
|
|
|
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
|
|
id, found := keyIDs[qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)]
|
|
if !found {
|
|
// id 0 means the class key is ambiguous — several local QSOs share
|
|
// it — and guessing between them would stamp the wrong one.
|
|
if cid, ok := classIDs[qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)]; ok && cid != 0 {
|
|
id, found = cid, true
|
|
res.ByClass++
|
|
}
|
|
}
|
|
if !found {
|
|
res.Unmatched++
|
|
if len(res.Samples) < hamlogCfmSamples {
|
|
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)
|
|
if e := a.qso.SetExtra(ctx, id, award.HamlogQSLKey, "Y"); e != nil {
|
|
return nil
|
|
}
|
|
_ = a.qso.SetExtra(ctx, id, hamlogQSLDateKey, date)
|
|
// A confirmed contact is by definition one they hold, so the sent side
|
|
// is true whether or not OpsLog is what uploaded it — a log uploaded
|
|
// 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",
|
|
res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched))
|
|
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.
|
|
const hamlogQSLDateKey = "APP_OPSLOG_HAMLOG_QSL_DATE"
|
|
|
|
// hamlogRecordConfirmed reads their flag off a raw ADIF record. The primary key
|
|
// is the one their own export writes; the alternates are the names OpsLog and
|
|
// other tools have used, so a file that went through another logger still reads.
|
|
func hamlogRecordConfirmed(rec adif.Record) bool {
|
|
keys := append([]string{award.HamlogQSLKey}, award.HamlogAltKeys()...)
|
|
for _, k := range keys {
|
|
v := strings.ToUpper(strings.TrimSpace(rec[strings.ToLower(k)]))
|
|
if v != "" && v != "N" && v != "NO" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hamlogCfmDate is the date to stamp: the import date, in ADIF form.
|
|
func hamlogCfmDate(rec adif.Record) string {
|
|
// If a future export ever carries one, take it rather than today's date.
|
|
for _, k := range []string{"app_hamlog_qso_cfm_date", "qslrdate"} {
|
|
if v := strings.TrimSpace(rec[k]); len(v) == 8 {
|
|
return v
|
|
}
|
|
}
|
|
return time.Now().UTC().Format("20060102")
|
|
}
|