fix(adif): one record, one line

An exported file was full of blank gaps: a record ran down a dozen lines
and the next appeared to start in the middle of the page. ADDRESS is a
multi-line field by the standard and callbooks and other loggers fill it
that way — "Kabul", four blank lines, "Afghanistan" — and the writer
copied the value out as it stood. The files were always valid, since ADIF
counts bytes; they were unreadable, and so was anything that quoted them.

Line breaks inside a value are now joined with a comma and tabs become
spaces, which is how an address reads on one line anyway. The length
prefix is computed after the flattening, so the record stays exact, and
every path through the writer gets it: the file exports, the uploads and
the record forwarded over UDP.

The changelog's 0.27.15 block also takes back the FT-map hover fix, which
landed after the 0.27.14 release commit and was sitting in that block.
This commit is contained in:
2026-09-06 17:09:18 +02:00
parent c8e2e3a29f
commit 381a7fdc40
3 changed files with 89 additions and 2 deletions
+29
View File
@@ -407,12 +407,41 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
// already a UTF-8 byte string.
func writeField(bw *bufio.Writer, tag, v string) {
v = oneLine(v)
if v == "" {
return
}
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
}
// oneLine flattens a value onto a single line.
//
// ADIF counts bytes, so a value carrying line breaks is still read correctly —
// and it turns the file into something nobody can read. ADDRESS is a multi-line
// field by the standard, and callbooks and other loggers fill it that way: a
// value of "Kabul" followed by four blank lines and "Afghanistan" came out of
// OpsLog as one record spread down a dozen lines, with the next record
// apparently starting in the middle of the page.
//
// The breaks are dropped rather than escaped: the parts are trimmed and joined
// with a comma, which is how an address reads on one line anyway, and empty
// fragments go. The length prefix is computed after this, so the record stays
// exact.
func oneLine(v string) string {
if !strings.ContainsAny(v, "\r\n\t") {
return v
}
parts := strings.FieldsFunc(v, func(r rune) bool { return r == '\r' || r == '\n' })
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.ReplaceAll(part, "\t", " "))
if part != "" {
out = append(out, part)
}
}
return strings.Join(out, ", ")
}
func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
if p == nil {
return
+46
View File
@@ -0,0 +1,46 @@
package adif
import (
"bufio"
"strings"
"testing"
"hamlog/internal/qso"
)
// An exported record has to fit on its own line. ADDRESS is a multi-line field
// by the standard and callbooks fill it that way, so an OpsLog export was one
// record spread down a dozen lines with the next apparently starting in the
// middle of the page.
func TestAMultiLineValueIsWrittenOnOneLine(t *testing.T) {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeField(bw, "ADDRESS", "Kabul\r\n\r\n\r\n\r\nAfghanistan\r\n")
bw.Flush()
got := b.String()
if strings.ContainsAny(got, "\r\n") {
t.Fatalf("the record still breaks across lines: %q", got)
}
if want := "<ADDRESS:18>Kabul, Afghanistan "; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// And the whole record, the way an operator reads the file.
func TestARecordIsOneLine(t *testing.T) {
hz := int64(28555000)
rec := SingleRecordADIF(qso.QSO{
Callsign: "T6T", Band: "10m", Mode: "SSB", FreqHz: &hz,
Address: "Kabul\n\n\nAfghanistan", Name: "Shuravi\t(Vyacheslav)",
})
if n := strings.Count(strings.TrimRight(rec, "\r\n"), "\n"); n != 0 {
t.Errorf("the record spans %d extra lines:\n%s", n, rec)
}
if !strings.Contains(rec, "Kabul, Afghanistan") {
t.Errorf("the address lost its parts:\n%s", rec)
}
if !strings.Contains(rec, "Shuravi (Vyacheslav)") {
t.Errorf("a tab was left in the value:\n%s", rec)
}
}