Introduce GiteaLocaleNumber custom element to handle number localization on pages. (#23861)

Follow #21429 & #22861

Use `<gitea-locale-number>` instead of backend `PrettyNumber`. All old
`PrettyNumber` related functions are removed. A lot of code could be
simplified.

And some functions haven't been used for long time (dead code), so they
are also removed by the way (eg: `SplitStringAtRuneN`, `Dedent`)

This PR only tries to improve the `PrettyNumber` rendering problem, it
doesn't touch the "plural" problem.

Screenshot:


![image](https://user-images.githubusercontent.com/2114189/229290804-1f63db65-1e34-4a54-84ba-e00b44331b17.png)


![image](https://user-images.githubusercontent.com/2114189/229290911-c88dea00-b11d-48dd-accb-9f52edd73ce4.png)
This commit is contained in:
wxiaoguang 2023-04-04 00:58:09 +08:00 committed by GitHub
parent 01d9466bfd
commit 19de52e0f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 94 additions and 227 deletions

View file

@ -22,7 +22,6 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/minio/sha256-simd" "github.com/minio/sha256-simd"
@ -142,12 +141,6 @@ func FileSize(s int64) string {
return humanize.IBytes(uint64(s)) return humanize.IBytes(uint64(s))
} }
// PrettyNumber produces a string form of the given number in base 10 with
// commas after every three orders of magnitude
func PrettyNumber(i interface{}) string {
return humanize.Comma(util.NumberIntoInt64(i))
}
// Subtract deals with subtraction of all types of number. // Subtract deals with subtraction of all types of number.
func Subtract(left, right interface{}) interface{} { func Subtract(left, right interface{}) interface{} {
var rleft, rright int64 var rleft, rright int64

View file

@ -114,13 +114,6 @@ func TestFileSize(t *testing.T) {
assert.Equal(t, "2.0 EiB", FileSize(size)) assert.Equal(t, "2.0 EiB", FileSize(size))
} }
func TestPrettyNumber(t *testing.T) {
assert.Equal(t, "23,342,432", PrettyNumber(23342432))
assert.Equal(t, "23,342,432", PrettyNumber(int32(23342432)))
assert.Equal(t, "0", PrettyNumber(0))
assert.Equal(t, "-100,000", PrettyNumber(-100000))
}
func TestSubtract(t *testing.T) { func TestSubtract(t *testing.T) {
toFloat64 := func(n interface{}) float64 { toFloat64 := func(n interface{}) float64 {
switch v := n.(type) { switch v := n.(type) {

View file

@ -19,7 +19,6 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"runtime" "runtime"
"strconv"
"strings" "strings"
texttmpl "text/template" texttmpl "text/template"
"time" "time"
@ -119,8 +118,7 @@ func NewFuncMap() []template.FuncMap {
"TimeSince": timeutil.TimeSince, "TimeSince": timeutil.TimeSince,
"TimeSinceUnix": timeutil.TimeSinceUnix, "TimeSinceUnix": timeutil.TimeSinceUnix,
"FileSize": base.FileSize, "FileSize": base.FileSize,
"PrettyNumber": base.PrettyNumber, "LocaleNumber": LocaleNumber,
"JsPrettyNumber": JsPrettyNumber,
"Subtract": base.Subtract, "Subtract": base.Subtract,
"EntryIcon": base.EntryIcon, "EntryIcon": base.EntryIcon,
"MigrationIcon": MigrationIcon, "MigrationIcon": MigrationIcon,
@ -410,62 +408,9 @@ func NewFuncMap() []template.FuncMap {
"Join": strings.Join, "Join": strings.Join,
"QueryEscape": url.QueryEscape, "QueryEscape": url.QueryEscape,
"DotEscape": DotEscape, "DotEscape": DotEscape,
"Iterate": func(arg interface{}) (items []uint64) { "Iterate": func(arg interface{}) (items []int64) {
count := uint64(0) count := util.ToInt64(arg)
switch val := arg.(type) { for i := int64(0); i < count; i++ {
case uint64:
count = val
case *uint64:
count = *val
case int64:
if val < 0 {
val = 0
}
count = uint64(val)
case *int64:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case int:
if val < 0 {
val = 0
}
count = uint64(val)
case *int:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case uint:
count = uint64(val)
case *uint:
count = uint64(*val)
case int32:
if val < 0 {
val = 0
}
count = uint64(val)
case *int32:
if *val < 0 {
*val = 0
}
count = uint64(*val)
case uint32:
count = uint64(val)
case *uint32:
count = uint64(*val)
case string:
cnt, _ := strconv.ParseInt(val, 10, 64)
if cnt < 0 {
cnt = 0
}
count = uint64(cnt)
}
if count <= 0 {
return items
}
for i := uint64(0); i < count; i++ {
items = append(items, i) items = append(items, i)
} }
return items return items
@ -1067,10 +1012,8 @@ func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteNa
return a return a
} }
// JsPrettyNumber renders a number using english decimal separators, e.g. 1,200 and subsequent // LocaleNumber renders a number with a Custom Element, browser will render it with a locale number
// JS will replace the number with locale-specific separators, based on the user's selected language func LocaleNumber(v interface{}) template.HTML {
func JsPrettyNumber(i interface{}) template.HTML { num := util.ToInt64(v)
num := util.NumberIntoInt64(i) return template.HTML(fmt.Sprintf(`<gitea-locale-number data-number="%d">%d</gitea-locale-number>`, num, num))
return template.HTML(`<span class="js-pretty-number" data-value="` + strconv.FormatInt(num, 10) + `">` + base.PrettyNumber(num) + `</span>`)
} }

View file

@ -35,27 +35,3 @@ func SplitStringAtByteN(input string, n int) (left, right string) {
return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:] return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:]
} }
// SplitStringAtRuneN splits a string at rune n accounting for rune boundaries. (Combining characters are not accounted for.)
func SplitStringAtRuneN(input string, n int) (left, right string) {
if !utf8.ValidString(input) {
if len(input) <= n || n-3 < 0 {
return input, ""
}
return input[:n-3] + asciiEllipsis, asciiEllipsis + input[n-3:]
}
if utf8.RuneCountInString(input) <= n {
return input, ""
}
count := 0
end := 0
for count < n-1 {
_, size := utf8.DecodeRuneInString(input[end:])
end += size
count++
}
return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:]
}

View file

@ -43,18 +43,4 @@ func TestSplitString(t *testing.T) {
{"\xef\x03", 1, "\xef\x03", ""}, {"\xef\x03", 1, "\xef\x03", ""},
} }
test(tc, SplitStringAtByteN) test(tc, SplitStringAtByteN)
tc = []*testCase{
{"abc123xyz", 0, "", utf8Ellipsis},
{"abc123xyz", 1, "", utf8Ellipsis},
{"abc123xyz", 4, "abc", utf8Ellipsis},
{"啊bc123xyz", 4, "啊bc", utf8Ellipsis},
{"啊bc123xyz", 6, "啊bc12", utf8Ellipsis},
{"啊bc", 3, "啊bc", ""},
{"啊bc", 4, "啊bc", ""},
{"abc\xef\x03\xfe", 3, "", asciiEllipsis},
{"abc\xef\x03\xfe", 4, "a", asciiEllipsis},
{"\xef\x03", 1, "\xef\x03", ""},
}
test(tc, SplitStringAtRuneN)
} }

View file

@ -7,8 +7,9 @@ import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"errors" "errors"
"fmt"
"math/big" "math/big"
"regexp" "os"
"strconv" "strconv"
"strings" "strings"
@ -200,40 +201,14 @@ func ToTitleCaseNoLower(s string) string {
return titleCaserNoLower.String(s) return titleCaserNoLower.String(s)
} }
var ( func logError(msg string, args ...any) {
whitespaceOnly = regexp.MustCompile("(?m)^[ \t]+$") // TODO: the "util" package can not import the "modules/log" package, so we use the "fmt" package here temporarily.
leadingWhitespace = regexp.MustCompile("(?m)(^[ \t]*)(?:[^ \t\n])") // In the future, we should decouple the dependency between them.
) _, _ = fmt.Fprintf(os.Stderr, msg, args...)
// Dedent removes common indentation of a multi-line string along with whitespace around it
// Based on https://github.com/lithammer/dedent
func Dedent(s string) string {
var margin string
s = whitespaceOnly.ReplaceAllString(s, "")
indents := leadingWhitespace.FindAllStringSubmatch(s, -1)
for i, indent := range indents {
if i == 0 {
margin = indent[1]
} else if strings.HasPrefix(indent[1], margin) {
continue
} else if strings.HasPrefix(margin, indent[1]) {
margin = indent[1]
} else {
margin = ""
break
}
}
if margin != "" {
s = regexp.MustCompile("(?m)^"+margin).ReplaceAllString(s, "")
}
return strings.TrimSpace(s)
} }
// NumberIntoInt64 transform a given int into int64. // ToInt64 transform a given int into int64.
func NumberIntoInt64(number interface{}) int64 { func ToInt64(number interface{}) int64 {
var value int64 var value int64
switch v := number.(type) { switch v := number.(type) {
case int: case int:
@ -246,6 +221,23 @@ func NumberIntoInt64(number interface{}) int64 {
value = int64(v) value = int64(v)
case int64: case int64:
value = v value = v
case uint:
value = int64(v)
case uint8:
value = int64(v)
case uint16:
value = int64(v)
case uint32:
value = int64(v)
case uint64:
value = int64(v)
case string:
var err error
if value, err = strconv.ParseInt(v, 10, 64); err != nil {
logError("strconv.ParseInt failed for %q: %v", v, err)
}
default:
logError("unable to convert %q to int64", v)
} }
return value return value
} }

View file

@ -224,10 +224,3 @@ func TestToTitleCase(t *testing.T) {
assert.Equal(t, ToTitleCase(`foo bar baz`), `Foo Bar Baz`) assert.Equal(t, ToTitleCase(`foo bar baz`), `Foo Bar Baz`)
assert.Equal(t, ToTitleCase(`FOO BAR BAZ`), `Foo Bar Baz`) assert.Equal(t, ToTitleCase(`FOO BAR BAZ`), `Foo Bar Baz`)
} }
func TestDedent(t *testing.T) {
assert.Equal(t, Dedent(`
foo
bar
`), "foo\n\tbar")
}

View file

@ -13,11 +13,11 @@
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=open"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=open">
{{svg "octicon-project-symlink" 16 "gt-mr-3"}} {{svg "octicon-project-symlink" 16 "gt-mr-3"}}
{{JsPrettyNumber .OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=closed"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=closed">
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
@ -46,9 +46,9 @@
{{end}} {{end}}
<span class="issue-stats"> <span class="issue-stats">
{{svg "octicon-issue-opened" 16 "gt-mr-3"}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}}
{{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}}
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}}
</span> </span>
</div> </div>
{{if and $.CanWriteProjects (not $.Repository.IsArchived)}} {{if and $.CanWriteProjects (not $.Repository.IsArchived)}}

View file

@ -18,11 +18,11 @@
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=open&q={{$.Keyword}}"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=open&q={{$.Keyword}}">
{{svg "octicon-milestone" 16 "gt-mr-3"}} {{svg "octicon-milestone" 16 "gt-mr-3"}}
{{JsPrettyNumber .OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=closed&q={{$.Keyword}}"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/milestones?state=closed&q={{$.Keyword}}">
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
</div> </div>
@ -84,9 +84,9 @@
{{end}} {{end}}
<span class="issue-stats"> <span class="issue-stats">
{{svg "octicon-issue-opened" 16 "gt-mr-3"}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}}
{{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}}
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}}
{{if .TotalTrackedTime}}{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}{{end}} {{if .TotalTrackedTime}}{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}{{end}}
{{if .UpdatedUnix}}{{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.update_ago" (.TimeSinceUpdate|Sec2Time)}}{{end}} {{if .UpdatedUnix}}{{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.update_ago" (.TimeSinceUpdate|Sec2Time)}}{{end}}
</span> </span>

