diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5f86757 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +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: Run Go tests + run: go test ./... diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d..f949622 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -10,14 +10,18 @@ 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(tr *i18n.Translator) *cobra.Command { + if tr == nil { + tr = i18n.Default() + } 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":"..."}'`, @@ -25,9 +29,9 @@ func NewAPICmd() *cobra.Command { RunE: runAPI, } - apiCmd.Flags().String("body", "", "Request body (JSON string)") - 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("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 ed9dc22..24ea8dc 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -11,27 +11,31 @@ import ( "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 { +func NewAuthCmd(tr *i18n.Translator) *cobra.Command { + if tr == nil { + tr = i18n.Default() + } 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() @@ -39,7 +43,7 @@ func newLoginCmd() *cobra.Command { return loginWithPassword() }, } - 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 } @@ -85,10 +89,10 @@ func loginWithToken() error { return nil } -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) @@ -99,10 +103,10 @@ func newLogoutCmd() *cobra.Command { } } -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 { // Check env var token first if envToken := os.Getenv(envTokenVar); envToken != "" { 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..414f786 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -6,24 +6,28 @@ import ( "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(tr *i18n.Translator) *cobra.Command { + if tr == nil { + tr = i18n.Default() + } 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(tr *i18n.Translator) *cobra.Command { 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 { @@ -35,10 +39,10 @@ func newInitCmd() *cobra.Command { } } -func newSetCmd() *cobra.Command { +func newSetCmd(tr *i18n.Translator) *cobra.Command { 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 { @@ -50,10 +54,10 @@ func newSetCmd() *cobra.Command { } } -func newGetCmd() *cobra.Command { +func newGetCmd(tr *i18n.Translator) *cobra.Command { 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]) @@ -70,10 +74,10 @@ func newGetCmd() *cobra.Command { } } -func newListCmd() *cobra.Command { +func newListCmd(tr *i18n.Translator) *cobra.Command { 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 { @@ -83,6 +87,7 @@ func newListCmd() *cobra.Command { fmt.Printf("default_format: %s\n", cfg.Format) fmt.Printf("editor: %s\n", cfg.Editor) fmt.Printf("pager: %s\n", cfg.Pager) + fmt.Printf("lang: %s\n", cfg.Lang) fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath()) return nil }, diff --git a/cmd/root.go b/cmd/root.go index 0ed4c66..f67b516 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,49 +6,111 @@ import ( "github.com/spf13/cobra" - "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" - authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" + 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 } -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 = i18n.New(i18n.Options{Locale: "en-US"}) + 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"), + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version})) + }, + } } func Execute() error { + args := os.Args[1:] + tr, err := newTranslator(args) + if err != nil { + return err + } + rootCmd, err := NewRootCmd(RootOptions{ + Version: Version, + Args: args, + }, tr) + if err != nil { + return err + } + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) return err } return nil } + +func newTranslator(args []string) (*i18n.Translator, error) { + available, err := i18n.AvailableLocales() + if err != nil { + return nil, err + } + locale := i18n.ResolveLocale(i18n.ResolveOptions{ + ExplicitLang: i18n.PreScanLang(args), + Env: i18n.EnvMap(), + ConfigLang: loadConfigLangBestEffort(), + }, available) + return i18n.New(i18n.Options{Locale: 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..3457b74 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,205 @@ +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 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{"issue", "+batch-close", "--help"}, + want: []string{"批量关闭筛选后的议题", "--older-than-days", "必需的安全筛选条件", "--yes"}, + }, + { + 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-project-summary.md b/docs/i18n-project-summary.md new file mode 100644 index 0000000..27d5b08 --- /dev/null +++ b/docs/i18n-project-summary.md @@ -0,0 +1,72 @@ +# GitLink CLI i18n Infrastructure Summary + +## Project Scope + +This work introduces a maintainable localization infrastructure for GitLink CLI. It turns user-facing command help and flag descriptions into validated locale resources while keeping machine-readable outputs stable. + +## Completed Capabilities + +- Added `internal/i18n` as a dedicated localization package. +- Embedded locale JSON files with `go:embed`. +- Added `Translator` with `T` and `Tf` APIs. +- Added fallback behavior from selected locale to `en-US`, then to the message key. +- Added locale resolution from `--lang`, `GITLINK_LANG`, `config.lang`, `LC_ALL`, and `LANG`. +- Added locale normalization and matching for common inputs such as `zh_CN`, `zh-Hans-CN`, `zh`, and `en`. +- Added simple `{name}` template rendering for parameterized messages. +- Added `go run ./internal/i18n/cmd/check` to validate key completeness, empty messages, key naming, and template argument consistency. +- Added GitHub Actions test workflow for i18n validation and Go tests. + +## CLI Integration + +- Reworked root command construction from package-level static initialization to `NewRootCmd(opts, tr)`. +- Added global `--lang` flag. +- Added `lang` support to the config model. +- Localized root command help, global flags, version help/output, and shortcut group descriptions. +- Localized command and flag help for: + - `api` + - `auth` + - `config` + - `repo` + - `issue` + - `pr` + - `branch` + - `release` + - `org` + - `user` + - `search` + - `ci` + - `webhook` + +## Deliberate Non-Goals + +The migration intentionally does not localize: + +- JSON field names +- API response bodies +- raw API errors returned by the server +- debug diagnostics +- internal developer-only error context + +This keeps scripted usage stable while improving human-facing CLI experience. + +## Validation + +The implementation has been validated with: + +```powershell +go run ./internal/i18n/cmd/check +go test ./... +git diff --check +``` + +The local Go toolchain used for validation is: + +```text +E:\tools\go-sdk\go1.26.1\go\bin\go.exe +``` + +## Follow-Up Work + +- Migrate selected runtime success/error messages where they are clearly user-facing. +- Consider a stricter scanner for new hardcoded help text. +- Add contributor documentation for adding a new locale. 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..adb0702 --- /dev/null +++ b/internal/i18n/cmd/check/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "os" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func main() { + 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) + } + fmt.Println("i18n messages are valid") +} 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..7d85c61 --- /dev/null +++ b/internal/i18n/locale.go @@ -0,0 +1,98 @@ +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, normalized, +// language-primary, then fallback matching. +func MatchLocale(requested string, available []string, fallback string) string { + fallback = NormalizeLocale(fallback) + if fallback == "" { + fallback = defaultFallbackLocale + } + if len(available) == 0 { + return fallback + } + + byLocale := make(map[string]string, len(available)) + byPrimary := make(map[string]string, len(available)) + for _, locale := range available { + normalized := NormalizeLocale(locale) + byLocale[normalized] = normalized + if primary := primaryLanguage(normalized); primary != "" { + if _, exists := byPrimary[primary]; !exists { + byPrimary[primary] = normalized + } + } + } + + candidate := NormalizeLocale(requested) + if candidate != "" { + if matched, ok := byLocale[candidate]; ok { + return matched + } + if alias := localeAlias(candidate); alias != "" { + if matched, ok := byLocale[alias]; ok { + return matched + } + } + if matched, ok := byPrimary[primaryLanguage(candidate)]; ok { + return matched + } + } + + if matched, ok := byLocale[fallback]; ok { + return matched + } + return NormalizeLocale(available[0]) +} + +func primaryLanguage(locale string) string { + if idx := strings.IndexByte(locale, '-'); idx >= 0 { + return locale[:idx] + } + return locale +} + +func localeAlias(locale string) string { + switch primaryLanguage(locale) { + case "zh": + return "zh-CN" + case "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..63de828 --- /dev/null +++ b/internal/i18n/locales/en-US.json @@ -0,0 +1,164 @@ +{ + "cmd.root.short": "GitLink CLI - command-line tool for GitLink", + "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", + "cmd.api.short": "Make raw API requests to GitLink", + "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", + "cmd.auth.short": "Authentication commands", + "cmd.auth.login.short": "Login to GitLink", + "cmd.auth.logout.short": "Logout from GitLink", + "cmd.auth.status.short": "Show authentication status", + "cmd.config.short": "Manage gitlink-cli configuration", + "cmd.config.init.short": "Initialize configuration file", + "cmd.config.set.short": "Set a configuration value", + "cmd.config.get.short": "Get a configuration value", + "cmd.config.list.short": "List all configuration values", + "cmd.version.short": "Print version information", + "cmd.repo.short": "Repository operations", + "cmd.repo.list.short": "List repositories for a user or organization", + "cmd.repo.info.short": "Show repository details", + "cmd.repo.create.short": "Create a new repository", + "cmd.repo.fork.short": "Fork a repository", + "cmd.repo.delete.short": "Delete a repository", + "cmd.issue.short": "Issue operations", + "cmd.issue.list.short": "List issues", + "cmd.issue.create.short": "Create a new issue", + "cmd.issue.view.short": "View issue details", + "cmd.issue.close.short": "Close an issue", + "cmd.issue.update.short": "Update an issue", + "cmd.issue.comment.short": "Add a comment to an issue", + "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", + "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_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "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_label.short": "Add a label to 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.pr.short": "Pull request operations", + "cmd.pr.list.short": "List pull requests", + "cmd.pr.create.short": "Create a pull request", + "cmd.pr.view.short": "View pull request details", + "cmd.pr.merge.short": "Merge a pull request", + "cmd.pr.close.short": "Close a pull request", + "cmd.pr.files.short": "List changed files in a pull request", + "cmd.pr.diff.short": "Show diff for a pull request", + "cmd.pr.versions.short": "List pull request patchset versions", + "cmd.pr.version_diff.short": "Show diff for a pull request patchset version", + "cmd.pr.reviews.short": "List pull request reviews", + "cmd.pr.review.short": "Create a pull request review", + "cmd.pr.comment.short": "Add a comment to a pull request", + "cmd.release.short": "Release operations", + "cmd.release.list.short": "List releases", + "cmd.release.create.short": "Create a release", + "cmd.release.view.short": "View release details", + "cmd.release.delete.short": "Delete a release", + "cmd.branch.short": "Branch operations", + "cmd.branch.list.short": "List branches", + "cmd.branch.create.short": "Create a branch", + "cmd.branch.delete.short": "Delete a branch", + "cmd.branch.protect.short": "Set branch protection", + "cmd.branch.unprotect.short": "Remove branch protection", + "cmd.org.short": "Organization operations", + "cmd.org.list.short": "List organizations", + "cmd.org.info.short": "Show organization details", + "cmd.org.members.short": "List organization members", + "cmd.org.create.short": "Create an organization", + "cmd.user.short": "User operations", + "cmd.user.me.short": "Show current authenticated user", + "cmd.user.info.short": "Show user profile", + "cmd.search.short": "Search operations", + "cmd.search.repos.short": "Search repositories", + "cmd.search.users.short": "Search users", + "cmd.ci.short": "CI/CD operations", + "cmd.ci.builds.short": "List CI builds", + "cmd.ci.logs.short": "View build logs", + "cmd.ci.restart.short": "Restart a build", + "cmd.ci.stop.short": "Stop a build", + "cmd.webhook.short": "Webhook operations", + "cmd.webhook.list.short": "List repository webhooks", + "cmd.webhook.view.short": "View webhook details", + "cmd.webhook.create.short": "Create a repository webhook", + "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", + "cmd.webhook.delete.short": "Delete a repository webhook", + "cmd.webhook.test.short": "Trigger a test delivery for a webhook", + "cmd.webhook.tasks.short": "List webhook delivery tasks", + "flag.owner": "Repository owner (auto-detected from git remote)", + "flag.repo": "Repository name (auto-detected from git remote)", + "flag.format": "Output format: json, table, yaml (default: table)", + "flag.debug": "Enable debug output", + "flag.lang": "Display language", + "flag.api.body": "Request body (JSON string)", + "flag.api.query": "Query parameters (key=val&key2=val2)", + "flag.api.header": "Additional headers (key:value)", + "flag.auth.token": "Login by pasting an existing token", + "flag.user": "User login (default: current user)", + "flag.user.login": "User login name", + "flag.page": "Page number", + "flag.limit": "Items per page", + "flag.description": "Description", + "flag.dry_run": "Preview the request without creating it", + "flag.comment.body": "Comment body", + "flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)", + "flag.repo.name": "Repository name", + "flag.repo.description": "Repository description", + "flag.repo.private": "Make repository private (true/false)", + "flag.issue.state": "Filter by state: open, closed, all", + "flag.issue.title": "Issue title", + "flag.issue.body": "Issue description", + "flag.issue.assignee": "Assignee login", + "flag.issue.milestone": "Milestone ID", + "flag.issue.label": "Label ID", + "flag.issue.number": "Issue number (as shown in the web URL)", + "flag.issue.new_title": "New title", + "flag.issue.new_body": "New description", + "flag.issue.new_state": "New state: open, closed, or numeric status_id", + "flag.issue.label_filter": "Filter by existing label", + "flag.issue.older_than_days": "Only include issues inactive for at least this many days", + "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", + "flag.issue.batch_close.state": "Filter by issue state before closing", + "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", + "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", + "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", + "flag.issue.batch.reason": "Optional reason shown in the batch result", + "flag.issue.batch_label.state": "Filter by issue state", + "flag.issue.add_label": "Label to add to each matching issue", + "flag.pr.state": "Filter: open, merged, closed", + "flag.pr.title": "PR title", + "flag.pr.body": "PR description", + "flag.pr.head": "Source branch", + "flag.pr.base": "Target branch", + "flag.pr.id": "PR number", + "flag.pr.merge_method": "Merge method: merge, rebase, squash", + "flag.pr.version_id": "Patchset version ID", + "flag.pr.file": "Filter diff by file path", + "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", + "flag.pr.review_status": "Review status: common, approved, rejected", + "flag.pr.review_content": "Review content", + "flag.pr.review_commit": "Commit SHA to attach the review to", + "flag.branch.name": "Branch name", + "flag.branch.from": "Source branch or commit", + "flag.release.tag": "Tag name", + "flag.release.name": "Release name", + "flag.release.body": "Release notes", + "flag.release.target": "Target branch", + "flag.release.prerelease": "Mark as prerelease (true/false)", + "flag.release.id_or_tag": "Release ID or tag", + "flag.release.id": "Release ID", + "flag.org.id_or_login": "Organization ID or login", + "flag.org.id": "Organization ID", + "flag.org.name": "Organization name", + "flag.search.keyword": "Search keyword", + "flag.ci.build": "Build number", + "flag.ci.stage": "Stage number", + "flag.ci.step": "Step number", + "flag.webhook.id": "Webhook ID", + "flag.webhook.url": "Webhook target URL", + "flag.webhook.events": "Comma-separated events, for example: push,issues_only", + "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.content_type": "Payload content type: json or form", + "flag.webhook.http_method": "HTTP method: POST or GET", + "flag.webhook.secret": "Webhook secret", + "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", + "flag.webhook.branch_filter": "Branch glob filter for push/create/delete events", + "flag.webhook.active": "Whether the webhook is active: true or false", + "error.unsupported_language": "unsupported language: {lang}", + "output.version": "gitlink-cli {version}" +} diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json new file mode 100644 index 0000000..b60f8f8 --- /dev/null +++ b/internal/i18n/locales/zh-CN.json @@ -0,0 +1,164 @@ +{ + "cmd.root.short": "GitLink CLI - GitLink 命令行工具", + "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", + "cmd.api.short": "向 GitLink 发起原始 API 请求", + "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", + "cmd.auth.short": "认证命令", + "cmd.auth.login.short": "登录 GitLink", + "cmd.auth.logout.short": "退出 GitLink 登录", + "cmd.auth.status.short": "显示认证状态", + "cmd.config.short": "管理 gitlink-cli 配置", + "cmd.config.init.short": "初始化配置文件", + "cmd.config.set.short": "设置配置项", + "cmd.config.get.short": "获取配置项", + "cmd.config.list.short": "列出所有配置项", + "cmd.version.short": "打印版本信息", + "cmd.repo.short": "仓库操作", + "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.info.short": "显示仓库详情", + "cmd.repo.create.short": "创建新仓库", + "cmd.repo.fork.short": "Fork 仓库", + "cmd.repo.delete.short": "删除仓库", + "cmd.issue.short": "议题操作", + "cmd.issue.list.short": "列出议题", + "cmd.issue.create.short": "创建新议题", + "cmd.issue.view.short": "查看议题详情", + "cmd.issue.close.short": "关闭议题", + "cmd.issue.update.short": "更新议题", + "cmd.issue.comment.short": "给议题添加评论", + "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", + "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_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", + "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_label.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.pr.short": "拉取请求操作", + "cmd.pr.list.short": "列出拉取请求", + "cmd.pr.create.short": "创建拉取请求", + "cmd.pr.view.short": "查看拉取请求详情", + "cmd.pr.merge.short": "合并拉取请求", + "cmd.pr.close.short": "关闭拉取请求", + "cmd.pr.files.short": "列出拉取请求中的变更文件", + "cmd.pr.diff.short": "显示拉取请求 diff", + "cmd.pr.versions.short": "列出拉取请求补丁集版本", + "cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff", + "cmd.pr.reviews.short": "列出拉取请求评审", + "cmd.pr.review.short": "创建拉取请求评审", + "cmd.pr.comment.short": "给拉取请求添加评论", + "cmd.release.short": "发布操作", + "cmd.release.list.short": "列出发布", + "cmd.release.create.short": "创建发布", + "cmd.release.view.short": "查看发布详情", + "cmd.release.delete.short": "删除发布", + "cmd.branch.short": "分支操作", + "cmd.branch.list.short": "列出分支", + "cmd.branch.create.short": "创建分支", + "cmd.branch.delete.short": "删除分支", + "cmd.branch.protect.short": "设置分支保护", + "cmd.branch.unprotect.short": "移除分支保护", + "cmd.org.short": "组织操作", + "cmd.org.list.short": "列出组织", + "cmd.org.info.short": "显示组织详情", + "cmd.org.members.short": "列出组织成员", + "cmd.org.create.short": "创建组织", + "cmd.user.short": "用户操作", + "cmd.user.me.short": "显示当前认证用户", + "cmd.user.info.short": "显示用户资料", + "cmd.search.short": "搜索操作", + "cmd.search.repos.short": "搜索仓库", + "cmd.search.users.short": "搜索用户", + "cmd.ci.short": "CI/CD 操作", + "cmd.ci.builds.short": "列出 CI 构建", + "cmd.ci.logs.short": "查看构建日志", + "cmd.ci.restart.short": "重启构建", + "cmd.ci.stop.short": "停止构建", + "cmd.webhook.short": "Webhook 操作", + "cmd.webhook.list.short": "列出仓库 Webhook", + "cmd.webhook.view.short": "查看 Webhook 详情", + "cmd.webhook.create.short": "创建仓库 Webhook", + "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", + "cmd.webhook.delete.short": "删除仓库 Webhook", + "cmd.webhook.test.short": "触发 Webhook 测试投递", + "cmd.webhook.tasks.short": "列出 Webhook 投递任务", + "flag.owner": "仓库所有者(自动从 git remote 检测)", + "flag.repo": "仓库名称(自动从 git remote 检测)", + "flag.format": "输出格式:json、table、yaml(默认:table)", + "flag.debug": "启用调试输出", + "flag.lang": "显示语言", + "flag.api.body": "请求体(JSON 字符串)", + "flag.api.query": "查询参数(key=val&key2=val2)", + "flag.api.header": "附加请求头(key:value)", + "flag.auth.token": "通过粘贴已有 Token 登录", + "flag.user": "用户登录名(默认:当前用户)", + "flag.user.login": "用户登录名", + "flag.page": "页码", + "flag.limit": "每页条目数", + "flag.description": "描述", + "flag.dry_run": "预览请求,不实际创建", + "flag.comment.body": "评论内容", + "flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)", + "flag.repo.name": "仓库名称", + "flag.repo.description": "仓库描述", + "flag.repo.private": "设为私有仓库(true/false)", + "flag.issue.state": "按状态筛选:open、closed、all", + "flag.issue.title": "议题标题", + "flag.issue.body": "议题描述", + "flag.issue.assignee": "负责人登录名", + "flag.issue.milestone": "里程碑 ID", + "flag.issue.label": "标签 ID", + "flag.issue.number": "议题编号(网页 URL 中显示的编号)", + "flag.issue.new_title": "新标题", + "flag.issue.new_body": "新描述", + "flag.issue.new_state": "新状态:open、closed 或数字 status_id", + "flag.issue.label_filter": "按已有标签筛选", + "flag.issue.older_than_days": "只包含至少这么多天未活动的议题", + "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", + "flag.issue.batch_close.state": "关闭前按议题状态筛选", + "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", + "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", + "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", + "flag.issue.batch.reason": "批量结果中显示的可选原因", + "flag.issue.batch_label.state": "按议题状态筛选", + "flag.issue.add_label": "要添加到每个匹配议题的标签", + "flag.pr.state": "筛选:open、merged、closed", + "flag.pr.title": "PR 标题", + "flag.pr.body": "PR 描述", + "flag.pr.head": "源分支", + "flag.pr.base": "目标分支", + "flag.pr.id": "PR 编号", + "flag.pr.merge_method": "合并方式:merge、rebase、squash", + "flag.pr.version_id": "补丁集版本 ID", + "flag.pr.file": "按文件路径筛选 diff", + "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", + "flag.pr.review_status": "评审状态:common、approved、rejected", + "flag.pr.review_content": "评审内容", + "flag.pr.review_commit": "关联评审的 Commit SHA", + "flag.branch.name": "分支名称", + "flag.branch.from": "源分支或 Commit", + "flag.release.tag": "标签名称", + "flag.release.name": "发布名称", + "flag.release.body": "发布说明", + "flag.release.target": "目标分支", + "flag.release.prerelease": "标记为预发布(true/false)", + "flag.release.id_or_tag": "发布 ID 或标签", + "flag.release.id": "发布 ID", + "flag.org.id_or_login": "组织 ID 或登录名", + "flag.org.id": "组织 ID", + "flag.org.name": "组织名称", + "flag.search.keyword": "搜索关键词", + "flag.ci.build": "构建编号", + "flag.ci.stage": "阶段编号", + "flag.ci.step": "步骤编号", + "flag.webhook.id": "Webhook ID", + "flag.webhook.url": "Webhook 目标 URL", + "flag.webhook.events": "逗号分隔的事件,例如:push,issues_only", + "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.content_type": "Payload 内容类型:json 或 form", + "flag.webhook.http_method": "HTTP 方法:POST 或 GET", + "flag.webhook.secret": "Webhook 密钥", + "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", + "flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", + "flag.webhook.active": "Webhook 是否启用:true 或 false", + "error.unsupported_language": "不支持的语言:{lang}", + "output.version": "gitlink-cli {version}" +} 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..f315815 --- /dev/null +++ b/internal/i18n/resolver.go @@ -0,0 +1,66 @@ +package i18n + +import ( + "os" + "strings" +) + +// ResolveOptions contains locale inputs ordered by caller intent. +type ResolveOptions struct { + ExplicitLang string + Env map[string]string + ConfigLang string +} + +// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback. +func ResolveLocale(opts ResolveOptions, available []string) string { + candidates := []string{ + opts.ExplicitLang, + envValue(opts.Env, "GITLINK_LANG"), + opts.ConfigLang, + envValue(opts.Env, "LC_ALL"), + envValue(opts.Env, "LANG"), + } + for _, candidate := range candidates { + if strings.TrimSpace(candidate) == "" { + continue + } + return MatchLocale(candidate, available, defaultFallbackLocale) + } + return MatchLocale(defaultFallbackLocale, available, defaultFallbackLocale) +} + +// 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..aa8e550 --- /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 call sites that have not been +// wired for dependency injection yet. +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..1510cfb --- /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|output|prompt|table)\.[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/issue/batch.go b/shortcuts/issue/batch.go index 73a7ee2..ae03986 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -60,68 +61,50 @@ type batchIssueOptions struct { Action string } -func newBatchListShortcut() *common.Shortcut { +func newBatchListShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "batch-list", - Description: "List issue batch maintenance candidates without changing remote data", - Long: `List issue batch maintenance candidates without changing remote data. - -Examples: - gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table - gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json`, + Description: tr.T("cmd.issue.batch_list.short"), + Long: tr.T("cmd.issue.batch_list.long"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by issue state: open, closed, all", Default: "open"}, - {Name: "label", Usage: "Filter by existing label"}, - {Name: "older-than-days", Usage: "Only include issues inactive for at least this many days"}, - {Name: "limit", Short: "l", Usage: "Maximum issues to return, capped at 100", Default: "50"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"}, + {Name: "label", Usage: tr.T("flag.issue.label_filter")}, + {Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")}, + {Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_list.limit"), Default: "50"}, }, Run: runBatchList, } } -func newBatchCloseShortcut() *common.Shortcut { +func newBatchCloseShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "batch-close", - Description: "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", - Long: `Close filtered issues in bulk. - -This command defaults to dry-run mode and only prints matching issues. -Pass --yes to execute remote close operations. Use restrictive filters and a small limit. - -Examples: - gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 - gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes`, + Description: tr.T("cmd.issue.batch_close.short"), + Long: tr.T("cmd.issue.batch_close.long"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by issue state before closing", Default: "open"}, - {Name: "label", Usage: "Filter by existing label"}, - {Name: "older-than-days", Usage: "Required safety filter; must be at least 7"}, - {Name: "limit", Short: "l", Usage: "Maximum issues to process, capped at 100", Default: "20"}, - {Name: "yes", Usage: "Execute remote close operations. Without this flag the command is dry-run only.", Bool: true, Default: "false"}, - {Name: "reason", Usage: "Optional reason shown in the batch result"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_close.state"), Default: "open"}, + {Name: "label", Usage: tr.T("flag.issue.label_filter")}, + {Name: "older-than-days", Usage: tr.T("flag.issue.batch_close.older_than_days")}, + {Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "20"}, + {Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"}, + {Name: "reason", Usage: tr.T("flag.issue.batch.reason")}, }, Run: runBatchClose, } } -func newBatchLabelShortcut() *common.Shortcut { +func newBatchLabelShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "batch-label", - Description: "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", - Long: `Add a label to filtered issues in bulk. - -This command defaults to dry-run mode and only prints matching issues. -Pass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable. - -Examples: - gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 - gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes`, + Description: tr.T("cmd.issue.batch_label.short"), + Long: tr.T("cmd.issue.batch_label.long"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by issue state", Default: "open"}, - {Name: "label", Usage: "Filter by existing label"}, - {Name: "add-label", Usage: "Label to add to each matching issue"}, - {Name: "older-than-days", Usage: "Only include issues inactive for at least this many days"}, - {Name: "limit", Short: "l", Usage: "Maximum issues to process, capped at 100", Default: "50"}, - {Name: "yes", Usage: "Execute remote label operations. Without this flag the command is dry-run only.", Bool: true, Default: "false"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_label.state"), Default: "open"}, + {Name: "label", Usage: tr.T("flag.issue.label_filter")}, + {Name: "add-label", Usage: tr.T("flag.issue.add_label")}, + {Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")}, + {Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "50"}, + {Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"}, }, Run: runBatchLabel, } diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 4ed2373..77760c7 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,18 +21,19 @@ type existingIssue struct { Description string } -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ - newBatchListShortcut(), - newBatchCloseShortcut(), - newBatchLabelShortcut(), + newBatchListShortcut(tr), + newBatchCloseShortcut(tr), + newBatchLabelShortcut(tr), { 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 { @@ -53,13 +55,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 { @@ -93,9 +95,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)", 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 { @@ -114,9 +116,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 { @@ -145,12 +147,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 { @@ -198,10 +200,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 { @@ -228,6 +230,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 7f9984c..5a4fca1 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -4,19 +4,21 @@ 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 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 { @@ -37,12 +39,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 { @@ -71,9 +73,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 { @@ -89,10 +91,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 { @@ -115,9 +117,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 { @@ -133,9 +135,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 { @@ -151,9 +153,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 { @@ -169,9 +171,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 { @@ -190,11 +192,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 { @@ -227,10 +229,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 { @@ -256,13 +258,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 { @@ -308,10 +310,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 { @@ -342,6 +344,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 84362f5..26e42ed 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" @@ -17,31 +18,31 @@ import ( ) // RegisterAll mounts all shortcut groups onto the root command. -func RegisterAll(root *cobra.Command) { +func RegisterAll(root *cobra.Command, tr *i18n.Translator) { groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(), - "issue": issue.Shortcuts(), - "pr": pr.Shortcuts(), - "release": release.Shortcuts(), - "branch": branch.Shortcuts(), - "org": org.Shortcuts(), - "user": user.Shortcuts(), - "search": search.Shortcuts(), - "ci": ci.Shortcuts(), - "webhook": webhook.Shortcuts(), + "repo": repo.Shortcuts(tr), + "issue": issue.Shortcuts(tr), + "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), + "webhook": webhook.Shortcuts(tr), } descriptions := map[string]string{ - "repo": "Repository operations", - "issue": "Issue operations", - "pr": "Pull request operations", - "release": "Release operations", - "branch": "Branch operations", - "org": "Organization operations", - "user": "User operations", - "search": "Search operations", - "ci": "CI/CD operations", - "webhook": "Webhook operations", + "repo": tr.T("cmd.repo.short"), + "issue": tr.T("cmd.issue.short"), + "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"), + "webhook": tr.T("cmd.webhook.short"), } for name, shortcuts := range groups { 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 75091a4..6d76264 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 @@ -54,11 +56,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") @@ -96,7 +98,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 @@ -110,7 +112,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 @@ -124,3 +126,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