fix(qsl): separate the QSL manager from the routing method (#16)

QSL_VIA is the manager. QSL_SENT_VIA and QSL_RCVD_VIA are the ADIF "QSL Via"
enumeration — B bureau, D direct, E electronic, M manager (import-only) —
and say how a card travelled. OpsLog had one column for all three:

  - the import folded QSL_SENT_VIA into QSL_VIA whenever QSL_VIA was empty,
    which is exactly a Log4OM export (it defaults QSL_SENT_VIA to E), so
    OE6CLD saw "E" everywhere OpsLog shows the manager;
  - QSL_RCVD_VIA was listed in adifPromoted with no column behind it, so it
    was not stored, not kept among the extras, and not exported — dropped
    outright on import;
  - neither was ever written on export, so an import followed by an export
    destroyed both;
  - and OpsLog polluted the field itself: the QSL Manager panel wrote
    "Bureau" / "Direct" / "Electronic", in full words, into QSL_VIA.

Two columns added (migration 0027), carried through the five places a
promoted ADIF field has to touch, with round-trip tests pinning the reported
case. The QSL panel now offers Bureau / Direct / Electronic for each
direction and stores the enumeration; the manager field is labelled as the
manager and holds only that. M is kept when a file gives it and never
written back out.

Existing logs hold a mixture of the two in one column. The repair is offered,
not performed: the count is shown once per log with a plain question, and a
"no" is remembered. It moves only where QSL_SENT_VIA is still empty, and only
values that normalise to the enumeration — a manager is a callsign and can
never be one of those six words, which a test pins against real manager calls.
This commit is contained in:
2026-08-14 11:50:25 +02:00
parent 30143b01bf
commit 4e88bdfaa7
20 changed files with 542 additions and 35 deletions
+8
View File
@@ -241,6 +241,14 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("QSLSDATE", q.QSLSentDate)
w("QSLRDATE", q.QSLRcvdDate)
w("QSL_VIA", q.QSLVia)
// M (manager) is import-only in the QSL Via enumeration: we keep it when a
// file gives it to us, but a file we write must not carry it.
if q.QSLSentVia != QSLViaManager {
w("QSL_SENT_VIA", q.QSLSentVia)
}
if q.QSLRcvdVia != QSLViaManager {
w("QSL_RCVD_VIA", q.QSLRcvdVia)
}
w("QSLMSG", q.QSLMsg)
w("QSLMSG_RCVD", q.QSLMsgRcvd)
w("LOTW_QSL_SENT", q.LOTWSent)
+2 -2
View File
@@ -135,8 +135,8 @@ var Fields = []FieldDef{
{Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL"},
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL"},
{Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true},
{Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true},
+10 -3
View File
@@ -464,10 +464,17 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.QSLRcvd = rec["qsl_rcvd"]
q.QSLSentDate = rec["qslsdate"]
q.QSLRcvdDate = rec["qslrdate"]
// QSL_VIA is the manager. QSL_SENT_VIA / QSL_RCVD_VIA are the routing
// method, an enumeration of their own.
//
// These used to be one field here: an empty QSL_VIA was filled from
// QSL_SENT_VIA, on the theory that loggers writing one meant the other.
// They do not — Log4OM defaults QSL_SENT_VIA to E, and the import put "E"
// where every panel in OpsLog shows the manager's callsign. Keeping them
// apart is also what lets an export give them back.
q.QSLVia = rec["qsl_via"]
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead
q.QSLVia = rec["qsl_sent_via"]
}
q.QSLSentVia = NormaliseQSLVia(rec["qsl_sent_via"])
q.QSLRcvdVia = NormaliseQSLVia(rec["qsl_rcvd_via"])
q.QSLMsg = rec["qslmsg"]
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
q.LOTWSent = rec["lotw_qsl_sent"]
+53
View File
@@ -0,0 +1,53 @@
package adif
import "strings"
// The ADIF "QSL Via" enumeration, used by QSL_SENT_VIA and QSL_RCVD_VIA. It
// says how a card travelled, and is a different thing entirely from QSL_VIA,
// which is the manager's callsign.
const (
QSLViaBureau = "B"
QSLViaDirect = "D"
QSLViaElectronic = "E"
// QSLViaManager is import-only in the standard: it may be read from another
// logger's file, never written to one. OpsLog keeps it when it arrives so
// the operator's own data is not silently altered, and NormaliseQSLVia is
// the only place that decides so.
QSLViaManager = "M"
)
// NormaliseQSLVia folds what other loggers and OpsLog's own older versions put
// in a routing field down to the ADIF enumeration.
//
// It accepts the letter, the English word, and the French one — OpsLog wrote
// "Bureau", "Direct" and "Electronic" in full for a long time, and the QSL
// Manager panel still shows those words to a French operator. Anything it does
// not recognise comes back empty rather than being passed through: this feeds
// an enumerated ADIF field, and inventing a value there breaks the file for
// every other logger that reads it.
func NormaliseQSLVia(s string) string {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "B", "BUREAU", "BURO", "VIA BUREAU":
return QSLViaBureau
case "D", "DIRECT":
return QSLViaDirect
case "E", "ELECTRONIC", "ELECTRONIQUE", "ÉLECTRONIQUE", "OQRS":
return QSLViaElectronic
case "M", "MANAGER":
return QSLViaManager
}
return ""
}
// IsQSLViaRouting reports whether a QSL_VIA value is in fact a routing method
// that ended up in the manager field.
//
// It exists for one repair: OpsLog's QSL Manager panel wrote "Bureau",
// "Direct" and "Electronic" into QSL_VIA, and imports folded QSL_SENT_VIA
// there too, so logs hold a mixture of managers and routing words in one
// column. A manager is a callsign, never one of these six words, so the test
// is exact — but it is deliberately narrow: anything else, including a manager
// whose callsign happens to be unusual, is left alone.
func IsQSLViaRouting(s string) bool {
return NormaliseQSLVia(s) != ""
}
+37
View File
@@ -0,0 +1,37 @@
package adif
import "testing"
func TestNormaliseQSLVia(t *testing.T) {
for in, want := range map[string]string{
"B": "B", "b": "B", "Bureau": "B", "BUREAU": "B", " buro ": "B",
"D": "D", "Direct": "D", "direct": "D",
"E": "E", "Electronic": "E", "électronique": "E", "OQRS": "E",
"M": "M", "Manager": "M",
// A manager's callsign is not a routing method, and neither is noise.
"M0OXO": "", "EA5GL": "", "": "", "Bureau via M0OXO": "", "X": "",
} {
if got := NormaliseQSLVia(in); got != want {
t.Errorf("NormaliseQSLVia(%q) = %q, want %q", in, got, want)
}
}
}
// The repair moves values out of the manager column. A false positive would
// erase a real manager, so the guard is worth its own test: every callsign-like
// value must be refused.
func TestIsQSLViaRoutingRefusesManagers(t *testing.T) {
for _, call := range []string{
"M0OXO", "EA5GL", "F5CWU", "DJ9ZB", "W3HNK", "IK2DUW", "N7RO",
"BUREAU M0OXO", "via bureau DL1XYZ", "QSL DIRECT ONLY",
} {
if IsQSLViaRouting(call) {
t.Errorf("%q was taken for a routing method — the repair would erase it", call)
}
}
for _, v := range []string{"B", "D", "E", "M", "Bureau", "Direct", "Electronic"} {
if !IsQSLViaRouting(v) {
t.Errorf("%q should be recognised as a routing method", v)
}
}
}
+82
View File
@@ -119,3 +119,85 @@ func renderRecord(q qso.QSO, includeApp bool) string {
bw.Flush()
return buf.String()
}
// TestQSLViaFieldsRoundTrip covers the case reported as issue #16: a log
// exported by another logger carries a manager in QSL_VIA and a routing method
// in QSL_SENT_VIA, and the two must stay apart.
//
// Before this, QSL_SENT_VIA was folded into QSL_VIA whenever QSL_VIA was empty
// — so a Log4OM log, which defaults QSL_SENT_VIA to E, showed "E" wherever
// OpsLog displays the manager — and QSL_RCVD_VIA was thrown away outright: it
// was listed as a promoted field with no column behind it, so it was not even
// kept among the extras. Neither was ever exported, which made an import
// followed by an export destroy both.
func TestQSLViaFieldsRoundTrip(t *testing.T) {
in := qso.QSO{
Callsign: "3B9FR", Band: "20m", Mode: "CW",
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
QSLVia: "M0OXO", // the manager
QSLSentVia: "D", // sent direct
QSLRcvdVia: "B", // came back via the bureau
}
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
bw.WriteString("<EOH>\n")
writeRecord(bw, in, true, nil)
bw.Flush()
var rec Record
if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
t.Fatalf("parse: %v", err)
}
out, ok := recordToQSO(rec)
if !ok {
t.Fatal("recordToQSO returned !ok")
}
for name, c := range map[string]struct{ got, want string }{
"QSL_VIA": {out.QSLVia, in.QSLVia},
"QSL_SENT_VIA": {out.QSLSentVia, in.QSLSentVia},
"QSL_RCVD_VIA": {out.QSLRcvdVia, in.QSLRcvdVia},
} {
if c.got != c.want {
t.Errorf("%s: got %q, want %q", name, c.got, c.want)
}
}
}
// TestQSLSentViaDoesNotBecomeManager pins the exact shape a Log4OM export has:
// no QSL_VIA at all, QSL_SENT_VIA defaulted to E. The manager field must come
// back empty rather than holding "E".
func TestQSLSentViaDoesNotBecomeManager(t *testing.T) {
const rec = "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW" +
"<QSL_SENT_VIA:1>E<EOR>\n"
var got Record
if err := Parse(strings.NewReader("<EOH>\n"+rec), func(r Record) error { got = r; return nil }); err != nil {
t.Fatalf("parse: %v", err)
}
q, ok := recordToQSO(got)
if !ok {
t.Fatal("recordToQSO returned !ok")
}
if q.QSLVia != "" {
t.Errorf("QSL_VIA = %q — the routing method leaked into the manager field again", q.QSLVia)
}
if q.QSLSentVia != "E" {
t.Errorf("QSL_SENT_VIA = %q, want %q", q.QSLSentVia, "E")
}
}
// M is import-only in the ADIF QSL Via enumeration: keep it when given, never
// write it back out.
func TestQSLViaManagerIsImportOnly(t *testing.T) {
in := qso.QSO{
Callsign: "3B9FR", Band: "20m", Mode: "CW",
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
QSLSentVia: "M", QSLRcvdVia: "M",
}
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
writeRecord(bw, in, true, nil)
bw.Flush()
if s := buf.String(); strings.Contains(s, "QSL_SENT_VIA") || strings.Contains(s, "QSL_RCVD_VIA") {
t.Errorf("exported an import-only value:\n%s", s)
}
}
@@ -0,0 +1,18 @@
-- QSL_SENT_VIA / QSL_RCVD_VIA — the ADIF fields that say HOW a card travelled.
--
-- Until now OpsLog had one column, qsl_via, and used it for two unrelated
-- things: the QSL manager (what ADIF's QSL_VIA holds) and the routing method.
-- Imports made it worse — QSL_SENT_VIA was folded into qsl_via when qsl_via was
-- empty, so a Log4OM log arrived with "E" sitting where the manager belongs —
-- and QSL_RCVD_VIA was dropped outright, listed as a promoted field with no
-- column behind it, so it was not even kept among the extras.
--
-- These two columns hold the ADIF enumeration: B (bureau), D (direct),
-- E (electronic). M (manager) is accepted on import only, per the standard.
--
-- Adding the columns is all that happens here. Existing qsl_via values are NOT
-- touched: separating a manager from a routing word rewrites what an operator
-- can see in their own log, so it is offered once, with a count, and only runs
-- when they say yes.
ALTER TABLE qso ADD COLUMN qsl_sent_via TEXT;
ALTER TABLE qso ADD COLUMN qsl_rcvd_via TEXT;
+93 -6
View File
@@ -99,7 +99,9 @@ type QSO struct {
QSLRcvd string `json:"qsl_rcvd,omitempty"`
QSLSentDate string `json:"qsl_sent_date,omitempty"`
QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"`
QSLVia string `json:"qsl_via,omitempty"`
QSLVia string `json:"qsl_via,omitempty"` // ADIF QSL_VIA — the QSL manager
QSLSentVia string `json:"qsl_sent_via,omitempty"` // ADIF enumeration B/D/E — how the card was sent
QSLRcvdVia string `json:"qsl_rcvd_via,omitempty"` // same enumeration, for the card received
QSLMsg string `json:"qsl_msg,omitempty"`
QSLMsgRcvd string `json:"qslmsg_rcvd,omitempty"`
@@ -246,7 +248,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
grid, gridsquare_ext, vucc_grids,
country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref,
age, lat, lon, rig, ant,
qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_msg, qslmsg_rcvd,
qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_sent_via, qsl_rcvd_via, qsl_msg, qslmsg_rcvd,
lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date,
eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
clublog_qso_upload_date, clublog_qso_upload_status,
@@ -321,7 +323,7 @@ func (q *QSO) args() []any {
q.Grid, q.GridExt, q.VUCCGrids,
q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef,
q.Age, q.Lat, q.Lon, q.Rig, q.Ant,
q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLMsg, q.QSLMsgRcvd,
q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLSentVia, q.QSLRcvdVia, q.QSLMsg, q.QSLMsgRcvd,
q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate,
q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
q.ClublogUploadDate, q.ClublogUploadStatus,
@@ -771,6 +773,8 @@ var bulkEditableCols = map[string]bool{
"qsl_sent": true,
"qsl_rcvd": true,
"qsl_via": true,
"qsl_sent_via": true,
"qsl_rcvd_via": true,
"qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true,
@@ -1250,7 +1254,7 @@ var filterableColumns = map[string]bool{
"grid": true, "country": true, "state": true, "cnty": true,
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent_via": true, "qsl_rcvd_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
@@ -2265,6 +2269,86 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err()
}
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
// instead of a manager, per isRouting.
//
// The test is applied in Go rather than in SQL because it has to hold the same
// vocabulary as the import and the QSL panel — one list of accepted spellings,
// in internal/adif, not a LIKE pattern drifting apart from it here. Only the
// distinct values are examined, so the log is scanned once whatever its size.
func (r *Repo) CountQSLViaRouting(ctx context.Context, isRouting func(string) bool) (int, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT qsl_via, COUNT(*) FROM qso
WHERE qsl_via IS NOT NULL AND qsl_via != ''
AND (qsl_sent_via IS NULL OR qsl_sent_via = '')
GROUP BY qsl_via`)
if err != nil {
return 0, err
}
defer rows.Close()
n := 0
for rows.Next() {
var via string
var c int
if err := rows.Scan(&via, &c); err != nil {
return 0, err
}
if isRouting(via) {
n += c
}
}
return n, rows.Err()
}
// RepairQSLViaRouting moves routing words out of qsl_via into qsl_sent_via,
// returning how many QSOs were changed.
//
// One UPDATE per distinct spelling, not per QSO: a log holds a handful of them
// ("Bureau", "E", "Direct"…), and a remote MySQL logbook must not be made to
// carry one round trip per contact for a tidy-up. Rows that already have a
// sent-via are left alone by the same condition the count uses, so running this
// twice cannot undo a later import.
func (r *Repo) RepairQSLViaRouting(ctx context.Context, normalise func(string) string) (int, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT qsl_via FROM qso
WHERE qsl_via IS NOT NULL AND qsl_via != ''
AND (qsl_sent_via IS NULL OR qsl_sent_via = '')`)
if err != nil {
return 0, err
}
type move struct{ from, to string }
var moves []move
for rows.Next() {
var via string
if err := rows.Scan(&via); err != nil {
rows.Close()
return 0, err
}
if to := normalise(via); to != "" {
moves = append(moves, move{from: via, to: to})
}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
total := 0
for _, m := range moves {
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET qsl_sent_via = ?, qsl_via = ''
WHERE qsl_via = ? AND (qsl_sent_via IS NULL OR qsl_sent_via = '')`,
m.to, m.from)
if err != nil {
return total, err
}
if n, err := res.RowsAffected(); err == nil {
total += int(n)
}
}
return total, nil
}
// CallCounties returns callsign → "STATE,County" for every US station already
// logged with a county, newest QSO winning.
//
@@ -2889,7 +2973,8 @@ func scanQSO(s scanner) (QSO, error) {
rig, ant sql.NullString
qslSent, qslRcvd sql.NullString
qslSentDate, qslRcvdDate sql.NullString
qslVia, qslMsg, qslMsgRcvd sql.NullString
qslVia, qslSentVia, qslRcvdVia sql.NullString
qslMsg, qslMsgRcvd sql.NullString
lotwSent, lotwRcvd sql.NullString
lotwSentDate, lotwRcvdDate sql.NullString
eqslSent, eqslRcvd sql.NullString
@@ -2934,7 +3019,7 @@ func scanQSO(s scanner) (QSO, error) {
&grid, &gridExt, &vucc,
&country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota,
&age, &lat, &lon, &rig, &ant,
&qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslMsg, &qslMsgRcvd,
&qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslSentVia, &qslRcvdVia, &qslMsg, &qslMsgRcvd,
&lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate,
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
&clublogDate, &clublogStatus,
@@ -3018,6 +3103,8 @@ func scanQSO(s scanner) (QSO, error) {
q.QSLSentDate = qslSentDate.String
q.QSLRcvdDate = qslRcvdDate.String
q.QSLVia = qslVia.String
q.QSLSentVia = qslSentVia.String
q.QSLRcvdVia = qslRcvdVia.String
q.QSLMsg = qslMsg.String
q.QSLMsgRcvd = qslMsgRcvd.String
q.LOTWSent = lotwSent.String