View file

@ -5,10 +5,10 @@
{{else}} {{else}}
{{svg "octicon-issue-opened" 16 "gt-mr-3"}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}}
{{end}} {{end}}
{{JsPrettyNumber .IssueStats.OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .IssueStats.OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}}
</a> </a>
<a class="{{if .IsShowClosed}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type={{.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&project={{.ProjectID}}&assignee={{.AssigneeID}}&poster={{.PosterID}}"> <a class="{{if .IsShowClosed}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&type={{.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&milestone={{.MilestoneID}}&project={{.ProjectID}}&assignee={{.AssigneeID}}&poster={{.PosterID}}">
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .IssueStats.ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .IssueStats.ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>

View file

@ -15,11 +15,11 @@
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=open"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=open">
{{svg "octicon-project" 16 "gt-mr-3"}} {{svg "octicon-project" 16 "gt-mr-3"}}
{{JsPrettyNumber .OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=closed"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{.RepoLink}}/projects?state=closed">
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
@ -48,9 +48,9 @@
{{end}} {{end}}
<span class="issue-stats"> <span class="issue-stats">
{{svg "octicon-issue-opened" 16 "gt-mr-3"}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}}
{{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}}
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}}
</span> </span>
</div> </div>
{{if and $.CanWriteProjects (not $.Repository.IsArchived)}} {{if and $.CanWriteProjects (not $.Repository.IsArchived)}}

