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:
|
cmds:
|
||||||
- task: cov
|
- task: cov
|
||||||
- go tool cover -html=coverage.txt
|
- go tool cover -html=coverage.txt
|
||||||
|
|
||||||
|
i18n:generate:
|
||||||
|
desc: "Regenerate i18n key constants from locale files"
|
||||||
|
cmds:
|
||||||
|
- go generate ./pkg/i18n/...
|
||||||
|
|
|
||||||
186
pkg/cli/utils.go
186
pkg/cli/utils.go
|
|
@ -34,6 +34,7 @@ type ConfirmOption func(*confirmConfig)
|
||||||
type confirmConfig struct {
|
type confirmConfig struct {
|
||||||
defaultYes bool
|
defaultYes bool
|
||||||
required bool
|
required bool
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
// 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.
|
// Confirm prompts the user for yes/no confirmation.
|
||||||
// Returns true if the user enters "y" or "yes" (case-insensitive).
|
// 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("Save changes?", DefaultYes()) { ... }
|
||||||
// if Confirm("Dangerous!", Required()) { ... }
|
// if Confirm("Dangerous!", Required()) { ... }
|
||||||
|
// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... }
|
||||||
func Confirm(prompt string, opts ...ConfirmOption) bool {
|
func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||||
cfg := &confirmConfig{}
|
cfg := &confirmConfig{}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
|
|
@ -77,12 +90,37 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||||
suffix = "[y/N] "
|
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)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
fmt.Printf("%s %s", prompt, suffix)
|
fmt.Printf("%s %s", prompt, suffix)
|
||||||
response, _ := reader.ReadString('\n')
|
|
||||||
response = strings.ToLower(strings.TrimSpace(response))
|
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
|
// Handle empty response
|
||||||
if response == "" {
|
if response == "" {
|
||||||
|
|
@ -237,7 +275,9 @@ type ChooseOption[T any] func(*chooseConfig[T])
|
||||||
|
|
||||||
type chooseConfig[T any] struct {
|
type chooseConfig[T any] struct {
|
||||||
displayFn func(T) string
|
displayFn func(T) string
|
||||||
defaultN int // 0-based index of default selection
|
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.
|
// 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.
|
// Choose prompts the user to select from a list of items.
|
||||||
// Returns the selected item. Uses simple numbered selection for terminal compatibility.
|
// 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...)
|
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.
|
// FormatAge formats a time as a human-readable age string.
|
||||||
// Examples: "5m ago", "2h ago", "3d ago", "1w ago", "2mo ago"
|
// Examples: "5m ago", "2h ago", "3d ago", "1w ago", "2mo ago"
|
||||||
func FormatAge(t time.Time) string {
|
func FormatAge(t time.Time) string {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,104 @@ package i18n
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"text/template"
|
"text/template"
|
||||||
"unicode"
|
"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.
|
// VerbForms holds irregular verb conjugations.
|
||||||
type VerbForms struct {
|
type VerbForms struct {
|
||||||
Past string // Past tense (e.g., "deleted")
|
Past string // Past tense (e.g., "deleted")
|
||||||
|
|
@ -100,7 +194,7 @@ var irregularVerbs = map[string]VerbForms{
|
||||||
}
|
}
|
||||||
|
|
||||||
// PastTense returns the past tense of a verb.
|
// 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("delete") // "deleted"
|
||||||
// PastTense("run") // "ran"
|
// PastTense("run") // "ran"
|
||||||
|
|
@ -111,7 +205,12 @@ func PastTense(verb string) string {
|
||||||
return ""
|
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 {
|
if forms, ok := irregularVerbs[verb]; ok {
|
||||||
return forms.Past
|
return forms.Past
|
||||||
}
|
}
|
||||||
|
|
@ -220,6 +319,7 @@ func shouldDoubleConsonant(verb string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gerund returns the present participle (-ing form) of a verb.
|
// 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("delete") // "deleting"
|
||||||
// Gerund("run") // "running"
|
// Gerund("run") // "running"
|
||||||
|
|
@ -230,7 +330,12 @@ func Gerund(verb string) string {
|
||||||
return ""
|
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 {
|
if forms, ok := irregularVerbs[verb]; ok {
|
||||||
return forms.Gerund
|
return forms.Gerund
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +436,7 @@ func Pluralize(noun string, count int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PluralForm returns the plural form of a noun.
|
// PluralForm returns the plural form of a noun.
|
||||||
|
// Checks JSON locale data first, then irregular nouns, then applies regular rules.
|
||||||
//
|
//
|
||||||
// PluralForm("file") // "files"
|
// PluralForm("file") // "files"
|
||||||
// PluralForm("child") // "children"
|
// PluralForm("child") // "children"
|
||||||
|
|
@ -343,6 +449,15 @@ func PluralForm(noun string) string {
|
||||||
|
|
||||||
lower := strings.ToLower(noun)
|
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
|
// Check irregular nouns
|
||||||
if plural, ok := irregularNouns[lower]; ok {
|
if plural, ok := irregularNouns[lower]; ok {
|
||||||
// Preserve original casing if title case
|
// 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.
|
// 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 {
|
func (s *Service) loadJSON(lang string, data []byte) error {
|
||||||
var raw map[string]any
|
var raw map[string]any
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
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)
|
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
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// flatten recursively flattens nested maps into dot-notation keys.
|
// flatten recursively flattens nested maps into dot-notation keys.
|
||||||
func flatten(prefix string, data map[string]any, out map[string]Message) {
|
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 {
|
for key, value := range data {
|
||||||
fullKey := key
|
fullKey := key
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
|
|
@ -193,6 +210,62 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
|
||||||
out[fullKey] = Message{Text: v}
|
out[fullKey] = Message{Text: v}
|
||||||
|
|
||||||
case map[string]any:
|
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)
|
// Check if this is a plural object (has CLDR plural category keys)
|
||||||
if isPluralObject(v) {
|
if isPluralObject(v) {
|
||||||
msg := Message{}
|
msg := Message{}
|
||||||
|
|
@ -217,12 +290,38 @@ func flatten(prefix string, data map[string]any, out map[string]Message) {
|
||||||
out[fullKey] = msg
|
out[fullKey] = msg
|
||||||
} else {
|
} else {
|
||||||
// Recurse into nested object
|
// 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.
|
// isPluralObject checks if a map represents plural forms.
|
||||||
// Recognizes all CLDR plural categories: zero, one, two, few, many, other.
|
// Recognizes all CLDR plural categories: zero, one, two, few, many, other.
|
||||||
func isPluralObject(m map[string]any) bool {
|
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:
|
// For semantic intents (core.* namespace), pass a Subject to get the Question form:
|
||||||
//
|
//
|
||||||
// svc.T("core.delete", S("file", "config.yaml")) // "Delete config.yaml?"
|
// 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 {
|
func (s *Service) T(messageID string, args ...any) string {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
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
|
// Get template data
|
||||||
var data any
|
var data any
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
data = args[0]
|
data = args[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the appropriate text
|
// Try fallback chain
|
||||||
text := msg.Text
|
text := s.resolveWithFallback(messageID, data)
|
||||||
if msg.IsPlural() {
|
|
||||||
count := getCount(data)
|
|
||||||
// Use CLDR plural category for current language
|
|
||||||
category := GetPluralCategory(s.currentLang, count)
|
|
||||||
text = msg.ForCategory(category)
|
|
||||||
}
|
|
||||||
|
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return s.handleMissingKey(messageID, args)
|
return s.handleMissingKey(messageID, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply template if we have data
|
|
||||||
if data != nil {
|
|
||||||
text = applyTemplate(text, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug mode: prefix with key
|
// Debug mode: prefix with key
|
||||||
if s.debug {
|
if s.debug {
|
||||||
return "[" + messageID + "] " + text
|
return "[" + messageID + "] " + text
|
||||||
|
|
@ -586,6 +672,74 @@ func (s *Service) T(messageID string, args ...any) string {
|
||||||
return text
|
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.
|
// handleMissingKey handles a missing translation key based on the current mode.
|
||||||
// Must be called with s.mu.RLock held.
|
// Must be called with s.mu.RLock held.
|
||||||
func (s *Service) handleMissingKey(key string, args []any) string {
|
func (s *Service) handleMissingKey(key string, args []any) string {
|
||||||
|
|
@ -669,17 +823,36 @@ func (s *Service) C(intent string, subject *Subject) *Composed {
|
||||||
return result
|
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.
|
// 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 {
|
func executeIntentTemplate(tmplStr string, data templateData) string {
|
||||||
if tmplStr == "" {
|
if tmplStr == "" {
|
||||||
return ""
|
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)
|
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tmplStr
|
return tmplStr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store in cache (safe even if another goroutine stored it first)
|
||||||
|
templateCache.Store(tmplStr, tmpl)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := tmpl.Execute(&buf, data); err != nil {
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
return tmplStr
|
return tmplStr
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
// Package i18n provides internationalization for the CLI.
|
// Package i18n provides internationalization for the CLI.
|
||||||
package i18n
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
// coreIntents defines the built-in semantic intents for common operations.
|
// coreIntents defines the built-in semantic intents for common operations.
|
||||||
// These are accessed via the "core.*" namespace in T() and C() calls.
|
// 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.
|
// Returns nil if the intent is not found.
|
||||||
func getIntent(key string) *Intent {
|
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 {
|
if intent, ok := coreIntents[key]; ok {
|
||||||
return &intent
|
return &intent
|
||||||
}
|
}
|
||||||
return nil
|
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.
|
// Use this to extend the built-in intents with application-specific ones.
|
||||||
|
// This function is thread-safe.
|
||||||
//
|
//
|
||||||
// i18n.RegisterIntent("myapp.archive", i18n.Intent{
|
// i18n.RegisterIntent("myapp.archive", i18n.Intent{
|
||||||
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"},
|
// Meta: i18n.IntentMeta{Type: "action", Verb: "archive", Default: "yes"},
|
||||||
|
|
@ -584,14 +606,74 @@ func getIntent(key string) *Intent {
|
||||||
// Failure: "Failed to archive {{.Subject}}",
|
// Failure: "Failed to archive {{.Subject}}",
|
||||||
// })
|
// })
|
||||||
func RegisterIntent(key string, intent Intent) {
|
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 {
|
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 {
|
for key := range coreIntents {
|
||||||
keys = append(keys, key)
|
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
|
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)
|
assert.Equal(t, "Custom {{.Subject}}?", intent.Question)
|
||||||
|
|
||||||
// Clean up
|
// 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) {
|
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.success": "Erfolg",
|
||||||
"cli.error": "Fehler",
|
"cli.error": "Fehler",
|
||||||
"cli.warning": "Warnung",
|
"cli.warning": "Warnung",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,53 @@
|
||||||
{
|
{
|
||||||
"common": {
|
"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": {
|
"prompt": {
|
||||||
"yes": "y",
|
"yes": "y",
|
||||||
"no": "n",
|
"no": "n",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue