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") }