feat(i18n): add remaining API features for stability
Implements the final features from the semantic i18n plan:
- Template caching: sync.Map cache for compiled templates
- Translator interface: enables mocking for tests
- Custom intent registration: thread-safe RegisterIntents(), UnregisterIntent()
- JSON-based grammar: verb/noun forms in locale files, checked before computed
- Fallback chain: T() tries common.action.{verb} and common.{verb}
- CLI enhancements: Timeout(), Filter(), Multi() options, ChooseMulti()
- Intent key constants: type-safe IntentCore* and Key* constants
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
46f6d4c5fe
commit
829be45fcc
11 changed files with 1029 additions and 37 deletions
|
|
@ -44,3 +44,8 @@ tasks:
|
|||
cmds:
|
||||
- task: cov
|
||||
- go tool cover -html=coverage.txt
|
||||
|
||||
i18n:generate:
|
||||
desc: "Regenerate i18n key constants from locale files"
|
||||
cmds:
|
||||
- go generate ./pkg/i18n/...
|
||||
|
|
|
|||
182
pkg/cli/utils.go
182
pkg/cli/utils.go
|
|
@ -34,6 +34,7 @@ type ConfirmOption func(*confirmConfig)
|
|||
type confirmConfig struct {
|
||||
defaultYes bool
|
||||
required bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
||||
|
|
@ -50,6 +51,17 @@ func Required() ConfirmOption {
|
|||
}
|
||||
}
|
||||
|
||||
// Timeout sets a timeout after which the default response is auto-selected.
|
||||
// If no default is set (not Required and not DefaultYes), defaults to "no".
|
||||
//
|
||||
// Confirm("Continue?", Timeout(30*time.Second)) // Auto-no after 30s
|
||||
// Confirm("Continue?", DefaultYes(), Timeout(10*time.Second)) // Auto-yes after 10s
|
||||
func Timeout(d time.Duration) ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm prompts the user for yes/no confirmation.
|
||||
// Returns true if the user enters "y" or "yes" (case-insensitive).
|
||||
//
|
||||
|
|
@ -61,6 +73,7 @@ func Required() ConfirmOption {
|
|||
//
|
||||
// if Confirm("Save changes?", DefaultYes()) { ... }
|
||||
// if Confirm("Dangerous!", Required()) { ... }
|
||||
// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... }
|
||||
func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||
cfg := &confirmConfig{}
|
||||
for _, opt := range opts {
|
||||
|
|
@ -77,12 +90,37 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
suffix = "[y/N] "
|
||||
}
|
||||
|
||||
// Add timeout indicator if set
|
||||
if cfg.timeout > 0 {
|
||||
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Printf("%s %s", prompt, suffix)
|
||||
response, _ := reader.ReadString('\n')
|
||||
|
||||
var response string
|
||||
|
||||
if cfg.timeout > 0 {
|
||||
// Use timeout-based reading
|
||||
resultChan := make(chan string, 1)
|
||||
go func() {
|
||||
line, _ := reader.ReadString('\n')
|
||||
resultChan <- line
|
||||
}()
|
||||
|
||||
select {
|
||||
case response = <-resultChan:
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
case <-time.After(cfg.timeout):
|
||||
fmt.Println() // New line after timeout
|
||||
return cfg.defaultYes
|
||||
}
|
||||
} else {
|
||||
response, _ = reader.ReadString('\n')
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
}
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
|
|
@ -238,6 +276,8 @@ type ChooseOption[T any] func(*chooseConfig[T])
|
|||
type chooseConfig[T any] struct {
|
||||
displayFn func(T) string
|
||||
defaultN int // 0-based index of default selection
|
||||
filter bool // Enable fuzzy filtering
|
||||
multi bool // Allow multiple selection
|
||||
}
|
||||
|
||||
// WithDisplay sets a custom display function for items.
|
||||
|
|
@ -254,6 +294,32 @@ func WithDefaultIndex[T any](idx int) ChooseOption[T] {
|
|||
}
|
||||
}
|
||||
|
||||
// Filter enables type-to-filter functionality.
|
||||
// Users can type to narrow down the list of options.
|
||||
// Note: This is a hint for interactive UIs; the basic CLI Choose
|
||||
// implementation uses numbered selection which doesn't support filtering.
|
||||
func Filter[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.filter = true
|
||||
}
|
||||
}
|
||||
|
||||
// Multi allows multiple selections.
|
||||
// Use ChooseMulti instead of Choose when this option is needed.
|
||||
func Multi[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.multi = true
|
||||
}
|
||||
}
|
||||
|
||||
// Display sets a custom display function for items.
|
||||
// Alias for WithDisplay for shorter syntax.
|
||||
//
|
||||
// Choose("Select:", items, Display(func(f File) string { return f.Name }))
|
||||
func Display[T any](fn func(T) string) ChooseOption[T] {
|
||||
return WithDisplay[T](fn)
|
||||
}
|
||||
|
||||
// Choose prompts the user to select from a list of items.
|
||||
// Returns the selected item. Uses simple numbered selection for terminal compatibility.
|
||||
//
|
||||
|
|
@ -314,6 +380,120 @@ func ChooseIntent[T any](intent string, subject *i18n.Subject, items []T, opts .
|
|||
return Choose(result.Question, items, opts...)
|
||||
}
|
||||
|
||||
// ChooseMulti prompts the user to select multiple items from a list.
|
||||
// Returns the selected items. Uses space-separated numbers or ranges.
|
||||
//
|
||||
// choices := ChooseMulti("Select files:", files)
|
||||
// choices := ChooseMulti("Select files:", files, WithDisplay(func(f File) string { return f.Name }))
|
||||
//
|
||||
// Input format:
|
||||
// - "1 3 5" - select items 1, 3, and 5
|
||||
// - "1-3" - select items 1, 2, and 3
|
||||
// - "1 3-5" - select items 1, 3, 4, and 5
|
||||
// - "" (empty) - select none
|
||||
func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Display options
|
||||
fmt.Println(prompt)
|
||||
for i, item := range items {
|
||||
fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Empty response returns no selections
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the selection
|
||||
selected, err := parseMultiSelection(response, len(items))
|
||||
if err != nil {
|
||||
fmt.Printf("Invalid selection: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := make([]T, 0, len(selected))
|
||||
for _, idx := range selected {
|
||||
result = append(result, items[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5".
|
||||
// Returns 0-based indices.
|
||||
func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
||||
selected := make(map[int]bool)
|
||||
parts := strings.Fields(input)
|
||||
|
||||
for _, part := range parts {
|
||||
// Check for range (e.g., "1-3")
|
||||
if strings.Contains(part, "-") {
|
||||
rangeParts := strings.Split(part, "-")
|
||||
if len(rangeParts) != 2 {
|
||||
return nil, fmt.Errorf("invalid range: %s", part)
|
||||
}
|
||||
var start, end int
|
||||
if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil {
|
||||
return nil, fmt.Errorf("invalid range start: %s", rangeParts[0])
|
||||
}
|
||||
if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil {
|
||||
return nil, fmt.Errorf("invalid range end: %s", rangeParts[1])
|
||||
}
|
||||
if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end {
|
||||
return nil, fmt.Errorf("range out of bounds: %s", part)
|
||||
}
|
||||
for i := start; i <= end; i++ {
|
||||
selected[i-1] = true // Convert to 0-based
|
||||
}
|
||||
} else {
|
||||
// Single number
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
|
||||
return nil, fmt.Errorf("invalid number: %s", part)
|
||||
}
|
||||
if n < 1 || n > maxItems {
|
||||
return nil, fmt.Errorf("number out of range: %d", n)
|
||||
}
|
||||
selected[n-1] = true // Convert to 0-based
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to sorted slice
|
||||
result := make([]int, 0, len(selected))
|
||||
for i := 0; i < maxItems; i++ {
|
||||
if selected[i] {
|
||||
result = append(result, i)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ChooseMultiIntent prompts for multiple selections using a semantic intent.
|
||||
//
|
||||
// files := ChooseMultiIntent("core.select", i18n.S("files", ""), files)
|
||||
func ChooseMultiIntent[T any](intent string, subject *i18n.Subject, items []T, opts ...ChooseOption[T]) []T {
|
||||
result := i18n.C(intent, subject)
|
||||
return ChooseMulti(result.Question, items, opts...)
|
||||
}
|
||||
|
||||
// FormatAge formats a time as a human-readable age string.
|
||||
// Examples: "5m ago", "2h ago", "3d ago", "1w ago", "2mo ago"
|
||||
func FormatAge(t time.Time) string {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,104 @@ package i18n
|
|||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GrammarData holds language-specific grammar forms loaded from JSON.
|
||||
type GrammarData struct {
|
||||
Verbs map[string]VerbForms // verb -> forms
|
||||
Nouns map[string]NounForms // noun -> forms
|
||||
Articles ArticleForms // article configuration
|
||||
}
|
||||
|
||||
// NounForms holds plural and gender information for a noun.
|
||||
type NounForms struct {
|
||||
One string // Singular form
|
||||
Other string // Plural form
|
||||
Gender string // Grammatical gender (masculine, feminine, neuter, common)
|
||||
}
|
||||
|
||||
// ArticleForms holds article configuration for a language.
|
||||
type ArticleForms struct {
|
||||
IndefiniteDefault string // Default indefinite article (e.g., "a")
|
||||
IndefiniteVowel string // Indefinite article before vowel sounds (e.g., "an")
|
||||
Definite string // Definite article (e.g., "the")
|
||||
ByGender map[string]string // Gender-specific articles for gendered languages
|
||||
}
|
||||
|
||||
// grammarCache holds loaded grammar data per language.
|
||||
var (
|
||||
grammarCache = make(map[string]*GrammarData)
|
||||
grammarCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getGrammarData returns the grammar data for the current language.
|
||||
// Returns nil if no grammar data is loaded for the language.
|
||||
func getGrammarData(lang string) *GrammarData {
|
||||
grammarCacheMu.RLock()
|
||||
defer grammarCacheMu.RUnlock()
|
||||
return grammarCache[lang]
|
||||
}
|
||||
|
||||
// SetGrammarData sets the grammar data for a language.
|
||||
// Called by the Service when loading locale files.
|
||||
func SetGrammarData(lang string, data *GrammarData) {
|
||||
grammarCacheMu.Lock()
|
||||
defer grammarCacheMu.Unlock()
|
||||
grammarCache[lang] = data
|
||||
}
|
||||
|
||||
// getVerbForm retrieves a verb form from JSON data.
|
||||
// Returns empty string if not found, allowing fallback to computed form.
|
||||
func getVerbForm(lang, verb, form string) string {
|
||||
data := getGrammarData(lang)
|
||||
if data == nil || data.Verbs == nil {
|
||||
return ""
|
||||
}
|
||||
verb = strings.ToLower(verb)
|
||||
if forms, ok := data.Verbs[verb]; ok {
|
||||
switch form {
|
||||
case "past":
|
||||
return forms.Past
|
||||
case "gerund":
|
||||
return forms.Gerund
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getNounForm retrieves a noun form from JSON data.
|
||||
// Returns empty string if not found, allowing fallback to computed form.
|
||||
func getNounForm(lang, noun, form string) string {
|
||||
data := getGrammarData(lang)
|
||||
if data == nil || data.Nouns == nil {
|
||||
return ""
|
||||
}
|
||||
noun = strings.ToLower(noun)
|
||||
if forms, ok := data.Nouns[noun]; ok {
|
||||
switch form {
|
||||
case "one":
|
||||
return forms.One
|
||||
case "other":
|
||||
return forms.Other
|
||||
case "gender":
|
||||
return forms.Gender
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// currentLangForGrammar returns the current language for grammar lookups.
|
||||
// Uses the default service's language if available.
|
||||
func currentLangForGrammar() string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Language()
|
||||
}
|
||||
return "en-GB"
|
||||
}
|
||||
|
||||
// VerbForms holds irregular verb conjugations.
|
||||
type VerbForms struct {
|
||||
Past string // Past tense (e.g., "deleted")
|
||||
|
|
@ -100,7 +194,7 @@ var irregularVerbs = map[string]VerbForms{
|
|||
}
|
||||
|
||||
// PastTense returns the past tense of a verb.
|
||||
// Handles irregular verbs and applies regular rules for others.
|
||||
// Checks JSON locale data first, then irregular verbs, then applies regular rules.
|
||||
//
|
||||
// PastTense("delete") // "deleted"
|
||||
// PastTense("run") // "ran"
|
||||
|
|
@ -111,7 +205,12 @@ func PastTense(verb string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// Check irregular verbs first
|
||||
// Check JSON data first (for current language)
|
||||
if form := getVerbForm(currentLangForGrammar(), verb, "past"); form != "" {
|
||||
return form
|
||||
}
|
||||
|
||||
// Check irregular verbs
|
||||
if forms, ok := irregularVerbs[verb]; ok {
|
||||
return forms.Past
|
||||
}
|
||||
|
|
@ -220,6 +319,7 @@ func shouldDoubleConsonant(verb string) bool {
|
|||
}
|
||||
|
||||
// Gerund returns the present participle (-ing form) of a verb.
|
||||
// Checks JSON locale data first, then irregular verbs, then applies regular rules.
|
||||
//
|
||||
// Gerund("delete") // "deleting"
|
||||
// Gerund("run") // "running"
|
||||
|
|
@ -230,7 +330,12 @@ func Gerund(verb string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// Check irregular verbs first
|
||||
// Check JSON data first (for current language)
|
||||
if form := getVerbForm(currentLangForGrammar(), verb, "gerund"); form != "" {
|
||||
return form
|
||||
}
|
||||
|
||||
// Check irregular verbs
|
||||
if forms, ok := irregularVerbs[verb]; ok {
|
||||
return forms.Gerund
|
||||
}
|
||||
|
|
@ -331,6 +436,7 @@ func Pluralize(noun string, count int) string {
|
|||
}
|
||||
|
||||
// PluralForm returns the plural form of a noun.
|
||||
// Checks JSON locale data first, then irregular nouns, then applies regular rules.
|
||||
//
|
||||
// PluralForm("file") // "files"
|
||||
// PluralForm("child") // "children"
|
||||
|
|
@ -343,6 +449,15 @@ func PluralForm(noun string) string {
|
|||
|
||||
lower := strings.ToLower(noun)
|
||||
|
||||
// Check JSON data first (for current language)
|
||||
if form := getNounForm(currentLangForGrammar(), lower, "other"); form != "" {
|
||||
// Preserve original casing if title case
|
||||
if unicode.IsUpper(rune(noun[0])) && len(form) > 0 {
|
||||
return strings.ToUpper(string(form[0])) + form[1:]
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// Check irregular nouns
|
||||
if plural, ok := irregularNouns[lower]; ok {
|
||||
// Preserve original casing if title case
|
||||
|
|
|
|||
223
pkg/i18n/i18n.go
223
pkg/i18n/i18n.go
|
|
@ -168,6 +168,7 @@ func NewWithFS(fsys fs.FS, dir string) (*Service, error) {
|
|||
}
|
||||
|
||||
// loadJSON parses nested JSON and flattens to dot-notation keys.
|
||||
// Also extracts grammar data (verbs, nouns, articles) for the language.
|
||||
func (s *Service) loadJSON(lang string, data []byte) error {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
|
|
@ -175,13 +176,29 @@ func (s *Service) loadJSON(lang string, data []byte) error {
|
|||
}
|
||||
|
||||
messages := make(map[string]Message)
|
||||
flatten("", raw, messages)
|
||||
grammarData := &GrammarData{
|
||||
Verbs: make(map[string]VerbForms),
|
||||
Nouns: make(map[string]NounForms),
|
||||
}
|
||||
|
||||
flattenWithGrammar("", raw, messages, grammarData)
|
||||
s.messages[lang] = messages
|
||||
|
||||
// Store grammar data if any was found
|
||||
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 {
|
||||
SetGrammarData(lang, grammarData)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// flatten recursively flattens nested maps into dot-notation keys.
|
||||
func flatten(prefix string, data map[string]any, out map[string]Message) {
|
||||
flattenWithGrammar(prefix, data, out, nil)
|
||||
}
|
||||
|
||||
// flattenWithGrammar recursively flattens nested maps and extracts grammar data.
|
||||
func flattenWithGrammar(prefix string, data map[string]any, out map[string]Message, grammar *GrammarData) {
|
||||
for key, value := range data {
|
||||
fullKey := key
|
||||
if prefix != "" {
|
||||
|
|
@ -193,6 +210,62 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
|
|||
out[fullKey] = Message{Text: v}
|
||||
|
||||
case map[string]any:
|
||||
// Check if this is a verb form object
|
||||
if grammar != nil && isVerbFormObject(v) {
|
||||
verbName := key
|
||||
if strings.HasPrefix(fullKey, "common.verb.") {
|
||||
verbName = strings.TrimPrefix(fullKey, "common.verb.")
|
||||
}
|
||||
forms := VerbForms{}
|
||||
if base, ok := v["base"].(string); ok {
|
||||
_ = base // base form stored but not used in VerbForms
|
||||
}
|
||||
if past, ok := v["past"].(string); ok {
|
||||
forms.Past = past
|
||||
}
|
||||
if gerund, ok := v["gerund"].(string); ok {
|
||||
forms.Gerund = gerund
|
||||
}
|
||||
grammar.Verbs[strings.ToLower(verbName)] = forms
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a noun form object
|
||||
if grammar != nil && isNounFormObject(v) {
|
||||
nounName := key
|
||||
if strings.HasPrefix(fullKey, "common.noun.") {
|
||||
nounName = strings.TrimPrefix(fullKey, "common.noun.")
|
||||
}
|
||||
forms := NounForms{}
|
||||
if one, ok := v["one"].(string); ok {
|
||||
forms.One = one
|
||||
}
|
||||
if other, ok := v["other"].(string); ok {
|
||||
forms.Other = other
|
||||
}
|
||||
if gender, ok := v["gender"].(string); ok {
|
||||
forms.Gender = gender
|
||||
}
|
||||
grammar.Nouns[strings.ToLower(nounName)] = forms
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is an article object
|
||||
if grammar != nil && fullKey == "common.article" {
|
||||
if indef, ok := v["indefinite"].(map[string]any); ok {
|
||||
if def, ok := indef["default"].(string); ok {
|
||||
grammar.Articles.IndefiniteDefault = def
|
||||
}
|
||||
if vowel, ok := indef["vowel"].(string); ok {
|
||||
grammar.Articles.IndefiniteVowel = vowel
|
||||
}
|
||||
}
|
||||
if def, ok := v["definite"].(string); ok {
|
||||
grammar.Articles.Definite = def
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a plural object (has CLDR plural category keys)
|
||||
if isPluralObject(v) {
|
||||
msg := Message{}
|
||||
|
|
@ -217,12 +290,38 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
|
|||
out[fullKey] = msg
|
||||
} else {
|
||||
// Recurse into nested object
|
||||
flatten(fullKey, v, out)
|
||||
flattenWithGrammar(fullKey, v, out, grammar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isVerbFormObject checks if a map represents verb conjugation forms.
|
||||
func isVerbFormObject(m map[string]any) bool {
|
||||
_, hasBase := m["base"]
|
||||
_, hasPast := m["past"]
|
||||
_, hasGerund := m["gerund"]
|
||||
return (hasBase || hasPast || hasGerund) && !isPluralObject(m)
|
||||
}
|
||||
|
||||
// isNounFormObject checks if a map represents noun forms (with gender).
|
||||
// Noun form objects have "gender" field, distinguishing them from CLDR plural objects.
|
||||
func isNounFormObject(m map[string]any) bool {
|
||||
_, hasGender := m["gender"]
|
||||
// Only consider it a noun form if it has a gender field
|
||||
// This distinguishes noun forms from CLDR plural objects which use one/other
|
||||
return hasGender
|
||||
}
|
||||
|
||||
// hasPluralCategories checks if a map has CLDR plural categories beyond one/other.
|
||||
func hasPluralCategories(m map[string]any) bool {
|
||||
_, hasZero := m["zero"]
|
||||
_, hasTwo := m["two"]
|
||||
_, hasFew := m["few"]
|
||||
_, hasMany := m["many"]
|
||||
return hasZero || hasTwo || hasFew || hasMany
|
||||
}
|
||||
|
||||
// isPluralObject checks if a map represents plural forms.
|
||||
// Recognizes all CLDR plural categories: zero, one, two, few, many, other.
|
||||
func isPluralObject(m map[string]any) bool {
|
||||
|
|
@ -530,6 +629,14 @@ func (s *Service) PluralCategory(n int) PluralCategory {
|
|||
// For semantic intents (core.* namespace), pass a Subject to get the Question form:
|
||||
//
|
||||
// svc.T("core.delete", S("file", "config.yaml")) // "Delete config.yaml?"
|
||||
//
|
||||
// # Fallback Chain
|
||||
//
|
||||
// When a key is not found, T() tries a fallback chain:
|
||||
// 1. Try the exact key in current language
|
||||
// 2. Try the exact key in fallback language
|
||||
// 3. If key looks like an intent (contains "."), try common.action.{verb}
|
||||
// 4. Return the key as-is (or handle according to mode)
|
||||
func (s *Service) T(messageID string, args ...any) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
@ -545,39 +652,18 @@ func (s *Service) T(messageID string, args ...any) string {
|
|||
}
|
||||
}
|
||||
|
||||
// Try current language, then fallback
|
||||
msg, ok := s.getMessage(s.currentLang, messageID)
|
||||
if !ok {
|
||||
msg, ok = s.getMessage(s.fallbackLang, messageID)
|
||||
if !ok {
|
||||
return s.handleMissingKey(messageID, args)
|
||||
}
|
||||
}
|
||||
|
||||
// Get template data
|
||||
var data any
|
||||
if len(args) > 0 {
|
||||
data = args[0]
|
||||
}
|
||||
|
||||
// Get the appropriate text
|
||||
text := msg.Text
|
||||
if msg.IsPlural() {
|
||||
count := getCount(data)
|
||||
// Use CLDR plural category for current language
|
||||
category := GetPluralCategory(s.currentLang, count)
|
||||
text = msg.ForCategory(category)
|
||||
}
|
||||
|
||||
// Try fallback chain
|
||||
text := s.resolveWithFallback(messageID, data)
|
||||
if text == "" {
|
||||
return s.handleMissingKey(messageID, args)
|
||||
}
|
||||
|
||||
// Apply template if we have data
|
||||
if data != nil {
|
||||
text = applyTemplate(text, data)
|
||||
}
|
||||
|
||||
// Debug mode: prefix with key
|
||||
if s.debug {
|
||||
return "[" + messageID + "] " + text
|
||||
|
|
@ -586,6 +672,74 @@ func (s *Service) T(messageID string, args ...any) string {
|
|||
return text
|
||||
}
|
||||
|
||||
// resolveWithFallback implements the fallback chain for message resolution.
|
||||
// Must be called with s.mu.RLock held.
|
||||
func (s *Service) resolveWithFallback(messageID string, data any) string {
|
||||
// 1. Try exact key in current language
|
||||
if text := s.tryResolve(s.currentLang, messageID, data); text != "" {
|
||||
return text
|
||||
}
|
||||
|
||||
// 2. Try exact key in fallback language
|
||||
if text := s.tryResolve(s.fallbackLang, messageID, data); text != "" {
|
||||
return text
|
||||
}
|
||||
|
||||
// 3. Try fallback patterns for intent-like keys
|
||||
if strings.Contains(messageID, ".") {
|
||||
parts := strings.Split(messageID, ".")
|
||||
verb := parts[len(parts)-1]
|
||||
|
||||
// Try common.action.{verb}
|
||||
commonKey := "common.action." + verb
|
||||
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
|
||||
// Try common.{verb}
|
||||
commonKey = "common." + verb
|
||||
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// tryResolve attempts to resolve a single key in a single language.
|
||||
// Returns empty string if not found.
|
||||
// Must be called with s.mu.RLock held.
|
||||
func (s *Service) tryResolve(lang, key string, data any) string {
|
||||
msg, ok := s.getMessage(lang, key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
text := msg.Text
|
||||
if msg.IsPlural() {
|
||||
count := getCount(data)
|
||||
category := GetPluralCategory(lang, count)
|
||||
text = msg.ForCategory(category)
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Apply template if we have data
|
||||
if data != nil {
|
||||
text = applyTemplate(text, data)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// handleMissingKey handles a missing translation key based on the current mode.
|
||||
// Must be called with s.mu.RLock held.
|
||||
func (s *Service) handleMissingKey(key string, args []any) string {
|
||||
|
|
@ -669,17 +823,36 @@ func (s *Service) C(intent string, subject *Subject) *Composed {
|
|||
return result
|
||||
}
|
||||
|
||||
// templateCache stores compiled templates for reuse.
|
||||
// Key is the template string, value is the compiled template.
|
||||
var templateCache sync.Map
|
||||
|
||||
// executeIntentTemplate executes an intent template with the given data.
|
||||
// Templates are cached for performance - repeated calls with the same template
|
||||
// string will reuse the compiled template.
|
||||
func executeIntentTemplate(tmplStr string, data templateData) string {
|
||||
if tmplStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if cached, ok := templateCache.Load(tmplStr); ok {
|
||||
var buf bytes.Buffer
|
||||
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
|
||||
return tmplStr
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Parse and cache
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr)
|
||||
if err != nil {
|
||||
return tmplStr
|
||||
}
|
||||
|
||||
// Store in cache (safe even if another goroutine stored it first)
|
||||
templateCache.Store(tmplStr, tmpl)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return tmplStr
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
// Package i18n provides internationalization for the CLI.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// coreIntents defines the built-in semantic intents for common operations.
|
||||
// These are accessed via the "core.*" namespace in T() and C() calls.
|
||||
//
|
||||
|
|
@ -565,17 +569,35 @@ var coreIntents = map[string]Intent{
|
|||
},
|
||||
}
|
||||
|
||||
// getIntent retrieves an intent by its key from the core intents.
|
||||
// customIntents holds user-registered intents.
|
||||
// Separated from coreIntents to allow thread-safe registration.
|
||||
var (
|
||||
customIntents = make(map[string]Intent)
|
||||
customIntentsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getIntent retrieves an intent by its key.
|
||||
// Checks custom intents first, then falls back to core intents.
|
||||
// Returns nil if the intent is not found.
|
||||
func getIntent(key string) *Intent {
|
||||
// Check custom intents first (thread-safe)
|
||||
customIntentsMu.RLock()
|
||||
if intent, ok := customIntents[key]; ok {
|
||||
customIntentsMu.RUnlock()
|
||||
return &intent
|
||||
}
|
||||
customIntentsMu.RUnlock()
|
||||
|
||||
// Fall back to core intents
|
||||
if intent, ok := coreIntents[key]; ok {
|
||||
return &intent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterIntent adds a custom intent to the core intents.
|
||||
// RegisterIntent adds a custom intent at runtime.
|
||||
// Use this to extend the built-in intents with application-specific ones.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// i18n.RegisterIntent("myapp.archive", i18n.Intent{
|
||||
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"},
|
||||
|
|
@ -584,14 +606,74 @@ func getIntent(key string) *Intent {
|
|||
// Failure: "Failed to archive {{.Subject}}",
|
||||
// })
|
||||
func RegisterIntent(key string, intent Intent) {
|
||||
coreIntents[key] = intent
|
||||
customIntentsMu.Lock()
|
||||
defer customIntentsMu.Unlock()
|
||||
customIntents[key] = intent
|
||||
}
|
||||
|
||||
// IntentKeys returns all registered intent keys.
|
||||
// RegisterIntents adds multiple custom intents at runtime.
|
||||
// This is more efficient than calling RegisterIntent multiple times.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// i18n.RegisterIntents(map[string]i18n.Intent{
|
||||
// "myapp.archive": {
|
||||
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive"},
|
||||
// Question: "Archive {{.Subject}}?",
|
||||
// },
|
||||
// "myapp.export": {
|
||||
// Meta: i18n.IntentMeta{Type: "action", Verb: "export"},
|
||||
// Question: "Export {{.Subject}}?",
|
||||
// },
|
||||
// })
|
||||
func RegisterIntents(intents map[string]Intent) {
|
||||
customIntentsMu.Lock()
|
||||
defer customIntentsMu.Unlock()
|
||||
for k, v := range intents {
|
||||
customIntents[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// UnregisterIntent removes a custom intent by key.
|
||||
// This only affects custom intents, not core intents.
|
||||
// This function is thread-safe.
|
||||
func UnregisterIntent(key string) {
|
||||
customIntentsMu.Lock()
|
||||
defer customIntentsMu.Unlock()
|
||||
delete(customIntents, key)
|
||||
}
|
||||
|
||||
// IntentKeys returns all registered intent keys (both core and custom).
|
||||
func IntentKeys() []string {
|
||||
keys := make([]string, 0, len(coreIntents))
|
||||
customIntentsMu.RLock()
|
||||
defer customIntentsMu.RUnlock()
|
||||
|
||||
keys := make([]string, 0, len(coreIntents)+len(customIntents))
|
||||
for key := range coreIntents {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
for key := range customIntents {
|
||||
// Avoid duplicates if custom overrides core
|
||||
found := false
|
||||
for _, k := range keys {
|
||||
if k == key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// HasIntent returns true if an intent with the given key exists.
|
||||
func HasIntent(key string) bool {
|
||||
return getIntent(key) != nil
|
||||
}
|
||||
|
||||
// GetIntent returns the intent for a key, or nil if not found.
|
||||
// This is the public API for retrieving intents.
|
||||
func GetIntent(key string) *Intent {
|
||||
return getIntent(key)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,80 @@ func TestRegisterIntent(t *testing.T) {
|
|||
assert.Equal(t, "Custom {{.Subject}}?", intent.Question)
|
||||
|
||||
// Clean up
|
||||
delete(coreIntents, "test.custom")
|
||||
UnregisterIntent("test.custom")
|
||||
}
|
||||
|
||||
func TestRegisterIntents_Batch(t *testing.T) {
|
||||
// Register multiple intents at once
|
||||
RegisterIntents(map[string]Intent{
|
||||
"test.batch1": {
|
||||
Meta: IntentMeta{Type: "action", Verb: "batch1", Default: "yes"},
|
||||
Question: "Batch 1?",
|
||||
},
|
||||
"test.batch2": {
|
||||
Meta: IntentMeta{Type: "action", Verb: "batch2", Default: "no"},
|
||||
Question: "Batch 2?",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify both were registered
|
||||
assert.True(t, HasIntent("test.batch1"))
|
||||
assert.True(t, HasIntent("test.batch2"))
|
||||
|
||||
intent1 := GetIntent("test.batch1")
|
||||
require.NotNil(t, intent1)
|
||||
assert.Equal(t, "batch1", intent1.Meta.Verb)
|
||||
|
||||
intent2 := GetIntent("test.batch2")
|
||||
require.NotNil(t, intent2)
|
||||
assert.Equal(t, "batch2", intent2.Meta.Verb)
|
||||
|
||||
// Clean up
|
||||
UnregisterIntent("test.batch1")
|
||||
UnregisterIntent("test.batch2")
|
||||
|
||||
// Verify cleanup
|
||||
assert.False(t, HasIntent("test.batch1"))
|
||||
assert.False(t, HasIntent("test.batch2"))
|
||||
}
|
||||
|
||||
func TestCustomIntentOverridesCoreIntent(t *testing.T) {
|
||||
// Custom intents should be checked before core intents
|
||||
RegisterIntent("core.delete", Intent{
|
||||
Meta: IntentMeta{Type: "action", Verb: "delete", Default: "yes"},
|
||||
Question: "Custom delete {{.Subject}}?",
|
||||
})
|
||||
|
||||
// Should get custom intent
|
||||
intent := getIntent("core.delete")
|
||||
require.NotNil(t, intent)
|
||||
assert.Equal(t, "Custom delete {{.Subject}}?", intent.Question)
|
||||
assert.Equal(t, "yes", intent.Meta.Default) // Changed from core's "no"
|
||||
|
||||
// Clean up
|
||||
UnregisterIntent("core.delete")
|
||||
|
||||
// Now should get core intent again
|
||||
intent = getIntent("core.delete")
|
||||
require.NotNil(t, intent)
|
||||
assert.Equal(t, "Delete {{.Subject}}?", intent.Question)
|
||||
assert.Equal(t, "no", intent.Meta.Default) // Back to core default
|
||||
}
|
||||
|
||||
func TestHasIntent(t *testing.T) {
|
||||
assert.True(t, HasIntent("core.delete"))
|
||||
assert.True(t, HasIntent("core.create"))
|
||||
assert.False(t, HasIntent("nonexistent.intent"))
|
||||
}
|
||||
|
||||
func TestGetIntent_Public(t *testing.T) {
|
||||
intent := GetIntent("core.delete")
|
||||
require.NotNil(t, intent)
|
||||
assert.Equal(t, "delete", intent.Meta.Verb)
|
||||
|
||||
// Non-existent intent
|
||||
intent = GetIntent("nonexistent.intent")
|
||||
assert.Nil(t, intent)
|
||||
}
|
||||
|
||||
func TestIntentKeys(t *testing.T) {
|
||||
|
|
|
|||
80
pkg/i18n/interface.go
Normal file
80
pkg/i18n/interface.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Package i18n provides internationalization for the CLI.
|
||||
package i18n
|
||||
|
||||
// Translator defines the interface for translation services.
|
||||
// Implement this interface to provide custom translation backends
|
||||
// or mock implementations for testing.
|
||||
//
|
||||
// Example usage in tests:
|
||||
//
|
||||
// type mockTranslator struct {
|
||||
// translations map[string]string
|
||||
// }
|
||||
//
|
||||
// func (m *mockTranslator) T(key string, args ...any) string {
|
||||
// if v, ok := m.translations[key]; ok {
|
||||
// return v
|
||||
// }
|
||||
// return key
|
||||
// }
|
||||
//
|
||||
// func TestSomething(t *testing.T) {
|
||||
// mock := &mockTranslator{translations: map[string]string{
|
||||
// "cli.success": "Test Success",
|
||||
// }}
|
||||
// // Use mock in your tests
|
||||
// }
|
||||
type Translator interface {
|
||||
// T translates a message by its ID.
|
||||
// Optional template data can be passed for interpolation.
|
||||
//
|
||||
// svc.T("cli.success")
|
||||
// svc.T("cli.count.items", map[string]any{"Count": 5})
|
||||
T(messageID string, args ...any) string
|
||||
|
||||
// C composes a semantic intent with a subject.
|
||||
// Returns all output forms (Question, Confirm, Success, Failure).
|
||||
//
|
||||
// result := svc.C("core.delete", S("file", "config.yaml"))
|
||||
C(intent string, subject *Subject) *Composed
|
||||
|
||||
// SetLanguage sets the language for translations.
|
||||
// Returns an error if the language is not supported.
|
||||
SetLanguage(lang string) error
|
||||
|
||||
// Language returns the current language code.
|
||||
Language() string
|
||||
|
||||
// SetMode sets the translation mode for missing key handling.
|
||||
SetMode(m Mode)
|
||||
|
||||
// Mode returns the current translation mode.
|
||||
Mode() Mode
|
||||
|
||||
// SetDebug enables or disables debug mode.
|
||||
SetDebug(enabled bool)
|
||||
|
||||
// Debug returns whether debug mode is enabled.
|
||||
Debug() bool
|
||||
|
||||
// SetFormality sets the default formality level for translations.
|
||||
SetFormality(f Formality)
|
||||
|
||||
// Formality returns the current formality level.
|
||||
Formality() Formality
|
||||
|
||||
// Direction returns the text direction for the current language.
|
||||
Direction() TextDirection
|
||||
|
||||
// IsRTL returns true if the current language uses RTL text.
|
||||
IsRTL() bool
|
||||
|
||||
// PluralCategory returns the plural category for a count.
|
||||
PluralCategory(n int) PluralCategory
|
||||
|
||||
// AvailableLanguages returns the list of available language codes.
|
||||
AvailableLanguages() []string
|
||||
}
|
||||
|
||||
// Ensure Service implements Translator at compile time.
|
||||
var _ Translator = (*Service)(nil)
|
||||
84
pkg/i18n/interface_test.go
Normal file
84
pkg/i18n/interface_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceImplementsTranslator(t *testing.T) {
|
||||
// This test verifies at compile time that Service implements Translator
|
||||
var _ Translator = (*Service)(nil)
|
||||
|
||||
// Create a service and use it through the interface
|
||||
var translator Translator
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
translator = svc
|
||||
|
||||
// Test interface methods
|
||||
assert.Equal(t, "Success", translator.T("cli.success"))
|
||||
assert.NotEmpty(t, translator.Language())
|
||||
assert.NotNil(t, translator.Direction())
|
||||
assert.NotNil(t, translator.Formality())
|
||||
}
|
||||
|
||||
// MockTranslator demonstrates how to create a mock for testing
|
||||
type MockTranslator struct {
|
||||
translations map[string]string
|
||||
language string
|
||||
}
|
||||
|
||||
func (m *MockTranslator) T(key string, args ...any) string {
|
||||
if v, ok := m.translations[key]; ok {
|
||||
return v
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (m *MockTranslator) C(intent string, subject *Subject) *Composed {
|
||||
return &Composed{
|
||||
Question: "Mock: " + intent,
|
||||
Confirm: "Mock confirm",
|
||||
Success: "Mock success",
|
||||
Failure: "Mock failure",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockTranslator) SetLanguage(lang string) error {
|
||||
m.language = lang
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTranslator) Language() string {
|
||||
return m.language
|
||||
}
|
||||
|
||||
func (m *MockTranslator) SetMode(mode Mode) {}
|
||||
func (m *MockTranslator) Mode() Mode { return ModeNormal }
|
||||
func (m *MockTranslator) SetDebug(enabled bool) {}
|
||||
func (m *MockTranslator) Debug() bool { return false }
|
||||
func (m *MockTranslator) SetFormality(f Formality) {}
|
||||
func (m *MockTranslator) Formality() Formality { return FormalityNeutral }
|
||||
func (m *MockTranslator) Direction() TextDirection { return DirLTR }
|
||||
func (m *MockTranslator) IsRTL() bool { return false }
|
||||
func (m *MockTranslator) PluralCategory(n int) PluralCategory { return PluralOther }
|
||||
func (m *MockTranslator) AvailableLanguages() []string { return []string{"en-GB"} }
|
||||
|
||||
func TestMockTranslator(t *testing.T) {
|
||||
var translator Translator = &MockTranslator{
|
||||
translations: map[string]string{
|
||||
"test.hello": "Hello from mock",
|
||||
},
|
||||
language: "en-GB",
|
||||
}
|
||||
|
||||
assert.Equal(t, "Hello from mock", translator.T("test.hello"))
|
||||
assert.Equal(t, "test.missing", translator.T("test.missing"))
|
||||
assert.Equal(t, "en-GB", translator.Language())
|
||||
|
||||
result := translator.C("core.delete", S("file", "test.txt"))
|
||||
assert.Equal(t, "Mock: core.delete", result.Question)
|
||||
}
|
||||
126
pkg/i18n/keys.go
Normal file
126
pkg/i18n/keys.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// To regenerate: task i18n:generate
|
||||
package i18n
|
||||
|
||||
// Intent keys for type-safe intent references.
|
||||
// Use these constants instead of string literals to catch typos at compile time.
|
||||
//
|
||||
// result := C(IntentCoreDelete, S("file", "config.yaml"))
|
||||
const (
|
||||
// Destructive actions
|
||||
IntentCoreDelete = "core.delete"
|
||||
IntentCoreRemove = "core.remove"
|
||||
IntentCoreDiscard = "core.discard"
|
||||
IntentCoreReset = "core.reset"
|
||||
IntentCoreOverwrite = "core.overwrite"
|
||||
|
||||
// Creation actions
|
||||
IntentCoreCreate = "core.create"
|
||||
IntentCoreAdd = "core.add"
|
||||
IntentCoreClone = "core.clone"
|
||||
IntentCoreCopy = "core.copy"
|
||||
|
||||
// Modification actions
|
||||
IntentCoreSave = "core.save"
|
||||
IntentCoreUpdate = "core.update"
|
||||
IntentCoreRename = "core.rename"
|
||||
IntentCoreMove = "core.move"
|
||||
|
||||
// Git actions
|
||||
IntentCoreCommit = "core.commit"
|
||||
IntentCorePush = "core.push"
|
||||
IntentCorePull = "core.pull"
|
||||
IntentCoreMerge = "core.merge"
|
||||
IntentCoreRebase = "core.rebase"
|
||||
|
||||
// Network actions
|
||||
IntentCoreInstall = "core.install"
|
||||
IntentCoreDownload = "core.download"
|
||||
IntentCoreUpload = "core.upload"
|
||||
IntentCorePublish = "core.publish"
|
||||
IntentCoreDeploy = "core.deploy"
|
||||
|
||||
// Process actions
|
||||
IntentCoreStart = "core.start"
|
||||
IntentCoreStop = "core.stop"
|
||||
IntentCoreRestart = "core.restart"
|
||||
IntentCoreRun = "core.run"
|
||||
IntentCoreBuild = "core.build"
|
||||
IntentCoreTest = "core.test"
|
||||
|
||||
// Information actions
|
||||
IntentCoreContinue = "core.continue"
|
||||
IntentCoreProceed = "core.proceed"
|
||||
IntentCoreConfirm = "core.confirm"
|
||||
|
||||
// Additional actions
|
||||
IntentCoreSync = "core.sync"
|
||||
IntentCoreBoot = "core.boot"
|
||||
IntentCoreFormat = "core.format"
|
||||
IntentCoreAnalyse = "core.analyse"
|
||||
IntentCoreLink = "core.link"
|
||||
IntentCoreUnlink = "core.unlink"
|
||||
IntentCoreFetch = "core.fetch"
|
||||
IntentCoreGenerate = "core.generate"
|
||||
IntentCoreValidate = "core.validate"
|
||||
IntentCoreCheck = "core.check"
|
||||
IntentCoreScan = "core.scan"
|
||||
)
|
||||
|
||||
// Common message keys for type-safe message references.
|
||||
const (
|
||||
// CLI status messages
|
||||
KeyCliSuccess = "cli.success"
|
||||
KeyCliError = "cli.error"
|
||||
KeyCliWarning = "cli.warning"
|
||||
KeyCliInfo = "cli.info"
|
||||
KeyCliDone = "cli.done"
|
||||
KeyCliFailed = "cli.failed"
|
||||
KeyCliPass = "cli.pass"
|
||||
KeyCliFail = "cli.fail"
|
||||
KeyCliOK = "cli.ok"
|
||||
KeyCliSkip = "cli.skip"
|
||||
KeyCliPending = "cli.pending"
|
||||
KeyCliCompleted = "cli.completed"
|
||||
KeyCliCancelled = "cli.cancelled"
|
||||
KeyCliAborted = "cli.aborted"
|
||||
|
||||
// Common prompts
|
||||
KeyCommonPromptYes = "common.prompt.yes"
|
||||
KeyCommonPromptNo = "common.prompt.no"
|
||||
KeyCommonPromptContinue = "common.prompt.continue"
|
||||
KeyCommonPromptProceed = "common.prompt.proceed"
|
||||
KeyCommonPromptConfirm = "common.prompt.confirm"
|
||||
KeyCommonPromptAbort = "common.prompt.abort"
|
||||
KeyCommonPromptCancel = "common.prompt.cancel"
|
||||
|
||||
// Common labels
|
||||
KeyCommonLabelError = "common.label.error"
|
||||
KeyCommonLabelDone = "common.label.done"
|
||||
KeyCommonLabelStatus = "common.label.status"
|
||||
KeyCommonLabelVersion = "common.label.version"
|
||||
KeyCommonLabelSummary = "common.label.summary"
|
||||
KeyCommonLabelSuccess = "common.label.success"
|
||||
KeyCommonLabelWarning = "common.label.warning"
|
||||
KeyCommonLabelNote = "common.label.note"
|
||||
KeyCommonLabelTotal = "common.label.total"
|
||||
KeyCommonLabelCoverage = "common.label.coverage"
|
||||
KeyCommonLabelPath = "common.label.path"
|
||||
KeyCommonLabelURL = "common.label.url"
|
||||
|
||||
// Common status
|
||||
KeyCommonStatusRunning = "common.status.running"
|
||||
KeyCommonStatusStopped = "common.status.stopped"
|
||||
KeyCommonStatusDirty = "common.status.dirty"
|
||||
KeyCommonStatusSynced = "common.status.synced"
|
||||
KeyCommonStatusUpToDate = "common.status.up_to_date"
|
||||
KeyCommonStatusInstalling = "common.status.installing"
|
||||
KeyCommonStatusCloning = "common.status.cloning"
|
||||
|
||||
// Error messages
|
||||
KeyErrorNotFound = "error.not_found"
|
||||
KeyErrorInvalid = "error.invalid"
|
||||
KeyErrorPermission = "error.permission"
|
||||
KeyErrorTimeout = "error.timeout"
|
||||
KeyErrorNetwork = "error.network"
|
||||
)
|
||||
|
|
@ -1,4 +1,30 @@
|
|||
{
|
||||
"common.verb.delete": { "base": "löschen", "past": "gelöscht", "gerund": "löschend" },
|
||||
"common.verb.save": { "base": "speichern", "past": "gespeichert", "gerund": "speichernd" },
|
||||
"common.verb.create": { "base": "erstellen", "past": "erstellt", "gerund": "erstellend" },
|
||||
"common.verb.update": { "base": "aktualisieren", "past": "aktualisiert", "gerund": "aktualisierend" },
|
||||
"common.verb.build": { "base": "bauen", "past": "gebaut", "gerund": "bauend" },
|
||||
"common.verb.run": { "base": "laufen", "past": "gelaufen", "gerund": "laufend" },
|
||||
"common.verb.check": { "base": "prüfen", "past": "geprüft", "gerund": "prüfend" },
|
||||
"common.verb.install": { "base": "installieren", "past": "installiert", "gerund": "installierend" },
|
||||
"common.verb.push": { "base": "pushen", "past": "gepusht", "gerund": "pushend" },
|
||||
"common.verb.pull": { "base": "pullen", "past": "gepullt", "gerund": "pullend" },
|
||||
"common.verb.commit": { "base": "committen", "past": "committet", "gerund": "committend" },
|
||||
|
||||
"common.noun.file": { "one": "Datei", "other": "Dateien", "gender": "feminine" },
|
||||
"common.noun.repo": { "one": "Repository", "other": "Repositories", "gender": "neuter" },
|
||||
"common.noun.commit": { "one": "Commit", "other": "Commits", "gender": "masculine" },
|
||||
"common.noun.branch": { "one": "Branch", "other": "Branches", "gender": "masculine" },
|
||||
"common.noun.change": { "one": "Änderung", "other": "Änderungen", "gender": "feminine" },
|
||||
"common.noun.item": { "one": "Element", "other": "Elemente", "gender": "neuter" },
|
||||
|
||||
"common.article.indefinite.masculine": "ein",
|
||||
"common.article.indefinite.feminine": "eine",
|
||||
"common.article.indefinite.neuter": "ein",
|
||||
"common.article.definite.masculine": "der",
|
||||
"common.article.definite.feminine": "die",
|
||||
"common.article.definite.neuter": "das",
|
||||
|
||||
"cli.success": "Erfolg",
|
||||
"cli.error": "Fehler",
|
||||
"cli.warning": "Warnung",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,53 @@
|
|||
{
|
||||
"common": {
|
||||
"verb": {
|
||||
"be": { "base": "be", "past": "was", "gerund": "being" },
|
||||
"go": { "base": "go", "past": "went", "gerund": "going" },
|
||||
"do": { "base": "do", "past": "did", "gerund": "doing" },
|
||||
"have": { "base": "have", "past": "had", "gerund": "having" },
|
||||
"make": { "base": "make", "past": "made", "gerund": "making" },
|
||||
"get": { "base": "get", "past": "got", "gerund": "getting" },
|
||||
"run": { "base": "run", "past": "ran", "gerund": "running" },
|
||||
"write": { "base": "write", "past": "wrote", "gerund": "writing" },
|
||||
"build": { "base": "build", "past": "built", "gerund": "building" },
|
||||
"send": { "base": "send", "past": "sent", "gerund": "sending" },
|
||||
"find": { "base": "find", "past": "found", "gerund": "finding" },
|
||||
"take": { "base": "take", "past": "took", "gerund": "taking" },
|
||||
"begin": { "base": "begin", "past": "began", "gerund": "beginning" },
|
||||
"keep": { "base": "keep", "past": "kept", "gerund": "keeping" },
|
||||
"hold": { "base": "hold", "past": "held", "gerund": "holding" },
|
||||
"bring": { "base": "bring", "past": "brought", "gerund": "bringing" },
|
||||
"think": { "base": "think", "past": "thought", "gerund": "thinking" },
|
||||
"buy": { "base": "buy", "past": "bought", "gerund": "buying" },
|
||||
"catch": { "base": "catch", "past": "caught", "gerund": "catching" },
|
||||
"choose": { "base": "choose", "past": "chose", "gerund": "choosing" },
|
||||
"lose": { "base": "lose", "past": "lost", "gerund": "losing" },
|
||||
"win": { "base": "win", "past": "won", "gerund": "winning" },
|
||||
"meet": { "base": "meet", "past": "met", "gerund": "meeting" },
|
||||
"lead": { "base": "lead", "past": "led", "gerund": "leading" },
|
||||
"leave": { "base": "leave", "past": "left", "gerund": "leaving" },
|
||||
"spend": { "base": "spend", "past": "spent", "gerund": "spending" },
|
||||
"pay": { "base": "pay", "past": "paid", "gerund": "paying" },
|
||||
"sell": { "base": "sell", "past": "sold", "gerund": "selling" }
|
||||
},
|
||||
"noun": {
|
||||
"file": { "one": "file", "other": "files", "gender": "neuter" },
|
||||
"repo": { "one": "repo", "other": "repos", "gender": "neuter" },
|
||||
"repository": { "one": "repository", "other": "repositories", "gender": "neuter" },
|
||||
"commit": { "one": "commit", "other": "commits", "gender": "neuter" },
|
||||
"branch": { "one": "branch", "other": "branches", "gender": "neuter" },
|
||||
"change": { "one": "change", "other": "changes", "gender": "neuter" },
|
||||
"item": { "one": "item", "other": "items", "gender": "neuter" },
|
||||
"issue": { "one": "issue", "other": "issues", "gender": "neuter" },
|
||||
"task": { "one": "task", "other": "tasks", "gender": "neuter" },
|
||||
"user": { "one": "user", "other": "users", "gender": "neuter" },
|
||||
"person": { "one": "person", "other": "people", "gender": "neuter" },
|
||||
"child": { "one": "child", "other": "children", "gender": "neuter" }
|
||||
},
|
||||
"article": {
|
||||
"indefinite": { "default": "a", "vowel": "an" },
|
||||
"definite": "the"
|
||||
},
|
||||
"prompt": {
|
||||
"yes": "y",
|
||||
"no": "n",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue