From 2a6e09a1d769a8d9427f4587389b5d828dd760f0 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 11 Aug 2026 20:39:46 +0200 Subject: [PATCH] fix(bulk): mode and RST were offered but could not be saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bulk-editable field passes through THREE tables: the list the dialog shows, bulkFieldColumns in app.go, and bulkEditableCols in internal/qso. I added mode, submode and RST to the first and the third and missed the middle one, so the dialog offered them and every save came back "unknown field". Nothing warns about that. Each table is perfectly valid on its own, and the mismatch only surfaces when an operator picks the one field that falls through the gap — which is exactly how it was found. So both directions are pinned now: every mapped field must be writable by the qso layer, and every writable column must have a field mapping to it. A column nothing maps to looks supported from the inside and cannot be reached from outside, which is the same fault wearing the other hat. --- app.go | 6 ++++++ bulkfields_test.go | 35 +++++++++++++++++++++++++++++++++++ internal/qso/qso.go | 17 +++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 bulkfields_test.go diff --git a/app.go b/app.go index 7945997..ec3c0d4 100644 --- a/app.go +++ b/app.go @@ -6162,6 +6162,12 @@ var bulkFieldColumns = map[string]string{ "iota": "iota", "sig": "sig", "sig_info": "sig_info", + // The contact itself — repair fields. Setting mode also clears submode, in + // qso.BulkSetField: a submode left over from the old mode contradicts the new. + "mode": "mode", + "submode": "submode", + "rst_sent": "rst_sent", + "rst_rcvd": "rst_rcvd", // Misc text "comment": "comment", "notes": "notes", diff --git a/bulkfields_test.go b/bulkfields_test.go new file mode 100644 index 0000000..7537fac --- /dev/null +++ b/bulkfields_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "testing" + + "hamlog/internal/qso" +) + +// A bulk-editable field passes through THREE tables: the field list in the UI, +// bulkFieldColumns here, and bulkEditableCols in internal/qso. Mode and RST were +// added to the first and the third and not the second, so the dialog offered +// them and the save failed with "unknown field" — reported from the field. +// +// Nothing warns about that: each table is valid on its own. This is the check. +func TestEveryMappedBulkFieldIsWhitelisted(t *testing.T) { + for id, col := range bulkFieldColumns { + if !qso.BulkEditable(col) { + t.Errorf("bulk field %q maps to column %q, which internal/qso refuses to write", id, col) + } + } +} + +// And the reverse: a column the qso layer allows but nothing maps to is dead +// weight — it looks supported from the inside and cannot be reached from outside. +func TestNoUnreachableBulkColumn(t *testing.T) { + mapped := map[string]bool{} + for _, col := range bulkFieldColumns { + mapped[col] = true + } + for _, col := range qso.BulkEditableColumns() { + if !mapped[col] { + t.Errorf("column %q is bulk-writable but no field maps to it — unreachable", col) + } + } +} diff --git a/internal/qso/qso.go b/internal/qso/qso.go index e552db1..9dfe84d 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -896,6 +896,23 @@ var bulkEditableExtras = map[string]string{ // BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "". func BulkExtraKey(field string) string { return bulkEditableExtras[field] } +// BulkEditable reports whether a COLUMN may be bulk-written. Exported so the +// app layer can check its own field mapping against this whitelist: the two +// lists are separate, valid on their own, and a field present in one and absent +// from the other fails only when an operator tries to use it. +func BulkEditable(column string) bool { return bulkEditableCols[column] } + +// BulkEditableColumns lists every bulk-writable column, for the same check from +// the other side: a column nothing maps to looks supported and cannot be used. +func BulkEditableColumns() []string { + out := make([]string, 0, len(bulkEditableCols)) + for c := range bulkEditableCols { + out = append(out, c) + } + sort.Strings(out) + return out +} + // BulkSetExtra sets one whitelisted extras_json field on every listed QSO, // leaving the other extras untouched. An empty value REMOVES the key rather than // storing a blank — an empty extra would otherwise be carried into every export.