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/model"
|
||||||
. "github.com/navidrome/navidrome/utils/gg"
|
. "github.com/navidrome/navidrome/utils/gg"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Share interface {
|
type Share interface {
|
||||||
@@ -120,21 +121,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
|||||||
return "", model.ErrNotFound
|
return "", model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxContentRunes = 30
|
s.Contents = str.TruncateRunes(s.Contents, 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] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err = r.Persistable.Save(s)
|
id, err = r.Persistable.Save(s)
|
||||||
return id, err
|
return id, err
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ var _ = Describe("Share", func() {
|
|||||||
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||||
_, err := repo.Save(entity)
|
_, err := repo.Save(entity)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
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() {
|
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"}
|
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||||
_, err := repo.Save(entity)
|
_, err := repo.Save(entity)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実..."))
|
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package str
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
var utf8ToAscii = func() *strings.Replacer {
|
var utf8ToAscii = func() *strings.Replacer {
|
||||||
@@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string {
|
|||||||
}
|
}
|
||||||
return list[0]
|
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"))
|
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{
|
var testPaths = []string{
|
||||||
|
|||||||
Reference in New Issue
Block a user