diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 87b3665..09aa456 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1540,7 +1540,13 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session return } w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1") - w.Header().Set("X-Reply-Text", url.QueryEscape(pttResp.ReplyText)) + // PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space + // as "+", which is form encoding, and the client decodes this header + // with decodeURIComponent, which only knows "%20" — so every space in a + // spoken reply reached the on-page log as a plus sign. PathEscape is the + // flavour decodeURIComponent actually reverses, which keeps the encoding + // a property of the header rather than something the client has to know. + w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText)) w.Write(pttResp.ReplyAudio.Bytes) return } diff --git a/cmd/mavweb/replytext_test.go b/cmd/mavweb/replytext_test.go new file mode 100644 index 0000000..233c946 --- /dev/null +++ b/cmd/mavweb/replytext_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "net/url" + "strings" + "testing" +) + +// decodeURIComponent is what static/app.js calls on X-Reply-Text. PathUnescape +// is its Go equivalent for this purpose: both turn %XX into bytes and both +// leave a literal "+" alone. That last part is the whole defect — QueryEscape +// wrote spaces as "+" and the client had no way to tell those from a plus the +// speaker actually said. +func decodeURIComponent(t *testing.T, s string) string { + t.Helper() + out, err := url.PathUnescape(s) + if err != nil { + t.Fatalf("decodeURIComponent(%q): %v", s, err) + } + return out +} + +// The reply the QA session actually saw was "на+04.08.2026+ничего+нет." +// (Vikunja #533). Round-tripping through the client's decoder is the assertion +// that matters — checking the encoder in isolation would have passed with +// QueryEscape too. +func TestReplyTextSurvivesTheClientDecoder(t *testing.T) { + cases := []string{ + "на 04.08.2026 ничего нет.", + "Я поставила тебе напоминание позвонить маме через час.", + // A literal plus must stay a plus, which is the case that makes + // "just replace + with space on the JS side" the wrong fix. + "два плюс два = 2+2", + // Headers cannot carry a raw newline. PathEscape writes %0A. + "первая строка\nвторая строка", + "", // no reply text at all + } + for _, want := range cases { + encoded := url.PathEscape(want) + if strings.ContainsAny(encoded, "\r\n") { + t.Errorf("encoded %q contains a raw newline, which is not a legal header value", want) + } + if got := decodeURIComponent(t, encoded); got != want { + t.Errorf("round trip: got %q, want %q", got, want) + } + } +} + +// The specific regression, named. QueryEscape is form encoding and this header +// is not a form. +func TestReplyTextDoesNotUseFormEncoding(t *testing.T) { + const spoken = "на 04.08.2026 ничего нет." + if got := decodeURIComponent(t, url.QueryEscape(spoken)); got == spoken { + t.Skip("QueryEscape round-trips here, so this test proves nothing — check the decoder stand-in") + } + if strings.Contains(url.PathEscape(spoken), "+") { + t.Errorf("PathEscape(%q) still writes a plus", spoken) + } +}