View file

@ -161,9 +161,9 @@
<li> <li>
<span class="ui text middle aligned right"> <span class="ui text middle aligned right">
<span class="ui text grey">{{.Size | FileSize}}</span> <span class="ui text grey">{{.Size | FileSize}}</span>
<span data-tooltip-content="{{$.locale.Tr "repo.release.download_count" (.DownloadCount | PrettyNumber)}}"> <gitea-locale-number data-number-in-tooltip="{{dict "message" ($.locale.Tr "repo.release.download_count") "number" .DownloadCount | Json}}">
{{svg "octicon-info"}} {{svg "octicon-info"}}
</span> </gitea-locale-number>
</span> </span>
<a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}"> <a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}">
<strong>{{svg "octicon-package" 16 "gt-mr-2"}}{{.Name}}</strong> <strong>{{svg "octicon-package" 16 "gt-mr-2"}}{{.Name}}</strong>

View file

@ -72,9 +72,9 @@
<input name="attachment-edit-{{.UUID}}" class="gt-mr-3 attachment_edit" required value="{{.Name}}"> <input name="attachment-edit-{{.UUID}}" class="gt-mr-3 attachment_edit" required value="{{.Name}}">
<input name="attachment-del-{{.UUID}}" type="hidden" value="false"> <input name="attachment-del-{{.UUID}}" type="hidden" value="false">
<span class="ui text grey gt-mr-3">{{.Size | FileSize}}</span> <span class="ui text grey gt-mr-3">{{.Size | FileSize}}</span>
<span data-tooltip-content="{{$.locale.Tr "repo.release.download_count" (.DownloadCount | PrettyNumber)}}"> <gitea-locale-number data-number-in-tooltip="{{dict "message" ($.locale.Tr "repo.release.download_count") "number" .DownloadCount | Json}}">
{{svg "octicon-info"}} {{svg "octicon-info"}}
</span> </gitea-locale-number>
</div> </div>
</div> </div>
{{end}} {{end}}

