diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3d5673c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate i18n messages + run: go run ./internal/i18n/cmd/check + + - name: Scan i18n key references + run: go run ./internal/i18n/cmd/check --scan-code + + - name: Run Go tests + run: go test ./... diff --git a/cmd/api/api.go b/cmd/api/api.go index a8f1f2d..a9df7b1 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -13,14 +13,19 @@ import ( "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) -func NewAPICmd() *cobra.Command { +func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } apiCmd := &cobra.Command{ Use: "api ", - Short: "Make raw API requests to GitLink", - Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, + Short: tr.T("cmd.api.short"), + Long: tr.T("cmd.api.long"), Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' @@ -29,11 +34,11 @@ func NewAPICmd() *cobra.Command { RunE: runAPI, } - apiCmd.Flags().String("body", "", "Request body (JSON string)") - apiCmd.Flags().String("body-file", "", "Read request body JSON from a file") - apiCmd.Flags().Bool("body-stdin", false, "Read request body JSON from stdin") - apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") - apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") + apiCmd.Flags().String("body", "", tr.T("flag.api.body")) + apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file")) + apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin")) + apiCmd.Flags().String("query", "", tr.T("flag.api.query")) + apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header")) return apiCmd } diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index e9af757..25653d7 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -2,141 +2,182 @@ package auth import ( "bufio" + "errors" "fmt" + "io" "os" "strings" - "syscall" "github.com/spf13/cobra" "golang.org/x/term" internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth" + "github.com/gitlink-org/gitlink-cli/internal/i18n" ) const envTokenVar = "GITLINK_TOKEN" -func NewAuthCmd() *cobra.Command { +var ( + storeToken = internalAuth.StoreToken + loadToken = internalAuth.LoadToken +) + +func NewAuthCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cmd := &cobra.Command{ Use: "auth", - Short: "Authentication commands", + Short: tr.T("cmd.auth.short"), } - cmd.AddCommand(newLoginCmd()) - cmd.AddCommand(newLogoutCmd()) - cmd.AddCommand(newStatusCmd()) + cmd.AddCommand(newLoginCmd(tr)) + cmd.AddCommand(newLogoutCmd(tr)) + cmd.AddCommand(newStatusCmd(tr)) return cmd } -func newLoginCmd() *cobra.Command { +func newLoginCmd(tr *i18n.Translator) *cobra.Command { var tokenMode bool cmd := &cobra.Command{ Use: "login", - Short: "Login to GitLink", + Short: tr.T("cmd.auth.login.short"), RunE: func(cmd *cobra.Command, args []string) error { if tokenMode { - return loginWithToken() + return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr) } - return loginWithPassword() + return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr) }, } - cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token") + cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token")) return cmd } -func loginWithPassword() error { - reader := bufio.NewReader(os.Stdin) - - fmt.Print("Username/Email/Phone: ") +func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error { + reader := bufio.NewReader(in) + if _, err := fmt.Fprint(out, tr.T("prompt.auth.username")); err != nil { + return err + } username, _ := reader.ReadString('\n') username = strings.TrimSpace(username) - fmt.Print("Password: ") - passwordBytes, err := term.ReadPassword(syscall.Stdin) + if _, err := fmt.Fprint(out, tr.T("prompt.auth.password")); err != nil { + return err + } + passwordBytes, err := readPassword(in, reader) if err != nil { return fmt.Errorf("failed to read password: %w", err) } - fmt.Println() + if _, err := fmt.Fprintln(out); err != nil { + return err + } password := string(passwordBytes) result, err := internalAuth.Login(username, password) if err != nil { - return fmt.Errorf("login failed: %w", err) + return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()})) } - fmt.Printf("✓ Logged in as %s\n", result.Login) - return nil + _, err = fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login})) + return err } -func loginWithToken() error { - reader := bufio.NewReader(os.Stdin) - fmt.Print("Paste your token: ") +func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) { + if file, ok := in.(*os.File); ok { + fd := int(file.Fd()) + if term.IsTerminal(fd) { + return term.ReadPassword(fd) + } + } + password, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return nil, err + } + return []byte(strings.TrimRight(password, "\r\n")), nil +} + +func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error { + reader := bufio.NewReader(in) + if _, err := fmt.Fprint(out, tr.T("prompt.auth.token")); err != nil { + return err + } token, _ := reader.ReadString('\n') token = strings.TrimSpace(token) if token == "" { - return fmt.Errorf("token cannot be empty") + return errors.New(tr.T("error.auth.token_empty")) } - if err := internalAuth.StoreToken(token); err != nil { - return fmt.Errorf("failed to store token: %w", err) + if err := storeToken(token); err != nil { + return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()})) } - fmt.Println("✓ Token saved") - return nil + _, err := fmt.Fprintln(out, tr.T("success.auth.token_saved")) + return err } -func newLogoutCmd() *cobra.Command { +func newLogoutCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "logout", - Short: "Logout from GitLink", + Short: tr.T("cmd.auth.logout.short"), RunE: func(cmd *cobra.Command, args []string) error { if err := internalAuth.DeleteToken(); err != nil { - return fmt.Errorf("failed to delete token: %w", err) + return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()})) } - fmt.Println("✓ Logged out") - return nil + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out")) + return err }, } } -func newStatusCmd() *cobra.Command { +func newStatusCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "status", - Short: "Show authentication status", + Short: tr.T("cmd.auth.status.short"), RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() // Check env var token first if envToken := os.Getenv(envTokenVar); envToken != "" { - fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar) + if _, err := fmt.Fprintln(out, tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})); err != nil { + return err + } } - token, err := internalAuth.LoadToken() + token, err := loadToken() if err != nil || token == "" { if os.Getenv(envTokenVar) == "" { - fmt.Println("✗ Not logged in") - fmt.Println(" Run: gitlink-cli auth login") - fmt.Printf(" Or set %s environment variable\n", envTokenVar) + if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil { + return err + } + if _, err := fmt.Fprintln(out, tr.T("output.auth.login_hint")); err != nil { + return err + } + if _, err := fmt.Fprintln(out, tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})); err != nil { + return err + } } return nil } user, err := internalAuth.GetCurrentUser() if err != nil { - fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err) - return nil + _, err := fmt.Fprintln(out, tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()})) + return err } login, _ := user["login"].(string) name, _ := user["name"].(string) if login != "" { - fmt.Printf("✓ Logged in as %s", login) + text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login}) if name != "" { - fmt.Printf(" (%s)", name) + text = fmt.Sprintf("%s (%s)", text, name) } - fmt.Println() - } else { - fmt.Println("✓ Token stored (user info unavailable)") + _, err := fmt.Fprintln(out, text) + return err } - return nil + _, err = fmt.Fprintln(out, tr.T("warning.auth.user_unavailable")) + return err }, } } diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 7e17142..04f114a 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -1,46 +1,68 @@ package cmd import ( + "bytes" + "strings" "testing" ) -func TestInit(t *testing.T) { - // init() runs automatically when package is imported. - // Verify rootCmd has the expected settings. - if rootCmd.Use != "gitlink-cli" { - t.Fatalf("Use = %q", rootCmd.Use) +func TestNewRootCmdDefaults(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test"}, nil) + if err != nil { + t.Fatal(err) } - if rootCmd.SilenceUsage != true { + if root.Use != "gitlink-cli" { + t.Fatalf("Use = %q", root.Use) + } + if !root.SilenceUsage { t.Fatal("expected SilenceUsage=true") } } -func TestExecute(t *testing.T) { - rootCmd.SetArgs([]string{"--help"}) - if err := Execute(); err != nil { - t.Fatalf("Execute error: %v", err) +func TestRootHelp(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, nil) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("help command error: %v", err) } } func TestVersionCmd(t *testing.T) { - rootCmd.SetArgs([]string{"version"}) - if err := rootCmd.Execute(); err != nil { + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"version"}}, nil) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { t.Fatalf("version command error: %v", err) } + if got := strings.TrimSpace(out.String()); got != "gitlink-cli test" { + t.Fatalf("version output = %q", got) + } } func TestRootCmdHasSubcommands(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test"}, nil) + if err != nil { + t.Fatal(err) + } names := map[string]bool{} - for _, sub := range rootCmd.Commands() { + for _, sub := range root.Commands() { names[sub.Use] = true } - // At minimum, these core commands should exist for _, want := range []string{"auth", "config", "version"} { if !names[want] { t.Fatalf("missing subcommand: %s", want) } } - if len(rootCmd.Commands()) < 4 { - t.Fatalf("expected at least 4 subcommands, got %d", len(rootCmd.Commands())) + if len(root.Commands()) < 4 { + t.Fatalf("expected at least 4 subcommands, got %d", len(root.Commands())) } } diff --git a/cmd/cmdutil/globals.go b/cmd/cmdutil/globals.go index 859992a..bea4c28 100644 --- a/cmd/cmdutil/globals.go +++ b/cmd/cmdutil/globals.go @@ -6,4 +6,5 @@ var ( Repo string Format string Debug bool + Lang string ) diff --git a/cmd/config/config.go b/cmd/config/config.go index d7dc6ad..dec6c9e 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -1,59 +1,80 @@ package config import ( + "errors" "fmt" "github.com/spf13/cobra" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/i18n" ) -func NewConfigCmd() *cobra.Command { +func NewConfigCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cmd := &cobra.Command{ Use: "config", - Short: "Manage gitlink-cli configuration", + Short: tr.T("cmd.config.short"), } - cmd.AddCommand(newInitCmd()) - cmd.AddCommand(newSetCmd()) - cmd.AddCommand(newGetCmd()) - cmd.AddCommand(newListCmd()) + cmd.AddCommand(newInitCmd(tr)) + cmd.AddCommand(newSetCmd(tr)) + cmd.AddCommand(newGetCmd(tr)) + cmd.AddCommand(newListCmd(tr)) return cmd } -func newInitCmd() *cobra.Command { +func newInitCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return &cobra.Command{ Use: "init", - Short: "Initialize configuration file", + Short: tr.T("cmd.config.init.short"), RunE: func(cmd *cobra.Command, args []string) error { cfg := internalConfig.DefaultConfig() if err := internalConfig.Save(cfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) + return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()})) } - fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath()) - return nil + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()})) + return err }, } } -func newSetCmd() *cobra.Command { +func newSetCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return &cobra.Command{ Use: "set ", - Short: "Set a configuration value", + Short: tr.T("cmd.config.set.short"), Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { if err := internalConfig.Set(args[0], args[1]); err != nil { return err } - fmt.Printf("✓ %s = %s\n", args[0], args[1]) - return nil + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{ + "key": args[0], + "value": args[1], + })) + return err }, } } -func newGetCmd() *cobra.Command { +func newGetCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return &cobra.Command{ Use: "get ", - Short: "Get a configuration value", + Short: tr.T("cmd.config.get.short"), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { val, err := internalConfig.Get(args[0]) @@ -61,30 +82,49 @@ func newGetCmd() *cobra.Command { return err } if val == "" { - fmt.Printf("%s: (not set)\n", args[0]) - } else { - fmt.Printf("%s: %s\n", args[0], val) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set")) + return err } - return nil + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val) + return err }, } } -func newListCmd() *cobra.Command { +func newListCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return &cobra.Command{ Use: "list", - Short: "List all configuration values", + Short: tr.T("cmd.config.list.short"), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := internalConfig.Load() if err != nil { return err } - fmt.Printf("base_url: %s\n", cfg.BaseURL) - fmt.Printf("default_format: %s\n", cfg.Format) - fmt.Printf("editor: %s\n", cfg.Editor) - fmt.Printf("pager: %s\n", cfg.Pager) - fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath()) - return nil + out := cmd.OutOrStdout() + if _, err := fmt.Fprintf(out, "base_url: %s\n", cfg.BaseURL); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "default_format: %s\n", cfg.Format); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "editor: %s\n", cfg.Editor); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "pager: %s\n", cfg.Pager); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "lang: %s\n", cfg.Lang); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + _, err = fmt.Fprintln(out, tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()})) + return err }, } } diff --git a/cmd/root.go b/cmd/root.go index 441a8ed..26278d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -10,45 +11,117 @@ import ( authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" + internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts" ) var Version = "dev" -var rootCmd = &cobra.Command{ - Use: "gitlink-cli", - Short: "GitLink CLI — command-line tool for gitlink.org.cn", - Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`, - SilenceUsage: true, - SilenceErrors: true, +type RootOptions struct { + Version string + Args []string + Env map[string]string + ConfigLang string } -func init() { - rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)") - rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)") - rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)") - rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output") +func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { + if tr == nil { + var err error + tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang) + if err != nil { + return nil, err + } + } - rootCmd.AddCommand(authCmd.NewAuthCmd()) - rootCmd.AddCommand(apiCmd.NewAPICmd()) - rootCmd.AddCommand(configCmd.NewConfigCmd()) - rootCmd.AddCommand(versionCmd) + version := opts.Version + if version == "" { + version = Version + } - shortcuts.RegisterAll(rootCmd) + rootCmd := &cobra.Command{ + Use: "gitlink-cli", + Short: tr.T("cmd.root.short"), + Long: tr.T("cmd.root.long"), + SilenceUsage: true, + SilenceErrors: true, + } + + rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format")) + rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang")) + + rootCmd.AddCommand(authCmd.NewAuthCmd(tr)) + rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) + rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) + rootCmd.AddCommand(newVersionCmd(version, tr)) + + shortcuts.RegisterAll(rootCmd, tr) + + if opts.Args != nil { + rootCmd.SetArgs(opts.Args) + } + return rootCmd, nil } -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print version information", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("gitlink-cli %s\n", Version) - }, +func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: tr.T("cmd.version.short"), + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version})) + return err + }, + } } func Execute() error { + args := os.Args[1:] + rootCmd, err := NewRootCmd(RootOptions{ + Version: Version, + Args: args, + }, nil) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) return err } return nil } + +func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) { + available, err := i18n.AvailableLocales() + if err != nil { + return nil, err + } + if env == nil { + env = i18n.EnvMap() + } + if configLang == "" { + configLang = loadConfigLangBestEffort() + } + resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{ + ExplicitLang: i18n.PreScanLang(args), + Env: env, + ConfigLang: configLang, + }, available) + if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") { + tr := i18n.Default() + return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested})) + } + return i18n.New(i18n.Options{Locale: resolved.Locale}) +} + +func loadConfigLangBestEffort() string { + cfg, err := internalConfig.Load() + if err != nil { + return "" + } + return cfg.Lang +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..35f810b --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func TestRootHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + if !strings.Contains(help, "用于管理 GitLink 上的仓库") { + t.Fatalf("expected Chinese root long help, got:\n%s", help) + } + if !strings.Contains(help, "仓库操作") { + t.Fatalf("expected Chinese shortcut group help, got:\n%s", help) + } + if !strings.Contains(help, "认证命令") || !strings.Contains(help, "管理 gitlink-cli 配置") { + t.Fatalf("expected Chinese core command help, got:\n%s", help) + } + if !strings.Contains(help, "--lang") || !strings.Contains(help, "显示语言") { + t.Fatalf("expected localized lang flag help, got:\n%s", help) + } +} + +func TestRootHelpUsesExplicitLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--lang", "zh-CN", "--help"}, Env: map[string]string{}}, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + for _, want := range []string{"用于管理 GitLink", "显示语言", "仓库"} { + if !strings.Contains(help, want) { + t.Fatalf("expected %q in help, got:\n%s", want, help) + } + } +} + +func TestRootHelpUsesEnvLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"repo", "--help"}, + Env: map[string]string{"GITLINK_LANG": "zh-CN"}, + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + for _, want := range []string{"仓库操作", "仓库所有者", "仓库名称"} { + if !strings.Contains(help, want) { + t.Fatalf("expected %q in help, got:\n%s", want, help) + } + } +} + +func TestExplicitLangOverridesConfigLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "en-US", "--help"}, + Env: map[string]string{}, + ConfigLang: "zh-CN", + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + if !strings.Contains(help, "Repository operations") { + t.Fatalf("expected English help, got:\n%s", help) + } + if strings.Contains(help, "仓库操作") { + t.Fatalf("expected explicit en-US to override config zh-CN, got:\n%s", help) + } +} + +func TestUnsupportedExplicitLangReturnsError(t *testing.T) { + _, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "fr-FR", "--help"}, + Env: map[string]string{}, + }, nil) + if err == nil { + t.Fatal("expected unsupported language error") + } + if !strings.Contains(err.Error(), "unsupported language") { + t.Fatalf("expected unsupported language error, got %q", err.Error()) + } +} + +func TestRequireArgUsesLocalizedError(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "zh-CN", "repo", "+create"}, + Env: map[string]string{}, + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + err = root.Execute() + if err == nil { + t.Fatal("expected missing required flag error") + } + if !strings.Contains(err.Error(), "缺少必需参数") { + t.Fatalf("expected localized missing flag error, got %q", err.Error()) + } +} + +func TestCoreCommandHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"api", "--help"}, + want: []string{"向 GitLink API 发送任意 HTTP 请求", "--body", "请求体(JSON 字符串)"}, + }, + { + args: []string{"auth", "login", "--help"}, + want: []string{"登录 GitLink", "--token", "通过粘贴已有 Token 登录"}, + }, + { + args: []string{"config", "--help"}, + want: []string{"管理 gitlink-cli 配置", "初始化配置文件", "列出所有配置项"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestShortcutHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"repo", "+create", "--help"}, + want: []string{"创建新仓库", "--name", "仓库名称", "--private", "设为私有仓库"}, + }, + { + args: []string{"pr", "+review", "--help"}, + want: []string{"创建拉取请求评审", "--content", "评审内容", "--dry-run"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestRemainingShortcutHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"branch", "+create", "--help"}, + want: []string{"创建分支", "--from", "源分支或 Commit"}, + }, + { + args: []string{"release", "+create", "--help"}, + want: []string{"创建发布", "--prerelease", "标记为预发布"}, + }, + { + args: []string{"webhook", "+create", "--help"}, + want: []string{"创建仓库 Webhook", "--events", "逗号分隔的事件"}, + }, + { + args: []string{"ci", "+logs", "--help"}, + want: []string{"查看构建日志", "--build", "构建编号"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestVersionUsesInjectedVersion(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "en-US"}) + if err != nil { + t.Fatal(err) + } + root, err := NewRootCmd(RootOptions{Version: "1.2.3", Args: []string{"version"}}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + if got := strings.TrimSpace(out.String()); got != "gitlink-cli 1.2.3" { + t.Fatalf("version output = %q", got) + } +} diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..23efe57 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,77 @@ +# GitLink CLI i18n Guide + +## Goals + +GitLink CLI localizes human-facing command-line text while keeping machine-readable output stable. The i18n layer is infrastructure, not a place to store every string in the project. + +## Translate + +- Cobra command `Short`, `Long`, and human-facing examples. +- Flag usage text. +- User-facing errors. +- Interactive prompts. +- Success messages. +- Warnings. +- Confirmation messages. +- Table column labels when the output is meant for humans. + +## Do Not Translate + +- JSON field names. +- Raw API response bodies. +- Debug logs and developer diagnostics. +- Machine-readable status enum values. +- HTTP methods, paths, query keys, and payload field names. +- Long-form README documentation. +- Test assertion descriptions. + +## Key Names + +Use stable, descriptive keys: + +- `cmd.*` for command help. +- `flag.*` for flag usage. +- `error.*` for user-facing errors. +- `prompt.*` for interactive input prompts. +- `success.*` for successful user-facing operations. +- `warning.*` for warnings. +- `confirm.*` for confirmation prompts. +- `table.*` for human table headers. + +Do not invent numbered keys such as `msg001`. Prefer names that describe ownership and intent, for example `error.missing_required_flag`. + +## Adding Text + +1. Add the key to `internal/i18n/locales/en-US.json`. +2. Add the same key to every other locale, including `zh-CN.json`. +3. Keep placeholders identical across locales, for example `{name}`. +4. Use `tr.T("key")` or `tr.Tf("key", i18n.Args{...})`. +5. Run: + + ```powershell + go run ./internal/i18n/cmd/check + go test ./... + ``` + +Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON. + +Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight: + +- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`. +- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code. +- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them. +- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages. + +## Runtime Access + +Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`. + +New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths. + +## Review Checklist + +- Locale JSON is sorted and formatted with two spaces. +- Every locale has the same keys as `en-US`. +- Template placeholders match across locales. +- New command/runtime text uses i18n only when it is human-facing. +- JSON output, API raw responses, debug logs, and machine-readable values are unchanged. diff --git a/internal/config/config.go b/internal/config/config.go index e8ec429..0b3f269 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Format string `yaml:"default_format"` Editor string `yaml:"editor,omitempty"` Pager string `yaml:"pager,omitempty"` + Lang string `yaml:"lang,omitempty"` } func DefaultConfig() *Config { @@ -85,6 +86,8 @@ func Get(key string) (string, error) { return cfg.Editor, nil case "pager": return cfg.Pager, nil + case "lang": + return cfg.Lang, nil default: return "", nil } @@ -104,6 +107,8 @@ func Set(key, value string) error { cfg.Editor = value case "pager": cfg.Pager = value + case "lang": + cfg.Lang = value } return Save(cfg) } diff --git a/internal/i18n/args.go b/internal/i18n/args.go new file mode 100644 index 0000000..0eb08fb --- /dev/null +++ b/internal/i18n/args.go @@ -0,0 +1,4 @@ +package i18n + +// Args contains named values used by parameterized messages. +type Args map[string]any diff --git a/internal/i18n/cmd/check/main.go b/internal/i18n/cmd/check/main.go new file mode 100644 index 0000000..f5de219 --- /dev/null +++ b/internal/i18n/cmd/check/main.go @@ -0,0 +1,157 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func main() { + fix := flag.Bool("fix", false, "format locale JSON files") + scanCode := flag.Bool("scan-code", false, "scan Go source for referenced i18n keys") + flag.Parse() + + problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(problems) > 0 { + for _, problem := range problems { + fmt.Fprintln(os.Stderr, problem.String()) + } + os.Exit(1) + } + if err := checkLocaleFormat(*fix); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if *scanCode { + if err := checkCodeReferences(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + fmt.Println("i18n messages are valid") +} + +func checkLocaleFormat(fix bool) error { + files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json")) + if err != nil { + return err + } + for _, path := range files { + data, err := os.ReadFile(path) + if err != nil { + return err + } + formatted, err := formatJSON(data) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if string(data) == string(formatted) { + continue + } + if fix { + if err := os.WriteFile(path, formatted, 0644); err != nil { + return err + } + continue + } + return fmt.Errorf("%s: locale JSON is not formatted; run go run ./internal/i18n/cmd/check --fix", path) + } + return nil +} + +func formatJSON(data []byte) ([]byte, error) { + var messages map[string]string + if err := json.Unmarshal(data, &messages); err != nil { + return nil, err + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(messages); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func checkCodeReferences() error { + loader := i18n.NewEmbedLoader() + base, err := loader.Load("en-US") + if err != nil { + return err + } + used, defaultUses, err := scanCodeKeys([]string{"cmd", "shortcuts", "internal"}) + if err != nil { + return err + } + var missing []string + for key := range used { + if _, ok := base[key]; !ok { + missing = append(missing, key) + } + } + sort.Strings(missing) + if len(missing) > 0 { + return fmt.Errorf("missing i18n key references: %s", strings.Join(missing, ", ")) + } + for _, item := range defaultUses { + fmt.Fprintf(os.Stderr, "warning: avoid new i18n.Default() usage at %s\n", item) + } + return nil +} + +func scanCodeKeys(roots []string) (map[string]struct{}, []string, error) { + keyPattern := regexp.MustCompile(`(?:tr|ctx\.Tr|i18n\.Default\(\))\.T(?:f)?\("([^"]+)"`) + defaultPattern := regexp.MustCompile(`i18n\.Default\(\)\.T(?:f)?\("([^"]+)"`) + used := map[string]struct{}{} + var defaultUses []string + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if strings.Contains(filepath.ToSlash(path), "internal/i18n/locales") { + return filepath.SkipDir + } + return nil + } + if filepath.Ext(path) != ".go" { + return nil + } + if strings.HasSuffix(path, "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + text := string(data) + for _, match := range keyPattern.FindAllStringSubmatch(text, -1) { + used[match[1]] = struct{}{} + } + for _, match := range defaultPattern.FindAllStringSubmatchIndex(text, -1) { + line := 1 + strings.Count(text[:match[0]], "\n") + defaultUses = append(defaultUses, fmt.Sprintf("%s:%d", filepath.ToSlash(path), line)) + } + return nil + }) + if err != nil { + return nil, nil, err + } + } + sort.Strings(defaultUses) + return used, defaultUses, nil +} diff --git a/internal/i18n/doc.go b/internal/i18n/doc.go new file mode 100644 index 0000000..1b19950 --- /dev/null +++ b/internal/i18n/doc.go @@ -0,0 +1,5 @@ +// Package i18n provides localized user-facing messages for the CLI. +// +// It intentionally does not localize machine-readable output such as JSON +// field names, API response bodies, or debug diagnostics. +package i18n diff --git a/internal/i18n/loader.go b/internal/i18n/loader.go new file mode 100644 index 0000000..ff29b1e --- /dev/null +++ b/internal/i18n/loader.go @@ -0,0 +1,66 @@ +package i18n + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" +) + +//go:embed locales/*.json +var embeddedLocales embed.FS + +// Loader loads locale messages from a backing store. +type Loader interface { + Load(locale string) (map[string]string, error) + AvailableLocales() ([]string, error) +} + +type embedLoader struct { + fs fs.FS +} + +// NewEmbedLoader returns the default loader backed by embedded locale files. +func NewEmbedLoader() Loader { + return embedLoader{fs: embeddedLocales} +} + +func (l embedLoader) Load(locale string) (map[string]string, error) { + locale = NormalizeLocale(locale) + path := filepath.ToSlash(filepath.Join("locales", locale+".json")) + data, err := fs.ReadFile(l.fs, path) + if err != nil { + return nil, fmt.Errorf("load locale %s: %w", locale, err) + } + + var messages map[string]string + if err := json.Unmarshal(data, &messages); err != nil { + return nil, fmt.Errorf("parse locale %s: %w", locale, err) + } + return messages, nil +} + +func (l embedLoader) AvailableLocales() ([]string, error) { + entries, err := fs.ReadDir(l.fs, "locales") + if err != nil { + return nil, err + } + + locales := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + locales = append(locales, strings.TrimSuffix(entry.Name(), ".json")) + } + sort.Strings(locales) + return locales, nil +} + +// AvailableLocales returns locales available from the embedded loader. +func AvailableLocales() ([]string, error) { + return NewEmbedLoader().AvailableLocales() +} diff --git a/internal/i18n/locale.go b/internal/i18n/locale.go new file mode 100644 index 0000000..0ec7131 --- /dev/null +++ b/internal/i18n/locale.go @@ -0,0 +1,100 @@ +package i18n + +import ( + "strings" +) + +// NormalizeLocale converts common locale spellings to a stable BCP-47-like form. +func NormalizeLocale(locale string) string { + locale = strings.TrimSpace(locale) + if locale == "" { + return "" + } + if idx := strings.IndexByte(locale, '.'); idx >= 0 { + locale = locale[:idx] + } + locale = strings.ReplaceAll(locale, "_", "-") + + parts := strings.Split(locale, "-") + normalized := make([]string, 0, len(parts)) + for i, part := range parts { + if part == "" { + continue + } + switch { + case i == 0: + normalized = append(normalized, strings.ToLower(part)) + case len(part) == 2: + normalized = append(normalized, strings.ToUpper(part)) + case len(part) == 4: + normalized = append(normalized, strings.ToUpper(part[:1])+strings.ToLower(part[1:])) + default: + normalized = append(normalized, part) + } + } + return strings.Join(normalized, "-") +} + +// MatchLocale resolves requested to one of available using exact, safe alias, +// then fallback matching. +func MatchLocale(requested string, available []string, fallback string) string { + return matchLocale(requested, available, fallback).Locale +} + +type localeMatch struct { + Locale string + Requested string + Fallbacked bool + Supported bool +} + +func matchLocale(requested string, available []string, fallback string) localeMatch { + fallback = NormalizeLocale(fallback) + if fallback == "" { + fallback = defaultFallbackLocale + } + if len(available) == 0 { + return localeMatch{Locale: fallback, Requested: NormalizeLocale(requested), Fallbacked: true} + } + + byLocale := make(map[string]string, len(available)) + for _, locale := range available { + normalized := NormalizeLocale(locale) + byLocale[normalized] = normalized + } + + candidate := NormalizeLocale(requested) + if candidate != "" { + if matched, ok := byLocale[candidate]; ok { + return localeMatch{Locale: matched, Requested: candidate, Supported: true} + } + if alias := localeAlias(candidate); alias != "" { + if matched, ok := byLocale[alias]; ok { + return localeMatch{Locale: matched, Requested: candidate, Supported: true} + } + } + } + + if matched, ok := byLocale[fallback]; ok { + return localeMatch{Locale: matched, Requested: candidate, Fallbacked: candidate != "", Supported: false} + } + return localeMatch{Locale: NormalizeLocale(available[0]), Requested: candidate, Fallbacked: candidate != "", Supported: false} +} + +func primaryLanguage(locale string) string { + if idx := strings.IndexByte(locale, '-'); idx >= 0 { + return locale[:idx] + } + return locale +} + +func localeAlias(locale string) string { + switch { + case locale == "zh" || locale == "zh-CN" || locale == "zh-Hans" || locale == "zh-Hans-CN": + return "zh-CN" + case primaryLanguage(locale) == "en": + return "en-US" + default: + return "" + } +} diff --git a/internal/i18n/locale_test.go b/internal/i18n/locale_test.go new file mode 100644 index 0000000..90dd4a6 --- /dev/null +++ b/internal/i18n/locale_test.go @@ -0,0 +1,35 @@ +package i18n + +import "testing" + +func TestNormalizeLocale(t *testing.T) { + cases := map[string]string{ + "zh_CN": "zh-CN", + "zh_CN.UTF-8": "zh-CN", + "zh-Hans-CN": "zh-Hans-CN", + "EN_us": "en-US", + " en-US ": "en-US", + "zh-hans-cn.utf": "zh-Hans-CN", + } + for input, want := range cases { + if got := NormalizeLocale(input); got != want { + t.Fatalf("NormalizeLocale(%q) = %q, want %q", input, got, want) + } + } +} + +func TestMatchLocale(t *testing.T) { + available := []string{"en-US", "zh-CN"} + cases := map[string]string{ + "zh_CN": "zh-CN", + "zh-Hans-CN": "zh-CN", + "zh": "zh-CN", + "en": "en-US", + "fr-FR": "en-US", + } + for input, want := range cases { + if got := MatchLocale(input, available, "en-US"); got != want { + t.Fatalf("MatchLocale(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json new file mode 100644 index 0000000..d9f3c34 --- /dev/null +++ b/internal/i18n/locales/en-US.json @@ -0,0 +1,188 @@ +{ + "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", + "cmd.api.short": "Make raw API requests to GitLink", + "cmd.auth.login.short": "Login to GitLink", + "cmd.auth.logout.short": "Logout from GitLink", + "cmd.auth.short": "Authentication commands", + "cmd.auth.status.short": "Show authentication status", + "cmd.branch.create.short": "Create a branch", + "cmd.branch.delete.short": "Delete a branch", + "cmd.branch.list.short": "List branches", + "cmd.branch.protect.short": "Set branch protection", + "cmd.branch.short": "Branch operations", + "cmd.branch.unprotect.short": "Remove branch protection", + "cmd.ci.builds.short": "List CI builds", + "cmd.ci.logs.short": "View build logs", + "cmd.ci.restart.short": "Restart a build", + "cmd.ci.short": "CI/CD operations", + "cmd.ci.stop.short": "Stop a build", + "cmd.config.get.short": "Get a configuration value", + "cmd.config.init.short": "Initialize configuration file", + "cmd.config.list.short": "List all configuration values", + "cmd.config.set.short": "Set a configuration value", + "cmd.config.short": "Manage gitlink-cli configuration", + "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", + "cmd.issue.close.short": "Close an issue", + "cmd.issue.comment.short": "Add a comment to an issue", + "cmd.issue.create.short": "Create a new issue", + "cmd.issue.list.short": "List issues", + "cmd.issue.short": "Issue operations", + "cmd.issue.update.short": "Update an issue", + "cmd.issue.view.short": "View issue details", + "cmd.org.create.short": "Create an organization", + "cmd.org.info.short": "Show organization details", + "cmd.org.list.short": "List organizations", + "cmd.org.members.short": "List organization members", + "cmd.org.short": "Organization operations", + "cmd.pr.close.short": "Close a pull request", + "cmd.pr.comment.short": "Add a comment to a pull request", + "cmd.pr.create.short": "Create a pull request", + "cmd.pr.diff.short": "Show diff for a pull request", + "cmd.pr.files.short": "List changed files in a pull request", + "cmd.pr.list.short": "List pull requests", + "cmd.pr.merge.short": "Merge a pull request", + "cmd.pr.review.short": "Create a pull request review", + "cmd.pr.reviews.short": "List pull request reviews", + "cmd.pr.short": "Pull request operations", + "cmd.pr.version_diff.short": "Show diff for a pull request patchset version", + "cmd.pr.versions.short": "List pull request patchset versions", + "cmd.pr.view.short": "View pull request details", + "cmd.release.create.short": "Create a release", + "cmd.release.delete.short": "Delete a release", + "cmd.release.list.short": "List releases", + "cmd.release.short": "Release operations", + "cmd.release.view.short": "View release details", + "cmd.repo.create.short": "Create a new repository", + "cmd.repo.delete.short": "Delete a repository", + "cmd.repo.fork.short": "Fork a repository", + "cmd.repo.info.short": "Show repository details", + "cmd.repo.list.short": "List repositories for a user or organization", + "cmd.repo.short": "Repository operations", + "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", + "cmd.root.short": "GitLink CLI - command-line tool for GitLink", + "cmd.search.repos.short": "Search repositories", + "cmd.search.short": "Search operations", + "cmd.search.users.short": "Search users", + "cmd.user.info.short": "Show user profile", + "cmd.user.me.short": "Show current authenticated user", + "cmd.user.short": "User operations", + "cmd.version.short": "Print version information", + "cmd.webhook.create.short": "Create a repository webhook", + "cmd.webhook.delete.short": "Delete a repository webhook", + "cmd.webhook.list.short": "List repository webhooks", + "cmd.webhook.short": "Webhook operations", + "cmd.webhook.tasks.short": "List webhook delivery tasks", + "cmd.webhook.test.short": "Trigger a test delivery for a webhook", + "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", + "cmd.webhook.view.short": "View webhook details", + "error.auth.delete_token_failed": "failed to delete token: {message}", + "error.auth.login_failed": "login failed: {message}", + "error.auth.store_token_failed": "failed to store token: {message}", + "error.auth.token_empty": "token cannot be empty", + "error.config.save_failed": "failed to save config: {message}", + "error.missing_required_flag": "required flag --{name} is missing", + "error.unsupported_language": "unsupported language: {lang}", + "flag.api.body": "Request body (JSON string)", + "flag.api.body_file": "Read request body JSON from a file", + "flag.api.body_stdin": "Read request body JSON from stdin", + "flag.api.header": "Additional headers (key:value)", + "flag.api.query": "Query parameters (key=val&key2=val2)", + "flag.auth.token": "Login by pasting an existing token", + "flag.branch.from": "Source branch or commit", + "flag.branch.name": "Branch name", + "flag.ci.build": "Build number", + "flag.ci.stage": "Stage number", + "flag.ci.step": "Step number", + "flag.comment.body": "Comment body", + "flag.debug": "Enable debug output", + "flag.description": "Description", + "flag.dry_run": "Preview the request without creating it", + "flag.format": "Output format: json, table, yaml (default: table)", + "flag.issue.add_label": "Label to add to each matching issue", + "flag.issue.assignee": "Assignee login", + "flag.issue.batch.reason": "Optional reason shown in the batch result", + "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", + "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", + "flag.issue.batch_close.state": "Filter by issue state before closing", + "flag.issue.batch_label.state": "Filter by issue state", + "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", + "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", + "flag.issue.body": "Issue description", + "flag.issue.label": "Label ID", + "flag.issue.label_filter": "Filter by existing label", + "flag.issue.milestone": "Milestone ID", + "flag.issue.new_body": "New description", + "flag.issue.new_state": "New state: open, closed, or numeric status_id", + "flag.issue.new_title": "New title", + "flag.issue.number": "Issue number (as shown in the web URL)", + "flag.issue.older_than_days": "Only include issues inactive for at least this many days", + "flag.issue.state": "Filter by state: open, closed, all", + "flag.issue.title": "Issue title", + "flag.lang": "Display language", + "flag.limit": "Items per page", + "flag.org.id": "Organization ID", + "flag.org.id_or_login": "Organization ID or login", + "flag.org.name": "Organization name", + "flag.owner": "Repository owner (auto-detected from git remote)", + "flag.page": "Page number", + "flag.pr.base": "Target branch", + "flag.pr.body": "PR description", + "flag.pr.file": "Filter diff by file path", + "flag.pr.head": "Source branch", + "flag.pr.id": "PR number", + "flag.pr.merge_method": "Merge method: merge, rebase, squash", + "flag.pr.review_commit": "Commit SHA to attach the review to", + "flag.pr.review_content": "Review content", + "flag.pr.review_status": "Review status: common, approved, rejected", + "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", + "flag.pr.state": "Filter: open, merged, closed", + "flag.pr.title": "PR title", + "flag.pr.version_id": "Patchset version ID", + "flag.release.body": "Release notes", + "flag.release.id": "Release ID", + "flag.release.id_or_tag": "Release ID or tag", + "flag.release.name": "Release name", + "flag.release.prerelease": "Mark as prerelease (true/false)", + "flag.release.tag": "Tag name", + "flag.release.target": "Target branch", + "flag.repo": "Repository name (auto-detected from git remote)", + "flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)", + "flag.repo.description": "Repository description", + "flag.repo.name": "Repository name", + "flag.repo.private": "Make repository private (true/false)", + "flag.search.keyword": "Search keyword", + "flag.user": "User login (default: current user)", + "flag.user.login": "User login name", + "flag.webhook.active": "Whether the webhook is active: true or false", + "flag.webhook.branch_filter": "Branch glob filter for push/create/delete events", + "flag.webhook.content_type": "Payload content type: json or form", + "flag.webhook.events": "Comma-separated events, for example: push,issues_only", + "flag.webhook.http_method": "HTTP method: POST or GET", + "flag.webhook.id": "Webhook ID", + "flag.webhook.secret": "Webhook secret", + "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", + "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.url": "Webhook target URL", + "output.auth.env_hint": " Or set {env} environment variable", + "output.auth.login_hint": " Run: gitlink-cli auth login", + "output.config.file": "Config file: {path}", + "output.config.not_set": "(not set)", + "output.version": "gitlink-cli {version}", + "prompt.auth.password": "Password: ", + "prompt.auth.token": "Paste your access token: ", + "prompt.auth.username": "Username/Email/Phone: ", + "success.auth.logged_in_as": "✓ Logged in as {login}", + "success.auth.logged_in_via_env": "✓ Logged in via {env} environment variable", + "success.auth.logged_out": "✓ Logged out", + "success.auth.token_saved": "✓ Token saved", + "success.config.initialized": "✓ Config initialized at {path}", + "success.config.set": "✓ {key} = {value}", + "warning.auth.not_logged_in": "✗ Not logged in", + "warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})", + "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)" +} diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json new file mode 100644 index 0000000..848e605 --- /dev/null +++ b/internal/i18n/locales/zh-CN.json @@ -0,0 +1,188 @@ +{ + "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", + "cmd.api.short": "向 GitLink 发起原始 API 请求", + "cmd.auth.login.short": "登录 GitLink", + "cmd.auth.logout.short": "退出 GitLink 登录", + "cmd.auth.short": "认证命令", + "cmd.auth.status.short": "显示认证状态", + "cmd.branch.create.short": "创建分支", + "cmd.branch.delete.short": "删除分支", + "cmd.branch.list.short": "列出分支", + "cmd.branch.protect.short": "设置分支保护", + "cmd.branch.short": "分支操作", + "cmd.branch.unprotect.short": "移除分支保护", + "cmd.ci.builds.short": "列出 CI 构建", + "cmd.ci.logs.short": "查看构建日志", + "cmd.ci.restart.short": "重启构建", + "cmd.ci.short": "CI/CD 操作", + "cmd.ci.stop.short": "停止构建", + "cmd.config.get.short": "获取配置项", + "cmd.config.init.short": "初始化配置文件", + "cmd.config.list.short": "列出所有配置项", + "cmd.config.set.short": "设置配置项", + "cmd.config.short": "管理 gitlink-cli 配置", + "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", + "cmd.issue.close.short": "关闭议题", + "cmd.issue.comment.short": "给议题添加评论", + "cmd.issue.create.short": "创建新议题", + "cmd.issue.list.short": "列出议题", + "cmd.issue.short": "议题操作", + "cmd.issue.update.short": "更新议题", + "cmd.issue.view.short": "查看议题详情", + "cmd.org.create.short": "创建组织", + "cmd.org.info.short": "显示组织详情", + "cmd.org.list.short": "列出组织", + "cmd.org.members.short": "列出组织成员", + "cmd.org.short": "组织操作", + "cmd.pr.close.short": "关闭拉取请求", + "cmd.pr.comment.short": "给拉取请求添加评论", + "cmd.pr.create.short": "创建拉取请求", + "cmd.pr.diff.short": "显示拉取请求 diff", + "cmd.pr.files.short": "列出拉取请求中的变更文件", + "cmd.pr.list.short": "列出拉取请求", + "cmd.pr.merge.short": "合并拉取请求", + "cmd.pr.review.short": "创建拉取请求评审", + "cmd.pr.reviews.short": "列出拉取请求评审", + "cmd.pr.short": "拉取请求操作", + "cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff", + "cmd.pr.versions.short": "列出拉取请求补丁集版本", + "cmd.pr.view.short": "查看拉取请求详情", + "cmd.release.create.short": "创建发布", + "cmd.release.delete.short": "删除发布", + "cmd.release.list.short": "列出发布", + "cmd.release.short": "发布操作", + "cmd.release.view.short": "查看发布详情", + "cmd.repo.create.short": "创建新仓库", + "cmd.repo.delete.short": "删除仓库", + "cmd.repo.fork.short": "Fork 仓库", + "cmd.repo.info.short": "显示仓库详情", + "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.short": "仓库操作", + "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", + "cmd.root.short": "GitLink CLI - GitLink 命令行工具", + "cmd.search.repos.short": "搜索仓库", + "cmd.search.short": "搜索操作", + "cmd.search.users.short": "搜索用户", + "cmd.user.info.short": "显示用户资料", + "cmd.user.me.short": "显示当前认证用户", + "cmd.user.short": "用户操作", + "cmd.version.short": "打印版本信息", + "cmd.webhook.create.short": "创建仓库 Webhook", + "cmd.webhook.delete.short": "删除仓库 Webhook", + "cmd.webhook.list.short": "列出仓库 Webhook", + "cmd.webhook.short": "Webhook 操作", + "cmd.webhook.tasks.short": "列出 Webhook 投递任务", + "cmd.webhook.test.short": "触发 Webhook 测试投递", + "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", + "cmd.webhook.view.short": "查看 Webhook 详情", + "error.auth.delete_token_failed": "删除 Token 失败:{message}", + "error.auth.login_failed": "登录失败:{message}", + "error.auth.store_token_failed": "保存 Token 失败:{message}", + "error.auth.token_empty": "Token 不能为空", + "error.config.save_failed": "保存配置失败:{message}", + "error.missing_required_flag": "缺少必需参数 --{name}", + "error.unsupported_language": "不支持的语言:{lang}", + "flag.api.body": "请求体(JSON 字符串)", + "flag.api.body_file": "从文件读取 JSON 请求体", + "flag.api.body_stdin": "从标准输入读取 JSON 请求体", + "flag.api.header": "附加请求头(key:value)", + "flag.api.query": "查询参数(key=val&key2=val2)", + "flag.auth.token": "通过粘贴已有 Token 登录", + "flag.branch.from": "源分支或 Commit", + "flag.branch.name": "分支名称", + "flag.ci.build": "构建编号", + "flag.ci.stage": "阶段编号", + "flag.ci.step": "步骤编号", + "flag.comment.body": "评论内容", + "flag.debug": "启用调试输出", + "flag.description": "描述", + "flag.dry_run": "预览请求,不实际创建", + "flag.format": "输出格式:json、table、yaml(默认:table)", + "flag.issue.add_label": "要添加到每个匹配议题的标签", + "flag.issue.assignee": "负责人登录名", + "flag.issue.batch.reason": "批量结果中显示的可选原因", + "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", + "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", + "flag.issue.batch_close.state": "关闭前按议题状态筛选", + "flag.issue.batch_label.state": "按议题状态筛选", + "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", + "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", + "flag.issue.body": "议题描述", + "flag.issue.label": "标签 ID", + "flag.issue.label_filter": "按已有标签筛选", + "flag.issue.milestone": "里程碑 ID", + "flag.issue.new_body": "新描述", + "flag.issue.new_state": "新状态:open、closed 或数字 status_id", + "flag.issue.new_title": "新标题", + "flag.issue.number": "议题编号(网页 URL 中显示的编号)", + "flag.issue.older_than_days": "只包含至少这么多天未活动的议题", + "flag.issue.state": "按状态筛选:open、closed、all", + "flag.issue.title": "议题标题", + "flag.lang": "显示语言", + "flag.limit": "每页条目数", + "flag.org.id": "组织 ID", + "flag.org.id_or_login": "组织 ID 或登录名", + "flag.org.name": "组织名称", + "flag.owner": "仓库所有者(自动从 git remote 检测)", + "flag.page": "页码", + "flag.pr.base": "目标分支", + "flag.pr.body": "PR 描述", + "flag.pr.file": "按文件路径筛选 diff", + "flag.pr.head": "源分支", + "flag.pr.id": "PR 编号", + "flag.pr.merge_method": "合并方式:merge、rebase、squash", + "flag.pr.review_commit": "关联评审的 Commit SHA", + "flag.pr.review_content": "评审内容", + "flag.pr.review_status": "评审状态:common、approved、rejected", + "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", + "flag.pr.state": "筛选:open、merged、closed", + "flag.pr.title": "PR 标题", + "flag.pr.version_id": "补丁集版本 ID", + "flag.release.body": "发布说明", + "flag.release.id": "发布 ID", + "flag.release.id_or_tag": "发布 ID 或标签", + "flag.release.name": "发布名称", + "flag.release.prerelease": "标记为预发布(true/false)", + "flag.release.tag": "标签名称", + "flag.release.target": "目标分支", + "flag.repo": "仓库名称(自动从 git remote 检测)", + "flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)", + "flag.repo.description": "仓库描述", + "flag.repo.name": "仓库名称", + "flag.repo.private": "设为私有仓库(true/false)", + "flag.search.keyword": "搜索关键词", + "flag.user": "用户登录名(默认:当前用户)", + "flag.user.login": "用户登录名", + "flag.webhook.active": "Webhook 是否启用:true 或 false", + "flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", + "flag.webhook.content_type": "Payload 内容类型:json 或 form", + "flag.webhook.events": "逗号分隔的事件,例如:push,issues_only", + "flag.webhook.http_method": "HTTP 方法:POST 或 GET", + "flag.webhook.id": "Webhook ID", + "flag.webhook.secret": "Webhook 密钥", + "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", + "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.url": "Webhook 目标 URL", + "output.auth.env_hint": " 或设置 {env} 环境变量", + "output.auth.login_hint": " 运行:gitlink-cli auth login", + "output.config.file": "配置文件:{path}", + "output.config.not_set": "(未设置)", + "output.version": "gitlink-cli {version}", + "prompt.auth.password": "密码:", + "prompt.auth.token": "粘贴你的访问 Token:", + "prompt.auth.username": "用户名/邮箱/手机号:", + "success.auth.logged_in_as": "✓ 已登录为 {login}", + "success.auth.logged_in_via_env": "✓ 已通过 {env} 环境变量登录", + "success.auth.logged_out": "✓ 已退出登录", + "success.auth.token_saved": "✓ Token 已保存", + "success.config.initialized": "✓ 配置已初始化:{path}", + "success.config.set": "✓ 已设置 {key} = {value}", + "warning.auth.not_logged_in": "✗ 未登录", + "warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})", + "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)" +} diff --git a/internal/i18n/options.go b/internal/i18n/options.go new file mode 100644 index 0000000..a1f055d --- /dev/null +++ b/internal/i18n/options.go @@ -0,0 +1,10 @@ +package i18n + +const defaultFallbackLocale = "en-US" + +// Options controls Translator construction. +type Options struct { + Locale string + FallbackLocale string + Loader Loader +} diff --git a/internal/i18n/resolver.go b/internal/i18n/resolver.go new file mode 100644 index 0000000..5e2e694 --- /dev/null +++ b/internal/i18n/resolver.go @@ -0,0 +1,95 @@ +package i18n + +import ( + "os" + "strings" +) + +// ResolveOptions contains locale inputs ordered by caller intent. +type ResolveOptions struct { + ExplicitLang string + Env map[string]string + ConfigLang string +} + +type ResolvedLocale struct { + Locale string + Source string + Requested string + Fallbacked bool + Supported bool +} + +// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback. +func ResolveLocale(opts ResolveOptions, available []string) string { + return ResolveLocaleDetailed(opts, available).Locale +} + +func ResolveLocaleDetailed(opts ResolveOptions, available []string) ResolvedLocale { + candidates := []struct { + source string + value string + }{ + {source: "flag", value: opts.ExplicitLang}, + {source: "env", value: envValue(opts.Env, "GITLINK_LANG")}, + {source: "config", value: opts.ConfigLang}, + {source: "lc_all", value: envValue(opts.Env, "LC_ALL")}, + {source: "lang", value: envValue(opts.Env, "LANG")}, + } + for _, candidate := range candidates { + if strings.TrimSpace(candidate.value) == "" { + continue + } + match := matchLocale(candidate.value, available, defaultFallbackLocale) + return ResolvedLocale{ + Locale: match.Locale, + Source: candidate.source, + Requested: match.Requested, + Fallbacked: match.Fallbacked, + Supported: match.Supported, + } + } + match := matchLocale(defaultFallbackLocale, available, defaultFallbackLocale) + return ResolvedLocale{ + Locale: match.Locale, + Source: "default", + Requested: match.Requested, + Fallbacked: match.Fallbacked, + Supported: match.Supported, + } +} + +// PreScanLang reads --lang before Cobra constructs localized help text. +func PreScanLang(args []string) string { + for i, arg := range args { + if arg == "--lang" { + if i+1 < len(args) { + return args[i+1] + } + return "" + } + if strings.HasPrefix(arg, "--lang=") { + return strings.TrimPrefix(arg, "--lang=") + } + } + return "" +} + +// EnvMap returns process environment as a string map. +func EnvMap() map[string]string { + env := make(map[string]string) + for _, item := range os.Environ() { + key, value, ok := strings.Cut(item, "=") + if ok { + env[key] = value + } + } + return env +} + +func envValue(env map[string]string, key string) string { + if env == nil { + return os.Getenv(key) + } + return env[key] +} diff --git a/internal/i18n/resolver_test.go b/internal/i18n/resolver_test.go new file mode 100644 index 0000000..07360df --- /dev/null +++ b/internal/i18n/resolver_test.go @@ -0,0 +1,31 @@ +package i18n + +import "testing" + +func TestPreScanLang(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"--lang", "zh-CN", "repo"}, "zh-CN"}, + {[]string{"repo", "--lang=zh-CN"}, "zh-CN"}, + {[]string{"repo"}, ""}, + } + for _, tc := range cases { + if got := PreScanLang(tc.args); got != tc.want { + t.Fatalf("PreScanLang(%v) = %q, want %q", tc.args, got, tc.want) + } + } +} + +func TestResolveLocalePriority(t *testing.T) { + available := []string{"en-US", "zh-CN"} + got := ResolveLocale(ResolveOptions{ + ExplicitLang: "en-US", + Env: map[string]string{"GITLINK_LANG": "zh-CN"}, + ConfigLang: "zh-CN", + }, available) + if got != "en-US" { + t.Fatalf("ResolveLocale() = %q, want en-US", got) + } +} diff --git a/internal/i18n/schema/messages.schema.json b/internal/i18n/schema/messages.schema.json new file mode 100644 index 0000000..a4a1ad5 --- /dev/null +++ b/internal/i18n/schema/messages.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitLink CLI locale messages", + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "propertyNames": { + "pattern": "^(cmd|flag|error|output|prompt|table)\\.[a-z0-9_.-]+$" + } +} diff --git a/internal/i18n/template.go b/internal/i18n/template.go new file mode 100644 index 0000000..21c299c --- /dev/null +++ b/internal/i18n/template.go @@ -0,0 +1,41 @@ +package i18n + +import ( + "fmt" + "regexp" + "sort" +) + +var placeholderPattern = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +func renderTemplate(message string, args Args) string { + if len(args) == 0 { + return message + } + return placeholderPattern.ReplaceAllStringFunc(message, func(match string) string { + name := match[1 : len(match)-1] + value, ok := args[name] + if !ok || value == nil { + return match + } + if stringer, ok := value.(fmt.Stringer); ok { + return stringer.String() + } + return fmt.Sprint(value) + }) +} + +func extractTemplateArgs(message string) []string { + matches := placeholderPattern.FindAllStringSubmatch(message, -1) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + seen[match[1]] = struct{}{} + } + + args := make([]string, 0, len(seen)) + for arg := range seen { + args = append(args, arg) + } + sort.Strings(args) + return args +} diff --git a/internal/i18n/template_test.go b/internal/i18n/template_test.go new file mode 100644 index 0000000..19e4730 --- /dev/null +++ b/internal/i18n/template_test.go @@ -0,0 +1,24 @@ +package i18n + +import "testing" + +func TestRenderTemplateKeepsMissingArgs(t *testing.T) { + got := renderTemplate("Delete {owner}/{repo}", Args{"owner": "alice"}) + want := "Delete alice/{repo}" + if got != want { + t.Fatalf("renderTemplate() = %q, want %q", got, want) + } +} + +func TestExtractTemplateArgs(t *testing.T) { + got := extractTemplateArgs("Delete {owner}/{repo}/{owner}") + want := []string{"owner", "repo"} + if len(got) != len(want) { + t.Fatalf("args length = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("arg[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/i18n/translator.go b/internal/i18n/translator.go new file mode 100644 index 0000000..170f3bd --- /dev/null +++ b/internal/i18n/translator.go @@ -0,0 +1,89 @@ +package i18n + +import "fmt" + +// Translator resolves localized messages with fallback behavior suitable for CLI use. +type Translator struct { + locale string + fallbackLocale string + messages map[string]string + fallback map[string]string +} + +// Default returns an English translator for legacy migration only. +// New command code should receive *Translator explicitly. +func Default() *Translator { + tr, err := New(Options{Locale: defaultFallbackLocale}) + if err != nil { + return &Translator{ + locale: defaultFallbackLocale, + fallbackLocale: defaultFallbackLocale, + messages: map[string]string{}, + fallback: map[string]string{}, + } + } + return tr +} + +// New constructs a Translator. Missing messages fall back to FallbackLocale. +func New(opts Options) (*Translator, error) { + loader := opts.Loader + if loader == nil { + loader = NewEmbedLoader() + } + + available, err := loader.AvailableLocales() + if err != nil { + return nil, err + } + + fallbackLocale := opts.FallbackLocale + if fallbackLocale == "" { + fallbackLocale = defaultFallbackLocale + } + fallbackLocale = MatchLocale(fallbackLocale, available, defaultFallbackLocale) + locale := MatchLocale(opts.Locale, available, fallbackLocale) + + fallback, err := loader.Load(fallbackLocale) + if err != nil { + return nil, fmt.Errorf("load fallback locale: %w", err) + } + + messages := fallback + if locale != fallbackLocale { + messages, err = loader.Load(locale) + if err != nil { + return nil, fmt.Errorf("load locale: %w", err) + } + } + + return &Translator{ + locale: locale, + fallbackLocale: fallbackLocale, + messages: messages, + fallback: fallback, + }, nil +} + +func (t *Translator) Locale() string { + return t.locale +} + +// T returns a localized message, falling back to en-US and then the key itself. +func (t *Translator) T(key string) string { + if t == nil { + return key + } + if value, ok := t.messages[key]; ok { + return value + } + if value, ok := t.fallback[key]; ok { + return value + } + return key +} + +// Tf returns a localized message with {name} placeholders rendered from args. +func (t *Translator) Tf(key string, args Args) string { + return renderTemplate(t.T(key), args) +} diff --git a/internal/i18n/translator_test.go b/internal/i18n/translator_test.go new file mode 100644 index 0000000..541047c --- /dev/null +++ b/internal/i18n/translator_test.go @@ -0,0 +1,79 @@ +package i18n + +import ( + "errors" + "testing" +) + +type mapLoader struct { + messages map[string]map[string]string +} + +func (l mapLoader) Load(locale string) (map[string]string, error) { + messages, ok := l.messages[locale] + if !ok { + return nil, errors.New("missing locale") + } + return messages, nil +} + +func (l mapLoader) AvailableLocales() ([]string, error) { + locales := make([]string, 0, len(l.messages)) + for locale := range l.messages { + locales = append(locales, locale) + } + return locales, nil +} + +func TestTranslatorReturnsLocalizedMessage(t *testing.T) { + tr, err := New(Options{ + Locale: "zh-CN", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"cmd.root.short": "GitLink CLI"}, + "zh-CN": {"cmd.root.short": "GitLink 命令行"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.T("cmd.root.short"); got != "GitLink 命令行" { + t.Fatalf("expected localized message, got %q", got) + } +} + +func TestTranslatorFallsBackToBaseThenKey(t *testing.T) { + tr, err := New(Options{ + Locale: "zh-CN", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"cmd.root.short": "GitLink CLI"}, + "zh-CN": {}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.T("cmd.root.short"); got != "GitLink CLI" { + t.Fatalf("expected fallback message, got %q", got) + } + if got := tr.T("cmd.missing.short"); got != "cmd.missing.short" { + t.Fatalf("expected key fallback, got %q", got) + } +} + +func TestTranslatorRendersArgs(t *testing.T) { + tr, err := New(Options{ + Locale: "en-US", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"output.version": "gitlink-cli {version}"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.Tf("output.version", Args{"version": "1.2.3"}); got != "gitlink-cli 1.2.3" { + t.Fatalf("expected rendered message, got %q", got) + } +} diff --git a/internal/i18n/validate.go b/internal/i18n/validate.go new file mode 100644 index 0000000..99bb4e9 --- /dev/null +++ b/internal/i18n/validate.go @@ -0,0 +1,104 @@ +package i18n + +import ( + "fmt" + "reflect" + "regexp" + "sort" + "strings" +) + +var keyPattern = regexp.MustCompile(`^(cmd|flag|error|prompt|success|warning|confirm|table|output)\.[a-z0-9_.-]+$`) + +// Problem describes a locale validation issue. +type Problem struct { + Locale string + Key string + Message string +} + +func (p Problem) String() string { + if p.Key == "" { + return fmt.Sprintf("%s: %s", p.Locale, p.Message) + } + return fmt.Sprintf("%s:%s: %s", p.Locale, p.Key, p.Message) +} + +// Validate checks all locales against baseLocale. +func Validate(loader Loader, baseLocale string) ([]Problem, error) { + if loader == nil { + loader = NewEmbedLoader() + } + baseLocale = NormalizeLocale(baseLocale) + if baseLocale == "" { + baseLocale = defaultFallbackLocale + } + + locales, err := loader.AvailableLocales() + if err != nil { + return nil, err + } + sort.Strings(locales) + + allMessages := make(map[string]map[string]string, len(locales)) + for _, locale := range locales { + normalized := NormalizeLocale(locale) + if normalized != locale { + return []Problem{{Locale: locale, Message: "locale filename is not normalized"}}, nil + } + messages, err := loader.Load(locale) + if err != nil { + return nil, err + } + allMessages[locale] = messages + } + + base, ok := allMessages[baseLocale] + if !ok { + return []Problem{{Locale: baseLocale, Message: "base locale is missing"}}, nil + } + + var problems []Problem + for key, value := range base { + problems = append(problems, validateMessage(baseLocale, key, value)...) + } + + for _, locale := range locales { + messages := allMessages[locale] + for key, value := range messages { + problems = append(problems, validateMessage(locale, key, value)...) + if _, ok := base[key]; !ok { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "key is not present in base locale"}) + } + } + for key, baseValue := range base { + value, ok := messages[key] + if !ok { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "missing key"}) + continue + } + baseArgs := extractTemplateArgs(baseValue) + args := extractTemplateArgs(value) + if !reflect.DeepEqual(baseArgs, args) { + problems = append(problems, Problem{ + Locale: locale, + Key: key, + Message: fmt.Sprintf("template args mismatch: expected {%s}, got {%s}", strings.Join(baseArgs, ","), strings.Join(args, ",")), + }) + } + } + } + + return problems, nil +} + +func validateMessage(locale, key, value string) []Problem { + var problems []Problem + if !keyPattern.MatchString(key) { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "key does not match naming rules"}) + } + if strings.TrimSpace(value) == "" { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "message is empty"}) + } + return problems +} diff --git a/internal/i18n/validate_test.go b/internal/i18n/validate_test.go new file mode 100644 index 0000000..d5d2cf1 --- /dev/null +++ b/internal/i18n/validate_test.go @@ -0,0 +1,31 @@ +package i18n + +import "testing" + +func TestValidateEmbeddedLocales(t *testing.T) { + problems, err := Validate(NewEmbedLoader(), "en-US") + if err != nil { + t.Fatal(err) + } + if len(problems) > 0 { + t.Fatalf("expected no problems, got %v", problems) + } +} + +func TestValidateFindsMissingKeyAndArgMismatch(t *testing.T) { + problems, err := Validate(mapLoader{messages: map[string]map[string]string{ + "en-US": { + "cmd.root.short": "Hello {name}", + "flag.owner": "Owner", + }, + "zh-CN": { + "cmd.root.short": "你好", + }, + }}, "en-US") + if err != nil { + t.Fatal(err) + } + if len(problems) != 2 { + t.Fatalf("expected 2 problems, got %d: %v", len(problems), problems) + } +} diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 37684b6..0393ad5 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List branches", + Description: tr.T("cmd.branch.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -32,10 +34,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a branch", + Description: tr.T("cmd.branch.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, - {Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + {Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -59,9 +61,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a branch", + Description: tr.T("cmd.branch.delete.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -80,9 +82,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "protect", - Description: "Set branch protection", + Description: tr.T("cmd.branch.protect.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -101,9 +103,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "unprotect", - Description: "Remove branch protection", + Description: tr.T("cmd.branch.unprotect.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -119,3 +121,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index eb15ad3..6794c47 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "builds", - Description: "List CI builds", + Description: tr.T("cmd.ci.builds.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -32,11 +34,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "logs", - Description: "View build logs", + Description: tr.T("cmd.ci.logs.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, - {Name: "stage", Short: "s", Usage: "Stage number", Default: "1"}, - {Name: "step", Usage: "Step number", Default: "1"}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, + {Name: "stage", Short: "s", Usage: tr.T("flag.ci.stage"), Default: "1"}, + {Name: "step", Usage: tr.T("flag.ci.step"), Default: "1"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -60,9 +62,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "restart", - Description: "Restart a build", + Description: tr.T("cmd.ci.restart.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -78,9 +80,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "stop", - Description: "Stop a build", + Description: tr.T("cmd.ci.stop.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -96,3 +98,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 03f67e2..9bdc5bc 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1,17 +1,22 @@ package common import ( - "fmt" "strconv" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/spf13/cobra" ) // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. -func MountShortcut(parent *cobra.Command, s *Shortcut) { +func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cmd := &cobra.Command{ Use: "+" + s.Name, Short: s.Description, + Long: s.Long, RunE: func(cmd *cobra.Command, args []string) error { // Collect flag values flagValues := make(map[string]string) @@ -27,10 +32,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } } - ctx, err := NewRuntimeContext(flagValues) + ctx, err := NewRuntimeContext(flagValues, tr) if err != nil { return err } + for _, f := range s.Flags { + if f.Required && flagValues[f.Name] == "" { + _, err := ctx.RequireArg(f.Name) + return err + } + } return s.Run(ctx) }, @@ -49,19 +60,18 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } else { cmd.Flags().String(f.Name, f.Default, f.Usage) } - if f.Required { - if err := cmd.MarkFlagRequired(f.Name); err != nil { - panic(fmt.Sprintf("failed to mark flag %s as required: %v", f.Name, err)) - } - } } parent.AddCommand(cmd) } // MountShortcuts mounts multiple shortcuts under a parent command. -func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) { +func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut, translators ...*i18n.Translator) { + var tr *i18n.Translator + if len(translators) > 0 { + tr = translators[0] + } for _, s := range shortcuts { - MountShortcut(parent, s) + MountShortcut(parent, s, tr) } } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15441c9..87a052a 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -2,12 +2,14 @@ package common import ( "encoding/json" + "errors" "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/context" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -15,6 +17,7 @@ import ( type Shortcut struct { Name string Description string + Long string Flags []Flag Run func(ctx *RuntimeContext) error } @@ -36,10 +39,15 @@ type RuntimeContext struct { Repo string Format string Args map[string]string + Tr *i18n.Translator } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. -func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { +func NewRuntimeContext(args map[string]string, translators ...*i18n.Translator) (*RuntimeContext, error) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cli, err := client.New() if err != nil { return nil, err @@ -57,6 +65,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { Repo: cmdutil.Repo, Format: format, Args: args, + Tr: tr, }, nil } @@ -113,7 +122,7 @@ func (ctx *RuntimeContext) Arg(name string) string { func (ctx *RuntimeContext) RequireArg(name string) (string, error) { v := ctx.Arg(name) if v == "" { - return "", fmt.Errorf("required flag --%s is missing", name) + return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name})) } return v, nil } diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 0459921..ad67c8a 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -20,16 +21,17 @@ type existingIssue struct { Description string } -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ newBatchCloseShortcut(), { Name: "list", - Description: "List issues", + Description: tr.T("cmd.issue.list.short"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -51,13 +53,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a new issue", + Description: tr.T("cmd.issue.create.short"), Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "Issue title", Required: true}, - {Name: "body", Short: "b", Usage: "Issue description"}, - {Name: "assignee", Short: "a", Usage: "Assignee login"}, - {Name: "milestone", Short: "m", Usage: "Milestone ID"}, - {Name: "label", Usage: "Label ID"}, + {Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.issue.body")}, + {Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")}, + {Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")}, + {Name: "label", Usage: tr.T("flag.issue.label")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -91,9 +93,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View issue details", + Description: tr.T("cmd.issue.view.short"), Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)"}, + {Name: "number", Short: "n", Usage: tr.T("flag.issue.number")}, {Name: "id", Usage: "Alias for --number; uses the issue number from the web URL"}, }, Run: func(ctx *common.RuntimeContext) error { @@ -113,9 +115,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "close", - Description: "Close an issue", + Description: tr.T("cmd.issue.close.short"), Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, + {Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -144,12 +146,12 @@ func Shortcuts() []*common.Shortcut { }, { Name: "update", - Description: "Update an issue", + Description: tr.T("cmd.issue.update.short"), Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - {Name: "title", Short: "t", Usage: "New title"}, - {Name: "body", Short: "b", Usage: "New description"}, - {Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"}, + {Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true}, + {Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")}, + {Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -197,10 +199,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "comment", - Description: "Add a comment to an issue", + Description: tr.T("cmd.issue.comment.short"), Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - {Name: "body", Short: "b", Usage: "Comment body", Required: true}, + {Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -269,6 +271,13 @@ func Shortcuts() []*common.Shortcut { } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 4cd6299..f0b5e72 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List organizations", + Description: tr.T("cmd.org.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { q := url.Values{} @@ -29,9 +31,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show organization details", + Description: tr.T("cmd.org.info.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Organization ID or login", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.org.id_or_login"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { id, _ := ctx.RequireArg("id") @@ -44,11 +46,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "members", - Description: "List organization members", + Description: tr.T("cmd.org.members.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Organization ID", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { id, _ := ctx.RequireArg("id") @@ -64,10 +66,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create an organization", + Description: tr.T("cmd.org.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Organization name", Required: true}, - {Name: "description", Short: "d", Usage: "Description"}, + {Name: "name", Short: "n", Usage: tr.T("flag.org.name"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.description")}, }, Run: func(ctx *common.RuntimeContext) error { name, _ := ctx.RequireArg("name") @@ -86,3 +88,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 5fd983b..a903363 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -5,19 +5,21 @@ import ( "net/url" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List pull requests", + Description: tr.T("cmd.pr.list.short"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -38,12 +40,12 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a pull request", + Description: tr.T("cmd.pr.create.short"), Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "PR title", Required: true}, - {Name: "body", Short: "b", Usage: "PR description"}, - {Name: "head", Usage: "Source branch", Required: true}, - {Name: "base", Usage: "Target branch", Default: "master"}, + {Name: "title", Short: "t", Usage: tr.T("flag.pr.title"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.pr.body")}, + {Name: "head", Usage: tr.T("flag.pr.head"), Required: true}, + {Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -72,9 +74,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View pull request details", + Description: tr.T("cmd.pr.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -93,10 +95,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "merge", - Description: "Merge a pull request", + Description: tr.T("cmd.pr.merge.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "method", Short: "m", Usage: tr.T("flag.pr.merge_method"), Default: "merge"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -119,9 +121,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "close", - Description: "Close a pull request", + Description: tr.T("cmd.pr.close.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -158,9 +160,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "files", - Description: "List changed files in a pull request", + Description: tr.T("cmd.pr.files.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -176,9 +178,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "diff", - Description: "Show diff for a pull request", + Description: tr.T("cmd.pr.diff.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -194,9 +196,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "versions", - Description: "List pull request patchset versions", + Description: tr.T("cmd.pr.versions.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -215,11 +217,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "version-diff", - Description: "Show diff for a pull request patchset version", + Description: tr.T("cmd.pr.version_diff.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true}, - {Name: "file", Short: "f", Usage: "Filter diff by file path"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "version-id", Short: "v", Usage: tr.T("flag.pr.version_id"), Required: true}, + {Name: "file", Short: "f", Usage: tr.T("flag.pr.file")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -252,10 +254,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "reviews", - Description: "List pull request reviews", + Description: tr.T("cmd.pr.reviews.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status_filter")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -281,13 +283,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "review", - Description: "Create a pull request review", + Description: tr.T("cmd.pr.review.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"}, - {Name: "content", Short: "c", Usage: "Review content", Required: true}, - {Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"}, - {Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status"), Default: "common"}, + {Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content"), Required: true}, + {Name: "commit", Short: "m", Usage: tr.T("flag.pr.review_commit")}, + {Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -333,10 +335,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "comment", - Description: "Add a comment to a pull request", + Description: tr.T("cmd.pr.comment.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "body", Short: "b", Usage: "Comment body", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -367,6 +369,13 @@ func Shortcuts() []*common.Shortcut { } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + func prV1Path(ctx *common.RuntimeContext, id string) string { return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) } diff --git a/shortcuts/register.go b/shortcuts/register.go index 50fa41e..3c814e0 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -3,6 +3,7 @@ package shortcuts import ( "github.com/spf13/cobra" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -23,42 +24,46 @@ import ( ) // RegisterAll mounts all shortcut groups onto the root command. -func RegisterAll(root *cobra.Command) { +func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(), - "issue": issue.Shortcuts(), + "repo": repo.Shortcuts(tr), + "issue": issue.Shortcuts(tr), "label": label.Shortcuts(), "member": member.Shortcuts(), "milestone": milestone.Shortcuts(), "pipeline": pipeline.Shortcuts(), - "pr": pr.Shortcuts(), - "release": release.Shortcuts(), - "branch": branch.Shortcuts(), - "org": org.Shortcuts(), - "user": user.Shortcuts(), - "search": search.Shortcuts(), - "ci": ci.Shortcuts(), + "pr": pr.Shortcuts(tr), + "release": release.Shortcuts(tr), + "branch": branch.Shortcuts(tr), + "org": org.Shortcuts(tr), + "user": user.Shortcuts(tr), + "search": search.Shortcuts(tr), + "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), - "webhook": webhook.Shortcuts(), + "webhook": webhook.Shortcuts(tr), "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "repo": "Repository operations", - "issue": "Issue operations", + "repo": tr.T("cmd.repo.short"), + "issue": tr.T("cmd.issue.short"), "label": "Issue label operations", "member": "Repository member operations", "milestone": "Milestone operations", "pipeline": "Pipeline operations", - "pr": "Pull request operations", - "release": "Release operations", - "branch": "Branch operations", - "org": "Organization operations", - "user": "User operations", - "search": "Search operations", - "ci": "CI/CD operations", + "pr": tr.T("cmd.pr.short"), + "release": tr.T("cmd.release.short"), + "branch": tr.T("cmd.branch.short"), + "org": tr.T("cmd.org.short"), + "user": tr.T("cmd.user.short"), + "search": tr.T("cmd.search.short"), + "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", - "webhook": "Webhook operations", + "webhook": tr.T("cmd.webhook.short"), "workflow": "AI agent workflow analysis", } @@ -67,7 +72,7 @@ func RegisterAll(root *cobra.Command) { Use: name, Short: descriptions[name], } - common.MountShortcuts(groupCmd, shortcuts) + common.MountShortcuts(groupCmd, shortcuts, tr) root.AddCommand(groupCmd) } } diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 06240bc..c93d6a0 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -4,18 +4,20 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List releases", + Description: tr.T("cmd.release.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -33,13 +35,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a release", + Description: tr.T("cmd.release.create.short"), Flags: []common.Flag{ - {Name: "tag", Short: "t", Usage: "Tag name", Required: true}, - {Name: "name", Short: "n", Usage: "Release name", Required: true}, - {Name: "body", Short: "b", Usage: "Release notes"}, - {Name: "target", Usage: "Target branch", Default: "master"}, - {Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"}, + {Name: "tag", Short: "t", Usage: tr.T("flag.release.tag"), Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.release.name"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.release.body")}, + {Name: "target", Usage: tr.T("flag.release.target"), Default: "master"}, + {Name: "prerelease", Usage: tr.T("flag.release.prerelease"), Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -69,9 +71,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View release details", + Description: tr.T("cmd.release.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release ID or tag", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -87,9 +89,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a release", + Description: tr.T("cmd.release.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -117,3 +119,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 4d621ad..ea4f6ee 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -4,19 +4,21 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List repositories for a user or organization", + Description: tr.T("cmd.repo.list.short"), Flags: []common.Flag{ - {Name: "user", Short: "u", Usage: "User login (default: current user)"}, - {Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "user", Short: "u", Usage: tr.T("flag.user"), Default: ""}, + {Name: "category", Short: "c", Usage: tr.T("flag.repo.category"), Default: "manage"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { user := ctx.Arg("user") @@ -40,7 +42,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show repository details", + Description: tr.T("cmd.repo.info.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -79,11 +81,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a new repository", + Description: tr.T("cmd.repo.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Repository name", Required: true}, - {Name: "description", Short: "d", Usage: "Repository description"}, - {Name: "private", Usage: "Make repository private (true/false)", Default: "false"}, + {Name: "name", Short: "n", Usage: tr.T("flag.repo.name"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.repo.description")}, + {Name: "private", Usage: tr.T("flag.repo.private"), Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { name, err := ctx.RequireArg("name") @@ -121,7 +123,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "fork", - Description: "Fork a repository", + Description: tr.T("cmd.repo.fork.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -135,7 +137,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a repository", + Description: tr.T("cmd.repo.delete.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -149,3 +151,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index 443dec3..a0ee4c2 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -3,18 +3,20 @@ package search import ( "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "repos", - Description: "Search repositories", + Description: tr.T("cmd.search.repos.short"), Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { keyword, _ := ctx.RequireArg("keyword") @@ -31,11 +33,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "users", - Description: "Search users", + Description: tr.T("cmd.search.users.short"), Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { keyword, _ := ctx.RequireArg("keyword") @@ -52,3 +54,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index cfcaa2a..563db88 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -3,14 +3,16 @@ package user import ( "fmt" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "me", - Description: "Show current authenticated user", + Description: tr.T("cmd.user.me.short"), Run: func(ctx *common.RuntimeContext) error { env, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { @@ -21,9 +23,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show user profile", + Description: tr.T("cmd.user.info.short"), Flags: []common.Flag{ - {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { login, err := ctx.RequireArg("login") @@ -39,3 +41,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 091200e..7d0574a 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -22,11 +23,12 @@ var allowedWebhookEvents = map[string]bool{ } // Shortcuts returns webhook management shortcuts. -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List repository webhooks", + Description: tr.T("cmd.webhook.list.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -40,9 +42,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View webhook details", + Description: tr.T("cmd.webhook.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -61,40 +63,40 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a repository webhook", + Description: tr.T("cmd.webhook.create.short"), Flags: []common.Flag{ - {Name: "url", Short: "u", Usage: "Webhook target URL", Required: true}, - {Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true}, - {Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", Default: "gitea"}, - {Name: "content-type", Usage: "Payload content type: json or form", Default: "json"}, - {Name: "http-method", Usage: "HTTP method: POST or GET", Default: "POST"}, - {Name: "secret", Short: "s", Usage: "Webhook secret"}, - {Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events", Default: "*"}, - {Name: "active", Usage: "Whether the webhook is active: true or false", Default: "true"}, + {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url"), Required: true}, + {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events"), Required: true}, + {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type"), Default: "gitea"}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, + {Name: "http-method", Usage: tr.T("flag.webhook.http_method"), Default: "POST"}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, + {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter"), Default: "*"}, + {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, }, Run: runCreate, }, { Name: "update", - Description: "Update a repository webhook while preserving unspecified fields when available", + Description: tr.T("cmd.webhook.update.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, - {Name: "url", Short: "u", Usage: "Webhook target URL"}, - {Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only"}, - {Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot"}, - {Name: "content-type", Usage: "Payload content type: json or form"}, - {Name: "http-method", Usage: "HTTP method: POST or GET"}, - {Name: "secret", Short: "s", Usage: "Webhook secret. Pass it again if the server does not return existing secrets."}, - {Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events"}, - {Name: "active", Usage: "Whether the webhook is active: true or false"}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, + {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url")}, + {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events")}, + {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type")}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type")}, + {Name: "http-method", Usage: tr.T("flag.webhook.http_method")}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret_update")}, + {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter")}, + {Name: "active", Usage: tr.T("flag.webhook.active")}, }, Run: runUpdate, }, { Name: "delete", - Description: "Delete a repository webhook", + Description: tr.T("cmd.webhook.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -113,9 +115,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "test", - Description: "Trigger a test delivery for a webhook", + Description: tr.T("cmd.webhook.test.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -134,9 +136,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "tasks", - Description: "List webhook delivery tasks", + Description: tr.T("cmd.webhook.tasks.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -156,6 +158,13 @@ func Shortcuts() []*common.Shortcut { } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + func runCreate(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err