feat(hamlog): read confirmations back from a HAMLOG.online ADIF
Their agent protocol has no download verb, so the return path is a file exported from their site. Running that file through the ordinary ADIF import is the wrong tool and does real damage: it matches on callsign + UTC minute + band + mode, their export is rebuilt from their own database and rarely agrees to the minute, and 'update duplicates' then inserts everything that failed to match -- several hundred copies of contacts already in the log. This path matches only. It stamps the confirmation on QSOs it finds, falls back to the mode-CLASS key for the modes their export renames, and REPORTS what it could not match instead of adding it: an unmatched confirmation is a question about the log, not a contact to create. Also logs and reports how many rows a delete actually removed -- silence there made a delete that did nothing indistinguishable from one that worked.
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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.
|
||||
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 + "…")
|
||||
|
||||
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))
|
||||
}
|
||||
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++
|
||||
return nil
|
||||
})
|
||||
if perr != nil {
|
||||
return res, perr
|
||||
}
|
||||
a.invalidateAwardStats() // confirmations move award counts
|
||||
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 a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": res.Matched, "total": res.Confirmed})
|
||||
}
|
||||
return res, 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")
|
||||
}
|
||||
Reference in New Issue
Block a user