mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
refactor: extract TruncateRunes function for safe string truncation with suffix
Signed-off-by: Deluan <deluan@navidrome.org> # Conflicts: # core/share.go # core/share_test.go
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user