From d01b738741e8ba3638e0aec344e9d08bb8827b89 Mon Sep 17 00:00:00 2001 From: Alik Send Date: Thu, 4 Dec 2025 11:11:10 -0600 Subject: [PATCH] Implement function to prune string keeping HTML closing tags (#1870) * Implement function to prune string keeping HTML closing tags Fixes #1587 * change const name remove unneeded comment * move pruneHTML to separated file * move const back to telegram.go * Add unit tests for string array manipulation and HTML pruning Introduce comprehensive test cases for stringArr methods (Push, Pop, Unshift, Shift, String) to ensure correct behavior and state management. Additionally, add tests for HTML pruning functions (pruneHTML, pruneStringToWord) to validate handling of length constraints and formatting scenarios. * Improve behavior * Fix pruneHTML to count visible text only, add parent text pruning - Fix bug where HTML tags were counted toward the character limit instead of only visible text content - Add pruning for parent comment text in Telegram notifications - Simplify pruneStringToWord using strings.LastIndex - Remove unused stringArr type and its tests - Consolidate and simplify test cases --------- Co-authored-by: Umputun Co-authored-by: Dmitry Verkhoturov --- backend/app/notify/prune_html.go | 77 +++++++++++++++++++++++++++ backend/app/notify/prune_html_test.go | 47 ++++++++++++++++ backend/app/notify/telegram.go | 6 ++- backend/app/notify/telegram_test.go | 9 ++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 backend/app/notify/prune_html.go create mode 100644 backend/app/notify/prune_html_test.go diff --git a/backend/app/notify/prune_html.go b/backend/app/notify/prune_html.go new file mode 100644 index 00000000..b2ebb7fa --- /dev/null +++ b/backend/app/notify/prune_html.go @@ -0,0 +1,77 @@ +package notify + +import ( + "fmt" + "strings" + + "golang.org/x/net/html" +) + +// pruneHTML prunes string keeping HTML closing tags. +// maxLength applies to visible text only, not HTML tags. +func pruneHTML(htmlText string, maxLength int) string { + var result strings.Builder + var endTokens []string + visibleLen := 0 + + suffix := "..." + suffixLen := len(suffix) + + tokenizer := html.NewTokenizer(strings.NewReader(htmlText)) + for { + if tokenizer.Next() == html.ErrorToken { + return result.String() + } + token := tokenizer.Token() + + switch token.Type { + case html.CommentToken, html.DoctypeToken: + continue + + case html.StartTagToken: + endTokens = append([]string{fmt.Sprintf("", token.Data)}, endTokens...) + result.WriteString(token.String()) + + case html.EndTagToken: + if len(endTokens) > 0 { + endTokens = endTokens[1:] + } + result.WriteString(token.String()) + + case html.SelfClosingTagToken: + result.WriteString(token.String()) + + case html.TextToken: + text := token.String() + if visibleLen+len(text)+suffixLen > maxLength { + remaining := maxLength - visibleLen - suffixLen + text = pruneStringToWord(text, remaining) + result.WriteString(text) + result.WriteString(suffix) + for _, endTag := range endTokens { + result.WriteString(endTag) + } + return result.String() + } + visibleLen += len(text) + result.WriteString(text) + } + } +} + +// pruneStringToWord prunes string to specified length respecting word boundaries +func pruneStringToWord(text string, maxLength int) string { + if maxLength <= 0 { + return "" + } + if len(text) <= maxLength { + return text + } + + // find last space at or before maxLength to cut at word boundary + lastSpace := strings.LastIndex(text[:maxLength+1], " ") + if lastSpace <= 0 { + return "" + } + return text[:lastSpace] +} diff --git a/backend/app/notify/prune_html_test.go b/backend/app/notify/prune_html_test.go new file mode 100644 index 00000000..4ec2f6ab --- /dev/null +++ b/backend/app/notify/prune_html_test.go @@ -0,0 +1,47 @@ +package notify + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPruneHTML(t *testing.T) { + tests := []struct { + name string + html string + maxLength int + expected string + }{ + {"within limit", "

Hello

", 20, "

Hello

"}, + {"exceeds limit", "

Hello world, this is a long text

", 15, "

Hello world,...

"}, + {"nested tags", "

Hello world

More text

", 20, "

Hello world

More...

"}, + {"html comment stripped", "

Hello

", 20, "

Hello

"}, + {"self-closing tag", "

Hello
World

", 8, "

Hello
...

"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, pruneHTML(tt.html, tt.maxLength)) + }) + } +} + +func TestPruneStringToWord(t *testing.T) { + tests := []struct { + name string + text string + maxLength int + expected string + }{ + {"within limit", "hello world", 15, "hello world"}, + {"cut at word boundary", "hello world and more", 11, "hello world"}, + {"zero length", "hello", 0, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, pruneStringToWord(tt.text, tt.maxLength)) + }) + } +} diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 472b5c80..924478bd 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -10,6 +10,8 @@ import ( "github.com/hashicorp/go-multierror" ) +const commentTextLengthLimit = 100 + // TelegramParams contain settings for telegram notifications type TelegramParams struct { AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername) @@ -85,10 +87,10 @@ func (t *Telegram) buildMessage(req Request) string { msg += fmt.Sprintf(" -> %s", commentURLPrefix+req.parent.ID, ntf.EscapeTelegramText(req.parent.User.Name)) } - msg += fmt.Sprintf("\n\n%s", ntf.TelegramSupportedHTML(req.Comment.Text)) + msg += fmt.Sprintf("\n\n%s", pruneHTML(ntf.TelegramSupportedHTML(req.Comment.Text), commentTextLengthLimit)) if req.Comment.ParentID != "" { - msg += fmt.Sprintf("\n\n\"%s\"", ntf.TelegramSupportedHTML(req.parent.Text)) + msg += fmt.Sprintf("\n\n\"%s\"", pruneHTML(ntf.TelegramSupportedHTML(req.parent.Text), commentTextLengthLimit)) } if req.Comment.PostTitle != "" { diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index 4518e156..dc832915 100644 --- a/backend/app/notify/telegram_test.go +++ b/backend/app/notify/telegram_test.go @@ -53,6 +53,15 @@ some text HelloWorld`, res) + + // prune string keeping HTML closing tags + c = store.Comment{ + Text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + } + res = tb.buildMessage(Request{Comment: c}) + assert.Equal(t, ` + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut...`, res) } func TestTelegram_SendVerification(t *testing.T) {