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:
+14
-2
@@ -1,4 +1,16 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.15",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"FT map: the callsign, square and report show on hover again. The invisible circle that catches the clicks sits on top of the dot, so it takes the hover too — and the label was bound only to the dot underneath, which left the map silent from the moment the stations became clickable.",
|
||||||
|
"ADIF export: a record is written on one line again. ADDRESS is a multi-line field by the standard and callbooks and other loggers fill it that way — “Kabul”, four blank lines, “Afghanistan” — and OpsLog wrote it out as it was, so a record ran down a dozen lines with the next apparently starting in the middle of the page. Line breaks inside a value are now joined with a comma, which is how an address reads on one line anyway. The files were always valid (ADIF counts bytes); they were unreadable."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Carte FTx : l’indicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée qu’au point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables.",
|
||||||
|
"Export ADIF : un enregistrement tient de nouveau sur une ligne. ADDRESS est un champ multiligne selon la norme, et les callbooks comme les autres logiciels le remplissent ainsi — « Kabul », quatre lignes vides, « Afghanistan » — qu’OpsLog recopiait tel quel : un enregistrement s’étalait sur une douzaine de lignes, le suivant semblant commencer au milieu de la page. Les retours à la ligne dans une valeur sont désormais réunis par une virgule, ce qui est de toute façon la façon de lire une adresse sur une ligne. Les fichiers étaient valides (l’ADIF compte les octets) ; ils étaient illisibles."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.14",
|
"version": "0.27.14",
|
||||||
"date": "",
|
"date": "",
|
||||||
@@ -7,7 +19,7 @@
|
|||||||
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
|
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
|
||||||
"The world map now waits for the station’s square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
|
"The world map now waits for the station’s square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
|
||||||
"[NEW] The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world map’s own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
|
"[NEW] The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world map’s own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
|
||||||
"[NEW] FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The callsign, square and report still show on hover — the wider hit area carries them, not just the dot under it — and a double click no longer zooms the map on its way through.",
|
"[NEW] FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The hit area is wider than the dot, and a double click no longer zooms the map on its way through.",
|
||||||
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
|
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
|
||||||
"Auto-call: the rest between two series is counted in the station’s own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
|
"Auto-call: the rest between two series is counted in the station’s own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
|
||||||
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
|
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
|
||||||
@@ -25,7 +37,7 @@
|
|||||||
"[NEW] La carte du monde s’ouvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à l’est, l’Europe et l’Afrique à l’ouest. La latitude penche vers votre hémisphère sans vous suivre jusqu’au pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
|
"[NEW] La carte du monde s’ouvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à l’est, l’Europe et l’Afrique à l’ouest. La latitude penche vers votre hémisphère sans vous suivre jusqu’au pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
|
||||||
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
|
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
|
||||||
"[NEW] La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, c’est dire quelle partie du monde on travaille, et c’était jeté à chaque changement d’onglet.",
|
"[NEW] La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, c’est dire quelle partie du monde on travaille, et c’était jeté à chaque changement d’onglet.",
|
||||||
"[NEW] Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met l’indicatif dans la saisie, deux l’appellent. L’indicatif, le locator et le report s’affichent toujours au survol — la zone cliquable les porte, pas seulement le point en dessous — et un double clic ne zoome plus la carte au passage.",
|
"[NEW] Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met l’indicatif dans la saisie, deux l’appellent. La zone cliquable est plus large que le point, et un double clic ne zoome plus la carte au passage.",
|
||||||
"L’affichage de l’auto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue s’affiche avec un sablier. Le bouton changeait de largeur à chaque période.",
|
"L’affichage de l’auto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue s’affiche avec un sablier. Le bouton changeait de largeur à chaque période.",
|
||||||
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, c’est quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
|
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, c’est quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
|
||||||
"L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble.",
|
"L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble.",
|
||||||
|
|||||||
@@ -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
|
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
|
||||||
// already a UTF-8 byte string.
|
// already a UTF-8 byte string.
|
||||||
func writeField(bw *bufio.Writer, tag, v string) {
|
func writeField(bw *bufio.Writer, tag, v string) {
|
||||||
|
v = oneLine(v)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
|
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) {
|
func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user