feat(awards): implement the QRZ.com and Custom confirmation sources
The award editor offered five confirmation sources and Def's own doc comment named five, but confirmed() had cases for three. "qrzcom" and "custom" fell through the switch, so ticking either marked nothing as confirmed — the exact failure the GrantCodes comment in this struct warns about: a checkbox that does nothing is worse than no checkbox, because it is trusted. QRZ.com reads qrzcom_qso_download_status, not the upload one: uploading a QSO is us telling QRZ about it, which confirms nothing. Custom names a field. Rather than a checkbox per external source, the Def gains ConfirmField + ConfirmValue: any QSO field or ADIF extras key, and optionally the comma-separated values that count. That one shape covers the three cases asked for — the OpsLog card marker (APP_OPSLOG_QSL_RCVD), an arbitrary ADIF tag, and a tag stamped by an imported club list — because all three end up as a field on the QSO. An empty ConfirmValue means any non-empty content confirms: the OpsLog marker stores the date the card arrived, not a Y/N flag. A custom source naming NO field confirms nothing, deliberately — the opposite default would silently mark a whole logbook confirmed.
This commit is contained in:
+73
-5
@@ -111,6 +111,18 @@ type Def struct {
|
||||
// --- Confirmation ---
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
// The "custom" source, for confirmations OpsLog has no dedicated column for:
|
||||
// ConfirmField names a QSO field or an ADIF extras key, ConfirmValue the
|
||||
// value(s) that count (comma-separated, case-insensitive).
|
||||
//
|
||||
// An EMPTY ConfirmValue means "any non-empty value confirms" — which is what
|
||||
// the OpsLog card marker needs: APP_OPSLOG_QSL_RCVD stores the timestamp of
|
||||
// the day the card arrived, not a Y/N flag. The same shape serves a key
|
||||
// stamped by an outside source (a club's CSV imported into an extras field),
|
||||
// so one mechanism covers every "confirmed somewhere else" case instead of a
|
||||
// new checkbox per site.
|
||||
ConfirmField string `json:"confirm_field,omitempty"`
|
||||
ConfirmValue string `json:"confirm_value,omitempty"`
|
||||
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
|
||||
// controls have been removed from the editor — a checkbox that quietly does
|
||||
@@ -523,8 +535,8 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(q.Band))
|
||||
modeClass := ModeClass(q.Mode)
|
||||
isConf := confirmed(q, d.Confirm)
|
||||
isVal := confirmed(q, d.Validate)
|
||||
isConf := confirmed(q, d.Confirm, d)
|
||||
isVal := confirmed(q, d.Validate, d)
|
||||
for _, ref := range refs {
|
||||
a := agg[i][ref]
|
||||
if a == nil {
|
||||
@@ -685,8 +697,9 @@ func manualRefs(q *qso.QSO, code string) []string {
|
||||
}
|
||||
|
||||
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
||||
// sources (lotw|qsl|eqsl|qrzcom|custom). Exported for the statistics view.
|
||||
// The Def is needed for the "custom" source, which reads the field IT names.
|
||||
func Confirmed(q *qso.QSO, d Def, sources []string) bool { return confirmed(q, sources, &d) }
|
||||
|
||||
// InScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||
// bands, modes, emission, dates) — independent of whether a reference was
|
||||
@@ -1374,7 +1387,7 @@ func dxccAllowed(dxcc *int, filter []int) bool {
|
||||
|
||||
// confirmed reports whether the QSO satisfies any accepted confirmation source.
|
||||
// ADIF *_QSL_RCVD values Y (confirmed) and V (verified) both count.
|
||||
func confirmed(q *qso.QSO, sources []string) bool {
|
||||
func confirmed(q *qso.QSO, sources []string, d *Def) bool {
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "lotw":
|
||||
@@ -1389,11 +1402,66 @@ func confirmed(q *qso.QSO, sources []string) bool {
|
||||
if isYes(q.EQSLRcvd) {
|
||||
return true
|
||||
}
|
||||
case "qrzcom":
|
||||
// The DOWNLOAD status, not the upload one: uploading a QSO to QRZ is
|
||||
// us telling them, not them confirming it back.
|
||||
if isYes(q.QRZComDownloadStatus) {
|
||||
return true
|
||||
}
|
||||
case "custom":
|
||||
if customConfirmed(q, d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// customConfirmed answers the operator-defined confirmation source: the field
|
||||
// named by ConfirmField, optionally required to hold one of ConfirmValue's
|
||||
// comma-separated values.
|
||||
//
|
||||
// A source that names no field confirms NOTHING. That is deliberate: ticking
|
||||
// "custom" without saying what it means used to mark every QSO as unconfirmed
|
||||
// anyway, and silently marking them all CONFIRMED instead would be far worse.
|
||||
func customConfirmed(q *qso.QSO, d *Def) bool {
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
field := strings.TrimSpace(d.ConfirmField)
|
||||
if field == "" {
|
||||
return false
|
||||
}
|
||||
got := strings.TrimSpace(customFieldValue(q, field))
|
||||
if got == "" {
|
||||
return false
|
||||
}
|
||||
want := strings.TrimSpace(d.ConfirmValue)
|
||||
if want == "" {
|
||||
return true // any value at all counts — see ConfirmField's doc
|
||||
}
|
||||
for _, w := range strings.Split(want, ",") {
|
||||
if w = strings.TrimSpace(w); w != "" && strings.EqualFold(w, got) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// customFieldValue resolves the field a custom confirmation names: one of the
|
||||
// engine's known QSO fields first, then the ADIF extras — which is where the
|
||||
// OpsLog card marker (APP_OPSLOG_QSL_RCVD) and anything stamped by an outside
|
||||
// import actually live.
|
||||
func customFieldValue(q *qso.QSO, field string) string {
|
||||
if v := strings.TrimSpace(fieldRaw(field, q)); v != "" {
|
||||
return v
|
||||
}
|
||||
if q.Extras == nil {
|
||||
return ""
|
||||
}
|
||||
return q.Extras[strings.ToUpper(strings.TrimSpace(field))]
|
||||
}
|
||||
|
||||
func isYes(v string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(v)) {
|
||||
case "Y", "V":
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Every source the editor offers must actually do something. "qrzcom" and
|
||||
// "custom" were listed in the UI and in Def's doc comment but had no case in the
|
||||
// switch, so ticking either marked nothing as confirmed — a checkbox that is
|
||||
// trusted and silently inert.
|
||||
func TestConfirmedSources(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
q qso.QSO
|
||||
d Def
|
||||
sources []string
|
||||
want bool
|
||||
}{
|
||||
{"lotw", qso.QSO{LOTWRcvd: "Y"}, Def{}, []string{"lotw"}, true},
|
||||
{"qsl", qso.QSO{QSLRcvd: "Y"}, Def{}, []string{"qsl"}, true},
|
||||
{"eqsl", qso.QSO{EQSLRcvd: "Y"}, Def{}, []string{"eqsl"}, true},
|
||||
|
||||
// QRZ confirms on the DOWNLOAD status. The upload one is us telling QRZ
|
||||
// about the QSO, which is not a confirmation of anything.
|
||||
{"qrz download", qso.QSO{QRZComDownloadStatus: "Y"}, Def{}, []string{"qrzcom"}, true},
|
||||
{"qrz upload only", qso.QSO{QRZComUploadStatus: "Y"}, Def{}, []string{"qrzcom"}, false},
|
||||
|
||||
// Custom, no value required: any non-empty value counts. This is the
|
||||
// OpsLog card case — the marker holds a timestamp, not a flag.
|
||||
{"custom any value",
|
||||
qso.QSO{Extras: map[string]string{"APP_OPSLOG_QSL_RCVD": "2026-08-09T01:00:00Z"}},
|
||||
Def{ConfirmField: "APP_OPSLOG_QSL_RCVD"}, []string{"custom"}, true},
|
||||
{"custom field empty",
|
||||
qso.QSO{Extras: map[string]string{"APP_OPSLOG_QSL_RCVD": ""}},
|
||||
Def{ConfirmField: "APP_OPSLOG_QSL_RCVD"}, []string{"custom"}, false},
|
||||
|
||||
// Custom with an explicit value list, matched case-insensitively.
|
||||
{"custom value match",
|
||||
qso.QSO{Extras: map[string]string{"APP_CLUB_CONF": "v"}},
|
||||
Def{ConfirmField: "APP_CLUB_CONF", ConfirmValue: "Y,V"}, []string{"custom"}, true},
|
||||
{"custom value mismatch",
|
||||
qso.QSO{Extras: map[string]string{"APP_CLUB_CONF": "N"}},
|
||||
Def{ConfirmField: "APP_CLUB_CONF", ConfirmValue: "Y,V"}, []string{"custom"}, false},
|
||||
|
||||
// A custom source naming no field must confirm NOTHING — never everything.
|
||||
{"custom without a field",
|
||||
qso.QSO{Extras: map[string]string{"APP_OPSLOG_QSL_RCVD": "x"}},
|
||||
Def{}, []string{"custom"}, false},
|
||||
|
||||
// Known QSO fields resolve too, not just extras.
|
||||
{"custom on a known field", qso.QSO{State: "TX"},
|
||||
Def{ConfirmField: "state", ConfirmValue: "TX"}, []string{"custom"}, true},
|
||||
|
||||
// Any source in the list is enough.
|
||||
{"first source misses, second hits", qso.QSO{EQSLRcvd: "Y"}, Def{},
|
||||
[]string{"lotw", "eqsl"}, true},
|
||||
{"no source set", qso.QSO{LOTWRcvd: "Y"}, Def{}, nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Confirmed(&c.q, c.d, c.sources); got != c.want {
|
||||
t.Errorf("%s: Confirmed = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user