External services (QRZ/Clublog/LoTW) + QSL Manager

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 00:52:10 +02:00
parent 33a7b6c4ac
commit 8f1ad126ac
9 changed files with 456 additions and 27 deletions
+189 -8
View File
@@ -100,13 +100,16 @@ const (
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download)
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
)
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -1327,7 +1330,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword,
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
keyExtLoTWAutoUpload, keyExtLoTWUploadMode)
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
keyExtLoTWUsername, keyExtLoTWWebPassword)
if err != nil {
return out
}
@@ -1358,6 +1362,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
KeyPassword: m[keyExtLoTWKeyPassword],
UploadFlag: m[keyExtLoTWUploadFlag],
WriteLog: m[keyExtLoTWWriteLog] == "1",
Username: m[keyExtLoTWUsername],
Password: m[keyExtLoTWWebPassword],
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
}
@@ -1432,6 +1438,8 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtLoTWWriteLog: ltWriteLog,
keyExtLoTWAutoUpload: ltAuto,
keyExtLoTWUploadMode: ltMode,
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
keyExtLoTWWebPassword: cfg.LoTW.Password,
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -1572,6 +1580,179 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
}
// ConfirmationItem is one downloaded confirmation shown in the QSL Manager,
// with award-style NEW flags computed against the log's prior confirmations.
type ConfirmationItem struct {
Callsign string `json:"callsign"`
QSODate string `json:"qso_date"` // ISO UTC
Band string `json:"band"`
Mode string `json:"mode"`
Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"`
NewSlot bool `json:"new_slot"`
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same
// "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window.
func (a *App) DownloadConfirmations(service string, addNotFound bool) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
cfg := a.loadExternalServices()
go a.runDownloadConfirmations(svc, cfg, addNotFound)
return nil
}
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
done := func(matched, total int) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
}
}
ctx := context.Background()
matched, total, added := 0, 0, 0
switch svc {
case extsvc.ServiceLoTW:
since := ""
if a.settings != nil {
if m, e := a.settings.GetMany(ctx, keyExtLoTWLastDownload); e == nil {
since = m[keyExtLoTWLastDownload]
}
}
if since != "" {
emit("Downloading LoTW confirmations received since " + since + "…")
} else {
emit("Downloading all LoTW confirmations…")
}
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, since)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
return
}
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
if kerr != nil {
emit("Error reading local log: " + kerr.Error())
done(matched, total)
return
}
// Snapshot what's already confirmed so we can flag each incoming
// confirmation as a NEW DXCC / band / slot.
sets, _ := a.qso.ConfirmedSlots(ctx)
var items []ConfirmationItem
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
}
total++
date := rec["qslrdate"]
if date == "" {
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if id, found := keyIDs[key]; found {
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
matched++
}
} else if addNotFound {
q.LOTWSent = "Y"
q.LOTWRcvd = "Y"
q.LOTWRcvdDate = date
if newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = newID // guard against dup records in the report
added++
}
}
// Build the result row + NEW flags (vs the pre-download snapshot),
// then fold this slot into the sets so a repeat in the same batch
// isn't flagged twice.
dxccNum := 0
if q.DXCC != nil {
dxccNum = *q.DXCC
}
it := ConfirmationItem{
Callsign: q.Callsign,
QSODate: q.QSODate.UTC().Format(time.RFC3339),
Band: q.Band,
Mode: q.Mode,
Country: q.Country,
}
if dxccNum != 0 {
it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
}
items = append(items, it)
return nil
})
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
if perr != nil {
emit("Parse error: " + perr.Error())
}
if addNotFound {
emit(fmt.Sprintf("Matched %d, added %d (of %d confirmed QSO(s))", matched, added, total))
} else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
}
// Remember today so the next pull is incremental.
if a.settings != nil {
_ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
}
default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
}
done(matched+added, total)
}
// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
// from cty.dat (offline) — used when adding a not-found confirmation that
// only carries call/band/mode/date.
func (a *App) enrichContactedFromCty(q *qso.QSO) {
if a.dxcc == nil || q.Callsign == "" {
return
}
m, ok := a.dxcc.Lookup(q.Callsign)
if !ok || m.Entity == nil {
return
}
if q.Country == "" {
q.Country = m.Entity.Name
}
if q.Continent == "" {
q.Continent = m.Continent
}
if q.DXCC == nil {
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
q.DXCC = &n
}
}
if q.CQZ == nil && m.CQZone != 0 {
v := m.CQZone
q.CQZ = &v
}
if q.ITUZ == nil && m.ITUZone != 0 {
v := m.ITUZone
q.ITUZ = &v
}
}
// ListTQSLStationLocations returns the Station Locations defined in TQSL,
// for the LoTW settings dropdown.
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {