Files
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

1104 lines
34 KiB
Go

// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package commands
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"log"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/mattermost/go-i18n/i18n/bundle"
"github.com/mattermost/go-i18n/i18n/language"
"github.com/spf13/cobra"
)
const enterpriseKeyPrefix = "ent."
const untranslatedKey = "<untranslated>"
type Translation struct {
Id string `json:"id"`
Translation any `json:"translation"`
}
type Item struct {
ID string `json:"id"`
Translation json.RawMessage `json:"translation"`
}
var I18nCmd = &cobra.Command{
Use: "i18n",
Short: "Management of Mattermost translations",
}
var ExtractCmd = &cobra.Command{
Use: "extract",
Short: "Extract translations",
Long: "Extract translations from the source code and put them into the i18n/en.json file",
Example: " i18n extract",
RunE: extractCmdF,
}
var CheckCmd = &cobra.Command{
Use: "check",
Short: "Check translations",
Long: "Check translations existing in the source code and compare it to the i18n/en.json file",
Example: " i18n check",
RunE: checkCmdF,
}
var CheckEmptySrcCmd = &cobra.Command{
Use: "check-empty-src",
Short: "Check for empty translation source strings",
Long: "Check the en.json file for empty translation source strings",
Example: " i18n check-empty-src",
RunE: checkEmptySrcCmdF,
}
var VerifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify the non-English translation files against en.json",
Long: "Checks every non-English file in i18n/ against i18n/en.json: it must load through the same go-i18n loader the server runs at startup, hold exactly the ids en.json holds, interpolate exactly the {{.Fields}} the source interpolates, and define exactly the CLDR plural categories its locale uses.",
Example: " i18n verify",
// A verification failure is a normal outcome, not a usage error.
SilenceUsage: true,
RunE: verifyCmdF,
}
var CleanEmptyCmd = &cobra.Command{
Use: "clean-empty",
Short: "Clean empty translations",
Long: "Clean empty translations in translation files other than i18n/en.json base file",
Example: " i18n clean-empty",
RunE: cleanEmptyCmdF,
}
func init() {
ExtractCmd.Flags().Bool("skip-dynamic", false, "Whether to skip dynamically added translations")
ExtractCmd.Flags().String("portal-dir", "../customer-web-server", "Path to folder with the Mattermost Customer Portal source code")
ExtractCmd.Flags().String("enterprise-dir", "../../enterprise", "Path to folder with the Mattermost enterprise source code")
ExtractCmd.Flags().String("server-dir", "./", "Path to folder with the Mattermost server source code")
ExtractCmd.Flags().String("model-dir", "../model", "Path to folder with the Mattermost model package source code")
ExtractCmd.Flags().String("plugin-dir", "../plugin", "Path to folder with the Mattermost plugin package source code")
ExtractCmd.Flags().Bool("contributor", false, "Allows contributors safely extract translations from source code without removing enterprise messages keys")
CheckCmd.Flags().Bool("skip-dynamic", false, "Whether to skip dynamically added translations")
CheckCmd.Flags().String("portal-dir", "../customer-web-server", "Path to folder with the Mattermost Customer Portal source code")
CheckCmd.Flags().String("enterprise-dir", "../../enterprise", "Path to folder with the Mattermost enterprise source code")
CheckCmd.Flags().String("server-dir", "./", "Path to folder with the Mattermost server source code")
CheckCmd.Flags().String("model-dir", "../model", "Path to folder with the Mattermost model package source code")
CheckCmd.Flags().String("plugin-dir", "../plugin", "Path to folder with the Mattermost plugin package source code")
CheckEmptySrcCmd.Flags().String("portal-dir", "../customer-web-server", "Path to folder with the Mattermost Customer Portal source code")
CheckEmptySrcCmd.Flags().String("enterprise-dir", "../../enterprise", "Path to folder with the Mattermost enterprise source code")
CheckEmptySrcCmd.Flags().String("server-dir", "./", "Path to folder with the Mattermost server source code")
VerifyCmd.Flags().Bool("warn-missing-ids", false, "Report ids missing from a locale as warnings instead of errors")
VerifyCmd.Flags().String("server-dir", "./", "Path to folder with the Mattermost server source code")
CleanEmptyCmd.Flags().Bool("dry-run", false, "Run without applying changes")
CleanEmptyCmd.Flags().Bool("check", false, "Throw exit code on empty translation strings")
CleanEmptyCmd.Flags().String("portal-dir", "../customer-web-server", "Path to folder with the Mattermost Customer Portal source code")
CleanEmptyCmd.Flags().String("enterprise-dir", "../../enterprise", "Path to folder with the Mattermost enterprise source code")
CleanEmptyCmd.Flags().String("server-dir", "./", "Path to folder with the Mattermost server source code")
I18nCmd.AddCommand(
ExtractCmd,
CheckCmd,
CheckEmptySrcCmd,
CleanEmptyCmd,
VerifyCmd,
)
RootCmd.AddCommand(I18nCmd)
}
func getBaseFileSrcStrings(mattermostDir string) ([]Translation, error) {
jsonFile, err := os.ReadFile(path.Join(mattermostDir, "i18n", "en.json"))
if err != nil {
return nil, err
}
var translations []Translation
err = json.Unmarshal(jsonFile, &translations)
return translations, err
}
// resolveSymlink resolves a path if it's a symlink, otherwise returns the original path
func resolveSymlink(path string) string {
if realPath, err := filepath.EvalSymlinks(path); err == nil {
return realPath
}
return path
}
func extractSrcStrings(enterpriseDir, mattermostDir, modelDir, pluginDir, portalDir string) (map[string]bool, error) {
i18nStrings := map[string]bool{}
walkFunc := func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasPrefix(p, path.Join(mattermostDir, "vendor")) {
return nil
}
return extractFromPath(p, i18nStrings)
}
dirs := []string{mattermostDir, enterpriseDir, modelDir, pluginDir}
if portalDir != "" {
dirs = []string{portalDir}
}
for _, dir := range dirs {
resolved := resolveSymlink(dir)
// Optional source trees, such as the enterprise repository, may not be checked
// out alongside the server. Skip them instead of failing the extraction.
if _, err := os.Stat(resolved); errors.Is(err, os.ErrNotExist) {
continue
}
if err := filepath.Walk(resolved, walkFunc); err != nil {
return nil, fmt.Errorf("failed to walk %q: %w", dir, err)
}
}
return i18nStrings, nil
}
func extractCmdF(command *cobra.Command, args []string) (err error) {
skipDynamic, err := command.Flags().GetBool("skip-dynamic")
if err != nil {
return errors.New("invalid skip-dynamic parameter")
}
enterpriseDir, err := command.Flags().GetString("enterprise-dir")
if err != nil {
return errors.New("invalid enterprise-dir parameter")
}
mattermostDir, err := command.Flags().GetString("server-dir")
if err != nil {
return errors.New("invalid server-dir parameter")
}
contributorMode, err := command.Flags().GetBool("contributor")
if err != nil {
return errors.New("invalid contributor parameter")
}
portalDir, err := command.Flags().GetString("portal-dir")
if err != nil {
return errors.New("invalid portal-dir parameter")
}
modelDir, err := command.Flags().GetString("model-dir")
if err != nil {
return errors.New("invalid model-dir parameter")
}
pluginDir, err := command.Flags().GetString("plugin-dir")
if err != nil {
return errors.New("invalid plugin-dir parameter")
}
translationDir := mattermostDir
if portalDir != "" {
if enterpriseDir != "" || mattermostDir != "" {
return errors.New("please specify EITHER portal-dir or enterprise-dir/server-dir")
}
skipDynamic = true // dynamics are not needed for portal
translationDir = portalDir
}
i18nStrings, err := extractSrcStrings(enterpriseDir, mattermostDir, modelDir, pluginDir, portalDir)
if err != nil {
return err
}
if !skipDynamic {
addDynamicallyGeneratedStrings(i18nStrings)
}
// Delete any untranslated keys
delete(i18nStrings, untranslatedKey)
var i18nStringsList []string
for id := range i18nStrings {
i18nStringsList = append(i18nStringsList, id)
}
sort.Strings(i18nStringsList)
sourceStrings, err := getBaseFileSrcStrings(translationDir)
if err != nil {
return err
}
var baseFileList []string
idx := map[string]bool{}
resultMap := map[string]Translation{}
for _, t := range sourceStrings {
idx[t.Id] = true
baseFileList = append(baseFileList, t.Id)
resultMap[t.Id] = t
}
sort.Strings(baseFileList)
for _, translationKey := range i18nStringsList {
if _, hasKey := idx[translationKey]; !hasKey {
resultMap[translationKey] = Translation{Id: translationKey, Translation: ""}
}
}
for _, translationKey := range baseFileList {
if _, hasKey := i18nStrings[translationKey]; !hasKey {
if contributorMode && strings.HasPrefix(translationKey, enterpriseKeyPrefix) {
continue
}
delete(resultMap, translationKey)
}
}
var result []Translation
for _, t := range resultMap {
result = append(result, t)
}
sort.Slice(result, func(i, j int) bool { return result[i].Id < result[j].Id })
f, err := os.Create(path.Join(mattermostDir, "i18n", "en.json"))
if err != nil {
return err
}
defer func() {
if closeErr := f.Close(); err == nil {
err = closeErr
}
}()
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
encoder.SetEscapeHTML(false)
return encoder.Encode(result)
}
func checkCmdF(command *cobra.Command, args []string) error {
skipDynamic, err := command.Flags().GetBool("skip-dynamic")
if err != nil {
return errors.New("invalid skip-dynamic parameter")
}
enterpriseDir, err := command.Flags().GetString("enterprise-dir")
if err != nil {
return errors.New("invalid enterprise-dir parameter")
}
mattermostDir, err := command.Flags().GetString("server-dir")
if err != nil {
return errors.New("invalid server-dir parameter")
}
portalDir, err := command.Flags().GetString("portal-dir")
if err != nil {
return errors.New("invalid portal-dir parameter")
}
modelDir, err := command.Flags().GetString("model-dir")
if err != nil {
return errors.New("invalid model-dir parameter")
}
pluginDir, err := command.Flags().GetString("plugin-dir")
if err != nil {
return errors.New("invalid plugin-dir parameter")
}
translationDir := mattermostDir
if portalDir != "" {
if enterpriseDir != "" || mattermostDir != "" {
return errors.New("please specify EITHER portal-dir or enterprise-dir/server-dir")
}
translationDir = portalDir
skipDynamic = true // dynamics are not needed for portal
}
extractedSrcStrings, err := extractSrcStrings(enterpriseDir, mattermostDir, modelDir, pluginDir, portalDir)
if err != nil {
return err
}
if !skipDynamic {
addDynamicallyGeneratedStrings(extractedSrcStrings)
}
// Delete any untranslated keys
delete(extractedSrcStrings, untranslatedKey)
var extractedList []string
for id := range extractedSrcStrings {
extractedList = append(extractedList, id)
}
sort.Strings(extractedList)
srcStrings, err := getBaseFileSrcStrings(translationDir)
if err != nil {
return err
}
var baseFileList []string
idx := map[string]bool{}
for _, t := range srcStrings {
idx[t.Id] = true
baseFileList = append(baseFileList, t.Id)
}
sort.Strings(baseFileList)
changed := false
for _, translationKey := range extractedList {
if _, hasKey := idx[translationKey]; !hasKey {
fmt.Println("Added:", translationKey)
changed = true
}
}
for _, translationKey := range baseFileList {
if _, hasKey := extractedSrcStrings[translationKey]; !hasKey {
fmt.Println("Removed:", translationKey)
changed = true
}
}
if changed {
command.SilenceUsage = true
return errors.New("translation source strings file out of date")
}
return nil
}
func addDynamicallyGeneratedStrings(i18nStrings map[string]bool) {
i18nStrings["model.user.is_valid.pwd_min_length.app_error"] = true
i18nStrings["model.user.is_valid.pwd_max_length.app_error"] = true
i18nStrings["model.user.is_valid.pwd_lowercase.app_error"] = true
i18nStrings["model.user.is_valid.pwd_lowercase_number.app_error"] = true
i18nStrings["model.user.is_valid.pwd_lowercase_number_symbol.app_error"] = true
i18nStrings["model.user.is_valid.pwd_lowercase_symbol.app_error"] = true
i18nStrings["model.user.is_valid.pwd_lowercase_uppercase.app_error"] = true
i18nStrings["model.user.is_valid.pwd_lowercase_uppercase_number.app_error"] = true
i18nStrings["model.user.is_valid.pwd_lowercase_uppercase_number_symbol.app_error"] = true
i18nStrings["model.user.is_valid.pwd_lowercase_uppercase_symbol.app_error"] = true
i18nStrings["model.user.is_valid.pwd_number.app_error"] = true
i18nStrings["model.user.is_valid.pwd_number_symbol.app_error"] = true
i18nStrings["model.user.is_valid.pwd_symbol.app_error"] = true
i18nStrings["model.user.is_valid.pwd_uppercase.app_error"] = true
i18nStrings["model.user.is_valid.pwd_uppercase_number.app_error"] = true
i18nStrings["model.user.is_valid.pwd_uppercase_number_symbol.app_error"] = true
i18nStrings["model.user.is_valid.pwd_uppercase_symbol.app_error"] = true
i18nStrings["model.user.is_valid.id.app_error"] = true
i18nStrings["model.user.is_valid.create_at.app_error"] = true
i18nStrings["model.user.is_valid.update_at.app_error"] = true
i18nStrings["model.user.is_valid.username.app_error"] = true
i18nStrings["model.user.is_valid.email.app_error"] = true
i18nStrings["model.user.is_valid.nickname.app_error"] = true
i18nStrings["model.user.is_valid.position.app_error"] = true
i18nStrings["model.user.is_valid.first_name.app_error"] = true
i18nStrings["model.user.is_valid.last_name.app_error"] = true
i18nStrings["model.user.is_valid.auth_data.app_error"] = true
i18nStrings["model.user.is_valid.auth_data_type.app_error"] = true
i18nStrings["model.user.is_valid.auth_data_pwd.app_error"] = true
i18nStrings["model.user.is_valid.password_limit.app_error"] = true
i18nStrings["model.user.is_valid.locale.app_error"] = true
i18nStrings["January"] = true
i18nStrings["February"] = true
i18nStrings["March"] = true
i18nStrings["April"] = true
i18nStrings["May"] = true
i18nStrings["June"] = true
i18nStrings["July"] = true
i18nStrings["August"] = true
i18nStrings["September"] = true
i18nStrings["October"] = true
i18nStrings["November"] = true
i18nStrings["December"] = true
}
func extractByFuncName(name string, args []ast.Expr) *string {
if name == "T" {
if len(args) == 0 {
return nil
}
key, ok := args[0].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
} else if name == "TranslationId" {
if len(args) == 0 {
return nil
}
key, ok := args[0].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
} else if name == "NewAppError" {
if len(args) < 2 {
return nil
}
key, ok := args[1].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
} else if name == "newAppError" {
if len(args) < 1 {
return nil
}
key, ok := args[0].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
} else if name == "NewUserFacingError" {
if len(args) < 1 {
return nil
}
key, ok := args[0].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
} else if name == "translateFunc" {
if len(args) < 1 {
return nil
}
key, ok := args[0].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
} else if name == "TranslateAsHTML" || name == "TranslateAsHtml" {
if len(args) < 2 {
return nil
}
key, ok := args[1].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
} else if name == "userLocale" {
if len(args) < 1 {
return nil
}
key, ok := args[0].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
} else if name == "localT" {
if len(args) < 1 {
return nil
}
key, ok := args[0].(*ast.BasicLit)
if !ok {
return nil
}
return &key.Value
}
return nil
}
func extractForConstants(name string, valueNode ast.Expr) *string {
validConstants := map[string]bool{
"MISSING_CHANNEL_ERROR": true,
"MISSING_CHANNEL_MEMBER_ERROR": true,
"CHANNEL_EXISTS_ERROR": true,
"MISSING_STATUS_ERROR": true,
"TEAM_MEMBER_EXISTS_ERROR": true,
"MISSING_AUTH_ACCOUNT_ERROR": true,
"MISSING_ACCOUNT_ERROR": true,
"EXPIRED_LICENSE_ERROR": true,
"INVALID_LICENSE_ERROR": true,
"MissingChannelError": true,
"MissingChannelMemberError": true,
"ChannelExistsError": true,
"MissingStatusError": true,
"TeamMemberExistsError": true,
"MissingAuthAccountError": true,
"MissingAccountError": true,
"ExpiredLicenseError": true,
"InvalidLicenseError": true,
"WrongEnvironmentProductionLicenseError": true,
"WrongEnvironmentTestLicenseError": true,
"NoTranslation": true,
"PayloadParseError": true,
}
if _, ok := validConstants[name]; !ok {
return nil
}
value, ok := valueNode.(*ast.BasicLit)
if !ok {
return nil
}
return &value.Value
}
func extractFromPath(path string, i18nStrings map[string]bool) error {
if strings.HasSuffix(path, "model/client4.go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.Contains(path, ".git/") || strings.HasPrefix(path, ".git/") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read %q: %w", path, err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "", src, 0)
if err != nil {
fmt.Printf("error parsing source: %s\n", path)
panic(err)
}
ast.Inspect(f, func(n ast.Node) bool {
var id *string
switch expr := n.(type) {
case *ast.CallExpr:
switch fun := expr.Fun.(type) {
case *ast.SelectorExpr:
id = extractByFuncName(fun.Sel.Name, expr.Args)
if id == nil {
return true
}
case *ast.Ident:
id = extractByFuncName(fun.Name, expr.Args)
default:
return true
}
case *ast.GenDecl:
if expr.Tok == token.CONST {
for _, spec := range expr.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
if len(valueSpec.Names) == 0 {
continue
}
if len(valueSpec.Values) == 0 {
continue
}
id = extractForConstants(valueSpec.Names[0].Name, valueSpec.Values[0])
if id == nil {
continue
}
i18nStrings[strings.Trim(*id, "\"")] = true
}
}
return true
default:
return true
}
if id != nil {
i18nStrings[strings.Trim(*id, "\"")] = true
}
return true
})
return nil
}
func checkEmptySrcCmdF(command *cobra.Command, args []string) error {
enterpriseDir, err := command.Flags().GetString("enterprise-dir")
if err != nil {
return errors.New("invalid enterprise-dir parameter")
}
mattermostDir, err := command.Flags().GetString("server-dir")
if err != nil {
return errors.New("invalid server-dir parameter")
}
portalDir, err := command.Flags().GetString("portal-dir")
if err != nil {
return errors.New("invalid portal-dir parameter")
}
translationDir := path.Join(mattermostDir, "i18n")
if portalDir != "" {
if enterpriseDir != "" || mattermostDir != "" {
return errors.New("please specify EITHER portal-dir or enterprise-dir/server-dir")
}
translationDir = portalDir
}
srcJSON, err := os.ReadFile(path.Join(translationDir, "en.json"))
if err != nil {
return err
}
var items []Item
if err = json.Unmarshal(srcJSON, &items); err != nil {
return err
}
err = countEmptyItems(items)
if err != nil {
return err
}
return nil
}
func countEmptyItems(items []Item) error {
hasError := false
for _, t := range items {
str := string(t.Translation)
if !strings.HasPrefix(str, "\"") {
continue
}
unquoted, err := strconv.Unquote(str)
if err != nil {
return fmt.Errorf("error unquoting translation for %s, %v", t.ID, err)
}
if strings.TrimSpace(unquoted) == "" {
log.Printf("Empty translation for %s. Please fix it.\n", t.ID)
hasError = true
}
}
if hasError {
return errors.New("empty translations found")
}
return nil
}
func cleanEmptyCmdF(command *cobra.Command, args []string) error {
dryRun, err := command.Flags().GetBool("dry-run")
if err != nil {
return errors.New("invalid dry-run parameter")
}
check, err := command.Flags().GetBool("check")
if err != nil {
return errors.New("invalid check parameter")
}
enterpriseDir, err := command.Flags().GetString("enterprise-dir")
if err != nil {
return errors.New("invalid enterprise-dir parameter")
}
mattermostDir, err := command.Flags().GetString("server-dir")
if err != nil {
return errors.New("invalid server-dir parameter")
}
portalDir, err := command.Flags().GetString("portal-dir")
if err != nil {
return errors.New("invalid portal-dir parameter")
}
translationDir := path.Join(mattermostDir, "i18n")
if portalDir != "" {
if enterpriseDir != "" || mattermostDir != "" {
return errors.New("please specify EITHER portal-dir or enterprise-dir/server-dir")
}
translationDir = portalDir
}
var shippedFiles []string
dirEntries, err := os.ReadDir(translationDir)
if err != nil {
return err
}
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() && filepath.Ext(dirEntry.Name()) == ".json" && dirEntry.Name() != "en.json" {
shippedFiles = append(shippedFiles, dirEntry.Name())
}
}
results := ""
for _, file := range shippedFiles {
result, err2 := clean(translationDir, file, dryRun, check)
if err2 != nil {
return err2
}
results += *result
}
if results == "" {
return nil
}
fmt.Print("\n" + results)
if check {
os.Exit(1)
}
return nil
}
func clean(translationDir string, file string, dryRun bool, check bool) (*string, error) {
oldJSON, err := os.ReadFile(path.Join(translationDir, file))
if err != nil {
return nil, err
}
if strings.TrimSpace(string(oldJSON)) == "{}" {
noIssue := ""
return &noIssue, err
}
var oldList []Item
if err = json.Unmarshal(oldJSON, &oldList); err != nil {
return nil, err
}
newList, count := removeEmptyTranslations(oldList)
result := ""
if count == 0 {
return &result, nil
}
result = fmt.Sprintf("%v has %v empty translations\n", file, count)
if dryRun || check {
return &result, nil
}
newJSON, err := JSONMarshal(newList)
if err != nil {
return nil, err
}
filename := path.Join(translationDir, file)
fileInfo, err := os.Lstat(filename)
if err != nil {
return nil, err
}
if err = os.WriteFile(filename, newJSON, fileInfo.Mode().Perm()); err != nil {
return nil, err
}
return &result, nil
}
func removeEmptyTranslations(oldList []Item) ([]Item, int) {
var count int
var newList []Item
for i, t := range oldList {
if string(t.Translation) != "\"\"" {
newList = append(newList, oldList[i])
} else {
count++
}
}
return newList, count
}
func JSONMarshal(t any) ([]byte, error) {
buffer := &bytes.Buffer{}
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
err := encoder.Encode(t)
return buffer.Bytes(), err
}
// templateTokenRe matches a Go template field interpolation such as {{.Username}}
// or {{ .Username }}, capturing the field name. Server translations interpolate
// values this way, so the verifier compares the tokens each translation uses
// against those the en.json source uses.
var templateTokenRe = regexp.MustCompile(`\{\{\s*\.([A-Za-z0-9_]+)\s*\}\}`)
// pluralForms returns the category-to-string map of a pluralised translation,
// and whether the translation is pluralised at all. A translation is either a
// plain string or a map of plural category to string.
//
// For example, api.command_groupmsg.invalid_user.app_error is pluralised as
//
// {"one": "Unable to find the user: {{.Users}}",
// "other": "Unable to find the users: {{.Users}}"}
//
// so pluralForms returns that map and true, where a plain string translation
// returns nil and false.
func pluralForms(raw json.RawMessage) (map[string]string, bool) {
var plural map[string]string
if err := json.Unmarshal(raw, &plural); err != nil {
return nil, false
}
return plural, true
}
// templateTokens returns the {{.Field}} names a translation interpolates.
func templateTokens(raw json.RawMessage) map[string]bool {
out := map[string]bool{}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
for _, m := range templateTokenRe.FindAllStringSubmatch(s, -1) {
out[m[1]] = true
}
return out
}
if plural, ok := pluralForms(raw); ok {
for _, v := range plural {
for _, m := range templateTokenRe.FindAllStringSubmatch(v, -1) {
out[m[1]] = true
}
}
}
return out
}
// loadItems keys a locale catalog's entries by translation ID. The catalogs are
// JSON arrays, so this is what lets a caller look an ID up in one catalog while
// walking another.
func loadItems(raw []byte) (map[string]Item, error) {
var list []Item
if err := json.Unmarshal(raw, &list); err != nil {
return nil, err
}
items := make(map[string]Item, len(list))
for _, item := range list {
items[item.ID] = item
}
return items, nil
}
// pluralCategories returns the CLDR plural categories go-i18n uses for a
// locale, or nil if it has no plural spec.
//
// The set is locale specific: "en" yields {one, other}, "ru" yields
// {one, few, many, other}, and "ja" yields {other} alone. A pluralised
// translation has to define exactly the categories its own locale uses, not
// the ones en.json happens to define.
func pluralCategories(locale string) map[language.Plural]bool {
spec := language.GetPluralSpec(locale)
if spec == nil {
return nil
}
categories := map[language.Plural]bool{}
for c := range spec.Plurals {
categories[c] = true
}
return categories
}
// verifyLocale checks one non-English catalog, raw, against the en.json
// items in en, returning the defects found and, separately, the ids the catalog
// has yet to translate. Whether a missing id is a defect or merely a warning is
// the caller's choice, via warnMissingIDs.
func verifyLocale(name string, raw []byte, en map[string]Item, warnMissingIDs bool) (problems, warnings []string) {
locale := strings.TrimSuffix(name, ".json")
// The strongest check available: the loader the server actually runs at
// startup. It rejects a name that is not exactly one language, invalid
// JSON, plural categories the locale does not have, and any translation
// that fails to parse as a text/template.
//
// A fresh bundle per file, rather than the package-global one, so that a
// locale cannot mask a defect in another and so the checks are order
// independent and safe to run in parallel.
if err := bundle.New().ParseTranslationFileBytes(name, raw); err != nil {
return []string{fmt.Sprintf("%s: rejected by the runtime translation loader: %v", name, err)}, nil
}
items, err := loadItems(raw)
if err != nil {
return []string{fmt.Sprintf("%s: %v", name, err)}, nil
}
// Unreachable in practice, and kept as an invariant guard: the loader above
// derives the language with language.Parse, which only yields one when
// GetPluralSpec is non-nil for the same tag, so a file that loaded always
// has a spec here.
categories := pluralCategories(locale)
if categories == nil {
problems = append(problems, fmt.Sprintf("%s: no CLDR plural spec for locale %q", name, locale))
}
for id := range en {
if _, ok := items[id]; !ok {
msg := fmt.Sprintf("%s: %s: missing id", name, id)
if warnMissingIDs {
warnings = append(warnings, msg)
} else {
problems = append(problems, msg)
}
}
}
for id, item := range items {
source, ok := en[id]
if !ok {
problems = append(problems, fmt.Sprintf("%s: %s: extra id not in en.json", name, id))
continue
}
// An unknown token renders as "<no value>" to the user; a dropped
// one quietly loses the value it was meant to show.
sourceTokens := templateTokens(source.Translation)
itemTokens := templateTokens(item.Translation)
for token := range itemTokens {
if !sourceTokens[token] {
problems = append(problems, fmt.Sprintf("%s: %s: unknown template token {{.%s}} not present in source", name, id, token))
}
}
for token := range sourceTokens {
if !itemTokens[token] {
problems = append(problems, fmt.Sprintf("%s: %s: source template token {{.%s}} is missing from the translation", name, id, token))
}
}
_, sourceIsPlural := pluralForms(source.Translation)
plural, itemIsPlural := pluralForms(item.Translation)
// Collapsing a pluralised source to a single string still loads and
// still renders, it just silently stops pluralising -- the same defect
// check_icu.mjs rejects on the webapp side.
if sourceIsPlural && !itemIsPlural {
problems = append(problems, fmt.Sprintf("%s: %s: en.json pluralises this id but the translation is a single string, which silently stops pluralising", name, id))
continue
}
// The opposite direction is deliberately allowed, matching
// check_icu.mjs. Whether a count reaches go-i18n is a property of the
// call site, not of en.json's shape: the batched email title is a plain
// string in English and pluralised in six locales, and its call site in
// email_batching.go passes len(notifications)-1. A language that has to
// inflect where English does not is translating correctly. The residual
// risk is a call site that passes no count, where go-i18n resolves to
// language.Invalid, finds no template, and renders the raw id -- not
// something the catalogs can tell us, so it is not checked here.
if !itemIsPlural {
continue
}
// An empty form is worse than an absent translation. go-i18n builds no
// template for it, finds none at format time, and falls through to
// returning the translation id itself, so the user is shown something
// like "api.command_invite.user_already_in_channel.app_error". Deleting
// the entry instead lets the en fallback serve real English.
//
// Empty single-string translations render the same raw id, but they are
// not flagged here: several hundred already exist, and `i18n clean-empty`
// already removes exactly that shape. This is the one the existing tools
// cannot see, because countEmptyItems and removeEmptyTranslations both
// test the raw JSON for `""` and so skip every plural map.
for c, form := range plural {
if strings.TrimSpace(form) == "" {
problems = append(problems, fmt.Sprintf("%s: %s: plural category %q is empty, which renders the raw translation id; delete the entry to fall back to English", name, id, c))
}
}
if categories == nil {
continue
}
for c := range categories {
if _, ok := plural[string(c)]; !ok {
problems = append(problems, fmt.Sprintf("%s: %s: missing plural category %q required for this locale", name, id, c))
}
}
for c := range plural {
if !categories[language.Plural(c)] {
problems = append(problems, fmt.Sprintf("%s: %s: plural category %q is not used by this locale", name, id, c))
}
}
}
return problems, warnings
}
// verifyCmdF is the entry point for `mmgotool i18n verify`. It loads
// server-dir's en.json as the source of truth, runs verifyLocale over every
// other catalog beside it, and prints the pooled warnings and defects sorted,
// so the report is stable across runs and diffable between them.
//
// It returns an error when any catalog has a defect, which is what fails the
// job in CI.
func verifyCmdF(command *cobra.Command, args []string) error {
warnMissingIDs, err := command.Flags().GetBool("warn-missing-ids")
if err != nil {
return errors.New("invalid warn-missing-ids parameter")
}
mattermostDir, err := command.Flags().GetString("server-dir")
if err != nil {
return errors.New("invalid server-dir parameter")
}
translationDir := path.Join(mattermostDir, "i18n")
root, err := os.OpenRoot(translationDir)
if err != nil {
return fmt.Errorf("failed to open translation directory %q: %w", translationDir, err)
}
defer func() {
_ = root.Close()
}()
enRaw, err := root.ReadFile("en.json")
if err != nil {
return fmt.Errorf("failed to read the source catalog in %q: %w", translationDir, err)
}
// en.json goes through the same runtime loader as every locale beside it.
// The server loads it at startup too, so a template or a plural form the
// loader rejects there has to fail here rather than pass as the source of
// truth every other catalog is checked against.
if err := bundle.New().ParseTranslationFileBytes("en.json", enRaw); err != nil {
return fmt.Errorf("the source catalog in %q was rejected by the runtime translation loader: %w", translationDir, err)
}
en, err := loadItems(enRaw)
if err != nil {
return fmt.Errorf("failed to parse the source catalog in %q: %w", translationDir, err)
}
dirEntries, err := fs.ReadDir(root.FS(), ".")
if err != nil {
return fmt.Errorf("failed to list translation directory %q: %w", translationDir, err)
}
var problems, warnings []string
checked := 0
for _, dirEntry := range dirEntries {
name := dirEntry.Name()
if dirEntry.IsDir() || filepath.Ext(name) != ".json" || name == "en.json" {
continue
}
checked++
raw, err := root.ReadFile(name)
if err != nil {
problems = append(problems, fmt.Sprintf("%s: %v", name, err))
continue
}
localeProblems, localeWarnings := verifyLocale(name, raw, en, warnMissingIDs)
problems = append(problems, localeProblems...)
warnings = append(warnings, localeWarnings...)
}
// Map iteration order is random, so sort for a stable, diffable report.
sort.Strings(warnings)
sort.Strings(problems)
for _, w := range warnings {
fmt.Println(w)
}
if len(warnings) > 0 {
fmt.Printf("\n%d warning(s)\n", len(warnings))
}
for _, p := range problems {
fmt.Println(p)
}
if len(problems) > 0 {
return fmt.Errorf("%d error(s) across %d locale files", len(problems), checked)
}
fmt.Printf("OK: %d locale files checked against %s\n", checked, path.Join(translationDir, "en.json"))
return nil
}