External services (QRZ/Clublog/LoTW) + QSL Manager
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user