View file

@ -4,7 +4,7 @@
<div class="ui two horizontal center link list"> <div class="ui two horizontal center link list">
{{if and (.Permission.CanRead $.UnitTypeCode) (not .IsEmptyRepo)}} {{if and (.Permission.CanRead $.UnitTypeCode) (not .IsEmptyRepo)}}
<div class="item{{if .PageIsCommits}} active{{end}}"> <div class="item{{if .PageIsCommits}} active{{end}}">
<a href="{{.RepoLink}}/commits/{{.BranchNameSubURL}}">{{svg "octicon-history"}} <b>{{JsPrettyNumber .CommitsCount}}</b> {{.locale.TrN .CommitsCount "repo.commit" "repo.commits"}}</a> <a href="{{.RepoLink}}/commits/{{.BranchNameSubURL}}">{{svg "octicon-history"}} <b>{{LocaleNumber .CommitsCount}}</b> {{.locale.TrN .CommitsCount "repo.commit" "repo.commits"}}</a>
</div> </div>
<div class="item{{if .PageIsBranches}} active{{end}}"> <div class="item{{if .PageIsBranches}} active{{end}}">
<a href="{{.RepoLink}}/branches">{{svg "octicon-git-branch"}} <b>{{.BranchesCount}}</b> {{.locale.TrN .BranchesCount "repo.branch" "repo.branches"}}</a> <a href="{{.RepoLink}}/branches">{{svg "octicon-git-branch"}} <b>{{.BranchesCount}}</b> {{.locale.TrN .BranchesCount "repo.branch" "repo.branches"}}</a>

