From 19de52e0f4cbd2d62f9d41589fe8815c2c3ceef2 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 4 Apr 2023 00:58:09 +0800 Subject: [PATCH] Introduce GiteaLocaleNumber custom element to handle number localization on pages. (#23861) Follow #21429 & #22861 Use `` 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) --- modules/base/tool.go | 7 -- modules/base/tool_test.go | 7 -- modules/templates/helper.go | 93 ++++--------------- modules/util/truncate.go | 24 ----- modules/util/truncate_test.go | 14 --- modules/util/util.go | 58 +++++------- modules/util/util_test.go | 7 -- templates/projects/list.tmpl | 8 +- templates/repo/issue/milestones.tmpl | 8 +- templates/repo/issue/openclose.tmpl | 4 +- templates/repo/projects/list.tmpl | 8 +- templates/repo/release/list.tmpl | 4 +- templates/repo/release/new.tmpl | 4 +- templates/repo/sub_menu.tmpl | 2 +- templates/user/dashboard/issues.tmpl | 4 +- templates/user/dashboard/milestones.tmpl | 8 +- web_src/js/features/formatting.js | 11 --- web_src/js/utils.js | 7 -- web_src/js/utils.test.js | 13 +-- web_src/js/webcomponents/GiteaLocaleNumber.js | 20 ++++ web_src/js/webcomponents/GiteaOriginUrl.js | 4 +- web_src/js/webcomponents/README.md | 1 - web_src/js/webcomponents/webcomponents.js | 3 + webpack.config.js | 2 +- 24 files changed, 94 insertions(+), 227 deletions(-) create mode 100644 web_src/js/webcomponents/GiteaLocaleNumber.js create mode 100644 web_src/js/webcomponents/webcomponents.js diff --git a/modules/base/tool.go b/modules/base/tool.go index 94f19576b48..bd3a8458eeb 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -22,7 +22,6 @@ import ( "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" "github.com/minio/sha256-simd" @@ -142,12 +141,6 @@ func FileSize(s int64) string { 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. func Subtract(left, right interface{}) interface{} { var rleft, rright int64 diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go index 81f4b464e6d..33677a910cc 100644 --- a/modules/base/tool_test.go +++ b/modules/base/tool_test.go @@ -114,13 +114,6 @@ func TestFileSize(t *testing.T) { 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) { toFloat64 := func(n interface{}) float64 { switch v := n.(type) { diff --git a/modules/templates/helper.go b/modules/templates/helper.go index a8343428dc1..54c85863bd8 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -19,7 +19,6 @@ import ( "reflect" "regexp" "runtime" - "strconv" "strings" texttmpl "text/template" "time" @@ -112,18 +111,17 @@ func NewFuncMap() []template.FuncMap { "IsShowFullName": func() bool { return setting.UI.DefaultShowFullName }, - "Safe": Safe, - "SafeJS": SafeJS, - "JSEscape": JSEscape, - "Str2html": Str2html, - "TimeSince": timeutil.TimeSince, - "TimeSinceUnix": timeutil.TimeSinceUnix, - "FileSize": base.FileSize, - "PrettyNumber": base.PrettyNumber, - "JsPrettyNumber": JsPrettyNumber, - "Subtract": base.Subtract, - "EntryIcon": base.EntryIcon, - "MigrationIcon": MigrationIcon, + "Safe": Safe, + "SafeJS": SafeJS, + "JSEscape": JSEscape, + "Str2html": Str2html, + "TimeSince": timeutil.TimeSince, + "TimeSinceUnix": timeutil.TimeSinceUnix, + "FileSize": base.FileSize, + "LocaleNumber": LocaleNumber, + "Subtract": base.Subtract, + "EntryIcon": base.EntryIcon, + "MigrationIcon": MigrationIcon, "Add": func(a ...int) int { sum := 0 for _, val := range a { @@ -410,62 +408,9 @@ func NewFuncMap() []template.FuncMap { "Join": strings.Join, "QueryEscape": url.QueryEscape, "DotEscape": DotEscape, - "Iterate": func(arg interface{}) (items []uint64) { - count := uint64(0) - switch val := arg.(type) { - 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++ { + "Iterate": func(arg interface{}) (items []int64) { + count := util.ToInt64(arg) + for i := int64(0); i < count; i++ { items = append(items, i) } return items @@ -1067,10 +1012,8 @@ func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteNa return a } -// JsPrettyNumber renders a number using english decimal separators, e.g. 1,200 and subsequent -// JS will replace the number with locale-specific separators, based on the user's selected language -func JsPrettyNumber(i interface{}) template.HTML { - num := util.NumberIntoInt64(i) - - return template.HTML(`` + base.PrettyNumber(num) + ``) +// LocaleNumber renders a number with a Custom Element, browser will render it with a locale number +func LocaleNumber(v interface{}) template.HTML { + num := util.ToInt64(v) + return template.HTML(fmt.Sprintf(`%d`, num, num)) } diff --git a/modules/util/truncate.go b/modules/util/truncate.go index 032a6c0872c..f41d27d8b74 100644 --- a/modules/util/truncate.go +++ b/modules/util/truncate.go @@ -35,27 +35,3 @@ func SplitStringAtByteN(input string, n int) (left, right string) { 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:] -} diff --git a/modules/util/truncate_test.go b/modules/util/truncate_test.go index 912bfb3d5fd..05e2bc03019 100644 --- a/modules/util/truncate_test.go +++ b/modules/util/truncate_test.go @@ -43,18 +43,4 @@ func TestSplitString(t *testing.T) { {"\xef\x03", 1, "\xef\x03", ""}, } 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) } diff --git a/modules/util/util.go b/modules/util/util.go index 9d3a8dcfac2..9c7097ad34c 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -7,8 +7,9 @@ import ( "bytes" "crypto/rand" "errors" + "fmt" "math/big" - "regexp" + "os" "strconv" "strings" @@ -200,40 +201,14 @@ func ToTitleCaseNoLower(s string) string { return titleCaserNoLower.String(s) } -var ( - whitespaceOnly = regexp.MustCompile("(?m)^[ \t]+$") - leadingWhitespace = regexp.MustCompile("(?m)(^[ \t]*)(?:[^ \t\n])") -) - -// 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) +func logError(msg string, args ...any) { + // TODO: the "util" package can not import the "modules/log" package, so we use the "fmt" package here temporarily. + // In the future, we should decouple the dependency between them. + _, _ = fmt.Fprintf(os.Stderr, msg, args...) } -// NumberIntoInt64 transform a given int into int64. -func NumberIntoInt64(number interface{}) int64 { +// ToInt64 transform a given int into int64. +func ToInt64(number interface{}) int64 { var value int64 switch v := number.(type) { case int: @@ -246,6 +221,23 @@ func NumberIntoInt64(number interface{}) int64 { value = int64(v) case int64: 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 } diff --git a/modules/util/util_test.go b/modules/util/util_test.go index 34fe070d22d..8cceafa2f66 100644 --- a/modules/util/util_test.go +++ b/modules/util/util_test.go @@ -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`) } - -func TestDedent(t *testing.T) { - assert.Equal(t, Dedent(` - foo - bar - `), "foo\n\tbar") -} diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl index 5062109161e..73ae5ab6e40 100644 --- a/templates/projects/list.tmpl +++ b/templates/projects/list.tmpl @@ -13,11 +13,11 @@ @@ -46,9 +46,9 @@ {{end}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}} - {{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} + {{LocaleNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} {{svg "octicon-check" 16 "gt-mr-3"}} - {{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} + {{LocaleNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} {{if and $.CanWriteProjects (not $.Repository.IsArchived)}} diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index 0a113e2484c..0336d35c178 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -18,11 +18,11 @@ @@ -84,9 +84,9 @@ {{end}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}} - {{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} + {{LocaleNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} {{svg "octicon-check" 16 "gt-mr-3"}} - {{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} + {{LocaleNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} {{if .TotalTrackedTime}}{{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}}{{end}} {{if .UpdatedUnix}}{{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.update_ago" (.TimeSinceUpdate|Sec2Time)}}{{end}} diff --git a/templates/repo/issue/openclose.tmpl b/templates/repo/issue/openclose.tmpl index e2c13fea187..6eb26b36c5d 100644 --- a/templates/repo/issue/openclose.tmpl +++ b/templates/repo/issue/openclose.tmpl @@ -5,10 +5,10 @@ {{else}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}} {{end}} - {{JsPrettyNumber .IssueStats.OpenCount}} {{.locale.Tr "repo.issues.open_title"}} + {{LocaleNumber .IssueStats.OpenCount}} {{.locale.Tr "repo.issues.open_title"}} {{svg "octicon-check" 16 "gt-mr-3"}} - {{JsPrettyNumber .IssueStats.ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}} + {{LocaleNumber .IssueStats.ClosedCount}} {{.locale.Tr "repo.issues.closed_title"}} diff --git a/templates/repo/projects/list.tmpl b/templates/repo/projects/list.tmpl index 2350a3af546..227a2727055 100644 --- a/templates/repo/projects/list.tmpl +++ b/templates/repo/projects/list.tmpl @@ -15,11 +15,11 @@ @@ -48,9 +48,9 @@ {{end}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}} - {{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} + {{LocaleNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} {{svg "octicon-check" 16 "gt-mr-3"}} - {{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} + {{LocaleNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} {{if and $.CanWriteProjects (not $.Repository.IsArchived)}} diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index 3dc3deb3954..9bc87fa80b3 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -161,9 +161,9 @@
  • {{.Size | FileSize}} - + {{svg "octicon-info"}} - + {{svg "octicon-package" 16 "gt-mr-2"}}{{.Name}} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index 589fe12cea7..ea5c70e7428 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -72,9 +72,9 @@ {{.Size | FileSize}} - + {{svg "octicon-info"}} - + {{end}} diff --git a/templates/repo/sub_menu.tmpl b/templates/repo/sub_menu.tmpl index 61d23915c3f..fe4148d7447 100644 --- a/templates/repo/sub_menu.tmpl +++ b/templates/repo/sub_menu.tmpl @@ -4,7 +4,7 @@ @@ -104,9 +104,9 @@ {{end}} {{svg "octicon-issue-opened" 16 "gt-mr-3"}} - {{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} + {{LocaleNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} {{svg "octicon-check" 16 "gt-mr-3"}} - {{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} + {{LocaleNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} {{if .TotalTrackedTime}} {{svg "octicon-clock"}} {{.TotalTrackedTime|Sec2Time}} {{end}} diff --git a/web_src/js/features/formatting.js b/web_src/js/features/formatting.js index 837e3233760..5590ba44d14 100644 --- a/web_src/js/features/formatting.js +++ b/web_src/js/features/formatting.js @@ -1,20 +1,9 @@ -import {prettyNumber} from '../utils.js'; - const {lang} = document.documentElement; 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 dateTimeFormatter = new Intl.DateTimeFormat(lang, {year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'}); 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 tag, if it has the data-format attribute, format // the text according to the user's chosen locale and formatter. formatAllTimeElements(); diff --git a/web_src/js/utils.js b/web_src/js/utils.js index e72e55dc65e..8e156839086 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -54,13 +54,6 @@ export function parseIssueHref(href) { 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' export function parseUrl(str) { return new URL(str, str.startsWith('http') ? undefined : window.location.origin); diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js index 46fbb28de48..2f9e5fb47d5 100644 --- a/web_src/js/utils.test.js +++ b/web_src/js/utils.test.js @@ -1,7 +1,7 @@ import {expect, test} from 'vitest'; import { basename, extname, isObject, stripTags, joinPaths, parseIssueHref, - prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI, + parseUrl, translateMonth, translateDay, blobToDataURI, toAbsoluteUrl, } from './utils.js'; @@ -84,17 +84,6 @@ test('parseIssueHref', () => { 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', () => { expect(parseUrl('').pathname).toEqual('/'); expect(parseUrl('/path').pathname).toEqual('/path'); diff --git a/web_src/js/webcomponents/GiteaLocaleNumber.js b/web_src/js/webcomponents/GiteaLocaleNumber.js new file mode 100644 index 00000000000..613aa673591 --- /dev/null +++ b/web_src/js/webcomponents/GiteaLocaleNumber.js @@ -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); + } + } +}); diff --git a/web_src/js/webcomponents/GiteaOriginUrl.js b/web_src/js/webcomponents/GiteaOriginUrl.js index c8736ac5c5f..fca736064c0 100644 --- a/web_src/js/webcomponents/GiteaOriginUrl.js +++ b/web_src/js/webcomponents/GiteaOriginUrl.js @@ -1,6 +1,4 @@ -import '@webcomponents/custom-elements'; // automatically adds custom elements for older browsers that don't support it - -// this is a Gitea's private HTML component, it converts an absolute or relative URL to an absolute URL with the current origin +// Convert an absolute or relative URL to an absolute URL with the current origin window.customElements.define('gitea-origin-url', class extends HTMLElement { connectedCallback() { const urlStr = this.getAttribute('data-url'); diff --git a/web_src/js/webcomponents/README.md b/web_src/js/webcomponents/README.md index eabbc24ad1d..2b586a63d25 100644 --- a/web_src/js/webcomponents/README.md +++ b/web_src/js/webcomponents/README.md @@ -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: -* `` * `