diff --git a/models/actions/run.go b/models/actions/run.go index f40bc1eb3d..a224a910ab 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -275,7 +275,7 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork return err } run.Index = index - run.Title, _ = util.SplitStringAtByteN(run.Title, 255) + run.Title = util.EllipsisDisplayString(run.Title, 255) if err := db.Insert(ctx, run); err != nil { return err @@ -308,7 +308,7 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork } else { hasWaiting = true } - job.Name, _ = util.SplitStringAtByteN(job.Name, 255) + job.Name = util.EllipsisDisplayString(job.Name, 255) runJobs = append(runJobs, &ActionRunJob{ RunID: run.ID, RepoID: run.RepoID, @@ -402,7 +402,7 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error { if len(cols) > 0 { sess.Cols(cols...) } - run.Title, _ = util.SplitStringAtByteN(run.Title, 255) + run.Title = util.EllipsisDisplayString(run.Title, 255) affected, err := sess.Update(run) if err != nil { return err diff --git a/models/actions/runner.go b/models/actions/runner.go index b35a76680c..0d5464a5be 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -252,7 +252,7 @@ func GetRunnerByID(ctx context.Context, id int64) (*ActionRunner, error) { // UpdateRunner updates runner's information. func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error { e := db.GetEngine(ctx) - r.Name, _ = util.SplitStringAtByteN(r.Name, 255) + r.Name = util.EllipsisDisplayString(r.Name, 255) var err error if len(cols) == 0 { _, err = e.ID(r.ID).AllCols().Update(r) @@ -279,7 +279,7 @@ func CreateRunner(ctx context.Context, t *ActionRunner) error { // Remove OwnerID to avoid confusion; it's not worth returning an error here. t.OwnerID = 0 } - t.Name, _ = util.SplitStringAtByteN(t.Name, 255) + t.Name = util.EllipsisDisplayString(t.Name, 255) return db.Insert(ctx, t) } diff --git a/models/actions/runner_token.go b/models/actions/runner_token.go index 1eab5efcce..bbd2af73b6 100644 --- a/models/actions/runner_token.go +++ b/models/actions/runner_token.go @@ -10,7 +10,6 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" ) @@ -52,7 +51,7 @@ func GetRunnerToken(ctx context.Context, token string) (*ActionRunnerToken, erro if err != nil { return nil, err } else if !has { - return nil, fmt.Errorf(`runner token "%s...": %w`, base.TruncateString(token, 3), util.ErrNotExist) + return nil, fmt.Errorf(`runner token "%s...": %w`, util.TruncateRunes(token, 3), util.ErrNotExist) } return &runnerToken, nil } diff --git a/models/actions/schedule.go b/models/actions/schedule.go index 961ffd0851..e2cc32eedc 100644 --- a/models/actions/schedule.go +++ b/models/actions/schedule.go @@ -68,7 +68,7 @@ func CreateScheduleTask(ctx context.Context, rows []*ActionSchedule) error { // Loop through each schedule row for _, row := range rows { - row.Title, _ = util.SplitStringAtByteN(row.Title, 255) + row.Title = util.EllipsisDisplayString(row.Title, 255) // Create new schedule row if err = db.Insert(ctx, row); err != nil { return err diff --git a/models/actions/task.go b/models/actions/task.go index af74faf937..9f13ff94c9 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -298,7 +298,7 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask if len(workflowJob.Steps) > 0 { steps := make([]*ActionTaskStep, len(workflowJob.Steps)) for i, v := range workflowJob.Steps { - name, _ := util.SplitStringAtByteN(v.String(), 255) + name := util.EllipsisDisplayString(v.String(), 255) steps[i] = &ActionTaskStep{ Name: name, TaskID: task.ID, diff --git a/models/activities/action.go b/models/activities/action.go index ff7fdb2f10..8304210188 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -20,12 +20,12 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" "xorm.io/builder" "xorm.io/xorm/schemas" @@ -226,7 +226,7 @@ func (a *Action) GetActUserName(ctx context.Context) string { // ShortActUserName gets the action's user name trimmed to max 20 // chars. func (a *Action) ShortActUserName(ctx context.Context) string { - return base.EllipsisString(a.GetActUserName(ctx), 20) + return util.EllipsisDisplayString(a.GetActUserName(ctx), 20) } // GetActDisplayName gets the action's display name based on DEFAULT_SHOW_FULL_NAME, or falls back to the username if it is blank. @@ -260,7 +260,7 @@ func (a *Action) GetRepoUserName(ctx context.Context) string { // ShortRepoUserName returns the name of the action repository owner // trimmed to max 20 chars. func (a *Action) ShortRepoUserName(ctx context.Context) string { - return base.EllipsisString(a.GetRepoUserName(ctx), 20) + return util.EllipsisDisplayString(a.GetRepoUserName(ctx), 20) } // GetRepoName returns the name of the action repository. @@ -275,7 +275,7 @@ func (a *Action) GetRepoName(ctx context.Context) string { // ShortRepoName returns the name of the action repository // trimmed to max 33 chars. func (a *Action) ShortRepoName(ctx context.Context) string { - return base.EllipsisString(a.GetRepoName(ctx), 33) + return util.EllipsisDisplayString(a.GetRepoName(ctx), 33) } // GetRepoPath returns the virtual path to the action repository. diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index ceb4a4027e..2a6f3603bc 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -177,7 +177,7 @@ func ChangeIssueTitle(ctx context.Context, issue *Issue, doer *user_model.User, } defer committer.Close() - issue.Title, _ = util.SplitStringAtByteN(issue.Title, 255) + issue.Title = util.EllipsisDisplayString(issue.Title, 255) if err = UpdateIssueCols(ctx, issue, "name"); err != nil { return fmt.Errorf("updateIssueCols: %w", err) } @@ -440,7 +440,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, la } issue.Index = idx - issue.Title, _ = util.SplitStringAtByteN(issue.Title, 255) + issue.Title = util.EllipsisDisplayString(issue.Title, 255) if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{ Repo: repo, diff --git a/models/issues/pull.go b/models/issues/pull.go index 853e2a69e6..efb24e8984 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -572,7 +572,7 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *Iss } issue.Index = idx - issue.Title, _ = util.SplitStringAtByteN(issue.Title, 255) + issue.Title = util.EllipsisDisplayString(issue.Title, 255) if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{ Repo: repo, diff --git a/models/project/project.go b/models/project/project.go index 9779908b9d..87e679e1b7 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -256,7 +256,7 @@ func NewProject(ctx context.Context, p *Project) error { return util.NewInvalidArgumentErrorf("project type is not valid") } - p.Title, _ = util.SplitStringAtByteN(p.Title, 255) + p.Title = util.EllipsisDisplayString(p.Title, 255) return db.WithTx(ctx, func(ctx context.Context) error { if err := db.Insert(ctx, p); err != nil { @@ -311,7 +311,7 @@ func UpdateProject(ctx context.Context, p *Project) error { p.CardType = CardTypeTextOnly } - p.Title, _ = util.SplitStringAtByteN(p.Title, 255) + p.Title = util.EllipsisDisplayString(p.Title, 255) _, err := db.GetEngine(ctx).ID(p.ID).Cols( "title", "description", diff --git a/models/repo/release.go b/models/repo/release.go index ba7a3b3159..1c2e4a48e3 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -156,7 +156,7 @@ func IsReleaseExist(ctx context.Context, repoID int64, tagName string) (bool, er // UpdateRelease updates all columns of a release func UpdateRelease(ctx context.Context, rel *Release) error { - rel.Title, _ = util.SplitStringAtByteN(rel.Title, 255) + rel.Title = util.EllipsisDisplayString(rel.Title, 255) _, err := db.GetEngine(ctx).ID(rel.ID).AllCols().Update(rel) return err } diff --git a/models/user/user.go b/models/user/user.go index 72caafc3ba..cf08d26498 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -190,9 +190,9 @@ func (u *User) BeforeUpdate() { } u.LowerName = strings.ToLower(u.Name) - u.Location = base.TruncateString(u.Location, 255) - u.Website = base.TruncateString(u.Website, 255) - u.Description = base.TruncateString(u.Description, 255) + u.Location = util.TruncateRunes(u.Location, 255) + u.Website = util.TruncateRunes(u.Website, 255) + u.Description = util.TruncateRunes(u.Description, 255) } // AfterLoad is invoked from XORM after filling all the fields of this object. @@ -501,9 +501,9 @@ func (u *User) GitName() string { // ShortName ellipses username to length func (u *User) ShortName(length int) string { if setting.UI.DefaultShowFullName && len(u.FullName) > 0 { - return base.EllipsisString(u.FullName, length) + return util.EllipsisDisplayString(u.FullName, length) } - return base.EllipsisString(u.Name, length) + return util.EllipsisDisplayString(u.Name, length) } // IsMailable checks if a user is eligible diff --git a/modules/base/tool.go b/modules/base/tool.go index 2303e64a68..1d16186bc5 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -16,11 +16,11 @@ import ( "strconv" "strings" "time" - "unicode/utf8" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "github.com/dustin/go-humanize" ) @@ -35,7 +35,7 @@ func EncodeSha256(str string) string { // ShortSha is basically just truncating. // It is DEPRECATED and will be removed in the future. func ShortSha(sha1 string) string { - return TruncateString(sha1, 10) + return util.TruncateRunes(sha1, 10) } // BasicAuthDecode decode basic auth string @@ -116,27 +116,6 @@ func FileSize(s int64) string { return humanize.IBytes(uint64(s)) } -// EllipsisString returns a truncated short string, -// it appends '...' in the end of the length of string is too large. -func EllipsisString(str string, length int) string { - if length <= 3 { - return "..." - } - if utf8.RuneCountInString(str) <= length { - return str - } - return string([]rune(str)[:length-3]) + "..." -} - -// TruncateString returns a truncated string with given limit, -// it returns input string if length is not reached limit. -func TruncateString(str string, limit int) string { - if utf8.RuneCountInString(str) < limit { - return str - } - return string([]rune(str)[:limit]) -} - // StringsToInt64s converts a slice of string to a slice of int64. func StringsToInt64s(strs []string) ([]int64, error) { if strs == nil { diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go index de6c311856..c821a55c19 100644 --- a/modules/base/tool_test.go +++ b/modules/base/tool_test.go @@ -113,36 +113,6 @@ func TestFileSize(t *testing.T) { assert.Equal(t, "2.0 EiB", FileSize(size)) } -func TestEllipsisString(t *testing.T) { - assert.Equal(t, "...", EllipsisString("foobar", 0)) - assert.Equal(t, "...", EllipsisString("foobar", 1)) - assert.Equal(t, "...", EllipsisString("foobar", 2)) - assert.Equal(t, "...", EllipsisString("foobar", 3)) - assert.Equal(t, "f...", EllipsisString("foobar", 4)) - assert.Equal(t, "fo...", EllipsisString("foobar", 5)) - assert.Equal(t, "foobar", EllipsisString("foobar", 6)) - assert.Equal(t, "foobar", EllipsisString("foobar", 10)) - assert.Equal(t, "测...", EllipsisString("测试文本一二三四", 4)) - assert.Equal(t, "测试...", EllipsisString("测试文本一二三四", 5)) - assert.Equal(t, "测试文...", EllipsisString("测试文本一二三四", 6)) - assert.Equal(t, "测试文本一二三四", EllipsisString("测试文本一二三四", 10)) -} - -func TestTruncateString(t *testing.T) { - assert.Equal(t, "", TruncateString("foobar", 0)) - assert.Equal(t, "f", TruncateString("foobar", 1)) - assert.Equal(t, "fo", TruncateString("foobar", 2)) - assert.Equal(t, "foo", TruncateString("foobar", 3)) - assert.Equal(t, "foob", TruncateString("foobar", 4)) - assert.Equal(t, "fooba", TruncateString("foobar", 5)) - assert.Equal(t, "foobar", TruncateString("foobar", 6)) - assert.Equal(t, "foobar", TruncateString("foobar", 7)) - assert.Equal(t, "测试文本", TruncateString("测试文本一二三四", 4)) - assert.Equal(t, "测试文本一", TruncateString("测试文本一二三四", 5)) - assert.Equal(t, "测试文本一二", TruncateString("测试文本一二三四", 6)) - assert.Equal(t, "测试文本一二三", TruncateString("测试文本一二三四", 7)) -} - func TestStringsToInt64s(t *testing.T) { testSuccess := func(input []string, expected []int64) { result, err := StringsToInt64s(input) diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go index 0fc13d7ddf..1d8e9dd02d 100644 --- a/modules/issue/template/unmarshal.go +++ b/modules/issue/template/unmarshal.go @@ -109,7 +109,7 @@ func unmarshal(filename string, content []byte) (*api.IssueTemplate, error) { it.Content = string(content) it.Name = path.Base(it.FileName) // paths in Git are always '/' separated - do not use filepath! - it.About, _ = util.SplitStringAtByteN(it.Content, 80) + it.About = util.EllipsisDisplayString(it.Content, 80) } else { it.Content = templateBody if it.About == "" { diff --git a/modules/markup/html_link.go b/modules/markup/html_link.go index fea82e50ab..0e7a988d36 100644 --- a/modules/markup/html_link.go +++ b/modules/markup/html_link.go @@ -173,7 +173,7 @@ func linkProcessor(ctx *RenderContext, node *html.Node) { uri := node.Data[m[0]:m[1]] remaining := node.Data[m[1]:] - if util.IsLikelySplitLeftPart(remaining) { + if util.IsLikelyEllipsisLeftPart(remaining) { return } replaceContent(node, m[0], m[1], createLink(ctx, uri, uri, "" /*link*/)) diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index f14fe4075c..6d8f24184b 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -207,12 +207,12 @@ func TestRender_links(t *testing.T) { "ftps://gitea.com", `
ftps://gitea.com
`) - t.Run("LinkSplit", func(t *testing.T) { - input, _ := util.SplitStringAtByteN("http://10.1.2.3", 12) + t.Run("LinkEllipsis", func(t *testing.T) { + input := util.EllipsisDisplayString("http://10.1.2.3", 12) assert.Equal(t, "http://10…", input) test(input, "http://10…
") - input, _ = util.SplitStringAtByteN("http://10.1.2.3", 13) + input = util.EllipsisDisplayString("http://10.1.2.3", 13) assert.Equal(t, "http://10.…", input) test(input, "http://10.…
") }) diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go index ace81bf4a5..310d645328 100644 --- a/modules/templates/mailer.go +++ b/modules/templates/mailer.go @@ -11,9 +11,9 @@ import ( "strings" texttmpl "text/template" - "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`) @@ -24,7 +24,7 @@ func mailSubjectTextFuncMap() texttmpl.FuncMap { "dict": dict, "Eval": evalTokens, - "EllipsisString": base.EllipsisString, + "EllipsisString": util.EllipsisDisplayString, "AppName": func() string { return setting.AppName }, diff --git a/modules/templates/util_string.go b/modules/templates/util_string.go index 382e2de13f..683c77a870 100644 --- a/modules/templates/util_string.go +++ b/modules/templates/util_string.go @@ -8,7 +8,7 @@ import ( "html/template" "strings" - "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/util" ) type StringUtils struct{} @@ -54,7 +54,7 @@ func (su *StringUtils) Cut(s, sep string) []any { } func (su *StringUtils) EllipsisString(s string, maxLength int) string { - return base.EllipsisString(s, maxLength) + return util.EllipsisDisplayString(s, maxLength) } func (su *StringUtils) ToUpper(s string) string { diff --git a/modules/util/truncate.go b/modules/util/truncate.go index 9f932facc9..331a98ef98 100644 --- a/modules/util/truncate.go +++ b/modules/util/truncate.go @@ -14,31 +14,92 @@ const ( asciiEllipsis = "..." ) -func IsLikelySplitLeftPart(s string) bool { +func IsLikelyEllipsisLeftPart(s string) bool { return strings.HasSuffix(s, utf8Ellipsis) || strings.HasSuffix(s, asciiEllipsis) } -// SplitStringAtByteN splits a string at byte n accounting for rune boundaries. (Combining characters are not accounted for.) -func SplitStringAtByteN(input string, n int) (left, right string) { - if len(input) <= n { - return input, "" - } +// EllipsisDisplayString returns a truncated short string for display purpose. +// The length is the approximate number of ASCII-width in the string (CJK/emoji are 2-ASCII width) +// It appends "…" or "..." at the end of truncated string. +// It guarantees the length of the returned runes doesn't exceed the limit. +func EllipsisDisplayString(str string, limit int) string { + s, _, _, _ := ellipsisDisplayString(str, limit) + return s +} - if !utf8.ValidString(input) { - if n-3 < 0 { - return input, "" +// EllipsisDisplayStringX works like EllipsisDisplayString while it also returns the right part +func EllipsisDisplayStringX(str string, limit int) (left, right string) { + left, offset, truncated, encounterInvalid := ellipsisDisplayString(str, limit) + if truncated { + right = str[offset:] + r, _ := utf8.DecodeRune(UnsafeStringToBytes(right)) + encounterInvalid = encounterInvalid || r == utf8.RuneError + ellipsis := utf8Ellipsis + if encounterInvalid { + ellipsis = asciiEllipsis } - return input[:n-3] + asciiEllipsis, asciiEllipsis + input[n-3:] + right = ellipsis + right + } + return left, right +} + +func ellipsisDisplayString(str string, limit int) (res string, offset int, truncated, encounterInvalid bool) { + if len(str) <= limit { + return str, len(str), false, false } - end := 0 - for end <= n-3 { - _, size := utf8.DecodeRuneInString(input[end:]) - if end+size > n-3 { + // To future maintainers: this logic must guarantee that the length of the returned runes doesn't exceed the limit, + // because the returned string will also be used as database value. UTF-8 VARCHAR(10) could store 10 rune characters, + // So each rune must be countered as at least 1 width. + // Even if there are some special Unicode characters (zero-width, combining, etc.), they should NEVER be counted as zero. + pos, used := 0, 0 + for i, r := range str { + encounterInvalid = encounterInvalid || r == utf8.RuneError + pos = i + runeWidth := 1 + if r >= 128 { + runeWidth = 2 // CJK/emoji chars are considered as 2-ASCII width + } + if used+runeWidth+3 > limit { break } - end += size + used += runeWidth + offset += utf8.RuneLen(r) } - return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:] + // if the remaining are fewer than 3 runes, then maybe we could add them, no need to ellipse + if len(str)-pos <= 12 { + var nextCnt, nextWidth int + for _, r := range str[pos:] { + if nextCnt >= 4 { + break + } + nextWidth++ + if r >= 128 { + nextWidth++ // CJK/emoji chars are considered as 2-ASCII width + } + nextCnt++ + } + if nextCnt <= 3 && used+nextWidth <= limit { + return str, len(str), false, false + } + } + if limit < 3 { + // if the limit is so small, do not add ellipsis + return str[:offset], offset, true, false + } + ellipsis := utf8Ellipsis + if encounterInvalid { + ellipsis = asciiEllipsis + } + return str[:offset] + ellipsis, offset, true, encounterInvalid +} + +// TruncateRunes returns a truncated string with given rune limit, +// it returns input string if its rune length doesn't exceed the limit. +func TruncateRunes(str string, limit int) string { + if utf8.RuneCountInString(str) < limit { + return str + } + return string([]rune(str)[:limit]) } diff --git a/modules/util/truncate_test.go b/modules/util/truncate_test.go index dfe1230fd4..573d6ece26 100644 --- a/modules/util/truncate_test.go +++ b/modules/util/truncate_test.go @@ -4,43 +4,94 @@ package util import ( + "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" ) -func TestSplitString(t *testing.T) { - type testCase struct { - input string - n int - leftSub string - ellipsis string +func TestEllipsisString(t *testing.T) { + cases := []struct { + limit int + + input, left, right string + }{ + {limit: 0, input: "abcde", left: "", right: "…abcde"}, + {limit: 1, input: "abcde", left: "", right: "…abcde"}, + {limit: 2, input: "abcde", left: "", right: "…abcde"}, + {limit: 3, input: "abcde", left: "…", right: "…abcde"}, + {limit: 4, input: "abcde", left: "a…", right: "…bcde"}, + {limit: 5, input: "abcde", left: "abcde", right: ""}, + {limit: 6, input: "abcde", left: "abcde", right: ""}, + {limit: 7, input: "abcde", left: "abcde", right: ""}, + + // a CJK char or emoji is considered as 2-ASCII width, the ellipsis is 3-ASCII width + {limit: 0, input: "测试文本", left: "", right: "…测试文本"}, + {limit: 1, input: "测试文本", left: "", right: "…测试文本"}, + {limit: 2, input: "测试文本", left: "", right: "…测试文本"}, + {limit: 3, input: "测试文本", left: "…", right: "…测试文本"}, + {limit: 4, input: "测试文本", left: "…", right: "…测试文本"}, + {limit: 5, input: "测试文本", left: "测…", right: "…试文本"}, + {limit: 6, input: "测试文本", left: "测…", right: "…试文本"}, + {limit: 7, input: "测试文本", left: "测试…", right: "…文本"}, + {limit: 8, input: "测试文本", left: "测试文本", right: ""}, + {limit: 9, input: "测试文本", left: "测试文本", right: ""}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%s(%d)", c.input, c.limit), func(t *testing.T) { + left, right := EllipsisDisplayStringX(c.input, c.limit) + assert.Equal(t, c.left, left, "left") + assert.Equal(t, c.right, right, "right") + }) } - test := func(tc []*testCase, f func(input string, n int) (left, right string)) { - for _, c := range tc { - l, r := f(c.input, c.n) - if c.ellipsis != "" { - assert.Equal(t, c.leftSub+c.ellipsis, l, "test split %q at %d, expected leftSub: %q", c.input, c.n, c.leftSub) - assert.Equal(t, c.ellipsis+c.input[len(c.leftSub):], r, "test split %s at %d, expected rightSub: %q", c.input, c.n, c.input[len(c.leftSub):]) - } else { - assert.Equal(t, c.leftSub, l, "test split %q at %d, expected leftSub: %q", c.input, c.n, c.leftSub) - assert.Empty(t, r, "test split %q at %d, expected rightSub: %q", c.input, c.n, "") - } + t.Run("LongInput", func(t *testing.T) { + left, right := EllipsisDisplayStringX(strings.Repeat("abc", 240), 90) + assert.Equal(t, strings.Repeat("abc", 29)+"…", left) + assert.Equal(t, "…"+strings.Repeat("abc", 211), right) + }) + + t.Run("InvalidUtf8", func(t *testing.T) { + invalidCases := []struct { + limit int + left, right string + }{ + {limit: 0, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 1, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 2, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 3, left: "...", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 4, left: "...", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 5, left: "\xef\x03\xfe...", right: "...\xef\x03\xfe"}, + {limit: 6, left: "\xef\x03\xfe\xef\x03\xfe", right: ""}, + {limit: 7, left: "\xef\x03\xfe\xef\x03\xfe", right: ""}, } - } + for _, c := range invalidCases { + t.Run(fmt.Sprintf("%d", c.limit), func(t *testing.T) { + left, right := EllipsisDisplayStringX("\xef\x03\xfe\xef\x03\xfe", c.limit) + assert.Equal(t, c.left, left, "left") + assert.Equal(t, c.right, right, "right") + }) + } + }) - tc := []*testCase{ - {"abc123xyz", 0, "", utf8Ellipsis}, - {"abc123xyz", 1, "", utf8Ellipsis}, - {"abc123xyz", 4, "a", utf8Ellipsis}, - {"啊bc123xyz", 4, "", utf8Ellipsis}, - {"啊bc123xyz", 6, "啊", utf8Ellipsis}, - {"啊bc", 5, "啊bc", ""}, - {"啊bc", 6, "啊bc", ""}, - {"abc\xef\x03\xfe", 3, "", asciiEllipsis}, - {"abc\xef\x03\xfe", 4, "a", asciiEllipsis}, - {"\xef\x03", 1, "\xef\x03", ""}, - } - test(tc, SplitStringAtByteN) + t.Run("IsLikelyEllipsisLeftPart", func(t *testing.T) { + assert.True(t, IsLikelyEllipsisLeftPart("abcde…")) + assert.True(t, IsLikelyEllipsisLeftPart("abcde...")) + }) +} + +func TestTruncateRunes(t *testing.T) { + assert.Equal(t, "", TruncateRunes("", 0)) + assert.Equal(t, "", TruncateRunes("", 1)) + + assert.Equal(t, "", TruncateRunes("ab", 0)) + assert.Equal(t, "a", TruncateRunes("ab", 1)) + assert.Equal(t, "ab", TruncateRunes("ab", 2)) + assert.Equal(t, "ab", TruncateRunes("ab", 3)) + + assert.Equal(t, "", TruncateRunes("测试", 0)) + assert.Equal(t, "测", TruncateRunes("测试", 1)) + assert.Equal(t, "测试", TruncateRunes("测试", 2)) + assert.Equal(t, "测试", TruncateRunes("测试", 3)) } diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index 8f365cc926..c55b30f7eb 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -69,7 +69,7 @@ func (s *Service) Register( labels := req.Msg.Labels // create new runner - name, _ := util.SplitStringAtByteN(req.Msg.Name, 255) + name := util.EllipsisDisplayString(req.Msg.Name, 255) runner := &actions_model.ActionRunner{ UUID: gouuid.New().String(), Name: name, diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 8c4003690a..b3c1eb7cb0 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -664,7 +664,7 @@ func PrepareCompareDiff( } if len(title) > 255 { var trailer string - title, trailer = util.SplitStringAtByteN(title, 255) + title, trailer = util.EllipsisDisplayStringX(title, 255) if len(trailer) > 0 { if ctx.Data["content"] != nil { ctx.Data["content"] = fmt.Sprintf("%s\n\n%s", trailer, ctx.Data["content"]) diff --git a/services/feed/notifier.go b/services/feed/notifier.go index d941027c35..702eb5ad53 100644 --- a/services/feed/notifier.go +++ b/services/feed/notifier.go @@ -109,7 +109,7 @@ func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_mode IsPrivate: issue.Repo.IsPrivate, } - truncatedContent, truncatedRight := util.SplitStringAtByteN(comment.Content, 200) + truncatedContent, truncatedRight := util.EllipsisDisplayStringX(comment.Content, 200) if truncatedRight != "" { // in case the content is in a Latin family language, we remove the last broken word. lastSpaceIdx := strings.LastIndex(truncatedContent, " ") diff --git a/services/mailer/sender/message.go b/services/mailer/sender/message.go index db20675572..55f03e4f7e 100644 --- a/services/mailer/sender/message.go +++ b/services/mailer/sender/message.go @@ -10,9 +10,9 @@ import ( "strings" "time" - "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "github.com/jaytaylor/html2text" gomail "github.com/wneessen/go-mail" @@ -54,7 +54,7 @@ func (m *Message) ToMessage() *gomail.Msg { plainBody, err := html2text.FromString(m.Body) if err != nil || setting.MailService.SendAsPlainText { - if strings.Contains(base.TruncateString(m.Body, 100), "") { + if strings.Contains(util.TruncateRunes(m.Body, 100), "") { log.Warn("Mail contains HTML but configured to send as plain text.") } msg.SetBodyString("text/plain", plainBody) diff --git a/services/migrations/gitea_uploader.go b/services/migrations/gitea_uploader.go index eb21b6534b..9e06b77b66 100644 --- a/services/migrations/gitea_uploader.go +++ b/services/migrations/gitea_uploader.go @@ -19,7 +19,6 @@ import ( issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - base_module "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/label" @@ -409,7 +408,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error { RepoID: g.repo.ID, Repo: g.repo, Index: issue.Number, - Title: base_module.TruncateString(issue.Title, 255), + Title: util.TruncateRunes(issue.Title, 255), Content: issue.Content, Ref: issue.Ref, IsClosed: issue.State == "closed", diff --git a/services/release/release.go b/services/release/release.go index 84c60a105a..835a5943b1 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -179,7 +179,7 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU return err } - rel.Title, _ = util.SplitStringAtByteN(rel.Title, 255) + rel.Title = util.EllipsisDisplayString(rel.Title, 255) rel.LowerTagName = strings.ToLower(rel.TagName) if err = db.Insert(gitRepo.Ctx, rel); err != nil { return err