diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 24ea8dc4..13ca350d 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -2,7 +2,9 @@ package auth import ( "bufio" + "errors" "fmt" + "io" "os" "strings" "syscall" @@ -38,54 +40,54 @@ func newLoginCmd(tr *i18n.Translator) *cobra.Command { Short: tr.T("cmd.auth.login.short"), RunE: func(cmd *cobra.Command, args []string) error { if tokenMode { - return loginWithToken() + return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr) } - return loginWithPassword() + return loginWithPassword(cmd.OutOrStdout(), tr) }, } cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token")) return cmd } -func loginWithPassword() error { +func loginWithPassword(out io.Writer, tr *i18n.Translator) error { reader := bufio.NewReader(os.Stdin) - fmt.Print("Username/Email/Phone: ") + fmt.Fprint(out, tr.T("prompt.auth.username")) username, _ := reader.ReadString('\n') username = strings.TrimSpace(username) - fmt.Print("Password: ") + fmt.Fprint(out, tr.T("prompt.auth.password")) passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { return fmt.Errorf("failed to read password: %w", err) } - fmt.Println() + fmt.Fprintln(out) password := string(passwordBytes) result, err := internalAuth.Login(username, password) if err != nil { - return fmt.Errorf("login failed: %w", err) + return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()})) } - fmt.Printf("✓ Logged in as %s\n", result.Login) + fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login})) return nil } -func loginWithToken() error { - reader := bufio.NewReader(os.Stdin) - fmt.Print("Paste your token: ") +func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error { + reader := bufio.NewReader(in) + fmt.Fprint(out, tr.T("prompt.auth.token")) token, _ := reader.ReadString('\n') token = strings.TrimSpace(token) if token == "" { - return fmt.Errorf("token cannot be empty") + return errors.New(tr.T("error.auth.token_empty")) } if err := internalAuth.StoreToken(token); err != nil { - return fmt.Errorf("failed to store token: %w", err) + return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()})) } - fmt.Println("✓ Token saved") + fmt.Fprintln(out, tr.T("success.auth.token_saved")) return nil } @@ -97,7 +99,7 @@ func newLogoutCmd(tr *i18n.Translator) *cobra.Command { if err := internalAuth.DeleteToken(); err != nil { return fmt.Errorf("failed to delete token: %w", err) } - fmt.Println("✓ Logged out") + fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out")) return nil }, } @@ -110,35 +112,35 @@ func newStatusCmd(tr *i18n.Translator) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { // Check env var token first if envToken := os.Getenv(envTokenVar); envToken != "" { - fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar) + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})) } token, err := internalAuth.LoadToken() if err != nil || token == "" { if os.Getenv(envTokenVar) == "" { - fmt.Println("✗ Not logged in") - fmt.Println(" Run: gitlink-cli auth login") - fmt.Printf(" Or set %s environment variable\n", envTokenVar) + fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.not_logged_in")) + fmt.Fprintln(cmd.OutOrStdout(), tr.T("output.auth.login_hint")) + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})) } return nil } user, err := internalAuth.GetCurrentUser() if err != nil { - fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err) + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()})) return nil } login, _ := user["login"].(string) name, _ := user["name"].(string) if login != "" { - fmt.Printf("✓ Logged in as %s", login) + text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login}) if name != "" { - fmt.Printf(" (%s)", name) + text = fmt.Sprintf("%s (%s)", text, name) } - fmt.Println() + fmt.Fprintln(cmd.OutOrStdout(), text) } else { - fmt.Println("✓ Token stored (user info unavailable)") + fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.user_unavailable")) } return nil }, diff --git a/cmd/config/config.go b/cmd/config/config.go index 414f786f..c21abc83 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "github.com/spf13/cobra" @@ -31,9 +32,9 @@ func newInitCmd(tr *i18n.Translator) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cfg := internalConfig.DefaultConfig() if err := internalConfig.Save(cfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) + return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()})) } - fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath()) + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()})) return nil }, } @@ -65,9 +66,9 @@ func newGetCmd(tr *i18n.Translator) *cobra.Command { return err } if val == "" { - fmt.Printf("%s: (not set)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set")) } else { - fmt.Printf("%s: %s\n", args[0], val) + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val) } return nil }, @@ -83,12 +84,13 @@ func newListCmd(tr *i18n.Translator) *cobra.Command { if err != nil { return err } - fmt.Printf("base_url: %s\n", cfg.BaseURL) - fmt.Printf("default_format: %s\n", cfg.Format) - fmt.Printf("editor: %s\n", cfg.Editor) - fmt.Printf("pager: %s\n", cfg.Pager) - fmt.Printf("lang: %s\n", cfg.Lang) - fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath()) + fmt.Fprintf(cmd.OutOrStdout(), "base_url: %s\n", cfg.BaseURL) + fmt.Fprintf(cmd.OutOrStdout(), "default_format: %s\n", cfg.Format) + fmt.Fprintf(cmd.OutOrStdout(), "editor: %s\n", cfg.Editor) + fmt.Fprintf(cmd.OutOrStdout(), "pager: %s\n", cfg.Pager) + fmt.Fprintf(cmd.OutOrStdout(), "lang: %s\n", cfg.Lang) + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()})) return nil }, } diff --git a/cmd/root.go b/cmd/root.go index f67b5169..eaaa3703 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -18,14 +19,16 @@ import ( var Version = "dev" type RootOptions struct { - Version string - Args []string + Version string + Args []string + Env map[string]string + ConfigLang string } 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"}) + tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang) if err != nil { return nil, err } @@ -75,15 +78,12 @@ func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command { 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) + }, nil) if err != nil { + fmt.Fprintln(os.Stderr, err) return err } @@ -94,17 +94,27 @@ func Execute() error { return nil } -func newTranslator(args []string) (*i18n.Translator, error) { +func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) { available, err := i18n.AvailableLocales() if err != nil { return nil, err } - locale := i18n.ResolveLocale(i18n.ResolveOptions{ + if env == nil { + env = i18n.EnvMap() + } + if configLang == "" { + configLang = loadConfigLangBestEffort() + } + resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{ ExplicitLang: i18n.PreScanLang(args), - Env: i18n.EnvMap(), - ConfigLang: loadConfigLangBestEffort(), + Env: env, + ConfigLang: configLang, }, available) - return i18n.New(i18n.Options{Locale: locale}) + if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") { + tr := i18n.Default() + return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested})) + } + return i18n.New(i18n.Options{Locale: resolved.Locale}) } func loadConfigLangBestEffort() string { diff --git a/cmd/root_test.go b/cmd/root_test.go index 3457b747..0c71a762 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -40,6 +40,115 @@ func TestRootHelpUsesSelectedLocale(t *testing.T) { } } +func TestRootHelpUsesExplicitLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--lang", "zh-CN", "--help"}, Env: map[string]string{}}, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + for _, want := range []string{"用于管理 GitLink", "显示语言", "仓库"} { + if !strings.Contains(help, want) { + t.Fatalf("expected %q in help, got:\n%s", want, help) + } + } +} + +func TestRootHelpUsesEnvLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"repo", "--help"}, + Env: map[string]string{"GITLINK_LANG": "zh-CN"}, + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + for _, want := range []string{"仓库操作", "仓库所有者", "仓库名称"} { + if !strings.Contains(help, want) { + t.Fatalf("expected %q in help, got:\n%s", want, help) + } + } +} + +func TestExplicitLangOverridesConfigLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "en-US", "--help"}, + Env: map[string]string{}, + ConfigLang: "zh-CN", + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + if !strings.Contains(help, "Repository operations") { + t.Fatalf("expected English help, got:\n%s", help) + } + if strings.Contains(help, "仓库操作") { + t.Fatalf("expected explicit en-US to override config zh-CN, got:\n%s", help) + } +} + +func TestUnsupportedExplicitLangReturnsError(t *testing.T) { + _, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "fr-FR", "--help"}, + Env: map[string]string{}, + }, nil) + if err == nil { + t.Fatal("expected unsupported language error") + } + if !strings.Contains(err.Error(), "unsupported language") { + t.Fatalf("expected unsupported language error, got %q", err.Error()) + } +} + +func TestRequireArgUsesLocalizedError(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "zh-CN", "repo", "+create"}, + Env: map[string]string{}, + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + err = root.Execute() + if err == nil { + t.Fatal("expected missing required flag error") + } + if !strings.Contains(err.Error(), "缺少必需参数") { + t.Fatalf("expected localized missing flag error, got %q", err.Error()) + } +} + func TestCoreCommandHelpUsesSelectedLocale(t *testing.T) { tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) if err != nil { diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 00000000..ee53fbc1 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,70 @@ +# GitLink CLI i18n Guide + +## Goals + +GitLink CLI localizes human-facing command-line text while keeping machine-readable output stable. The i18n layer is infrastructure, not a place to store every string in the project. + +## Translate + +- Cobra command `Short`, `Long`, and human-facing examples. +- Flag usage text. +- User-facing errors. +- Interactive prompts. +- Success messages. +- Warnings. +- Confirmation messages. +- Table column labels when the output is meant for humans. + +## Do Not Translate + +- JSON field names. +- Raw API response bodies. +- Debug logs and developer diagnostics. +- Machine-readable status enum values. +- HTTP methods, paths, query keys, and payload field names. +- Long-form README documentation. +- Test assertion descriptions. + +## Key Names + +Use stable, descriptive keys: + +- `cmd.*` for command help. +- `flag.*` for flag usage. +- `error.*` for user-facing errors. +- `prompt.*` for interactive input prompts. +- `success.*` for successful user-facing operations. +- `warning.*` for warnings. +- `confirm.*` for confirmation prompts. +- `table.*` for human table headers. + +Do not invent numbered keys such as `msg001`. Prefer names that describe ownership and intent, for example `error.missing_required_flag`. + +## Adding Text + +1. Add the key to `internal/i18n/locales/en-US.json`. +2. Add the same key to every other locale, including `zh-CN.json`. +3. Keep placeholders identical across locales, for example `{name}`. +4. Use `tr.T("key")` or `tr.Tf("key", i18n.Args{...})`. +5. Run: + + ```powershell + go run ./internal/i18n/cmd/check + go test ./... + ``` + +Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON. + +## Runtime Access + +Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`. + +New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths. + +## Review Checklist + +- Locale JSON is sorted and formatted with two spaces. +- Every locale has the same keys as `en-US`. +- Template placeholders match across locales. +- New command/runtime text uses i18n only when it is human-facing. +- JSON output, API raw responses, debug logs, and machine-readable values are unchanged. diff --git a/internal/i18n/cmd/check/main.go b/internal/i18n/cmd/check/main.go index adb07025..f5de219e 100644 --- a/internal/i18n/cmd/check/main.go +++ b/internal/i18n/cmd/check/main.go @@ -1,13 +1,24 @@ package main import ( + "bytes" + "encoding/json" + "flag" "fmt" "os" + "path/filepath" + "regexp" + "sort" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" ) func main() { + fix := flag.Bool("fix", false, "format locale JSON files") + scanCode := flag.Bool("scan-code", false, "scan Go source for referenced i18n keys") + flag.Parse() + problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US") if err != nil { fmt.Fprintln(os.Stderr, err) @@ -19,5 +30,128 @@ func main() { } os.Exit(1) } + if err := checkLocaleFormat(*fix); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if *scanCode { + if err := checkCodeReferences(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } fmt.Println("i18n messages are valid") } + +func checkLocaleFormat(fix bool) error { + files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json")) + if err != nil { + return err + } + for _, path := range files { + data, err := os.ReadFile(path) + if err != nil { + return err + } + formatted, err := formatJSON(data) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if string(data) == string(formatted) { + continue + } + if fix { + if err := os.WriteFile(path, formatted, 0644); err != nil { + return err + } + continue + } + return fmt.Errorf("%s: locale JSON is not formatted; run go run ./internal/i18n/cmd/check --fix", path) + } + return nil +} + +func formatJSON(data []byte) ([]byte, error) { + var messages map[string]string + if err := json.Unmarshal(data, &messages); err != nil { + return nil, err + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(messages); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func checkCodeReferences() error { + loader := i18n.NewEmbedLoader() + base, err := loader.Load("en-US") + if err != nil { + return err + } + used, defaultUses, err := scanCodeKeys([]string{"cmd", "shortcuts", "internal"}) + if err != nil { + return err + } + var missing []string + for key := range used { + if _, ok := base[key]; !ok { + missing = append(missing, key) + } + } + sort.Strings(missing) + if len(missing) > 0 { + return fmt.Errorf("missing i18n key references: %s", strings.Join(missing, ", ")) + } + for _, item := range defaultUses { + fmt.Fprintf(os.Stderr, "warning: avoid new i18n.Default() usage at %s\n", item) + } + return nil +} + +func scanCodeKeys(roots []string) (map[string]struct{}, []string, error) { + keyPattern := regexp.MustCompile(`(?:tr|ctx\.Tr|i18n\.Default\(\))\.T(?:f)?\("([^"]+)"`) + defaultPattern := regexp.MustCompile(`i18n\.Default\(\)\.T(?:f)?\("([^"]+)"`) + used := map[string]struct{}{} + var defaultUses []string + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if strings.Contains(filepath.ToSlash(path), "internal/i18n/locales") { + return filepath.SkipDir + } + return nil + } + if filepath.Ext(path) != ".go" { + return nil + } + if strings.HasSuffix(path, "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + text := string(data) + for _, match := range keyPattern.FindAllStringSubmatch(text, -1) { + used[match[1]] = struct{}{} + } + for _, match := range defaultPattern.FindAllStringSubmatchIndex(text, -1) { + line := 1 + strings.Count(text[:match[0]], "\n") + defaultUses = append(defaultUses, fmt.Sprintf("%s:%d", filepath.ToSlash(path), line)) + } + return nil + }) + if err != nil { + return nil, nil, err + } + } + sort.Strings(defaultUses) + return used, defaultUses, nil +} diff --git a/internal/i18n/locale.go b/internal/i18n/locale.go index 7d85c61b..0ec71312 100644 --- a/internal/i18n/locale.go +++ b/internal/i18n/locale.go @@ -35,48 +35,50 @@ func NormalizeLocale(locale string) string { return strings.Join(normalized, "-") } -// MatchLocale resolves requested to one of available using exact, normalized, -// language-primary, then fallback matching. +// MatchLocale resolves requested to one of available using exact, safe alias, +// then fallback matching. func MatchLocale(requested string, available []string, fallback string) string { + return matchLocale(requested, available, fallback).Locale +} + +type localeMatch struct { + Locale string + Requested string + Fallbacked bool + Supported bool +} + +func matchLocale(requested string, available []string, fallback string) localeMatch { fallback = NormalizeLocale(fallback) if fallback == "" { fallback = defaultFallbackLocale } if len(available) == 0 { - return fallback + return localeMatch{Locale: fallback, Requested: NormalizeLocale(requested), Fallbacked: true} } 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 + return localeMatch{Locale: matched, Requested: candidate, Supported: true} } if alias := localeAlias(candidate); alias != "" { if matched, ok := byLocale[alias]; ok { - return matched + return localeMatch{Locale: matched, Requested: candidate, Supported: true} } } - if matched, ok := byPrimary[primaryLanguage(candidate)]; ok { - return matched - } } if matched, ok := byLocale[fallback]; ok { - return matched + return localeMatch{Locale: matched, Requested: candidate, Fallbacked: candidate != "", Supported: false} } - return NormalizeLocale(available[0]) + return localeMatch{Locale: NormalizeLocale(available[0]), Requested: candidate, Fallbacked: candidate != "", Supported: false} } func primaryLanguage(locale string) string { @@ -87,10 +89,10 @@ func primaryLanguage(locale string) string { } func localeAlias(locale string) string { - switch primaryLanguage(locale) { - case "zh": + switch { + case locale == "zh" || locale == "zh-CN" || locale == "zh-Hans" || locale == "zh-Hans-CN": return "zh-CN" - case "en": + case primaryLanguage(locale) == "en": return "en-US" default: return "" diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 63de8280..c8ba55ce 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -1,164 +1,184 @@ { - "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.api.short": "Make raw API requests to GitLink", "cmd.auth.login.short": "Login to GitLink", "cmd.auth.logout.short": "Logout from GitLink", + "cmd.auth.short": "Authentication commands", "cmd.auth.status.short": "Show authentication status", - "cmd.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.list.short": "List branches", "cmd.branch.protect.short": "Set branch protection", + "cmd.branch.short": "Branch operations", "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.short": "CI/CD operations", "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.config.get.short": "Get a configuration value", + "cmd.config.init.short": "Initialize configuration file", + "cmd.config.list.short": "List all configuration values", + "cmd.config.set.short": "Set a configuration value", + "cmd.config.short": "Manage gitlink-cli configuration", + "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", + "cmd.issue.close.short": "Close an issue", + "cmd.issue.comment.short": "Add a comment to an issue", + "cmd.issue.create.short": "Create a new issue", + "cmd.issue.list.short": "List issues", + "cmd.issue.short": "Issue operations", + "cmd.issue.update.short": "Update an issue", + "cmd.issue.view.short": "View issue details", + "cmd.org.create.short": "Create an organization", + "cmd.org.info.short": "Show organization details", + "cmd.org.list.short": "List organizations", + "cmd.org.members.short": "List organization members", + "cmd.org.short": "Organization operations", + "cmd.pr.close.short": "Close a pull request", + "cmd.pr.comment.short": "Add a comment to a pull request", + "cmd.pr.create.short": "Create a pull request", + "cmd.pr.diff.short": "Show diff for a pull request", + "cmd.pr.files.short": "List changed files in a pull request", + "cmd.pr.list.short": "List pull requests", + "cmd.pr.merge.short": "Merge a pull request", + "cmd.pr.review.short": "Create a pull request review", + "cmd.pr.reviews.short": "List pull request reviews", + "cmd.pr.short": "Pull request operations", + "cmd.pr.version_diff.short": "Show diff for a pull request patchset version", + "cmd.pr.versions.short": "List pull request patchset versions", + "cmd.pr.view.short": "View pull request details", + "cmd.release.create.short": "Create a release", + "cmd.release.delete.short": "Delete a release", + "cmd.release.list.short": "List releases", + "cmd.release.short": "Release operations", + "cmd.release.view.short": "View release details", + "cmd.repo.create.short": "Create a new repository", + "cmd.repo.delete.short": "Delete a repository", + "cmd.repo.fork.short": "Fork a repository", + "cmd.repo.info.short": "Show repository details", + "cmd.repo.list.short": "List repositories for a user or organization", + "cmd.repo.short": "Repository operations", + "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", + "cmd.root.short": "GitLink CLI - command-line tool for GitLink", + "cmd.search.repos.short": "Search repositories", + "cmd.search.short": "Search operations", + "cmd.search.users.short": "Search users", + "cmd.user.info.short": "Show user profile", + "cmd.user.me.short": "Show current authenticated user", + "cmd.user.short": "User operations", + "cmd.version.short": "Print version information", "cmd.webhook.create.short": "Create a repository webhook", - "cmd.webhook.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.list.short": "List repository webhooks", + "cmd.webhook.short": "Webhook operations", "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", + "cmd.webhook.test.short": "Trigger a test delivery for a webhook", + "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", + "cmd.webhook.view.short": "View webhook details", + "error.auth.login_failed": "login failed: {message}", + "error.auth.store_token_failed": "failed to store token: {message}", + "error.auth.token_empty": "token cannot be empty", + "error.config.save_failed": "failed to save config: {message}", + "error.missing_required_flag": "required flag --{name} is missing", + "error.unsupported_language": "unsupported language: {lang}", "flag.api.body": "Request body (JSON string)", - "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.api.header": "Additional headers (key:value)", + "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.auth.token": "Login by pasting an existing token", - "flag.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.branch.name": "Branch name", "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.comment.body": "Comment body", + "flag.debug": "Enable debug output", + "flag.description": "Description", + "flag.dry_run": "Preview the request without creating it", + "flag.format": "Output format: json, table, yaml (default: table)", + "flag.issue.add_label": "Label to add to each matching issue", + "flag.issue.assignee": "Assignee login", + "flag.issue.batch.reason": "Optional reason shown in the batch result", + "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", + "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", + "flag.issue.batch_close.state": "Filter by issue state before closing", + "flag.issue.batch_label.state": "Filter by issue state", + "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", + "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", + "flag.issue.body": "Issue description", + "flag.issue.label": "Label ID", + "flag.issue.label_filter": "Filter by existing label", + "flag.issue.milestone": "Milestone ID", + "flag.issue.new_body": "New description", + "flag.issue.new_state": "New state: open, closed, or numeric status_id", + "flag.issue.new_title": "New title", + "flag.issue.number": "Issue number (as shown in the web URL)", + "flag.issue.older_than_days": "Only include issues inactive for at least this many days", + "flag.issue.state": "Filter by state: open, closed, all", + "flag.issue.title": "Issue title", + "flag.lang": "Display language", + "flag.limit": "Items per page", + "flag.org.id": "Organization ID", + "flag.org.id_or_login": "Organization ID or login", + "flag.org.name": "Organization name", + "flag.owner": "Repository owner (auto-detected from git remote)", + "flag.page": "Page number", + "flag.pr.base": "Target branch", + "flag.pr.body": "PR description", + "flag.pr.file": "Filter diff by file path", + "flag.pr.head": "Source branch", + "flag.pr.id": "PR number", + "flag.pr.merge_method": "Merge method: merge, rebase, squash", + "flag.pr.review_commit": "Commit SHA to attach the review to", + "flag.pr.review_content": "Review content", + "flag.pr.review_status": "Review status: common, approved, rejected", + "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", + "flag.pr.state": "Filter: open, merged, closed", + "flag.pr.title": "PR title", + "flag.pr.version_id": "Patchset version ID", + "flag.release.body": "Release notes", + "flag.release.id": "Release ID", + "flag.release.id_or_tag": "Release ID or tag", + "flag.release.name": "Release name", + "flag.release.prerelease": "Mark as prerelease (true/false)", + "flag.release.tag": "Tag name", + "flag.release.target": "Target branch", + "flag.repo": "Repository name (auto-detected from git remote)", + "flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)", + "flag.repo.description": "Repository description", + "flag.repo.name": "Repository name", + "flag.repo.private": "Make repository private (true/false)", + "flag.search.keyword": "Search keyword", + "flag.user": "User login (default: current user)", + "flag.user.login": "User login name", + "flag.webhook.active": "Whether the webhook is active: true or false", + "flag.webhook.branch_filter": "Branch glob filter for push/create/delete events", "flag.webhook.content_type": "Payload content type: json or form", + "flag.webhook.events": "Comma-separated events, for example: push,issues_only", "flag.webhook.http_method": "HTTP method: POST or GET", + "flag.webhook.id": "Webhook ID", "flag.webhook.secret": "Webhook secret", "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", - "flag.webhook.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}" + "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.url": "Webhook target URL", + "output.auth.env_hint": " Or set {env} environment variable", + "output.auth.login_hint": " Run: gitlink-cli auth login", + "output.config.file": "Config file: {path}", + "output.config.not_set": "(not set)", + "output.version": "gitlink-cli {version}", + "prompt.auth.password": "Password: ", + "prompt.auth.token": "Paste your access token: ", + "prompt.auth.username": "Username/Email/Phone: ", + "success.auth.logged_in_as": "✓ Logged in as {login}", + "success.auth.logged_in_via_env": "✓ Logged in via {env} environment variable", + "success.auth.logged_out": "✓ Logged out", + "success.auth.token_saved": "✓ Token saved", + "success.config.initialized": "✓ Config initialized at {path}", + "warning.auth.not_logged_in": "✗ Not logged in", + "warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})", + "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)" } diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index b60f8f87..b70298c8 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -1,164 +1,184 @@ { - "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.api.short": "向 GitLink 发起原始 API 请求", "cmd.auth.login.short": "登录 GitLink", "cmd.auth.logout.short": "退出 GitLink 登录", + "cmd.auth.short": "认证命令", "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.list.short": "列出分支", "cmd.branch.protect.short": "设置分支保护", + "cmd.branch.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.short": "CI/CD 操作", "cmd.ci.stop.short": "停止构建", - "cmd.webhook.short": "Webhook 操作", - "cmd.webhook.list.short": "列出仓库 Webhook", - "cmd.webhook.view.short": "查看 Webhook 详情", + "cmd.config.get.short": "获取配置项", + "cmd.config.init.short": "初始化配置文件", + "cmd.config.list.short": "列出所有配置项", + "cmd.config.set.short": "设置配置项", + "cmd.config.short": "管理 gitlink-cli 配置", + "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", + "cmd.issue.close.short": "关闭议题", + "cmd.issue.comment.short": "给议题添加评论", + "cmd.issue.create.short": "创建新议题", + "cmd.issue.list.short": "列出议题", + "cmd.issue.short": "议题操作", + "cmd.issue.update.short": "更新议题", + "cmd.issue.view.short": "查看议题详情", + "cmd.org.create.short": "创建组织", + "cmd.org.info.short": "显示组织详情", + "cmd.org.list.short": "列出组织", + "cmd.org.members.short": "列出组织成员", + "cmd.org.short": "组织操作", + "cmd.pr.close.short": "关闭拉取请求", + "cmd.pr.comment.short": "给拉取请求添加评论", + "cmd.pr.create.short": "创建拉取请求", + "cmd.pr.diff.short": "显示拉取请求 diff", + "cmd.pr.files.short": "列出拉取请求中的变更文件", + "cmd.pr.list.short": "列出拉取请求", + "cmd.pr.merge.short": "合并拉取请求", + "cmd.pr.review.short": "创建拉取请求评审", + "cmd.pr.reviews.short": "列出拉取请求评审", + "cmd.pr.short": "拉取请求操作", + "cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff", + "cmd.pr.versions.short": "列出拉取请求补丁集版本", + "cmd.pr.view.short": "查看拉取请求详情", + "cmd.release.create.short": "创建发布", + "cmd.release.delete.short": "删除发布", + "cmd.release.list.short": "列出发布", + "cmd.release.short": "发布操作", + "cmd.release.view.short": "查看发布详情", + "cmd.repo.create.short": "创建新仓库", + "cmd.repo.delete.short": "删除仓库", + "cmd.repo.fork.short": "Fork 仓库", + "cmd.repo.info.short": "显示仓库详情", + "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.short": "仓库操作", + "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", + "cmd.root.short": "GitLink CLI - GitLink 命令行工具", + "cmd.search.repos.short": "搜索仓库", + "cmd.search.short": "搜索操作", + "cmd.search.users.short": "搜索用户", + "cmd.user.info.short": "显示用户资料", + "cmd.user.me.short": "显示当前认证用户", + "cmd.user.short": "用户操作", + "cmd.version.short": "打印版本信息", "cmd.webhook.create.short": "创建仓库 Webhook", - "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", "cmd.webhook.delete.short": "删除仓库 Webhook", - "cmd.webhook.test.short": "触发 Webhook 测试投递", + "cmd.webhook.list.short": "列出仓库 Webhook", + "cmd.webhook.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": "显示语言", + "cmd.webhook.test.short": "触发 Webhook 测试投递", + "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", + "cmd.webhook.view.short": "查看 Webhook 详情", + "error.auth.login_failed": "登录失败:{message}", + "error.auth.store_token_failed": "保存 Token 失败:{message}", + "error.auth.token_empty": "Token 不能为空", + "error.config.save_failed": "保存配置失败:{message}", + "error.missing_required_flag": "缺少必需参数 --{name}", + "error.unsupported_language": "不支持的语言:{lang}", "flag.api.body": "请求体(JSON 字符串)", - "flag.api.query": "查询参数(key=val&key2=val2)", "flag.api.header": "附加请求头(key:value)", + "flag.api.query": "查询参数(key=val&key2=val2)", "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.branch.name": "分支名称", "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.comment.body": "评论内容", + "flag.debug": "启用调试输出", + "flag.description": "描述", + "flag.dry_run": "预览请求,不实际创建", + "flag.format": "输出格式:json、table、yaml(默认:table)", + "flag.issue.add_label": "要添加到每个匹配议题的标签", + "flag.issue.assignee": "负责人登录名", + "flag.issue.batch.reason": "批量结果中显示的可选原因", + "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", + "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", + "flag.issue.batch_close.state": "关闭前按议题状态筛选", + "flag.issue.batch_label.state": "按议题状态筛选", + "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", + "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", + "flag.issue.body": "议题描述", + "flag.issue.label": "标签 ID", + "flag.issue.label_filter": "按已有标签筛选", + "flag.issue.milestone": "里程碑 ID", + "flag.issue.new_body": "新描述", + "flag.issue.new_state": "新状态:open、closed 或数字 status_id", + "flag.issue.new_title": "新标题", + "flag.issue.number": "议题编号(网页 URL 中显示的编号)", + "flag.issue.older_than_days": "只包含至少这么多天未活动的议题", + "flag.issue.state": "按状态筛选:open、closed、all", + "flag.issue.title": "议题标题", + "flag.lang": "显示语言", + "flag.limit": "每页条目数", + "flag.org.id": "组织 ID", + "flag.org.id_or_login": "组织 ID 或登录名", + "flag.org.name": "组织名称", + "flag.owner": "仓库所有者(自动从 git remote 检测)", + "flag.page": "页码", + "flag.pr.base": "目标分支", + "flag.pr.body": "PR 描述", + "flag.pr.file": "按文件路径筛选 diff", + "flag.pr.head": "源分支", + "flag.pr.id": "PR 编号", + "flag.pr.merge_method": "合并方式:merge、rebase、squash", + "flag.pr.review_commit": "关联评审的 Commit SHA", + "flag.pr.review_content": "评审内容", + "flag.pr.review_status": "评审状态:common、approved、rejected", + "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", + "flag.pr.state": "筛选:open、merged、closed", + "flag.pr.title": "PR 标题", + "flag.pr.version_id": "补丁集版本 ID", + "flag.release.body": "发布说明", + "flag.release.id": "发布 ID", + "flag.release.id_or_tag": "发布 ID 或标签", + "flag.release.name": "发布名称", + "flag.release.prerelease": "标记为预发布(true/false)", + "flag.release.tag": "标签名称", + "flag.release.target": "目标分支", + "flag.repo": "仓库名称(自动从 git remote 检测)", + "flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)", + "flag.repo.description": "仓库描述", + "flag.repo.name": "仓库名称", + "flag.repo.private": "设为私有仓库(true/false)", + "flag.search.keyword": "搜索关键词", + "flag.user": "用户登录名(默认:当前用户)", + "flag.user.login": "用户登录名", + "flag.webhook.active": "Webhook 是否启用:true 或 false", + "flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", "flag.webhook.content_type": "Payload 内容类型:json 或 form", + "flag.webhook.events": "逗号分隔的事件,例如:push,issues_only", "flag.webhook.http_method": "HTTP 方法:POST 或 GET", + "flag.webhook.id": "Webhook ID", "flag.webhook.secret": "Webhook 密钥", "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", - "flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", - "flag.webhook.active": "Webhook 是否启用:true 或 false", - "error.unsupported_language": "不支持的语言:{lang}", - "output.version": "gitlink-cli {version}" + "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.url": "Webhook 目标 URL", + "output.auth.env_hint": " 或设置 {env} 环境变量", + "output.auth.login_hint": " 运行:gitlink-cli auth login", + "output.config.file": "配置文件:{path}", + "output.config.not_set": "(未设置)", + "output.version": "gitlink-cli {version}", + "prompt.auth.password": "密码:", + "prompt.auth.token": "粘贴你的访问 Token:", + "prompt.auth.username": "用户名/邮箱/手机号:", + "success.auth.logged_in_as": "✓ 已登录为 {login}", + "success.auth.logged_in_via_env": "✓ 已通过 {env} 环境变量登录", + "success.auth.logged_out": "✓ 已退出登录", + "success.auth.token_saved": "✓ Token 已保存", + "success.config.initialized": "✓ 配置已初始化:{path}", + "warning.auth.not_logged_in": "✗ 未登录", + "warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})", + "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)" } diff --git a/internal/i18n/resolver.go b/internal/i18n/resolver.go index f315815a..5e2e6942 100644 --- a/internal/i18n/resolver.go +++ b/internal/i18n/resolver.go @@ -12,22 +12,51 @@ type ResolveOptions struct { ConfigLang string } +type ResolvedLocale struct { + Locale string + Source string + Requested string + Fallbacked bool + Supported bool +} + // ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback. func ResolveLocale(opts ResolveOptions, available []string) string { - candidates := []string{ - opts.ExplicitLang, - envValue(opts.Env, "GITLINK_LANG"), - opts.ConfigLang, - envValue(opts.Env, "LC_ALL"), - envValue(opts.Env, "LANG"), + return ResolveLocaleDetailed(opts, available).Locale +} + +func ResolveLocaleDetailed(opts ResolveOptions, available []string) ResolvedLocale { + candidates := []struct { + source string + value string + }{ + {source: "flag", value: opts.ExplicitLang}, + {source: "env", value: envValue(opts.Env, "GITLINK_LANG")}, + {source: "config", value: opts.ConfigLang}, + {source: "lc_all", value: envValue(opts.Env, "LC_ALL")}, + {source: "lang", value: envValue(opts.Env, "LANG")}, } for _, candidate := range candidates { - if strings.TrimSpace(candidate) == "" { + if strings.TrimSpace(candidate.value) == "" { continue } - return MatchLocale(candidate, available, defaultFallbackLocale) + match := matchLocale(candidate.value, available, defaultFallbackLocale) + return ResolvedLocale{ + Locale: match.Locale, + Source: candidate.source, + Requested: match.Requested, + Fallbacked: match.Fallbacked, + Supported: match.Supported, + } + } + match := matchLocale(defaultFallbackLocale, available, defaultFallbackLocale) + return ResolvedLocale{ + Locale: match.Locale, + Source: "default", + Requested: match.Requested, + Fallbacked: match.Fallbacked, + Supported: match.Supported, } - return MatchLocale(defaultFallbackLocale, available, defaultFallbackLocale) } // PreScanLang reads --lang before Cobra constructs localized help text. diff --git a/internal/i18n/translator.go b/internal/i18n/translator.go index aa8e5506..170f3bd9 100644 --- a/internal/i18n/translator.go +++ b/internal/i18n/translator.go @@ -10,8 +10,8 @@ type Translator struct { fallback map[string]string } -// Default returns an English translator for call sites that have not been -// wired for dependency injection yet. +// Default returns an English translator for legacy migration only. +// New command code should receive *Translator explicitly. func Default() *Translator { tr, err := New(Options{Locale: defaultFallbackLocale}) if err != nil { diff --git a/internal/i18n/validate.go b/internal/i18n/validate.go index 1510cfbe..99bb4e94 100644 --- a/internal/i18n/validate.go +++ b/internal/i18n/validate.go @@ -8,7 +8,7 @@ import ( "strings" ) -var keyPattern = regexp.MustCompile(`^(cmd|flag|error|output|prompt|table)\.[a-z0-9_.-]+$`) +var keyPattern = regexp.MustCompile(`^(cmd|flag|error|prompt|success|warning|confirm|table|output)\.[a-z0-9_.-]+$`) // Problem describes a locale validation issue. type Problem struct { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 3648d1ff..9bdc5bcb 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -3,11 +3,16 @@ package common import ( "strconv" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/spf13/cobra" ) // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. -func MountShortcut(parent *cobra.Command, s *Shortcut) { +func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cmd := &cobra.Command{ Use: "+" + s.Name, Short: s.Description, @@ -27,10 +32,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } } - ctx, err := NewRuntimeContext(flagValues) + ctx, err := NewRuntimeContext(flagValues, tr) if err != nil { return err } + for _, f := range s.Flags { + if f.Required && flagValues[f.Name] == "" { + _, err := ctx.RequireArg(f.Name) + return err + } + } return s.Run(ctx) }, @@ -49,17 +60,18 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } else { cmd.Flags().String(f.Name, f.Default, f.Usage) } - if f.Required { - cmd.MarkFlagRequired(f.Name) - } } parent.AddCommand(cmd) } // MountShortcuts mounts multiple shortcuts under a parent command. -func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) { +func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut, translators ...*i18n.Translator) { + var tr *i18n.Translator + if len(translators) > 0 { + tr = translators[0] + } for _, s := range shortcuts { - MountShortcut(parent, s) + MountShortcut(parent, s, tr) } } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index ad01ce38..f7d5a457 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -2,12 +2,14 @@ package common import ( "encoding/json" + "errors" "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/context" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -37,10 +39,14 @@ type RuntimeContext struct { Repo string Format string Args map[string]string + Tr *i18n.Translator } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. -func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { +func NewRuntimeContext(args map[string]string, tr *i18n.Translator) (*RuntimeContext, error) { + if tr == nil { + tr = i18n.Default() + } cli, err := client.New() if err != nil { return nil, err @@ -58,6 +64,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { Repo: cmdutil.Repo, Format: format, Args: args, + Tr: tr, }, nil } @@ -114,7 +121,7 @@ func (ctx *RuntimeContext) Arg(name string) string { func (ctx *RuntimeContext) RequireArg(name string) (string, error) { v := ctx.Arg(name) if v == "" { - return "", fmt.Errorf("required flag --%s is missing", name) + return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name})) } return v, nil } diff --git a/shortcuts/register.go b/shortcuts/register.go index 26e42eda..5d642222 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -50,7 +50,7 @@ func RegisterAll(root *cobra.Command, tr *i18n.Translator) { Use: name, Short: descriptions[name], } - common.MountShortcuts(groupCmd, shortcuts) + common.MountShortcuts(groupCmd, shortcuts, tr) root.AddCommand(groupCmd) } }