From 00595c2211b785d1003e6737b93affb72e949a4e Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 01:32:40 +0400 Subject: [PATCH] web: the PTT reply log shows spaces, not plus signs (V-533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript beside the spoken reply read "на+04.08.2026+ничего+нет." X-Reply-Text was written with url.QueryEscape, which is form encoding and writes a space as "+", and static/app.js reads it with decodeURIComponent, which only knows "%20". Every space in a spoken reply arrived as a plus. Fixed on the Go side rather than by replacing plus with space in the client: the encoding is a property of the header, and a client that has to know which flavour it got is a client that will get it wrong again. Escaping in the client's own dialect also keeps a plus the speaker actually said — "2+2" — from becoming a space. PathEscape writes %0A for a newline too, so a two-line reply stays a legal header value instead of a truncated one. The test round-trips through a stand-in for decodeURIComponent rather than checking the encoder alone, because QueryEscape passes any assertion that only looks at what went in. Cosmetic and log-only. The audio was never affected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot --- cmd/mavweb/main.go | 8 ++++- cmd/mavweb/replytext_test.go | 59 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 cmd/mavweb/replytext_test.go 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) + } +}