From 58b5ed86dffb91c0da71f8933c332249a3613414 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 6 Nov 2025 14:26:51 -0500 Subject: [PATCH] refactor: extract TruncateRunes function for safe string truncation with suffix Signed-off-by: Deluan # Conflicts: # core/share.go # core/share_test.go --- core/share.go | 17 ++--------- core/share_test.go | 4 +-- utils/str/str.go | 23 +++++++++++++++ utils/str/str_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/core/share.go b/core/share.go index d653795ec..eb5e6679b 100644 --- a/core/share.go +++ b/core/share.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" ) type Share interface { @@ -120,21 +121,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) { return "", model.ErrNotFound } - const maxContentRunes = 30 - const truncateToRunes = 26 - - var runeCount int - var truncateIndex int - for i := range s.Contents { - runeCount++ - if runeCount == truncateToRunes+1 { - truncateIndex = i - } - } - - if runeCount > maxContentRunes { - s.Contents = s.Contents[:truncateIndex] + "..." - } + s.Contents = str.TruncateRunes(s.Contents, 30, "...") id, err = r.Persistable.Save(s) return id, err diff --git a/core/share_test.go b/core/share_test.go index ad5a986b1..475d40ec9 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -52,7 +52,7 @@ var _ = Describe("Share", func() { entity := &model.Share{Description: "test", ResourceIDs: "789"} _, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) - Expect(entity.Contents).To(Equal("Example Media File But The...")) + Expect(entity.Contents).To(Equal("Example Media File But The ...")) }) It("does not truncate CJK labels shorter than 30 runes", func() { @@ -68,7 +68,7 @@ var _ = Describe("Share", func() { entity := &model.Share{Description: "test", ResourceIDs: "789"} _, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) - Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実...")) + Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) }) }) diff --git a/utils/str/str.go b/utils/str/str.go index 8a94488de..f662473da 100644 --- a/utils/str/str.go +++ b/utils/str/str.go @@ -2,6 +2,7 @@ package str import ( "strings" + "unicode/utf8" ) var utf8ToAscii = func() *strings.Replacer { @@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string { } return list[0] } + +// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated. +// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual +// string content will be truncated to fit within the maxRunes limit including the suffix. +func TruncateRunes(s string, maxRunes int, suffix string) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + + suffixRunes := utf8.RuneCountInString(suffix) + truncateAt := maxRunes - suffixRunes + if truncateAt < 0 { + truncateAt = 0 + } + + runes := []rune(s) + if truncateAt >= len(runes) { + return s + suffix + } + + return string(runes[:truncateAt]) + suffix +} diff --git a/utils/str/str_test.go b/utils/str/str_test.go index 0c3524e4e..511805831 100644 --- a/utils/str/str_test.go +++ b/utils/str/str_test.go @@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() { Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album")) }) }) + + Describe("TruncateRunes", func() { + It("returns string unchanged if under max runes", func() { + Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello")) + }) + + It("returns string unchanged if exactly at max runes", func() { + Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello")) + }) + + It("truncates and adds suffix when over max runes", func() { + Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello...")) + }) + + It("handles unicode characters correctly", func() { + // 6 emoji characters, maxRunes=5, suffix="..." (3 runes) + // So content gets 5-3=2 runes + Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁...")) + }) + + It("handles multi-byte UTF-8 characters", func() { + // Characters like é are single runes + Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca...")) + }) + + It("works with empty suffix", func() { + Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello")) + }) + + It("accounts for suffix length in truncation", func() { + // maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content + result := str.TruncateRunes("hello world this is long", 10, "...") + Expect(result).To(Equal("hello w...")) + // Verify total rune count is <= maxRunes + runeCount := len([]rune(result)) + Expect(runeCount).To(BeNumerically("<=", 10)) + }) + + It("handles very long suffix gracefully", func() { + // If suffix is longer than maxRunes, we still add it + // but the content will be truncated to 0 + result := str.TruncateRunes("hello world", 5, "... (truncated)") + // Result will be just the suffix (since truncateAt=0) + Expect(result).To(Equal("... (truncated)")) + }) + + It("handles empty string", func() { + Expect(str.TruncateRunes("", 10, "...")).To(Equal("")) + }) + + It("uses custom suffix", func() { + // maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes + // "hello world" is 11 runes exactly, so we need a longer string + Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]")) + }) + + DescribeTable("truncates at rune boundaries (not byte boundaries)", + func(input string, maxRunes int, suffix string, expected string) { + Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected)) + }, + Entry("ASCII", "abcdefghij", 5, "...", "ab..."), + Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."), + Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"), + Entry("Japanese", "こんにちは世界", 3, "…", "こん…"), + ) + }) }) var testPaths = []string{