mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Make better use of i18n (#20096)
* Prototyping * Start work on creating offsets * Modify tests * Start prototyping with actual MPH * Twiddle around * Twiddle around comments * Convert templates * Fix external languages * Fix latest translation * Fix some test * Tidy up code * Use simple map * go mod tidy * Move back to data structure - Uses less memory by creating for each language a map. * Apply suggestions from code review Co-authored-by: delvh <dev.lh@web.de> * Add some comments * Fix tests * Try to fix tests * Use en-US as defacto fallback * Use correct slices * refactor (#4) * Remove TryTr, add log for missing translation key * Refactor i18n - Separate dev and production locale stores. - Allow for live-reloading in dev mode. Co-authored-by: zeripath <art27@cantab.net> * Fix live-reloading & check for errors * Make linter happy * live-reload with periodic check (#5) * Fix tests Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: zeripath <art27@cantab.net>
This commit is contained in:
		| @@ -9,7 +9,7 @@ import ( | ||||
| 	"net/url" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/markup" | ||||
| 	"code.gitea.io/gitea/modules/translation/i18n" | ||||
| 	"code.gitea.io/gitea/modules/translation" | ||||
|  | ||||
| 	"github.com/yuin/goldmark/ast" | ||||
| ) | ||||
| @@ -18,7 +18,7 @@ func createTOCNode(toc []markup.Header, lang string) ast.Node { | ||||
| 	details := NewDetails() | ||||
| 	summary := NewSummary() | ||||
|  | ||||
| 	summary.AppendChild(summary, ast.NewString([]byte(i18n.Tr(lang, "toc")))) | ||||
| 	summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).Tr("toc")))) | ||||
| 	details.AppendChild(details, summary) | ||||
| 	ul := ast.NewList('-') | ||||
| 	details.AppendChild(details, ul) | ||||
|   | ||||
| @@ -105,7 +105,6 @@ func NewFuncMap() []template.FuncMap { | ||||
| 		"Str2html":       Str2html, | ||||
| 		"TimeSince":      timeutil.TimeSince, | ||||
| 		"TimeSinceUnix":  timeutil.TimeSinceUnix, | ||||
| 		"RawTimeSince":   timeutil.RawTimeSince, | ||||
| 		"FileSize":       base.FileSize, | ||||
| 		"PrettyNumber":   base.PrettyNumber, | ||||
| 		"JsPrettyNumber": JsPrettyNumber, | ||||
| @@ -484,7 +483,6 @@ func NewTextFuncMap() []texttmpl.FuncMap { | ||||
| 		}, | ||||
| 		"TimeSince":     timeutil.TimeSince, | ||||
| 		"TimeSinceUnix": timeutil.TimeSinceUnix, | ||||
| 		"RawTimeSince":  timeutil.RawTimeSince, | ||||
| 		"DateFmtLong": func(t time.Time) string { | ||||
| 			return t.Format(time.RFC1123Z) | ||||
| 		}, | ||||
|   | ||||
| @@ -12,7 +12,7 @@ import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/translation/i18n" | ||||
| 	"code.gitea.io/gitea/modules/translation" | ||||
| ) | ||||
|  | ||||
| // Seconds-based time units | ||||
| @@ -29,146 +29,146 @@ func round(s float64) int64 { | ||||
| 	return int64(math.Round(s)) | ||||
| } | ||||
|  | ||||
| func computeTimeDiffFloor(diff int64, lang string) (int64, string) { | ||||
| 	var diffStr string | ||||
| func computeTimeDiffFloor(diff int64, lang translation.Locale) (int64, string) { | ||||
| 	diffStr := "" | ||||
| 	switch { | ||||
| 	case diff <= 0: | ||||
| 		diff = 0 | ||||
| 		diffStr = i18n.Tr(lang, "tool.now") | ||||
| 		diffStr = lang.Tr("tool.now") | ||||
| 	case diff < 2: | ||||
| 		diff = 0 | ||||
| 		diffStr = i18n.Tr(lang, "tool.1s") | ||||
| 		diffStr = lang.Tr("tool.1s") | ||||
| 	case diff < 1*Minute: | ||||
| 		diffStr = i18n.Tr(lang, "tool.seconds", diff) | ||||
| 		diffStr = lang.Tr("tool.seconds", diff) | ||||
| 		diff = 0 | ||||
|  | ||||
| 	case diff < 2*Minute: | ||||
| 		diff -= 1 * Minute | ||||
| 		diffStr = i18n.Tr(lang, "tool.1m") | ||||
| 		diffStr = lang.Tr("tool.1m") | ||||
| 	case diff < 1*Hour: | ||||
| 		diffStr = i18n.Tr(lang, "tool.minutes", diff/Minute) | ||||
| 		diffStr = lang.Tr("tool.minutes", diff/Minute) | ||||
| 		diff -= diff / Minute * Minute | ||||
|  | ||||
| 	case diff < 2*Hour: | ||||
| 		diff -= 1 * Hour | ||||
| 		diffStr = i18n.Tr(lang, "tool.1h") | ||||
| 		diffStr = lang.Tr("tool.1h") | ||||
| 	case diff < 1*Day: | ||||
| 		diffStr = i18n.Tr(lang, "tool.hours", diff/Hour) | ||||
| 		diffStr = lang.Tr("tool.hours", diff/Hour) | ||||
| 		diff -= diff / Hour * Hour | ||||
|  | ||||
| 	case diff < 2*Day: | ||||
| 		diff -= 1 * Day | ||||
| 		diffStr = i18n.Tr(lang, "tool.1d") | ||||
| 		diffStr = lang.Tr("tool.1d") | ||||
| 	case diff < 1*Week: | ||||
| 		diffStr = i18n.Tr(lang, "tool.days", diff/Day) | ||||
| 		diffStr = lang.Tr("tool.days", diff/Day) | ||||
| 		diff -= diff / Day * Day | ||||
|  | ||||
| 	case diff < 2*Week: | ||||
| 		diff -= 1 * Week | ||||
| 		diffStr = i18n.Tr(lang, "tool.1w") | ||||
| 		diffStr = lang.Tr("tool.1w") | ||||
| 	case diff < 1*Month: | ||||
| 		diffStr = i18n.Tr(lang, "tool.weeks", diff/Week) | ||||
| 		diffStr = lang.Tr("tool.weeks", diff/Week) | ||||
| 		diff -= diff / Week * Week | ||||
|  | ||||
| 	case diff < 2*Month: | ||||
| 		diff -= 1 * Month | ||||
| 		diffStr = i18n.Tr(lang, "tool.1mon") | ||||
| 		diffStr = lang.Tr("tool.1mon") | ||||
| 	case diff < 1*Year: | ||||
| 		diffStr = i18n.Tr(lang, "tool.months", diff/Month) | ||||
| 		diffStr = lang.Tr("tool.months", diff/Month) | ||||
| 		diff -= diff / Month * Month | ||||
|  | ||||
| 	case diff < 2*Year: | ||||
| 		diff -= 1 * Year | ||||
| 		diffStr = i18n.Tr(lang, "tool.1y") | ||||
| 		diffStr = lang.Tr("tool.1y") | ||||
| 	default: | ||||
| 		diffStr = i18n.Tr(lang, "tool.years", diff/Year) | ||||
| 		diffStr = lang.Tr("tool.years", diff/Year) | ||||
| 		diff -= (diff / Year) * Year | ||||
| 	} | ||||
| 	return diff, diffStr | ||||
| } | ||||
|  | ||||
| func computeTimeDiff(diff int64, lang string) (int64, string) { | ||||
| 	var diffStr string | ||||
| func computeTimeDiff(diff int64, lang translation.Locale) (int64, string) { | ||||
| 	diffStr := "" | ||||
| 	switch { | ||||
| 	case diff <= 0: | ||||
| 		diff = 0 | ||||
| 		diffStr = i18n.Tr(lang, "tool.now") | ||||
| 		diffStr = lang.Tr("tool.now") | ||||
| 	case diff < 2: | ||||
| 		diff = 0 | ||||
| 		diffStr = i18n.Tr(lang, "tool.1s") | ||||
| 		diffStr = lang.Tr("tool.1s") | ||||
| 	case diff < 1*Minute: | ||||
| 		diffStr = i18n.Tr(lang, "tool.seconds", diff) | ||||
| 		diffStr = lang.Tr("tool.seconds", diff) | ||||
| 		diff = 0 | ||||
|  | ||||
| 	case diff < Minute+Minute/2: | ||||
| 		diff -= 1 * Minute | ||||
| 		diffStr = i18n.Tr(lang, "tool.1m") | ||||
| 		diffStr = lang.Tr("tool.1m") | ||||
| 	case diff < 1*Hour: | ||||
| 		minutes := round(float64(diff) / Minute) | ||||
| 		if minutes > 1 { | ||||
| 			diffStr = i18n.Tr(lang, "tool.minutes", minutes) | ||||
| 			diffStr = lang.Tr("tool.minutes", minutes) | ||||
| 		} else { | ||||
| 			diffStr = i18n.Tr(lang, "tool.1m") | ||||
| 			diffStr = lang.Tr("tool.1m") | ||||
| 		} | ||||
| 		diff -= diff / Minute * Minute | ||||
|  | ||||
| 	case diff < Hour+Hour/2: | ||||
| 		diff -= 1 * Hour | ||||
| 		diffStr = i18n.Tr(lang, "tool.1h") | ||||
| 		diffStr = lang.Tr("tool.1h") | ||||
| 	case diff < 1*Day: | ||||
| 		hours := round(float64(diff) / Hour) | ||||
| 		if hours > 1 { | ||||
| 			diffStr = i18n.Tr(lang, "tool.hours", hours) | ||||
| 			diffStr = lang.Tr("tool.hours", hours) | ||||
| 		} else { | ||||
| 			diffStr = i18n.Tr(lang, "tool.1h") | ||||
| 			diffStr = lang.Tr("tool.1h") | ||||
| 		} | ||||
| 		diff -= diff / Hour * Hour | ||||
|  | ||||
| 	case diff < Day+Day/2: | ||||
| 		diff -= 1 * Day | ||||
| 		diffStr = i18n.Tr(lang, "tool.1d") | ||||
| 		diffStr = lang.Tr("tool.1d") | ||||
| 	case diff < 1*Week: | ||||
| 		days := round(float64(diff) / Day) | ||||
| 		if days > 1 { | ||||
| 			diffStr = i18n.Tr(lang, "tool.days", days) | ||||
| 			diffStr = lang.Tr("tool.days", days) | ||||
| 		} else { | ||||
| 			diffStr = i18n.Tr(lang, "tool.1d") | ||||
| 			diffStr = lang.Tr("tool.1d") | ||||
| 		} | ||||
| 		diff -= diff / Day * Day | ||||
|  | ||||
| 	case diff < Week+Week/2: | ||||
| 		diff -= 1 * Week | ||||
| 		diffStr = i18n.Tr(lang, "tool.1w") | ||||
| 		diffStr = lang.Tr("tool.1w") | ||||
| 	case diff < 1*Month: | ||||
| 		weeks := round(float64(diff) / Week) | ||||
| 		if weeks > 1 { | ||||
| 			diffStr = i18n.Tr(lang, "tool.weeks", weeks) | ||||
| 			diffStr = lang.Tr("tool.weeks", weeks) | ||||
| 		} else { | ||||
| 			diffStr = i18n.Tr(lang, "tool.1w") | ||||
| 			diffStr = lang.Tr("tool.1w") | ||||
| 		} | ||||
| 		diff -= diff / Week * Week | ||||
|  | ||||
| 	case diff < 1*Month+Month/2: | ||||
| 		diff -= 1 * Month | ||||
| 		diffStr = i18n.Tr(lang, "tool.1mon") | ||||
| 		diffStr = lang.Tr("tool.1mon") | ||||
| 	case diff < 1*Year: | ||||
| 		months := round(float64(diff) / Month) | ||||
| 		if months > 1 { | ||||
| 			diffStr = i18n.Tr(lang, "tool.months", months) | ||||
| 			diffStr = lang.Tr("tool.months", months) | ||||
| 		} else { | ||||
| 			diffStr = i18n.Tr(lang, "tool.1mon") | ||||
| 			diffStr = lang.Tr("tool.1mon") | ||||
| 		} | ||||
| 		diff -= diff / Month * Month | ||||
|  | ||||
| 	case diff < Year+Year/2: | ||||
| 		diff -= 1 * Year | ||||
| 		diffStr = i18n.Tr(lang, "tool.1y") | ||||
| 		diffStr = lang.Tr("tool.1y") | ||||
| 	default: | ||||
| 		years := round(float64(diff) / Year) | ||||
| 		if years > 1 { | ||||
| 			diffStr = i18n.Tr(lang, "tool.years", years) | ||||
| 			diffStr = lang.Tr("tool.years", years) | ||||
| 		} else { | ||||
| 			diffStr = i18n.Tr(lang, "tool.1y") | ||||
| 			diffStr = lang.Tr("tool.1y") | ||||
| 		} | ||||
| 		diff -= (diff / Year) * Year | ||||
| 	} | ||||
| @@ -177,24 +177,24 @@ func computeTimeDiff(diff int64, lang string) (int64, string) { | ||||
|  | ||||
| // MinutesToFriendly returns a user friendly string with number of minutes | ||||
| // converted to hours and minutes. | ||||
| func MinutesToFriendly(minutes int, lang string) string { | ||||
| func MinutesToFriendly(minutes int, lang translation.Locale) string { | ||||
| 	duration := time.Duration(minutes) * time.Minute | ||||
| 	return TimeSincePro(time.Now().Add(-duration), lang) | ||||
| } | ||||
|  | ||||
| // TimeSincePro calculates the time interval and generate full user-friendly string. | ||||
| func TimeSincePro(then time.Time, lang string) string { | ||||
| func TimeSincePro(then time.Time, lang translation.Locale) string { | ||||
| 	return timeSincePro(then, time.Now(), lang) | ||||
| } | ||||
|  | ||||
| func timeSincePro(then, now time.Time, lang string) string { | ||||
| func timeSincePro(then, now time.Time, lang translation.Locale) string { | ||||
| 	diff := now.Unix() - then.Unix() | ||||
|  | ||||
| 	if then.After(now) { | ||||
| 		return i18n.Tr(lang, "tool.future") | ||||
| 		return lang.Tr("tool.future") | ||||
| 	} | ||||
| 	if diff == 0 { | ||||
| 		return i18n.Tr(lang, "tool.now") | ||||
| 		return lang.Tr("tool.now") | ||||
| 	} | ||||
|  | ||||
| 	var timeStr, diffStr string | ||||
| @@ -209,11 +209,11 @@ func timeSincePro(then, now time.Time, lang string) string { | ||||
| 	return strings.TrimPrefix(timeStr, ", ") | ||||
| } | ||||
|  | ||||
| func timeSince(then, now time.Time, lang string) string { | ||||
| func timeSince(then, now time.Time, lang translation.Locale) string { | ||||
| 	return timeSinceUnix(then.Unix(), now.Unix(), lang) | ||||
| } | ||||
|  | ||||
| func timeSinceUnix(then, now int64, lang string) string { | ||||
| func timeSinceUnix(then, now int64, lang translation.Locale) string { | ||||
| 	lbl := "tool.ago" | ||||
| 	diff := now - then | ||||
| 	if then > now { | ||||
| @@ -221,36 +221,31 @@ func timeSinceUnix(then, now int64, lang string) string { | ||||
| 		diff = then - now | ||||
| 	} | ||||
| 	if diff <= 0 { | ||||
| 		return i18n.Tr(lang, "tool.now") | ||||
| 		return lang.Tr("tool.now") | ||||
| 	} | ||||
|  | ||||
| 	_, diffStr := computeTimeDiff(diff, lang) | ||||
| 	return i18n.Tr(lang, lbl, diffStr) | ||||
| } | ||||
|  | ||||
| // RawTimeSince retrieves i18n key of time since t | ||||
| func RawTimeSince(t time.Time, lang string) string { | ||||
| 	return timeSince(t, time.Now(), lang) | ||||
| 	return lang.Tr(lbl, diffStr) | ||||
| } | ||||
|  | ||||
| // TimeSince calculates the time interval and generate user-friendly string. | ||||
| func TimeSince(then time.Time, lang string) template.HTML { | ||||
| func TimeSince(then time.Time, lang translation.Locale) template.HTML { | ||||
| 	return htmlTimeSince(then, time.Now(), lang) | ||||
| } | ||||
|  | ||||
| func htmlTimeSince(then, now time.Time, lang string) template.HTML { | ||||
| func htmlTimeSince(then, now time.Time, lang translation.Locale) template.HTML { | ||||
| 	return template.HTML(fmt.Sprintf(`<span class="time-since" title="%s">%s</span>`, | ||||
| 		then.In(setting.DefaultUILocation).Format(GetTimeFormat(lang)), | ||||
| 		then.In(setting.DefaultUILocation).Format(GetTimeFormat(lang.Language())), | ||||
| 		timeSince(then, now, lang))) | ||||
| } | ||||
|  | ||||
| // TimeSinceUnix calculates the time interval and generate user-friendly string. | ||||
| func TimeSinceUnix(then TimeStamp, lang string) template.HTML { | ||||
| func TimeSinceUnix(then TimeStamp, lang translation.Locale) template.HTML { | ||||
| 	return htmlTimeSinceUnix(then, TimeStamp(time.Now().Unix()), lang) | ||||
| } | ||||
|  | ||||
| func htmlTimeSinceUnix(then, now TimeStamp, lang string) template.HTML { | ||||
| func htmlTimeSinceUnix(then, now TimeStamp, lang translation.Locale) template.HTML { | ||||
| 	return template.HTML(fmt.Sprintf(`<span class="time-since" title="%s">%s</span>`, | ||||
| 		then.FormatInLocation(GetTimeFormat(lang), setting.DefaultUILocation), | ||||
| 		then.FormatInLocation(GetTimeFormat(lang.Language()), setting.DefaultUILocation), | ||||
| 		timeSinceUnix(int64(then), int64(now), lang))) | ||||
| } | ||||
|   | ||||
| @@ -12,7 +12,6 @@ import ( | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/translation" | ||||
| 	"code.gitea.io/gitea/modules/translation/i18n" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| @@ -42,16 +41,16 @@ func TestMain(m *testing.M) { | ||||
| } | ||||
|  | ||||
| func TestTimeSince(t *testing.T) { | ||||
| 	assert.Equal(t, "now", timeSince(BaseDate, BaseDate, "en")) | ||||
| 	assert.Equal(t, "now", timeSince(BaseDate, BaseDate, translation.NewLocale("en-US"))) | ||||
|  | ||||
| 	// test that each diff in `diffs` yields the expected string | ||||
| 	test := func(expected string, diffs ...time.Duration) { | ||||
| 		t.Run(expected, func(t *testing.T) { | ||||
| 			for _, diff := range diffs { | ||||
| 				actual := timeSince(BaseDate, BaseDate.Add(diff), "en") | ||||
| 				assert.Equal(t, i18n.Tr("en", "tool.ago", expected), actual) | ||||
| 				actual = timeSince(BaseDate.Add(diff), BaseDate, "en") | ||||
| 				assert.Equal(t, i18n.Tr("en", "tool.from_now", expected), actual) | ||||
| 				actual := timeSince(BaseDate, BaseDate.Add(diff), translation.NewLocale("en-US")) | ||||
| 				assert.Equal(t, translation.NewLocale("en-US").Tr("tool.ago", expected), actual) | ||||
| 				actual = timeSince(BaseDate.Add(diff), BaseDate, translation.NewLocale("en-US")) | ||||
| 				assert.Equal(t, translation.NewLocale("en-US").Tr("tool.from_now", expected), actual) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| @@ -82,13 +81,13 @@ func TestTimeSince(t *testing.T) { | ||||
| } | ||||
|  | ||||
| func TestTimeSincePro(t *testing.T) { | ||||
| 	assert.Equal(t, "now", timeSincePro(BaseDate, BaseDate, "en")) | ||||
| 	assert.Equal(t, "now", timeSincePro(BaseDate, BaseDate, translation.NewLocale("en-US"))) | ||||
|  | ||||
| 	// test that a difference of `diff` yields the expected string | ||||
| 	test := func(expected string, diff time.Duration) { | ||||
| 		actual := timeSincePro(BaseDate, BaseDate.Add(diff), "en") | ||||
| 		actual := timeSincePro(BaseDate, BaseDate.Add(diff), translation.NewLocale("en-US")) | ||||
| 		assert.Equal(t, expected, actual) | ||||
| 		assert.Equal(t, "future", timeSincePro(BaseDate.Add(diff), BaseDate, "en")) | ||||
| 		assert.Equal(t, "future", timeSincePro(BaseDate.Add(diff), BaseDate, translation.NewLocale("en-US"))) | ||||
| 	} | ||||
| 	test("1 second", time.Second) | ||||
| 	test("2 seconds", 2*time.Second) | ||||
| @@ -119,7 +118,7 @@ func TestHtmlTimeSince(t *testing.T) { | ||||
| 	setting.DefaultUILocation = time.UTC | ||||
| 	// test that `diff` yields a result containing `expected` | ||||
| 	test := func(expected string, diff time.Duration) { | ||||
| 		actual := htmlTimeSince(BaseDate, BaseDate.Add(diff), "en") | ||||
| 		actual := htmlTimeSince(BaseDate, BaseDate.Add(diff), translation.NewLocale("en-US")) | ||||
| 		assert.Contains(t, actual, `title="Sat Jan  1 00:00:00 UTC 2000"`) | ||||
| 		assert.Contains(t, actual, expected) | ||||
| 	} | ||||
| @@ -138,7 +137,7 @@ func TestComputeTimeDiff(t *testing.T) { | ||||
| 	test := func(base int64, str string, offsets ...int64) { | ||||
| 		for _, offset := range offsets { | ||||
| 			t.Run(fmt.Sprintf("%s:%d", str, offset), func(t *testing.T) { | ||||
| 				diff, diffStr := computeTimeDiff(base+offset, "en") | ||||
| 				diff, diffStr := computeTimeDiff(base+offset, translation.NewLocale("en-US")) | ||||
| 				assert.Equal(t, offset, diff) | ||||
| 				assert.Equal(t, str, diffStr) | ||||
| 			}) | ||||
| @@ -171,7 +170,7 @@ func TestComputeTimeDiff(t *testing.T) { | ||||
| func TestMinutesToFriendly(t *testing.T) { | ||||
| 	// test that a number of minutes yields the expected string | ||||
| 	test := func(expected string, minutes int) { | ||||
| 		actual := MinutesToFriendly(minutes, "en") | ||||
| 		actual := MinutesToFriendly(minutes, translation.NewLocale("en-US")) | ||||
| 		assert.Equal(t, expected, actual) | ||||
| 	} | ||||
| 	test("1 minute", 1) | ||||
|   | ||||
| @@ -7,10 +7,13 @@ package i18n | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"reflect" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
|  | ||||
| 	"gopkg.in/ini.v1" | ||||
| ) | ||||
| @@ -18,45 +21,90 @@ import ( | ||||
| var ( | ||||
| 	ErrLocaleAlreadyExist = errors.New("lang already exists") | ||||
|  | ||||
| 	DefaultLocales = NewLocaleStore() | ||||
| 	DefaultLocales = NewLocaleStore(true) | ||||
| ) | ||||
|  | ||||
| type locale struct { | ||||
| 	store    *LocaleStore | ||||
| 	langName string | ||||
| 	langDesc string | ||||
| 	messages *ini.File | ||||
| 	textMap  map[int]string // the map key (idx) is generated by store's textIdxMap | ||||
|  | ||||
| 	sourceFileName      string | ||||
| 	sourceFileInfo      os.FileInfo | ||||
| 	lastReloadCheckTime time.Time | ||||
| } | ||||
|  | ||||
| type LocaleStore struct { | ||||
| 	// at the moment, all these fields are readonly after initialization | ||||
| 	langNames   []string | ||||
| 	langDescs   []string | ||||
| 	localeMap   map[string]*locale | ||||
| 	reloadMu *sync.Mutex // for non-prod(dev), use a mutex for live-reload. for prod, no mutex, no live-reload. | ||||
|  | ||||
| 	langNames []string | ||||
| 	langDescs []string | ||||
|  | ||||
| 	localeMap  map[string]*locale | ||||
| 	textIdxMap map[string]int | ||||
|  | ||||
| 	defaultLang string | ||||
| } | ||||
|  | ||||
| func NewLocaleStore() *LocaleStore { | ||||
| 	return &LocaleStore{localeMap: make(map[string]*locale)} | ||||
| func NewLocaleStore(isProd bool) *LocaleStore { | ||||
| 	ls := &LocaleStore{localeMap: make(map[string]*locale), textIdxMap: make(map[string]int)} | ||||
| 	if !isProd { | ||||
| 		ls.reloadMu = &sync.Mutex{} | ||||
| 	} | ||||
| 	return ls | ||||
| } | ||||
|  | ||||
| // AddLocaleByIni adds locale by ini into the store | ||||
| func (ls *LocaleStore) AddLocaleByIni(langName, langDesc string, localeFile interface{}, otherLocaleFiles ...interface{}) error { | ||||
| // if source is a string, then the file is loaded. in dev mode, the file can be live-reloaded | ||||
| // if source is a []byte, then the content is used | ||||
| func (ls *LocaleStore) AddLocaleByIni(langName, langDesc string, source interface{}) error { | ||||
| 	if _, ok := ls.localeMap[langName]; ok { | ||||
| 		return ErrLocaleAlreadyExist | ||||
| 	} | ||||
|  | ||||
| 	lc := &locale{store: ls, langName: langName} | ||||
| 	if fileName, ok := source.(string); ok { | ||||
| 		lc.sourceFileName = fileName | ||||
| 		lc.sourceFileInfo, _ = os.Stat(fileName) // live-reload only works for regular files. the error can be ignored | ||||
| 	} | ||||
|  | ||||
| 	ls.langNames = append(ls.langNames, langName) | ||||
| 	ls.langDescs = append(ls.langDescs, langDesc) | ||||
| 	ls.localeMap[lc.langName] = lc | ||||
|  | ||||
| 	return ls.reloadLocaleByIni(langName, source) | ||||
| } | ||||
|  | ||||
| func (ls *LocaleStore) reloadLocaleByIni(langName string, source interface{}) error { | ||||
| 	iniFile, err := ini.LoadSources(ini.LoadOptions{ | ||||
| 		IgnoreInlineComment:         true, | ||||
| 		UnescapeValueCommentSymbols: true, | ||||
| 	}, localeFile, otherLocaleFiles...) | ||||
| 	if err == nil { | ||||
| 		iniFile.BlockMode = false | ||||
| 		lc := &locale{store: ls, langName: langName, langDesc: langDesc, messages: iniFile} | ||||
| 		ls.langNames = append(ls.langNames, lc.langName) | ||||
| 		ls.langDescs = append(ls.langDescs, lc.langDesc) | ||||
| 		ls.localeMap[lc.langName] = lc | ||||
| 	}, source) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unable to load ini: %w", err) | ||||
| 	} | ||||
| 	return err | ||||
| 	iniFile.BlockMode = false | ||||
|  | ||||
| 	lc := ls.localeMap[langName] | ||||
| 	lc.textMap = make(map[int]string) | ||||
| 	for _, section := range iniFile.Sections() { | ||||
| 		for _, key := range section.Keys() { | ||||
| 			var trKey string | ||||
| 			if section.Name() == "" || section.Name() == "DEFAULT" { | ||||
| 				trKey = key.Name() | ||||
| 			} else { | ||||
| 				trKey = section.Name() + "." + key.Name() | ||||
| 			} | ||||
| 			textIdx, ok := ls.textIdxMap[trKey] | ||||
| 			if !ok { | ||||
| 				textIdx = len(ls.textIdxMap) | ||||
| 				ls.textIdxMap[trKey] = textIdx | ||||
| 			} | ||||
| 			lc.textMap[textIdx] = key.Value() | ||||
| 		} | ||||
| 	} | ||||
| 	iniFile = nil | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (ls *LocaleStore) HasLang(langName string) bool { | ||||
| @@ -87,25 +135,39 @@ func (ls *LocaleStore) Tr(lang, trKey string, trArgs ...interface{}) string { | ||||
|  | ||||
| // Tr translates content to locale language. fall back to default language. | ||||
| func (l *locale) Tr(trKey string, trArgs ...interface{}) string { | ||||
| 	var section string | ||||
|  | ||||
| 	idx := strings.IndexByte(trKey, '.') | ||||
| 	if idx > 0 { | ||||
| 		section = trKey[:idx] | ||||
| 		trKey = trKey[idx+1:] | ||||
| 	} | ||||
|  | ||||
| 	trMsg := trKey | ||||
| 	if trIni, err := l.messages.Section(section).GetKey(trKey); err == nil { | ||||
| 		trMsg = trIni.Value() | ||||
| 	} else if l.store.defaultLang != "" && l.langName != l.store.defaultLang { | ||||
| 		// try to fall back to default | ||||
| 		if defaultLocale, ok := l.store.localeMap[l.store.defaultLang]; ok { | ||||
| 			if trIni, err = defaultLocale.messages.Section(section).GetKey(trKey); err == nil { | ||||
| 				trMsg = trIni.Value() | ||||
| 	if l.store.reloadMu != nil { | ||||
| 		l.store.reloadMu.Lock() | ||||
| 		defer l.store.reloadMu.Unlock() | ||||
| 		now := time.Now() | ||||
| 		if now.Sub(l.lastReloadCheckTime) >= time.Second && l.sourceFileInfo != nil && l.sourceFileName != "" { | ||||
| 			l.lastReloadCheckTime = now | ||||
| 			if sourceFileInfo, err := os.Stat(l.sourceFileName); err == nil && !sourceFileInfo.ModTime().Equal(l.sourceFileInfo.ModTime()) { | ||||
| 				if err = l.store.reloadLocaleByIni(l.langName, l.sourceFileName); err == nil { | ||||
| 					l.sourceFileInfo = sourceFileInfo | ||||
| 				} else { | ||||
| 					log.Error("unable to live-reload the locale file %q, err: %v", l.sourceFileName, err) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	msg, _ := l.tryTr(trKey, trArgs...) | ||||
| 	return msg | ||||
| } | ||||
|  | ||||
| func (l *locale) tryTr(trKey string, trArgs ...interface{}) (msg string, found bool) { | ||||
| 	trMsg := trKey | ||||
| 	textIdx, ok := l.store.textIdxMap[trKey] | ||||
| 	if ok { | ||||
| 		if msg, found = l.textMap[textIdx]; found { | ||||
| 			trMsg = msg // use current translation | ||||
| 		} else if l.langName != l.store.defaultLang { | ||||
| 			if def, ok := l.store.localeMap[l.store.defaultLang]; ok { | ||||
| 				return def.tryTr(trKey, trArgs...) | ||||
| 			} | ||||
| 		} else if !setting.IsProd { | ||||
| 			log.Error("missing i18n translation key: %q", trKey) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if len(trArgs) > 0 { | ||||
| 		fmtArgs := make([]interface{}, 0, len(trArgs)) | ||||
| @@ -128,13 +190,15 @@ func (l *locale) Tr(trKey string, trArgs ...interface{}) string { | ||||
| 				fmtArgs = append(fmtArgs, arg) | ||||
| 			} | ||||
| 		} | ||||
| 		return fmt.Sprintf(trMsg, fmtArgs...) | ||||
| 		return fmt.Sprintf(trMsg, fmtArgs...), found | ||||
| 	} | ||||
| 	return trMsg | ||||
| 	return trMsg, found | ||||
| } | ||||
|  | ||||
| func ResetDefaultLocales() { | ||||
| 	DefaultLocales = NewLocaleStore() | ||||
| // ResetDefaultLocales resets the current default locales | ||||
| // NOTE: this is not synchronized | ||||
| func ResetDefaultLocales(isProd bool) { | ||||
| 	DefaultLocales = NewLocaleStore(isProd) | ||||
| } | ||||
|  | ||||
| // Tr use default locales to translate content to target language. | ||||
|   | ||||
| @@ -27,30 +27,36 @@ fmt = %[2]s %[1]s | ||||
| sub = Changed Sub String | ||||
| `) | ||||
|  | ||||
| 	ls := NewLocaleStore() | ||||
| 	assert.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1)) | ||||
| 	assert.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2)) | ||||
| 	ls.SetDefaultLang("lang1") | ||||
| 	for _, isProd := range []bool{true, false} { | ||||
| 		ls := NewLocaleStore(isProd) | ||||
| 		assert.NoError(t, ls.AddLocaleByIni("lang1", "Lang1", testData1)) | ||||
| 		assert.NoError(t, ls.AddLocaleByIni("lang2", "Lang2", testData2)) | ||||
| 		ls.SetDefaultLang("lang1") | ||||
|  | ||||
| 	result := ls.Tr("lang1", "fmt", "a", "b") | ||||
| 	assert.Equal(t, "a b", result) | ||||
| 		result := ls.Tr("lang1", "fmt", "a", "b") | ||||
| 		assert.Equal(t, "a b", result) | ||||
|  | ||||
| 	result = ls.Tr("lang2", "fmt", "a", "b") | ||||
| 	assert.Equal(t, "b a", result) | ||||
| 		result = ls.Tr("lang2", "fmt", "a", "b") | ||||
| 		assert.Equal(t, "b a", result) | ||||
|  | ||||
| 	result = ls.Tr("lang1", "section.sub") | ||||
| 	assert.Equal(t, "Sub String", result) | ||||
| 		result = ls.Tr("lang1", "section.sub") | ||||
| 		assert.Equal(t, "Sub String", result) | ||||
|  | ||||
| 	result = ls.Tr("lang2", "section.sub") | ||||
| 	assert.Equal(t, "Changed Sub String", result) | ||||
| 		result = ls.Tr("lang2", "section.sub") | ||||
| 		assert.Equal(t, "Changed Sub String", result) | ||||
|  | ||||
| 	result = ls.Tr("", ".dot.name") | ||||
| 	assert.Equal(t, "Dot Name", result) | ||||
| 		result = ls.Tr("", ".dot.name") | ||||
| 		assert.Equal(t, "Dot Name", result) | ||||
|  | ||||
| 	result = ls.Tr("lang2", "section.mixed") | ||||
| 	assert.Equal(t, `test value; <span style="color: red; background: none;">more text</span>`, result) | ||||
| 		result = ls.Tr("lang2", "section.mixed") | ||||
| 		assert.Equal(t, `test value; <span style="color: red; background: none;">more text</span>`, result) | ||||
|  | ||||
| 	langs, descs := ls.ListLangNameDesc() | ||||
| 	assert.Equal(t, []string{"lang1", "lang2"}, langs) | ||||
| 	assert.Equal(t, []string{"Lang1", "Lang2"}, descs) | ||||
| 		langs, descs := ls.ListLangNameDesc() | ||||
| 		assert.Equal(t, []string{"lang1", "lang2"}, langs) | ||||
| 		assert.Equal(t, []string{"Lang1", "Lang2"}, descs) | ||||
|  | ||||
| 		result, found := ls.localeMap["lang1"].tryTr("no-such") | ||||
| 		assert.Equal(t, "no-such", result) | ||||
| 		assert.False(t, found) | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
| package translation | ||||
|  | ||||
| import ( | ||||
| 	"path" | ||||
| 	"sort" | ||||
| 	"strings" | ||||
|  | ||||
| @@ -12,6 +13,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/options" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/translation/i18n" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
|  | ||||
| 	"golang.org/x/text/language" | ||||
| ) | ||||
| @@ -40,31 +42,35 @@ func AllLangs() []*LangType { | ||||
| 	return allLangs | ||||
| } | ||||
|  | ||||
| // TryTr tries to do the translation, if no translation, it returns (format, false) | ||||
| func TryTr(lang, format string, args ...interface{}) (string, bool) { | ||||
| 	s := i18n.Tr(lang, format, args...) | ||||
| 	// now the i18n library is not good enough and we can only use this hacky method to detect whether the transaction exists | ||||
| 	idx := strings.IndexByte(format, '.') | ||||
| 	defaultText := format | ||||
| 	if idx > 0 { | ||||
| 		defaultText = format[idx+1:] | ||||
| 	} | ||||
| 	return s, s != defaultText | ||||
| } | ||||
|  | ||||
| // InitLocales loads the locales | ||||
| func InitLocales() { | ||||
| 	i18n.ResetDefaultLocales() | ||||
| 	i18n.ResetDefaultLocales(setting.IsProd) | ||||
| 	localeNames, err := options.Dir("locale") | ||||
| 	if err != nil { | ||||
| 		log.Fatal("Failed to list locale files: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	localFiles := make(map[string][]byte, len(localeNames)) | ||||
| 	localFiles := make(map[string]interface{}, len(localeNames)) | ||||
| 	for _, name := range localeNames { | ||||
| 		localFiles[name], err = options.Locale(name) | ||||
| 		if err != nil { | ||||
| 			log.Fatal("Failed to load %s locale file. %v", name, err) | ||||
| 		if options.IsDynamic() { | ||||
| 			// Try to check if CustomPath has the file, otherwise fallback to StaticRootPath | ||||
| 			value := path.Join(setting.CustomPath, "options/locale", name) | ||||
|  | ||||
| 			isFile, err := util.IsFile(value) | ||||
| 			if err != nil { | ||||
| 				log.Fatal("Failed to load %s locale file. %v", name, err) | ||||
| 			} | ||||
|  | ||||
| 			if isFile { | ||||
| 				localFiles[name] = value | ||||
| 			} else { | ||||
| 				localFiles[name] = path.Join(setting.StaticRootPath, "options/locale", name) | ||||
| 			} | ||||
| 		} else { | ||||
| 			localFiles[name], err = options.Locale(name) | ||||
| 			if err != nil { | ||||
| 				log.Fatal("Failed to load %s locale file. %v", name, err) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -76,6 +82,7 @@ func InitLocales() { | ||||
| 	matcher = language.NewMatcher(supportedTags) | ||||
| 	for i := range setting.Names { | ||||
| 		key := "locale_" + setting.Langs[i] + ".ini" | ||||
|  | ||||
| 		if err = i18n.DefaultLocales.AddLocaleByIni(setting.Langs[i], setting.Names[i], localFiles[key]); err != nil { | ||||
| 			log.Error("Failed to set messages to %s: %v", setting.Langs[i], err) | ||||
| 		} | ||||
| @@ -132,16 +139,7 @@ func (l *locale) Language() string { | ||||
|  | ||||
| // Tr translates content to target language. | ||||
| func (l *locale) Tr(format string, args ...interface{}) string { | ||||
| 	if setting.IsProd { | ||||
| 		return i18n.Tr(l.Lang, format, args...) | ||||
| 	} | ||||
|  | ||||
| 	// in development, we should show an error if a translation key is missing | ||||
| 	s, ok := TryTr(l.Lang, format, args...) | ||||
| 	if !ok { | ||||
| 		log.Error("missing i18n translation key: %q", format) | ||||
| 	} | ||||
| 	return s | ||||
| 	return i18n.Tr(l.Lang, format, args...) | ||||
| } | ||||
|  | ||||
| // Language specific rules for translating plural texts | ||||
|   | ||||
		Reference in New Issue
	
	Block a user