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