View file

@ -65,11 +65,11 @@
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
{{svg "octicon-issue-opened" 16 "gt-mr-3"}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}}
{{JsPrettyNumber .IssueStats.OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .IssueStats.OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
{{svg "octicon-issue-closed" 16 "gt-mr-3"}} {{svg "octicon-issue-closed" 16 "gt-mr-3"}}
{{JsPrettyNumber .IssueStats.ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .IssueStats.ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
</div> </div>

View file

@ -39,11 +39,11 @@
<div class="ui compact tiny menu"> <div class="ui compact tiny menu">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}"> <a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
{{svg "octicon-milestone" 16 "gt-mr-3"}} {{svg "octicon-milestone" 16 "gt-mr-3"}}
{{JsPrettyNumber .MilestoneStats.OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .MilestoneStats.OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}}
</a> </a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}"> <a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .MilestoneStats.ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .MilestoneStats.ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}}
</a> </a>
</div> </div>
</div> </div>
@ -104,9 +104,9 @@
{{end}} {{end}}
<span class="issue-stats"> <span class="issue-stats">
{{svg "octicon-issue-opened" 16 "gt-mr-3"}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}}
{{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}} {{LocaleNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}}
{{svg "octicon-check" 16 "gt-mr-3"}} {{svg "octicon-check" 16 "gt-mr-3"}}
{{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}} {{LocaleNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}}
{{if .TotalTrackedTime}} {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}} {{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}
{{end}} {{end}}

View file

@ -1,20 +1,9 @@
import {prettyNumber} from '../utils.js';
const {lang} = document.documentElement; const {lang} = document.documentElement;
const dateFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'long', day: 'numeric'}); const dateFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'long', day: 'numeric'});
const shortDateFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric'}); const shortDateFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric'});
const dateTimeFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'}); const dateTimeFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'});
export function initFormattingReplacements() { export function initFormattingReplacements() {
// replace english formatted numbers with locale-specific separators
for (const el of document.getElementsByClassName('js-pretty-number')) {
const num = Number(el.getAttribute('data-value'));
const formatted = prettyNumber(num, lang);
if (formatted && formatted !== el.textContent) {
el.textContent = formatted;
}
}
// for each <time></time> tag, if it has the data-format attribute, format // for each <time></time> tag, if it has the data-format attribute, format
// the text according to the user's chosen locale and formatter. // the text according to the user's chosen locale and formatter.
formatAllTimeElements(); formatAllTimeElements();

View file

@ -54,13 +54,6 @@ export function parseIssueHref(href) {
return {owner, repo, type, index}; return {owner, repo, type, index};
} }
// pretty-print a number using locale-specific separators, e.g. 1200 -> 1,200
export function prettyNumber(num, locale = 'en-US') {
if (typeof num !== 'number') return '';
const {format} = new Intl.NumberFormat(locale);
return format(num);
}
// parse a URL, either relative '/path' or absolute 'https://localhost/path' // parse a URL, either relative '/path' or absolute 'https://localhost/path'
export function parseUrl(str) { export function parseUrl(str) {
return new URL(str, str.startsWith('http') ? undefined : window.location.origin); return new URL(str, str.startsWith('http') ? undefined : window.location.origin);

View file

@ -1,7 +1,7 @@
import {expect, test} from 'vitest'; import {expect, test} from 'vitest';
import { import {
basename, extname, isObject, stripTags, joinPaths, parseIssueHref, basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI, parseUrl, translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl, toAbsoluteUrl,
} from './utils.js'; } from './utils.js';
@ -84,17 +84,6 @@ test('parseIssueHref', () => {
expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined}); expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined});
}); });
test('prettyNumber', () => {
expect(prettyNumber()).toEqual('');
expect(prettyNumber(null)).toEqual('');
expect(prettyNumber(undefined)).toEqual('');
expect(prettyNumber('1200')).toEqual('');
expect(prettyNumber(12345678, 'en-US')).toEqual('12,345,678');
expect(prettyNumber(12345678, 'de-DE')).toEqual('12.345.678');
expect(prettyNumber(12345678, 'be-BE')).toEqual('12 345 678');
expect(prettyNumber(12345678, 'hi-IN')).toEqual('1,23,45,678');
});
test('parseUrl', () => { test('parseUrl', () => {
expect(parseUrl('').pathname).toEqual('/'); expect(parseUrl('').pathname).toEqual('/');
expect(parseUrl('/path').pathname).toEqual('/path'); expect(parseUrl('/path').pathname).toEqual('/path');

View file

@ -0,0 +1,20 @@
// Convert a number to a locale string by data-number attribute.
// Or add a tooltip by data-number-in-tooltip attribute. JSON: {message: "count: %s", number: 123}
window.customElements.define('gitea-locale-number', class extends HTMLElement {
connectedCallback() {
// ideally, the number locale formatting and plural processing should be done by backend with translation strings.
// if we have complete backend locale support (eg: Golang "x/text" package), we can drop this component.
const number = this.getAttribute('data-number');
if (number) {
this.attachShadow({mode: 'open'});
this.shadowRoot.textContent = new Intl.NumberFormat().format(Number(number));
}
const numberInTooltip = this.getAttribute('data-number-in-tooltip');
if (numberInTooltip) {
// TODO: only 2 usages of this, we can replace it with Golang's "x/text/number" package in the future
const {message, number} = JSON.parse(numberInTooltip);
const tooltipContent = message.replace(/%[ds]/, new Intl.NumberFormat().format(Number(number)));
this.setAttribute('data-tooltip-content', tooltipContent);
}
}
});

View file

@ -1,6 +1,4 @@
import '@webcomponents/custom-elements'; // automatically adds custom elements for older browsers that don't support it // Convert an absolute or relative URL to an absolute URL with the current origin
// this is a Gitea's private HTML component, it converts an absolute or relative URL to an absolute URL with the current origin
window.customElements.define('gitea-origin-url', class extends HTMLElement { window.customElements.define('gitea-origin-url', class extends HTMLElement {
connectedCallback() { connectedCallback() {
const urlStr = this.getAttribute('data-url'); const urlStr = this.getAttribute('data-url');

View file

@ -15,5 +15,4 @@ https://developer.mozilla.org/en-US/docs/Web/Web_Components
There are still some components that are not migrated to web components yet: There are still some components that are not migrated to web components yet:
* `<span class="js-pretty-number">`
* `<time data-format>` * `<time data-format>`

View file

@ -0,0 +1,3 @@
import '@webcomponents/custom-elements'; // polyfill for some browsers like Pale Moon
import './GiteaLocaleNumber.js';
import './GiteaOriginUrl.js';

View file

@ -60,7 +60,7 @@ export default {
fileURLToPath(new URL('web_src/css/index.css', import.meta.url)), fileURLToPath(new URL('web_src/css/index.css', import.meta.url)),
], ],
webcomponents: [ webcomponents: [
fileURLToPath(new URL('web_src/js/webcomponents/GiteaOriginUrl.js', import.meta.url)), fileURLToPath(new URL('web_src/js/webcomponents/webcomponents.js', import.meta.url)),
], ],
swagger: [ swagger: [
fileURLToPath(new URL('web_src/js/standalone/swagger.js', import.meta.url)), fileURLToPath(new URL('web_src/js/standalone/swagger.js', import.meta.url)),