diff --git a/config/config.go b/config/config.go index b3e1b0f0b1d39cd691ad1da17f7cab394ea46e52..7b3720fce59dbb989c7afcaad5f469402043966b 100644 --- a/config/config.go +++ b/config/config.go @@ -87,6 +87,7 @@ type Config struct { Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` + Language string `json:"language,omitempty"` // Language code (e.g., "en", "es", "de") } // GetDateFormat returns the Go time reference layout translated from the @@ -99,6 +100,14 @@ func (c *Config) GetDateFormat() string { return translateDateFormat(f) } +// GetLanguage returns the configured language code, defaulting to "en". +func (c *Config) GetLanguage() string { + if c.Language == "" { + return "en" + } + return c.Language +} + // translateDateFormat converts a human-readable format string (e.g. // "DD/MM/YYYY HH:MM") into a Go reference-time layout usable by // time.Format. MM is disambiguated by context: when it directly follows @@ -505,6 +514,7 @@ func LoadConfig() (*Config, error) { Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` + Language string `json:"language,omitempty"` } var raw diskConfig @@ -538,6 +548,7 @@ func LoadConfig() (*Config, error) { config.Theme = raw.Theme config.MailingLists = raw.MailingLists config.DateFormat = raw.DateFormat + config.Language = raw.Language for _, rawAcc := range raw.Accounts { acc := Account{ ID: rawAcc.ID, diff --git a/i18n/bundle.go b/i18n/bundle.go new file mode 100644 index 0000000000000000000000000000000000000000..beda44c4ab8d738c522282c1ab5b403c40c1e18a --- /dev/null +++ b/i18n/bundle.go @@ -0,0 +1,120 @@ +package i18n + +import ( + "fmt" + "sync" +) + +// Bundle holds all translation messages for all languages. +type Bundle struct { + defaultLang string + messages map[string]MessageMap // lang -> MessageMap + locales map[string]*Locale // lang -> Locale + mu sync.RWMutex +} + +// NewBundle creates a new Bundle with a default language. +func NewBundle(defaultLang string) *Bundle { + return &Bundle{ + defaultLang: defaultLang, + messages: make(map[string]MessageMap), + locales: make(map[string]*Locale), + } +} + +// AddMessages adds translation messages for a language. +func (b *Bundle) AddMessages(lang string, messages MessageMap) error { + if lang == "" { + return ErrInvalidLocale + } + + b.mu.Lock() + defer b.mu.Unlock() + + if b.messages[lang] == nil { + b.messages[lang] = make(MessageMap) + } + + // Merge messages + for id, msg := range messages { + b.messages[lang][id] = msg + } + + return nil +} + +// GetMessage retrieves a message for a specific language and ID. +func (b *Bundle) GetMessage(lang, id string) (*Message, error) { + b.mu.RLock() + defer b.mu.RUnlock() + + langMessages, ok := b.messages[lang] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrLanguageNotFound, lang) + } + + msg, ok := langMessages[id] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrMessageNotFound, id) + } + + return msg, nil +} + +// RegisterLocale registers a locale configuration. +func (b *Bundle) RegisterLocale(locale *Locale) { + if locale == nil || locale.Code == "" { + return + } + + b.mu.Lock() + defer b.mu.Unlock() + + b.locales[locale.Code] = locale +} + +// GetLocale retrieves a registered locale. +func (b *Bundle) GetLocale(lang string) (*Locale, bool) { + b.mu.RLock() + defer b.mu.RUnlock() + + locale, ok := b.locales[lang] + return locale, ok +} + +// AvailableLanguages returns a list of all languages with loaded messages. +func (b *Bundle) AvailableLanguages() []string { + b.mu.RLock() + defer b.mu.RUnlock() + + langs := make([]string, 0, len(b.messages)) + for lang := range b.messages { + langs = append(langs, lang) + } + return langs +} + +// DefaultLanguage returns the default language code. +func (b *Bundle) DefaultLanguage() string { + return b.defaultLang +} + +// MessageCount returns the number of messages for a language. +func (b *Bundle) MessageCount(lang string) int { + b.mu.RLock() + defer b.mu.RUnlock() + + if messages, ok := b.messages[lang]; ok { + return len(messages) + } + return 0 +} + +// HasLanguage checks if a language has been loaded. +func (b *Bundle) HasLanguage(lang string) bool { + b.mu.RLock() + defer b.mu.RUnlock() + + _, ok := b.messages[lang] + return ok +} diff --git a/i18n/cache.go b/i18n/cache.go new file mode 100644 index 0000000000000000000000000000000000000000..99cda963ecfd4fab8bb88bcc44318f987b7ea9ac --- /dev/null +++ b/i18n/cache.go @@ -0,0 +1,66 @@ +package i18n + +import "sync" + +// Cache provides thread-safe caching for translated strings. +type Cache struct { + items map[string]string + mu sync.RWMutex +} + +// NewCache creates a new Cache. +func NewCache() *Cache { + return &Cache{ + items: make(map[string]string), + } +} + +// Get retrieves a cached value. +func (c *Cache) Get(key string) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + val, ok := c.items[key] + return val, ok +} + +// Set stores a value in the cache. +func (c *Cache) Set(key, value string) { + c.mu.Lock() + defer c.mu.Unlock() + + c.items[key] = value +} + +// Clear removes all cached values. +func (c *Cache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + c.items = make(map[string]string) +} + +// Size returns the number of cached items. +func (c *Cache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + + return len(c.items) +} + +// Delete removes a specific key from the cache. +func (c *Cache) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.items, key) +} + +// Has checks if a key exists in the cache. +func (c *Cache) Has(key string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + + _, ok := c.items[key] + return ok +} diff --git a/i18n/context.go b/i18n/context.go new file mode 100644 index 0000000000000000000000000000000000000000..cacb96dcc24044bc5382755f2422b777a652a35f --- /dev/null +++ b/i18n/context.go @@ -0,0 +1,24 @@ +package i18n + +import "context" + +type contextKey int + +const ( + // localeContextKey is the key for storing locale in context. + localeContextKey contextKey = iota +) + +// WithLocale returns a new context with the given locale. +func WithLocale(ctx context.Context, locale string) context.Context { + return context.WithValue(ctx, localeContextKey, locale) +} + +// LocaleFromContext extracts the locale from context. +// Returns empty string if no locale is set. +func LocaleFromContext(ctx context.Context) string { + if locale, ok := ctx.Value(localeContextKey).(string); ok { + return locale + } + return "" +} diff --git a/i18n/date_formatter.go b/i18n/date_formatter.go new file mode 100644 index 0000000000000000000000000000000000000000..2aabcb73962f6b35c7b3a20508d15dfb4ef2b6c3 --- /dev/null +++ b/i18n/date_formatter.go @@ -0,0 +1,119 @@ +package i18n + +import ( + "fmt" + "time" +) + +// DateFormatter formats dates and times according to locale rules. +type DateFormatter struct { + locale *Locale +} + +// NewDateFormatter creates a date formatter for a locale. +func NewDateFormatter(locale *Locale) *DateFormatter { + return &DateFormatter{ + locale: locale, + } +} + +// FormatDate formats a time according to the given layout. +func (f *DateFormatter) FormatDate(t time.Time, layout string) string { + return t.Format(layout) +} + +// FormatTime formats just the time portion. +func (f *DateFormatter) FormatTime(t time.Time) string { + return t.Format("15:04") +} + +// FormatDateTime formats both date and time. +func (f *DateFormatter) FormatDateTime(t time.Time) string { + return t.Format("2006-01-02 15:04") +} + +// FormatRelative formats a time relative to now (e.g., "5 minutes ago"). +// This should use translated strings from the message catalog. +func (f *DateFormatter) FormatRelative(t time.Time) string { + now := time.Now() + duration := now.Sub(t) + + // Future times + if duration < 0 { + duration = -duration + return formatFutureDuration(duration) + } + + // Past times + return formatPastDuration(duration) +} + +// formatPastDuration formats a duration as "X ago". +func formatPastDuration(d time.Duration) string { + seconds := int(d.Seconds()) + minutes := seconds / 60 + hours := minutes / 60 + days := hours / 24 + + switch { + case seconds < 60: + return "just now" + case minutes == 1: + return "1 minute ago" + case minutes < 60: + return fmt.Sprintf("%d minutes ago", minutes) + case hours == 1: + return "1 hour ago" + case hours < 24: + return fmt.Sprintf("%d hours ago", hours) + case days == 1: + return "1 day ago" + case days < 7: + return fmt.Sprintf("%d days ago", days) + case days < 30: + weeks := days / 7 + if weeks == 1 { + return "1 week ago" + } + return fmt.Sprintf("%d weeks ago", weeks) + case days < 365: + months := days / 30 + if months == 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + default: + years := days / 365 + if years == 1 { + return "1 year ago" + } + return fmt.Sprintf("%d years ago", years) + } +} + +// formatFutureDuration formats a duration as "in X". +func formatFutureDuration(d time.Duration) string { + seconds := int(d.Seconds()) + minutes := seconds / 60 + hours := minutes / 60 + days := hours / 24 + + switch { + case seconds < 60: + return "in a moment" + case minutes == 1: + return "in 1 minute" + case minutes < 60: + return fmt.Sprintf("in %d minutes", minutes) + case hours == 1: + return "in 1 hour" + case hours < 24: + return fmt.Sprintf("in %d hours", hours) + case days == 1: + return "in 1 day" + case days < 7: + return fmt.Sprintf("in %d days", days) + default: + return "in the future" + } +} diff --git a/i18n/detector.go b/i18n/detector.go new file mode 100644 index 0000000000000000000000000000000000000000..8bc68db67fc60768b14243a3cdaa2921076be81c --- /dev/null +++ b/i18n/detector.go @@ -0,0 +1,80 @@ +package i18n + +import ( + "os" + "strings" + + "github.com/floatpane/matcha/config" +) + +// DetectLanguage determines the language to use based on config and environment. +func DetectLanguage(cfg *config.Config) string { + // 1. Check config first + if lang := detectFromConfig(cfg); lang != "" { + return normalizeLanguageCode(lang) + } + + // 2. Check environment variables + if lang := detectFromEnv(); lang != "" { + return normalizeLanguageCode(lang) + } + + // 3. Default to English + return "en" +} + +// detectFromConfig gets language from configuration. +func detectFromConfig(cfg *config.Config) string { + if cfg == nil { + return "" + } + return cfg.GetLanguage() +} + +// detectFromEnv gets language from environment variables. +func detectFromEnv() string { + // Check standard language environment variables + for _, envVar := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} { + if lang := os.Getenv(envVar); lang != "" { + return lang + } + } + return "" +} + +// normalizeLanguageCode converts various language code formats to a standard form. +// Examples: +// - "en_US.UTF-8" -> "en" +// - "en-US" -> "en" +// - "pt_BR" -> "pt" +func normalizeLanguageCode(code string) string { + if code == "" { + return "" + } + + // Remove encoding (e.g., ".UTF-8") + if idx := strings.Index(code, "."); idx != -1 { + code = code[:idx] + } + + // Replace underscore with hyphen + code = strings.ReplaceAll(code, "_", "-") + + // Split on hyphen and take base language + parts := strings.Split(code, "-") + if len(parts) > 0 { + base := strings.ToLower(parts[0]) + + // Validate it's a known language + if HasLanguage(base) { + return base + } + } + + return code +} + +// isValidLanguage checks if a language code is registered. +func isValidLanguage(code string) bool { + return HasLanguage(code) +} diff --git a/i18n/embed.go b/i18n/embed.go new file mode 100644 index 0000000000000000000000000000000000000000..54db1aa3f81290ffc590203d2ceb706d20ec0e47 --- /dev/null +++ b/i18n/embed.go @@ -0,0 +1,8 @@ +package i18n + +import "embed" + +// localeFS embeds all translation files from the locales directory. +// +//go:embed locales/*.json +var localeFS embed.FS diff --git a/i18n/errors.go b/i18n/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..c09fc4d25b13a0b80cba00a1c08ad7e407405353 --- /dev/null +++ b/i18n/errors.go @@ -0,0 +1,23 @@ +package i18n + +import "errors" + +var ( + // ErrLanguageNotFound is returned when a requested language is not available. + ErrLanguageNotFound = errors.New("language not found") + + // ErrMessageNotFound is returned when a translation key does not exist. + ErrMessageNotFound = errors.New("message not found") + + // ErrInvalidLocale is returned when a locale code is malformed. + ErrInvalidLocale = errors.New("invalid locale code") + + // ErrLoadFailed is returned when translation files fail to load. + ErrLoadFailed = errors.New("failed to load translations") + + // ErrParseFailed is returned when a translation file cannot be parsed. + ErrParseFailed = errors.New("failed to parse translation file") + + // ErrNoDefaultLanguage is returned when no default language is set. + ErrNoDefaultLanguage = errors.New("no default language set") +) diff --git a/i18n/fallback.go b/i18n/fallback.go new file mode 100644 index 0000000000000000000000000000000000000000..9fa949e42d466eca9ef97be93168cd11c86e2480 --- /dev/null +++ b/i18n/fallback.go @@ -0,0 +1,66 @@ +package i18n + +import "strings" + +// FallbackChain defines a sequence of languages to try when looking up translations. +type FallbackChain struct { + langs []string +} + +// NewFallbackChain creates a new fallback chain with a preferred language and defaults. +// Example: NewFallbackChain("pt-BR", "pt", "en") creates chain: pt-BR → pt → en +func NewFallbackChain(preferred string, defaults ...string) *FallbackChain { + chain := &FallbackChain{ + langs: make([]string, 0, len(defaults)+2), + } + + // Add preferred language + if preferred != "" { + chain.langs = append(chain.langs, preferred) + + // If preferred has region code (e.g., "en-US"), also add base (e.g., "en") + if parts := strings.Split(preferred, "-"); len(parts) > 1 { + base := parts[0] + if !contains(chain.langs, base) { + chain.langs = append(chain.langs, base) + } + } + } + + // Add fallback languages + for _, lang := range defaults { + if lang != "" && !contains(chain.langs, lang) { + chain.langs = append(chain.langs, lang) + } + } + + return chain +} + +// Resolve attempts to find a message in the fallback chain. +// Returns the message, the language it was found in, and any error. +func (f *FallbackChain) Resolve(bundle *Bundle, key string) (*Message, string, error) { + for _, lang := range f.langs { + msg, err := bundle.GetMessage(lang, key) + if err == nil { + return msg, lang, nil + } + } + + return nil, "", ErrMessageNotFound +} + +// Languages returns the ordered list of languages in the fallback chain. +func (f *FallbackChain) Languages() []string { + return f.langs +} + +// contains checks if a slice contains a string. +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/i18n/formatter.go b/i18n/formatter.go new file mode 100644 index 0000000000000000000000000000000000000000..98e10e190465b1e40d378559cfadcc94fb16c498 --- /dev/null +++ b/i18n/formatter.go @@ -0,0 +1,67 @@ +package i18n + +import ( + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +// NumberFormatter formats numbers according to locale rules. +type NumberFormatter struct { + locale *Locale + printer *message.Printer +} + +// NewNumberFormatter creates a formatter for a locale. +func NewNumberFormatter(locale *Locale) *NumberFormatter { + tag := locale.Tag + if tag == language.Und { + tag = language.English + } + + return &NumberFormatter{ + locale: locale, + printer: message.NewPrinter(tag), + } +} + +// FormatInt formats an integer according to locale rules. +func (f *NumberFormatter) FormatInt(n int) string { + return f.printer.Sprintf("%d", n) +} + +// FormatInt64 formats an int64 according to locale rules. +func (f *NumberFormatter) FormatInt64(n int64) string { + return f.printer.Sprintf("%d", n) +} + +// FormatFloat formats a float64 with the specified precision. +func (f *NumberFormatter) FormatFloat(n float64, precision int) string { + format := "%." + string(rune(precision+'0')) + "f" + return f.printer.Sprintf(format, n) +} + +// FormatPercent formats a number as a percentage (0.5 -> "50%"). +func (f *NumberFormatter) FormatPercent(n float64) string { + return f.printer.Sprintf("%.0f%%", n*100) +} + +// FormatFileSize formats a byte count as a human-readable size. +func (f *NumberFormatter) FormatFileSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return f.printer.Sprintf("%d B", bytes) + } + + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + units := []string{"KB", "MB", "GB", "TB", "PB"} + if exp >= len(units) { + exp = len(units) - 1 + } + + return f.printer.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp]) +} diff --git a/i18n/init.go b/i18n/init.go new file mode 100644 index 0000000000000000000000000000000000000000..f658990c110f62ad2acb88a5a891952827201611 --- /dev/null +++ b/i18n/init.go @@ -0,0 +1,20 @@ +package i18n + +// Package i18n provides internationalization support for the matcha email client. +// +// Usage: +// import "github.com/floatpane/matcha/i18n" +// import _ "github.com/floatpane/matcha/i18n/languages" // Register all languages +// +// func main() { +// // Initialize i18n +// if err := i18n.Init("en"); err != nil { +// log.Fatal(err) +// } +// +// // Set language (optional, can also be done via config) +// i18n.GetManager().SetLanguage("es") +// +// // Translate +// text := i18n.GetManager().T("composer.title") +// } diff --git a/i18n/interpolator.go b/i18n/interpolator.go new file mode 100644 index 0000000000000000000000000000000000000000..7c68b9b3ddf05deae73b431735858e12e45f0c42 --- /dev/null +++ b/i18n/interpolator.go @@ -0,0 +1,45 @@ +package i18n + +import ( + "fmt" + "strings" +) + +// Interpolate replaces placeholders in a template string with values from data. +// Supports {key} syntax for variable interpolation. +func Interpolate(template string, data map[string]interface{}) string { + if data == nil || len(data) == 0 { + return template + } + + result := template + for key, value := range data { + placeholder := "{" + key + "}" + replacement := formatValue(value) + result = strings.ReplaceAll(result, placeholder, replacement) + } + + return result +} + +// formatValue converts a value to its string representation. +func formatValue(v interface{}) string { + if v == nil { + return "" + } + + switch val := v.(type) { + case string: + return val + case int: + return fmt.Sprintf("%d", val) + case int64: + return fmt.Sprintf("%d", val) + case float64: + return fmt.Sprintf("%g", val) + case bool: + return fmt.Sprintf("%t", val) + default: + return fmt.Sprintf("%v", val) + } +} diff --git a/i18n/languages/ar.go b/i18n/languages/ar.go new file mode 100644 index 0000000000000000000000000000000000000000..d309b1fb8c26bffec26cae3d8b650cf93ae81482 --- /dev/null +++ b/i18n/languages/ar.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.Arabic, + Code: "ar", + Name: "Arabic", + NativeName: "العربية", + Direction: "rtl", + PluralFunc: i18n.ArabicPlural, + }) +} diff --git a/i18n/languages/base.go b/i18n/languages/base.go new file mode 100644 index 0000000000000000000000000000000000000000..cf8015d27202a9883a6181041d476def4c30ad23 --- /dev/null +++ b/i18n/languages/base.go @@ -0,0 +1,14 @@ +package languages + +import "github.com/floatpane/matcha/i18n" + +// LanguageInfo provides metadata about a language. +type LanguageInfo struct { + Code string + Name string + NativeName string + Direction string + PluralFunc i18n.PluralFunc +} + +// All available languages are registered via init() functions in their respective files. diff --git a/i18n/languages/de.go b/i18n/languages/de.go new file mode 100644 index 0000000000000000000000000000000000000000..07a803b108e61e10267bb4f67ff55f097f92424b --- /dev/null +++ b/i18n/languages/de.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.German, + Code: "de", + Name: "German", + NativeName: "Deutsch", + Direction: "ltr", + PluralFunc: i18n.GermanPlural, + }) +} diff --git a/i18n/languages/en.go b/i18n/languages/en.go new file mode 100644 index 0000000000000000000000000000000000000000..d4c8001364a0eae20b08d61a3fb5c34373982544 --- /dev/null +++ b/i18n/languages/en.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.English, + Code: "en", + Name: "English", + NativeName: "English", + Direction: "ltr", + PluralFunc: i18n.EnglishPlural, + }) +} diff --git a/i18n/languages/es.go b/i18n/languages/es.go new file mode 100644 index 0000000000000000000000000000000000000000..00d0fd2a5dfacbd8d3bd263c5f6e1a0e1581b86b --- /dev/null +++ b/i18n/languages/es.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.Spanish, + Code: "es", + Name: "Spanish", + NativeName: "Español", + Direction: "ltr", + PluralFunc: i18n.SpanishPlural, + }) +} diff --git a/i18n/languages/fr.go b/i18n/languages/fr.go new file mode 100644 index 0000000000000000000000000000000000000000..fd4681e0f13855016c72e7b9e6d2207f585011e2 --- /dev/null +++ b/i18n/languages/fr.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.French, + Code: "fr", + Name: "French", + NativeName: "Français", + Direction: "ltr", + PluralFunc: i18n.FrenchPlural, + }) +} diff --git a/i18n/languages/ja.go b/i18n/languages/ja.go new file mode 100644 index 0000000000000000000000000000000000000000..33f94be8a6431eae1f6d9da6f28de75c3b25087d --- /dev/null +++ b/i18n/languages/ja.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.Japanese, + Code: "ja", + Name: "Japanese", + NativeName: "日本語", + Direction: "ltr", + PluralFunc: i18n.JapanesePlural, + }) +} diff --git a/i18n/languages/pl.go b/i18n/languages/pl.go new file mode 100644 index 0000000000000000000000000000000000000000..394d09892dee70218680efffc3051237809315db --- /dev/null +++ b/i18n/languages/pl.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.Polish, + Code: "pl", + Name: "Polish", + NativeName: "Polski", + Direction: "ltr", + PluralFunc: i18n.PolishPlural, + }) +} diff --git a/i18n/languages/pt.go b/i18n/languages/pt.go new file mode 100644 index 0000000000000000000000000000000000000000..dfc42c63d526ccd7a09722504bb27839025c7dc8 --- /dev/null +++ b/i18n/languages/pt.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.Portuguese, + Code: "pt", + Name: "Portuguese", + NativeName: "Português", + Direction: "ltr", + PluralFunc: i18n.PortuguesePlural, + }) +} diff --git a/i18n/languages/ru.go b/i18n/languages/ru.go new file mode 100644 index 0000000000000000000000000000000000000000..86c954666401ab6a2c27787667fe69e7d00259e3 --- /dev/null +++ b/i18n/languages/ru.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.Russian, + Code: "ru", + Name: "Russian", + NativeName: "Русский", + Direction: "ltr", + PluralFunc: i18n.RussianPlural, + }) +} diff --git a/i18n/languages/uk.go b/i18n/languages/uk.go new file mode 100644 index 0000000000000000000000000000000000000000..27d35140cc7b9df03274d49ee58cfd0430d90c47 --- /dev/null +++ b/i18n/languages/uk.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.Ukrainian, + Code: "uk", + Name: "Ukrainian", + NativeName: "Українська", + Direction: "ltr", + PluralFunc: i18n.UkrainianPlural, + }) +} diff --git a/i18n/languages/zh.go b/i18n/languages/zh.go new file mode 100644 index 0000000000000000000000000000000000000000..cdd2bccc6424522bfe2a9276c3427d0dd6859ca5 --- /dev/null +++ b/i18n/languages/zh.go @@ -0,0 +1,17 @@ +package languages + +import ( + "github.com/floatpane/matcha/i18n" + "golang.org/x/text/language" +) + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Tag: language.Chinese, + Code: "zh", + Name: "Chinese", + NativeName: "中文", + Direction: "ltr", + PluralFunc: i18n.ChinesePlural, + }) +} diff --git a/i18n/loader.go b/i18n/loader.go new file mode 100644 index 0000000000000000000000000000000000000000..e929a5bf41fc61b0beb196af7123f18368377eaf --- /dev/null +++ b/i18n/loader.go @@ -0,0 +1,101 @@ +package i18n + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// LoadTranslations loads all translation files into a bundle. +// First attempts to load from embedded files, then checks for external files. +func LoadTranslations(bundle *Bundle) error { + // Load from embedded files + if err := loadFromEmbedded(bundle); err != nil { + return fmt.Errorf("%w: embedded load failed: %v", ErrLoadFailed, err) + } + + return nil +} + +// loadFromEmbedded loads translation files from the embedded filesystem. +func loadFromEmbedded(bundle *Bundle) error { + entries, err := localeFS.ReadDir("locales") + if err != nil { + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filename := entry.Name() + if !strings.HasSuffix(filename, ".json") { + continue + } + + // Read file + data, err := localeFS.ReadFile(filepath.Join("locales", filename)) + if err != nil { + continue + } + + // Extract language code from filename (e.g., "en.json" -> "en") + lang := strings.TrimSuffix(filename, ".json") + + // Load into bundle + if err := loadLanguageFile(bundle, lang, data); err != nil { + return err + } + } + + return nil +} + +// LoadFromDirectory loads translation files from a directory on disk. +// This allows overriding embedded translations with external files. +func LoadFromDirectory(bundle *Bundle, dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("%w: %v", ErrLoadFailed, err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filename := entry.Name() + if !strings.HasSuffix(filename, ".json") { + continue + } + + // Read file + path := filepath.Join(dir, filename) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + // Extract language code + lang := strings.TrimSuffix(filename, ".json") + + // Load into bundle + if err := loadLanguageFile(bundle, lang, data); err != nil { + return err + } + } + + return nil +} + +// loadLanguageFile parses and loads a single language file into the bundle. +func loadLanguageFile(bundle *Bundle, lang string, data []byte) error { + messages, err := ParseJSON(data) + if err != nil { + return fmt.Errorf("%w: language %s: %v", ErrParseFailed, lang, err) + } + + return bundle.AddMessages(lang, messages) +} diff --git a/i18n/locale.go b/i18n/locale.go new file mode 100644 index 0000000000000000000000000000000000000000..6388a6da5cc25dca1a42feb80fb842dd98433e46 --- /dev/null +++ b/i18n/locale.go @@ -0,0 +1,74 @@ +package i18n + +import ( + "strings" + + "golang.org/x/text/language" +) + +// Locale represents a language/region configuration. +type Locale struct { + // Tag is the BCP 47 language tag + Tag language.Tag + + // Code is the short language code (e.g., "en", "es", "de") + Code string + + // Name is the English name of the language + Name string + + // NativeName is the language's name in its own language + NativeName string + + // Direction is the text direction ("ltr" or "rtl") + Direction string + + // PluralFunc is the plural rule function for this language + PluralFunc PluralFunc +} + +// ParseLocale parses a language code and returns a Locale. +// Supports formats like "en", "en-US", "en_US". +func ParseLocale(code string) (*Locale, error) { + if code == "" { + return nil, ErrInvalidLocale + } + + // Normalize separators + code = strings.ReplaceAll(code, "_", "-") + + // Parse language tag + tag, err := language.Parse(code) + if err != nil { + return nil, ErrInvalidLocale + } + + // Extract base language + base, _ := tag.Base() + langCode := base.String() + + // Look up in registry + if locale, ok := GetLanguage(langCode); ok { + return locale, nil + } + + // Return a basic locale if not registered + return &Locale{ + Tag: tag, + Code: langCode, + Name: langCode, + NativeName: langCode, + Direction: "ltr", + PluralFunc: DefaultPlural, + }, nil +} + +// String returns the string representation of the locale. +func (l *Locale) String() string { + return l.Code +} + +// IsRTL returns true if the locale uses right-to-left text direction. +func (l *Locale) IsRTL() bool { + return l.Direction == "rtl" +} diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json new file mode 100644 index 0000000000000000000000000000000000000000..a22572a02c4c1db0256bbb97c5817ad59f6dab2c --- /dev/null +++ b/i18n/locales/ar.json @@ -0,0 +1,275 @@ +{ + "language": "ar", + "messages": { + "common": { + "yes": "نعم", + "no": "لا", + "cancel": "إلغاء", + "ok": "موافق", + "save": "حفظ", + "delete": "حذف", + "archive": "أرشفة", + "back": "رجوع", + "next": "التالي", + "previous": "السابق", + "loading": "جاري التحميل...", + "error": "خطأ", + "success": "نجاح" + }, + "composer": { + "title": "إنشاء بريد إلكتروني جديد", + "from": "من", + "to_placeholder": "أدخل عناوين البريد الإلكتروني للمستلمين.", + "cc_placeholder": "مستلمو نسخة كربونية.", + "bcc_placeholder": "مستلمو نسخة كربونية مخفية.", + "subject_placeholder": "الموضوع", + "body_placeholder": "اكتب رسالتك...", + "signature": "التوقيع", + "signature_placeholder": "توقيع بريدك الإلكتروني.", + "attachments": "المرفقات", + "attachments_none": "لا يوجد", + "enter_to_add": "اضغط Enter للإضافة", + "encrypt_smime": "تشفير البريد الإلكتروني (S/MIME)", + "send": "إرسال", + "switchable": "قابل للتبديل", + "enter_to_switch": "اضغط Enter للتبديل", + "no_account": "لم يتم تكوين حساب", + "send_confirm": "اضغط Enter لإرسال البريد الإلكتروني.", + "help": "Markdown/HTML • tab/shift+tab: التنقل • ctrl+e: $EDITOR • esc: حفظ المسودة والخروج", + "exit_confirm": "هل أنت متأكد أنك تريد الخروج؟ سيتم حفظ هذه المسودة", + "sending": "جاري إرسال البريد الإلكتروني...", + "sent": "تم إرسال البريد الإلكتروني بنجاح", + "draft_saved": "تم حفظ المسودة" + }, + "inbox": { + "title": "صندوق الوارد", + "all_accounts": "جميع الحسابات", + "sent": "المرسلة", + "trash": "سلة المهملات", + "archive": "الأرشيف", + "empty": "لا توجد رسائل", + "loading": "جاري تحميل الرسائل...", + "refreshing": "جاري التحديث...", + "visual_mode": "الوضع المرئي", + "delete": "حذف", + "archive": "أرشفة", + "refresh": "تحديث", + "reply": "رد", + "forward": "إعادة توجيه", + "move": "نقل", + "mark_read": "وضع علامة كمقروء", + "mark_unread": "وضع علامة كغير مقروء", + "help_visual": "v: الوضع المرئي • d: حذف • a: أرشفة", + "help_navigation": "j/k: التنقل • enter: فتح • r: تحديث" + }, + "choice": { + "what_to_do": "ماذا تريد أن تفعل؟", + "compose": "إنشاء بريد إلكتروني", + "inbox": "عرض صندوق الوارد", + "calendar": "عرض التقويم", + "settings": "الإعدادات", + "marketplace": "متجر الإضافات", + "drafts": "المسودات", + "help": "استخدم ↑/↓ للتنقل، enter للاختيار، وctrl+c للخروج.", + "unknown": "غير معروف", + "update_available": "تحديث متاح: {latest} (المثبت: {current}) — قم بتشغيل `matcha update` للترقية" + }, + "folder_inbox": { + "folders_title": "المجلدات", + "move_to_folder": "نقل إلى المجلد:", + "move_single": "نقل البريد الإلكتروني إلى المجلد:", + "move_multiple": { + "one": "نقل بريد إلكتروني {count} إلى المجلد:", + "few": "نقل {count} رسائل بريد إلكتروني إلى المجلد:", + "many": "نقل {count} رسالة بريد إلكتروني إلى المجلد:", + "other": "نقل {count} رسالة بريد إلكتروني إلى المجلد:" + }, + "help": "j/k: التنقل enter: نقل esc: إلغاء", + "help_folders": "tab: المجلد التالي • shift+tab: المجلد السابق • m: نقل" + }, + "login": { + "title": "حسابات البريد الإلكتروني", + "add_account": "إضافة حساب", + "edit_account": "تعديل الحساب", + "description": "أدخل بيانات اعتماد حساب البريد الإلكتروني الخاص بك.", + "protocol_label": "البروتوكول", + "protocol_placeholder": "البروتوكول (imap أو jmap أو pop3)", + "email_label": "البريد الإلكتروني", + "email_placeholder": "your.email@example.com", + "password_label": "كلمة المرور", + "password_placeholder": "كلمة المرور / كلمة مرور التطبيق", + "display_name_label": "الاسم المعروض", + "display_name_placeholder": "اسمك", + "imap_server_label": "خادم IMAP", + "smtp_server_label": "خادم SMTP", + "port_label": "المنفذ", + "save": "حفظ الحساب", + "delete": "حذف الحساب", + "delete_confirm": "حذف هذا الحساب؟", + "tip_protocol": "اختر البروتوكول: imap (افتراضي)، jmap، أو pop3.", + "tip_app_password": "بالنسبة لـ Gmail، استخدم كلمة مرور التطبيق بدلاً من كلمة المرور العادية." + }, + "settings": { + "title": "الإعدادات", + "category_general": "عام", + "category_accounts": "الحسابات", + "category_theme": "المظهر", + "category_mailing_lists": "القوائم البريدية", + "category_encryption": "تشفير التطبيق", + "help_menu": "↑/↓: التنقل • يمين/enter: اختيار • esc: رجوع", + "help_content": "esc: العودة للقائمة" + }, + "settings_accounts": { + "title": "إعدادات الحسابات", + "no_accounts": "لم يتم تكوين حسابات.", + "add_account": "إضافة حساب جديد", + "help": "↑/↓: التنقل • enter: تعديل إعدادات التشفير • e: تعديل الخادم • d: حذف" + }, + "settings_theme": { + "title": "المظهر", + "current": "نشط", + "help": "↑/↓: التنقل • enter/مسافة: تطبيق المظهر" + }, + "settings_mailing_lists": { + "title": "القوائم البريدية", + "no_lists": "لم يتم تكوين قوائم بريدية.", + "add_list": "إضافة قائمة بريدية جديدة", + "delete_confirm": "حذف القائمة البريدية؟", + "address_count": { + "one": "عنوان {count}", + "few": "{count} عناوين", + "many": "{count} عنواناً", + "other": "{count} عنوان" + }, + "help": "↑/↓: التنقل • enter: اختيار • e: تعديل • d: حذف" + }, + "settings_general": { + "title": "الإعدادات العامة", + "disable_images": "تعطيل عرض الصور", + "hide_tips": "إخفاء النصائح السياقية", + "disable_notifications": "تعطيل الإشعارات", + "date_format": "تنسيق التاريخ", + "language": "اللغة", + "signature": "تعديل التوقيع", + "signature_configured": "مكوّن", + "signature_not_configured": "غير مكوّن", + "on": "تشغيل", + "off": "إيقاف", + "restart_required": "يتطلب إعادة التشغيل لتطبيق تغيير اللغة" + }, + "settings_encryption": { + "title": "تشفير التطبيق", + "enabled": "التشفير مفعّل حالياً.", + "disabled": "عيّن كلمة مرور لتشفير جميع البيانات.", + "password_label": "كلمة المرور:", + "confirm_label": "تأكيد كلمة المرور:", + "enable_button": "تفعيل التشفير", + "disable_button": "اضغط enter لتعطيل التشفير", + "disable_confirm": "تعطيل التشفير؟", + "disable_warning": "سيتم تخزين جميع البيانات بدون تشفير.", + "encrypting": "جاري تشفير البيانات...", + "error_empty": "لا يمكن أن تكون كلمة المرور فارغة", + "error_mismatch": "كلمات المرور غير متطابقة", + "help": "tab: التالي • enter: حفظ" + }, + "password_prompt": { + "title": "Matcha مقفل", + "enter_password": "أدخل كلمة المرور", + "error_empty": "لا يمكن أن تكون كلمة المرور فارغة", + "error_incorrect": "كلمة مرور غير صحيحة", + "help": "enter: فتح القفل • ctrl+c: خروج" + }, + "email_view": { + "from": "من", + "to": "إلى", + "cc": "نسخة", + "bcc": "نسخة مخفية", + "subject": "الموضوع", + "date": "التاريخ", + "attachments": "المرفقات", + "download": "تنزيل", + "save": "حفظ", + "reply": "رد", + "reply_all": "رد على الكل", + "forward": "إعادة توجيه", + "delete": "حذف", + "archive": "أرشفة", + "help": "r: رد • f: إعادة توجيه • d: حذف • a: أرشفة • esc: رجوع" + }, + "calendar": { + "title": "التقويم", + "meeting": "اجتماع", + "event": "حدث", + "accept": "قبول", + "decline": "رفض", + "tentative": "مؤقت", + "rsvp_sent": "تم إرسال RSVP: {response}" + }, + "marketplace": { + "title": "متجر الإضافات", + "installing": "جاري التثبيت...", + "installed": "مثبت", + "install": "تثبيت", + "error": "فشل التثبيت", + "help": "j/k: التنقل • enter: تثبيت • esc: رجوع" + }, + "time": { + "just_now": "الآن", + "minute_ago": { + "one": "منذ دقيقة واحدة", + "few": "منذ دقيقتين", + "many": "منذ {count} دقيقة", + "other": "منذ {count} دقيقة" + }, + "hour_ago": { + "one": "منذ ساعة واحدة", + "few": "منذ ساعتين", + "many": "منذ {count} ساعة", + "other": "منذ {count} ساعة" + }, + "day_ago": { + "one": "منذ يوم واحد", + "few": "منذ يومين", + "many": "منذ {count} يوماً", + "other": "منذ {count} يوم" + }, + "week_ago": { + "one": "منذ أسبوع واحد", + "few": "منذ أسبوعين", + "many": "منذ {count} أسبوعاً", + "other": "منذ {count} أسبوع" + }, + "month_ago": { + "one": "منذ شهر واحد", + "few": "منذ شهرين", + "many": "منذ {count} شهراً", + "other": "منذ {count} شهر" + }, + "year_ago": { + "one": "منذ سنة واحدة", + "few": "منذ سنتين", + "many": "منذ {count} سنة", + "other": "منذ {count} سنة" + }, + "in_moment": "بعد لحظة", + "in_minute": { + "one": "بعد دقيقة واحدة", + "few": "بعد دقيقتين", + "many": "بعد {count} دقيقة", + "other": "بعد {count} دقيقة" + }, + "in_hour": { + "one": "بعد ساعة واحدة", + "few": "بعد ساعتين", + "many": "بعد {count} ساعة", + "other": "بعد {count} ساعة" + }, + "in_day": { + "one": "بعد يوم واحد", + "few": "بعد يومين", + "many": "بعد {count} يوماً", + "other": "بعد {count} يوم" + } + } + } +} diff --git a/i18n/locales/de.json b/i18n/locales/de.json new file mode 100644 index 0000000000000000000000000000000000000000..d2e7a10d76c754cbc2d71e831b5fe3d3217caa54 --- /dev/null +++ b/i18n/locales/de.json @@ -0,0 +1,253 @@ +{ + "language": "de", + "messages": { + "common": { + "yes": "Ja", + "no": "Nein", + "cancel": "Abbrechen", + "ok": "OK", + "save": "Speichern", + "delete": "Löschen", + "archive": "Archivieren", + "back": "Zurück", + "next": "Weiter", + "previous": "Vorherige", + "loading": "Lädt...", + "error": "Fehler", + "success": "Erfolg" + }, + "composer": { + "title": "Neue E-Mail Verfassen", + "from": "Von", + "to_placeholder": "E-Mail-Adressen der Empfänger eingeben.", + "cc_placeholder": "Kopie-Empfänger.", + "bcc_placeholder": "Blindkopie-Empfänger.", + "subject_placeholder": "Betreff", + "body_placeholder": "Verfassen Sie Ihre Nachricht...", + "signature": "Signatur", + "signature_placeholder": "Ihre E-Mail-Signatur.", + "attachments": "Anhänge", + "attachments_none": "Keine", + "enter_to_add": "Enter zum Hinzufügen", + "encrypt_smime": "E-Mail Verschlüsseln (S/MIME)", + "send": "Senden", + "switchable": "wechselbar", + "enter_to_switch": "Enter zum Wechseln", + "no_account": "kein Konto konfiguriert", + "send_confirm": "Drücken Sie Enter, um die E-Mail zu senden.", + "help": "Markdown/HTML • tab/shift+tab: navigieren • ctrl+e: $EDITOR • esc: Entwurf speichern & beenden", + "exit_confirm": "Sind Sie sicher, dass Sie beenden möchten? Dieser Entwurf wird gespeichert", + "sending": "E-Mail wird gesendet...", + "sent": "E-Mail erfolgreich gesendet", + "draft_saved": "Entwurf gespeichert" + }, + "inbox": { + "title": "Posteingang", + "all_accounts": "Alle Konten", + "sent": "Gesendet", + "trash": "Papierkorb", + "archive": "Archiv", + "empty": "Keine E-Mails", + "loading": "E-Mails werden geladen...", + "refreshing": "Wird aktualisiert...", + "visual_mode": "visueller Modus", + "delete": "löschen", + "archive": "archivieren", + "refresh": "aktualisieren", + "reply": "antworten", + "forward": "weiterleiten", + "move": "verschieben", + "mark_read": "als gelesen markieren", + "mark_unread": "als ungelesen markieren", + "help_visual": "v: visueller Modus • d: löschen • a: archivieren", + "help_navigation": "j/k: navigieren • enter: öffnen • r: aktualisieren" + }, + "choice": { + "what_to_do": "Was möchten Sie tun?", + "compose": "E-Mail Verfassen", + "inbox": "Posteingang Anzeigen", + "calendar": "Kalender Anzeigen", + "settings": "Einstellungen", + "marketplace": "Plugin-Marktplatz", + "drafts": "Entwürfe", + "help": "Verwenden Sie ↑/↓ zum Navigieren, Enter zum Auswählen und ctrl+c zum Beenden.", + "unknown": "unbekannt", + "update_available": "Update verfügbar: {latest} (installiert: {current}) — führen Sie `matcha update` aus, um zu aktualisieren" + }, + "folder_inbox": { + "folders_title": "Ordner", + "move_to_folder": "In Ordner verschieben:", + "move_single": "E-Mail in Ordner verschieben:", + "move_multiple": { + "one": "{count} E-Mail in Ordner verschieben:", + "other": "{count} E-Mails in Ordner verschieben:" + }, + "help": "j/k: navigieren enter: verschieben esc: abbrechen", + "help_folders": "tab: nächster Ordner • shift+tab: vorheriger Ordner • m: verschieben" + }, + "login": { + "title": "E-Mail-Konten", + "add_account": "Konto Hinzufügen", + "edit_account": "Konto Bearbeiten", + "description": "Geben Sie Ihre E-Mail-Kontodaten ein.", + "protocol_label": "Protokoll", + "protocol_placeholder": "Protokoll (imap, jmap oder pop3)", + "email_label": "E-Mail", + "email_placeholder": "ihre.email@beispiel.de", + "password_label": "Passwort", + "password_placeholder": "Passwort / App-Passwort", + "display_name_label": "Anzeigename", + "display_name_placeholder": "Ihr Name", + "imap_server_label": "IMAP-Server", + "smtp_server_label": "SMTP-Server", + "port_label": "Port", + "save": "Konto Speichern", + "delete": "Konto Löschen", + "delete_confirm": "Dieses Konto löschen?", + "tip_protocol": "Wählen Sie das Protokoll: imap (Standard), jmap oder pop3.", + "tip_app_password": "Für Gmail verwenden Sie ein App-Passwort anstelle Ihres regulären Passworts." + }, + "settings": { + "title": "Einstellungen", + "category_general": "Allgemein", + "category_accounts": "Konten", + "category_theme": "Design", + "category_mailing_lists": "Mailinglisten", + "category_encryption": "App-Verschlüsselung", + "help_menu": "↑/↓: navigieren • rechts/enter: auswählen • esc: zurück", + "help_content": "esc: zurück zum Menü" + }, + "settings_accounts": { + "title": "Kontoeinstellungen", + "no_accounts": "Keine Konten konfiguriert.", + "add_account": "Neues Konto Hinzufügen", + "help": "↑/↓: navigieren • enter: Krypto-Konfig. bearbeiten • e: Server bearbeiten • d: löschen" + }, + "settings_theme": { + "title": "Design", + "current": "aktiv", + "help": "↑/↓: navigieren • enter/Leertaste: Design anwenden" + }, + "settings_mailing_lists": { + "title": "Mailinglisten", + "no_lists": "Keine Mailinglisten konfiguriert.", + "add_list": "Neue Mailingliste Hinzufügen", + "delete_confirm": "Mailingliste löschen?", + "address_count": { + "one": "{count} Adresse", + "other": "{count} Adressen" + }, + "help": "↑/↓: navigieren • enter: auswählen • e: bearbeiten • d: löschen" + }, + "settings_general": { + "title": "Allgemeine Einstellungen", + "disable_images": "Bildanzeige Deaktivieren", + "hide_tips": "Kontextuelle Tipps Ausblenden", + "disable_notifications": "Benachrichtigungen Deaktivieren", + "date_format": "Datumsformat", + "language": "Sprache", + "signature": "Signatur Bearbeiten", + "signature_configured": "konfiguriert", + "signature_not_configured": "nicht konfiguriert", + "on": "AN", + "off": "AUS", + "restart_required": "Neustart erforderlich, um die Sprachänderung anzuwenden" + }, + "settings_encryption": { + "title": "App-Verschlüsselung", + "enabled": "Verschlüsselung ist derzeit aktiviert.", + "disabled": "Legen Sie ein Passwort fest, um alle Daten zu verschlüsseln.", + "password_label": "Passwort:", + "confirm_label": "Passwort Bestätigen:", + "enable_button": "Verschlüsselung Aktivieren", + "disable_button": "Drücken Sie Enter, um die Verschlüsselung zu deaktivieren", + "disable_confirm": "Verschlüsselung deaktivieren?", + "disable_warning": "Alle Daten werden unverschlüsselt gespeichert.", + "encrypting": "Daten werden verschlüsselt...", + "error_empty": "Passwort darf nicht leer sein", + "error_mismatch": "Passwörter stimmen nicht überein", + "help": "tab: nächstes • enter: speichern" + }, + "password_prompt": { + "title": "Matcha ist gesperrt", + "enter_password": "Geben Sie Ihr Passwort ein", + "error_empty": "Passwort darf nicht leer sein", + "error_incorrect": "Falsches Passwort", + "help": "enter: entsperren • ctrl+c: beenden" + }, + "email_view": { + "from": "Von", + "to": "An", + "cc": "Cc", + "bcc": "Bcc", + "subject": "Betreff", + "date": "Datum", + "attachments": "Anhänge", + "download": "Herunterladen", + "save": "Speichern", + "reply": "Antworten", + "reply_all": "Allen Antworten", + "forward": "Weiterleiten", + "delete": "Löschen", + "archive": "Archivieren", + "help": "r: antworten • f: weiterleiten • d: löschen • a: archivieren • esc: zurück" + }, + "calendar": { + "title": "Kalender", + "meeting": "Besprechung", + "event": "Ereignis", + "accept": "Annehmen", + "decline": "Ablehnen", + "tentative": "Mit Vorbehalt", + "rsvp_sent": "RSVP gesendet: {response}" + }, + "marketplace": { + "title": "Plugin-Marktplatz", + "installing": "Wird installiert...", + "installed": "Installiert", + "install": "Installieren", + "error": "Installation fehlgeschlagen", + "help": "j/k: navigieren • enter: installieren • esc: zurück" + }, + "time": { + "just_now": "gerade eben", + "minute_ago": { + "one": "vor 1 Minute", + "other": "vor {count} Minuten" + }, + "hour_ago": { + "one": "vor 1 Stunde", + "other": "vor {count} Stunden" + }, + "day_ago": { + "one": "vor 1 Tag", + "other": "vor {count} Tagen" + }, + "week_ago": { + "one": "vor 1 Woche", + "other": "vor {count} Wochen" + }, + "month_ago": { + "one": "vor 1 Monat", + "other": "vor {count} Monaten" + }, + "year_ago": { + "one": "vor 1 Jahr", + "other": "vor {count} Jahren" + }, + "in_moment": "in einem Moment", + "in_minute": { + "one": "in 1 Minute", + "other": "in {count} Minuten" + }, + "in_hour": { + "one": "in 1 Stunde", + "other": "in {count} Stunden" + }, + "in_day": { + "one": "in 1 Tag", + "other": "in {count} Tagen" + } + } + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json new file mode 100644 index 0000000000000000000000000000000000000000..92f520f05456314c032513b3434271523ac95481 --- /dev/null +++ b/i18n/locales/en.json @@ -0,0 +1,253 @@ +{ + "language": "en", + "messages": { + "common": { + "yes": "Yes", + "no": "No", + "cancel": "Cancel", + "ok": "OK", + "save": "Save", + "delete": "Delete", + "archive": "Archive", + "back": "Back", + "next": "Next", + "previous": "Previous", + "loading": "Loading...", + "error": "Error", + "success": "Success" + }, + "composer": { + "title": "Compose New Email", + "from": "From", + "to_placeholder": "Enter recipient email addresses.", + "cc_placeholder": "Carbon copy recipients.", + "bcc_placeholder": "Blind carbon copy recipients.", + "subject_placeholder": "Subject", + "body_placeholder": "Compose your message...", + "signature": "Signature", + "signature_placeholder": "Your email signature.", + "attachments": "Attachments", + "attachments_none": "None", + "enter_to_add": "Enter to add", + "encrypt_smime": "Encrypt Email (S/MIME)", + "send": "Send", + "switchable": "switchable", + "enter_to_switch": "Enter to switch", + "no_account": "no account configured", + "send_confirm": "Press Enter to send the email.", + "help": "Markdown/HTML • tab/shift+tab: navigate • ctrl+e: $EDITOR • esc: save draft & exit", + "exit_confirm": "Are you sure you want to exit? This draft will be saved", + "sending": "Sending email...", + "sent": "Email sent successfully", + "draft_saved": "Draft saved" + }, + "inbox": { + "title": "Inbox", + "all_accounts": "All Accounts", + "sent": "Sent", + "trash": "Trash", + "archive": "Archive", + "empty": "No emails", + "loading": "Loading emails...", + "refreshing": "Refreshing...", + "visual_mode": "visual mode", + "delete": "delete", + "archive": "archive", + "refresh": "refresh", + "reply": "reply", + "forward": "forward", + "move": "move", + "mark_read": "mark as read", + "mark_unread": "mark as unread", + "help_visual": "v: visual mode • d: delete • a: archive", + "help_navigation": "j/k: navigate • enter: open • r: refresh" + }, + "choice": { + "what_to_do": "What would you like to do?", + "compose": "Compose Email", + "inbox": "View Inbox", + "calendar": "View Calendar", + "settings": "Settings", + "marketplace": "Plugin Marketplace", + "drafts": "Drafts", + "help": "Use ↑/↓ to navigate, enter to select, and ctrl+c to quit.", + "unknown": "unknown", + "update_available": "Update available: {latest} (installed: {current}) — run `matcha update` to upgrade" + }, + "folder_inbox": { + "folders_title": "Folders", + "move_to_folder": "Move to folder:", + "move_single": "Move email to folder:", + "move_multiple": { + "one": "Move {count} email to folder:", + "other": "Move {count} emails to folder:" + }, + "help": "j/k: navigate enter: move esc: cancel", + "help_folders": "tab: next folder • shift+tab: prev folder • m: move" + }, + "login": { + "title": "Email Accounts", + "add_account": "Add Account", + "edit_account": "Edit Account", + "description": "Enter your email account credentials.", + "protocol_label": "Protocol", + "protocol_placeholder": "Protocol (imap, jmap, or pop3)", + "email_label": "Email", + "email_placeholder": "your.email@example.com", + "password_label": "Password", + "password_placeholder": "Password / App Password", + "display_name_label": "Display Name", + "display_name_placeholder": "Your Name", + "imap_server_label": "IMAP Server", + "smtp_server_label": "SMTP Server", + "port_label": "Port", + "save": "Save Account", + "delete": "Delete Account", + "delete_confirm": "Delete this account?", + "tip_protocol": "Choose the protocol: imap (default), jmap, or pop3.", + "tip_app_password": "For Gmail, use an App Password instead of your regular password." + }, + "settings": { + "title": "Settings", + "category_general": "General", + "category_accounts": "Accounts", + "category_theme": "Theme", + "category_mailing_lists": "Mailing Lists", + "category_encryption": "App Encryption", + "help_menu": "↑/↓: navigate • right/enter: select • esc: go back", + "help_content": "esc: back to menu" + }, + "settings_accounts": { + "title": "Account Settings", + "no_accounts": "No accounts configured.", + "add_account": "Add New Account", + "help": "↑/↓: navigate • enter: edit crypto config • e: edit server • d: delete" + }, + "settings_theme": { + "title": "Theme", + "current": "active", + "help": "↑/↓: navigate • enter/space: apply theme" + }, + "settings_mailing_lists": { + "title": "Mailing Lists", + "no_lists": "No mailing lists configured.", + "add_list": "Add New Mailing List", + "delete_confirm": "Delete mailing list?", + "address_count": { + "one": "{count} address", + "other": "{count} addresses" + }, + "help": "↑/↓: navigate • enter: select • e: edit • d: delete" + }, + "settings_general": { + "title": "General Settings", + "disable_images": "Disable Image Display", + "hide_tips": "Hide Contextual Tips", + "disable_notifications": "Disable Notifications", + "date_format": "Date Format", + "language": "Language", + "signature": "Edit Signature", + "signature_configured": "configured", + "signature_not_configured": "not configured", + "on": "ON", + "off": "OFF", + "restart_required": "Restart required to apply language change" + }, + "settings_encryption": { + "title": "App Encryption", + "enabled": "Encryption is currently enabled.", + "disabled": "Set a password to encrypt all data.", + "password_label": "Password:", + "confirm_label": "Confirm Password:", + "enable_button": "Enable Encryption", + "disable_button": "Press enter to disable encryption", + "disable_confirm": "Disable encryption?", + "disable_warning": "All data will be stored unencrypted.", + "encrypting": "Encrypting data...", + "error_empty": "Password cannot be empty", + "error_mismatch": "Passwords do not match", + "help": "tab: next • enter: save" + }, + "password_prompt": { + "title": "Matcha is locked", + "enter_password": "Enter your password", + "error_empty": "Password cannot be empty", + "error_incorrect": "Incorrect password", + "help": "enter: unlock • ctrl+c: quit" + }, + "email_view": { + "from": "From", + "to": "To", + "cc": "Cc", + "bcc": "Bcc", + "subject": "Subject", + "date": "Date", + "attachments": "Attachments", + "download": "Download", + "save": "Save", + "reply": "Reply", + "reply_all": "Reply All", + "forward": "Forward", + "delete": "Delete", + "archive": "Archive", + "help": "r: reply • f: forward • d: delete • a: archive • esc: back" + }, + "calendar": { + "title": "Calendar", + "meeting": "Meeting", + "event": "Event", + "accept": "Accept", + "decline": "Decline", + "tentative": "Tentative", + "rsvp_sent": "RSVP sent: {response}" + }, + "marketplace": { + "title": "Plugin Marketplace", + "installing": "Installing...", + "installed": "Installed", + "install": "Install", + "error": "Installation failed", + "help": "j/k: navigate • enter: install • esc: back" + }, + "time": { + "just_now": "just now", + "minute_ago": { + "one": "1 minute ago", + "other": "{count} minutes ago" + }, + "hour_ago": { + "one": "1 hour ago", + "other": "{count} hours ago" + }, + "day_ago": { + "one": "1 day ago", + "other": "{count} days ago" + }, + "week_ago": { + "one": "1 week ago", + "other": "{count} weeks ago" + }, + "month_ago": { + "one": "1 month ago", + "other": "{count} months ago" + }, + "year_ago": { + "one": "1 year ago", + "other": "{count} years ago" + }, + "in_moment": "in a moment", + "in_minute": { + "one": "in 1 minute", + "other": "in {count} minutes" + }, + "in_hour": { + "one": "in 1 hour", + "other": "in {count} hours" + }, + "in_day": { + "one": "in 1 day", + "other": "in {count} days" + } + } + } +} diff --git a/i18n/locales/es.json b/i18n/locales/es.json new file mode 100644 index 0000000000000000000000000000000000000000..537b973b6a28e9059217917651c1dec92bac6b57 --- /dev/null +++ b/i18n/locales/es.json @@ -0,0 +1,253 @@ +{ + "language": "es", + "messages": { + "common": { + "yes": "Sí", + "no": "No", + "cancel": "Cancelar", + "ok": "Aceptar", + "save": "Guardar", + "delete": "Eliminar", + "archive": "Archivar", + "back": "Atrás", + "next": "Siguiente", + "previous": "Anterior", + "loading": "Cargando...", + "error": "Error", + "success": "Éxito" + }, + "composer": { + "title": "Redactar Nuevo Correo", + "from": "De", + "to_placeholder": "Ingrese direcciones de correo de los destinatarios.", + "cc_placeholder": "Destinatarios con copia.", + "bcc_placeholder": "Destinatarios con copia oculta.", + "subject_placeholder": "Asunto", + "body_placeholder": "Redacte su mensaje...", + "signature": "Firma", + "signature_placeholder": "Su firma de correo.", + "attachments": "Archivos adjuntos", + "attachments_none": "Ninguno", + "enter_to_add": "Enter para agregar", + "encrypt_smime": "Cifrar Correo (S/MIME)", + "send": "Enviar", + "switchable": "intercambiable", + "enter_to_switch": "Enter para cambiar", + "no_account": "ninguna cuenta configurada", + "send_confirm": "Presione Enter para enviar el correo.", + "help": "Markdown/HTML • tab/shift+tab: navegar • ctrl+e: $EDITOR • esc: guardar borrador y salir", + "exit_confirm": "¿Está seguro de que desea salir? Este borrador se guardará", + "sending": "Enviando correo...", + "sent": "Correo enviado exitosamente", + "draft_saved": "Borrador guardado" + }, + "inbox": { + "title": "Bandeja de entrada", + "all_accounts": "Todas las Cuentas", + "sent": "Enviados", + "trash": "Papelera", + "archive": "Archivo", + "empty": "Sin correos", + "loading": "Cargando correos...", + "refreshing": "Actualizando...", + "visual_mode": "modo visual", + "delete": "eliminar", + "archive": "archivar", + "refresh": "actualizar", + "reply": "responder", + "forward": "reenviar", + "move": "mover", + "mark_read": "marcar como leído", + "mark_unread": "marcar como no leído", + "help_visual": "v: modo visual • d: eliminar • a: archivar", + "help_navigation": "j/k: navegar • enter: abrir • r: actualizar" + }, + "choice": { + "what_to_do": "¿Qué le gustaría hacer?", + "compose": "Redactar Correo", + "inbox": "Ver Bandeja de Entrada", + "calendar": "Ver Calendario", + "settings": "Configuración", + "marketplace": "Tienda de Plugins", + "drafts": "Borradores", + "help": "Use ↑/↓ para navegar, enter para seleccionar, y ctrl+c para salir.", + "unknown": "desconocido", + "update_available": "Actualización disponible: {latest} (instalada: {current}) — ejecute `matcha update` para actualizar" + }, + "folder_inbox": { + "folders_title": "Carpetas", + "move_to_folder": "Mover a carpeta:", + "move_single": "Mover correo a carpeta:", + "move_multiple": { + "one": "Mover {count} correo a carpeta:", + "other": "Mover {count} correos a carpeta:" + }, + "help": "j/k: navegar enter: mover esc: cancelar", + "help_folders": "tab: siguiente carpeta • shift+tab: carpeta anterior • m: mover" + }, + "login": { + "title": "Cuentas de Correo", + "add_account": "Agregar Cuenta", + "edit_account": "Editar Cuenta", + "description": "Ingrese las credenciales de su cuenta de correo.", + "protocol_label": "Protocolo", + "protocol_placeholder": "Protocolo (imap, jmap, o pop3)", + "email_label": "Correo", + "email_placeholder": "su.correo@ejemplo.com", + "password_label": "Contraseña", + "password_placeholder": "Contraseña / Contraseña de Aplicación", + "display_name_label": "Nombre a Mostrar", + "display_name_placeholder": "Su Nombre", + "imap_server_label": "Servidor IMAP", + "smtp_server_label": "Servidor SMTP", + "port_label": "Puerto", + "save": "Guardar Cuenta", + "delete": "Eliminar Cuenta", + "delete_confirm": "¿Eliminar esta cuenta?", + "tip_protocol": "Elija el protocolo: imap (predeterminado), jmap, o pop3.", + "tip_app_password": "Para Gmail, use una Contraseña de Aplicación en lugar de su contraseña normal." + }, + "settings": { + "title": "Configuración", + "category_general": "General", + "category_accounts": "Cuentas", + "category_theme": "Tema", + "category_mailing_lists": "Listas de Correo", + "category_encryption": "Cifrado de Aplicación", + "help_menu": "↑/↓: navegar • derecha/enter: seleccionar • esc: volver", + "help_content": "esc: volver al menú" + }, + "settings_accounts": { + "title": "Configuración de Cuentas", + "no_accounts": "No hay cuentas configuradas.", + "add_account": "Agregar Nueva Cuenta", + "help": "↑/↓: navegar • enter: editar config. de cifrado • e: editar servidor • d: eliminar" + }, + "settings_theme": { + "title": "Tema", + "current": "activo", + "help": "↑/↓: navegar • enter/espacio: aplicar tema" + }, + "settings_mailing_lists": { + "title": "Listas de Correo", + "no_lists": "No hay listas de correo configuradas.", + "add_list": "Agregar Nueva Lista de Correo", + "delete_confirm": "¿Eliminar lista de correo?", + "address_count": { + "one": "{count} dirección", + "other": "{count} direcciones" + }, + "help": "↑/↓: navegar • enter: seleccionar • e: editar • d: eliminar" + }, + "settings_general": { + "title": "Configuración General", + "disable_images": "Deshabilitar Visualización de Imágenes", + "hide_tips": "Ocultar Consejos Contextuales", + "disable_notifications": "Deshabilitar Notificaciones", + "date_format": "Formato de Fecha", + "language": "Idioma", + "signature": "Editar Firma", + "signature_configured": "configurada", + "signature_not_configured": "no configurada", + "on": "ACTIVADO", + "off": "DESACTIVADO", + "restart_required": "Se requiere reiniciar para aplicar el cambio de idioma" + }, + "settings_encryption": { + "title": "Cifrado de Aplicación", + "enabled": "El cifrado está actualmente habilitado.", + "disabled": "Establezca una contraseña para cifrar todos los datos.", + "password_label": "Contraseña:", + "confirm_label": "Confirmar Contraseña:", + "enable_button": "Habilitar Cifrado", + "disable_button": "Presione enter para deshabilitar el cifrado", + "disable_confirm": "¿Deshabilitar cifrado?", + "disable_warning": "Todos los datos se almacenarán sin cifrar.", + "encrypting": "Cifrando datos...", + "error_empty": "La contraseña no puede estar vacía", + "error_mismatch": "Las contraseñas no coinciden", + "help": "tab: siguiente • enter: guardar" + }, + "password_prompt": { + "title": "Matcha está bloqueado", + "enter_password": "Ingrese su contraseña", + "error_empty": "La contraseña no puede estar vacía", + "error_incorrect": "Contraseña incorrecta", + "help": "enter: desbloquear • ctrl+c: salir" + }, + "email_view": { + "from": "De", + "to": "Para", + "cc": "Cc", + "bcc": "Cco", + "subject": "Asunto", + "date": "Fecha", + "attachments": "Archivos Adjuntos", + "download": "Descargar", + "save": "Guardar", + "reply": "Responder", + "reply_all": "Responder a Todos", + "forward": "Reenviar", + "delete": "Eliminar", + "archive": "Archivar", + "help": "r: responder • f: reenviar • d: eliminar • a: archivar • esc: atrás" + }, + "calendar": { + "title": "Calendario", + "meeting": "Reunión", + "event": "Evento", + "accept": "Aceptar", + "decline": "Rechazar", + "tentative": "Provisional", + "rsvp_sent": "RSVP enviado: {response}" + }, + "marketplace": { + "title": "Tienda de Plugins", + "installing": "Instalando...", + "installed": "Instalado", + "install": "Instalar", + "error": "Falló la instalación", + "help": "j/k: navegar • enter: instalar • esc: atrás" + }, + "time": { + "just_now": "justo ahora", + "minute_ago": { + "one": "hace 1 minuto", + "other": "hace {count} minutos" + }, + "hour_ago": { + "one": "hace 1 hora", + "other": "hace {count} horas" + }, + "day_ago": { + "one": "hace 1 día", + "other": "hace {count} días" + }, + "week_ago": { + "one": "hace 1 semana", + "other": "hace {count} semanas" + }, + "month_ago": { + "one": "hace 1 mes", + "other": "hace {count} meses" + }, + "year_ago": { + "one": "hace 1 año", + "other": "hace {count} años" + }, + "in_moment": "en un momento", + "in_minute": { + "one": "en 1 minuto", + "other": "en {count} minutos" + }, + "in_hour": { + "one": "en 1 hora", + "other": "en {count} horas" + }, + "in_day": { + "one": "en 1 día", + "other": "en {count} días" + } + } + } +} diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..d4436bc8b222f933492a893f6ce09adef0c63389 --- /dev/null +++ b/i18n/locales/fr.json @@ -0,0 +1,253 @@ +{ + "language": "fr", + "messages": { + "common": { + "yes": "Oui", + "no": "Non", + "cancel": "Annuler", + "ok": "OK", + "save": "Enregistrer", + "delete": "Supprimer", + "archive": "Archiver", + "back": "Retour", + "next": "Suivant", + "previous": "Précédent", + "loading": "Chargement...", + "error": "Erreur", + "success": "Succès" + }, + "composer": { + "title": "Rédiger un Nouveau Message", + "from": "De", + "to_placeholder": "Entrez les adresses e-mail des destinataires.", + "cc_placeholder": "Destinataires en copie.", + "bcc_placeholder": "Destinataires en copie cachée.", + "subject_placeholder": "Objet", + "body_placeholder": "Rédigez votre message...", + "signature": "Signature", + "signature_placeholder": "Votre signature e-mail.", + "attachments": "Pièces jointes", + "attachments_none": "Aucune", + "enter_to_add": "Entrée pour ajouter", + "encrypt_smime": "Chiffrer l'E-mail (S/MIME)", + "send": "Envoyer", + "switchable": "interchangeable", + "enter_to_switch": "Entrée pour changer", + "no_account": "aucun compte configuré", + "send_confirm": "Appuyez sur Entrée pour envoyer l'e-mail.", + "help": "Markdown/HTML • tab/shift+tab: naviguer • ctrl+e: $EDITOR • esc: sauvegarder brouillon & quitter", + "exit_confirm": "Êtes-vous sûr de vouloir quitter ? Ce brouillon sera sauvegardé", + "sending": "Envoi de l'e-mail...", + "sent": "E-mail envoyé avec succès", + "draft_saved": "Brouillon sauvegardé" + }, + "inbox": { + "title": "Boîte de réception", + "all_accounts": "Tous les Comptes", + "sent": "Envoyés", + "trash": "Corbeille", + "archive": "Archives", + "empty": "Aucun e-mail", + "loading": "Chargement des e-mails...", + "refreshing": "Actualisation...", + "visual_mode": "mode visuel", + "delete": "supprimer", + "archive": "archiver", + "refresh": "actualiser", + "reply": "répondre", + "forward": "transférer", + "move": "déplacer", + "mark_read": "marquer comme lu", + "mark_unread": "marquer comme non lu", + "help_visual": "v: mode visuel • d: supprimer • a: archiver", + "help_navigation": "j/k: naviguer • entrée: ouvrir • r: actualiser" + }, + "choice": { + "what_to_do": "Que souhaitez-vous faire ?", + "compose": "Rédiger un E-mail", + "inbox": "Voir la Boîte de Réception", + "calendar": "Voir le Calendrier", + "settings": "Paramètres", + "marketplace": "Marketplace de Plugins", + "drafts": "Brouillons", + "help": "Utilisez ↑/↓ pour naviguer, entrée pour sélectionner, et ctrl+c pour quitter.", + "unknown": "inconnu", + "update_available": "Mise à jour disponible : {latest} (installée : {current}) — exécutez `matcha update` pour mettre à jour" + }, + "folder_inbox": { + "folders_title": "Dossiers", + "move_to_folder": "Déplacer vers le dossier :", + "move_single": "Déplacer l'e-mail vers le dossier :", + "move_multiple": { + "one": "Déplacer {count} e-mail vers le dossier :", + "other": "Déplacer {count} e-mails vers le dossier :" + }, + "help": "j/k: naviguer entrée: déplacer esc: annuler", + "help_folders": "tab: dossier suivant • shift+tab: dossier précédent • m: déplacer" + }, + "login": { + "title": "Comptes E-mail", + "add_account": "Ajouter un Compte", + "edit_account": "Modifier le Compte", + "description": "Entrez les identifiants de votre compte e-mail.", + "protocol_label": "Protocole", + "protocol_placeholder": "Protocole (imap, jmap ou pop3)", + "email_label": "E-mail", + "email_placeholder": "votre.email@exemple.fr", + "password_label": "Mot de passe", + "password_placeholder": "Mot de passe / Mot de passe d'application", + "display_name_label": "Nom d'Affichage", + "display_name_placeholder": "Votre Nom", + "imap_server_label": "Serveur IMAP", + "smtp_server_label": "Serveur SMTP", + "port_label": "Port", + "save": "Enregistrer le Compte", + "delete": "Supprimer le Compte", + "delete_confirm": "Supprimer ce compte ?", + "tip_protocol": "Choisissez le protocole : imap (par défaut), jmap ou pop3.", + "tip_app_password": "Pour Gmail, utilisez un mot de passe d'application au lieu de votre mot de passe habituel." + }, + "settings": { + "title": "Paramètres", + "category_general": "Général", + "category_accounts": "Comptes", + "category_theme": "Thème", + "category_mailing_lists": "Listes de Diffusion", + "category_encryption": "Chiffrement de l'Application", + "help_menu": "↑/↓: naviguer • droite/entrée: sélectionner • esc: retour", + "help_content": "esc: retour au menu" + }, + "settings_accounts": { + "title": "Paramètres des Comptes", + "no_accounts": "Aucun compte configuré.", + "add_account": "Ajouter un Nouveau Compte", + "help": "↑/↓: naviguer • entrée: modifier config. crypto • e: modifier serveur • d: supprimer" + }, + "settings_theme": { + "title": "Thème", + "current": "actif", + "help": "↑/↓: naviguer • entrée/espace: appliquer le thème" + }, + "settings_mailing_lists": { + "title": "Listes de Diffusion", + "no_lists": "Aucune liste de diffusion configurée.", + "add_list": "Ajouter une Nouvelle Liste de Diffusion", + "delete_confirm": "Supprimer la liste de diffusion ?", + "address_count": { + "one": "{count} adresse", + "other": "{count} adresses" + }, + "help": "↑/↓: naviguer • entrée: sélectionner • e: modifier • d: supprimer" + }, + "settings_general": { + "title": "Paramètres Généraux", + "disable_images": "Désactiver l'Affichage des Images", + "hide_tips": "Masquer les Conseils Contextuels", + "disable_notifications": "Désactiver les Notifications", + "date_format": "Format de Date", + "language": "Langue", + "signature": "Modifier la Signature", + "signature_configured": "configurée", + "signature_not_configured": "non configurée", + "on": "ACTIVÉ", + "off": "DÉSACTIVÉ", + "restart_required": "Redémarrage requis pour appliquer le changement de langue" + }, + "settings_encryption": { + "title": "Chiffrement de l'Application", + "enabled": "Le chiffrement est actuellement activé.", + "disabled": "Définissez un mot de passe pour chiffrer toutes les données.", + "password_label": "Mot de passe :", + "confirm_label": "Confirmer le Mot de Passe :", + "enable_button": "Activer le Chiffrement", + "disable_button": "Appuyez sur entrée pour désactiver le chiffrement", + "disable_confirm": "Désactiver le chiffrement ?", + "disable_warning": "Toutes les données seront stockées non chiffrées.", + "encrypting": "Chiffrement des données...", + "error_empty": "Le mot de passe ne peut pas être vide", + "error_mismatch": "Les mots de passe ne correspondent pas", + "help": "tab: suivant • entrée: enregistrer" + }, + "password_prompt": { + "title": "Matcha est verrouillé", + "enter_password": "Entrez votre mot de passe", + "error_empty": "Le mot de passe ne peut pas être vide", + "error_incorrect": "Mot de passe incorrect", + "help": "entrée: déverrouiller • ctrl+c: quitter" + }, + "email_view": { + "from": "De", + "to": "À", + "cc": "Cc", + "bcc": "Cci", + "subject": "Objet", + "date": "Date", + "attachments": "Pièces Jointes", + "download": "Télécharger", + "save": "Enregistrer", + "reply": "Répondre", + "reply_all": "Répondre à Tous", + "forward": "Transférer", + "delete": "Supprimer", + "archive": "Archiver", + "help": "r: répondre • f: transférer • d: supprimer • a: archiver • esc: retour" + }, + "calendar": { + "title": "Calendrier", + "meeting": "Réunion", + "event": "Événement", + "accept": "Accepter", + "decline": "Refuser", + "tentative": "Provisoire", + "rsvp_sent": "RSVP envoyé : {response}" + }, + "marketplace": { + "title": "Marketplace de Plugins", + "installing": "Installation...", + "installed": "Installé", + "install": "Installer", + "error": "Échec de l'installation", + "help": "j/k: naviguer • entrée: installer • esc: retour" + }, + "time": { + "just_now": "à l'instant", + "minute_ago": { + "one": "il y a 1 minute", + "other": "il y a {count} minutes" + }, + "hour_ago": { + "one": "il y a 1 heure", + "other": "il y a {count} heures" + }, + "day_ago": { + "one": "il y a 1 jour", + "other": "il y a {count} jours" + }, + "week_ago": { + "one": "il y a 1 semaine", + "other": "il y a {count} semaines" + }, + "month_ago": { + "one": "il y a 1 mois", + "other": "il y a {count} mois" + }, + "year_ago": { + "one": "il y a 1 an", + "other": "il y a {count} ans" + }, + "in_moment": "dans un instant", + "in_minute": { + "one": "dans 1 minute", + "other": "dans {count} minutes" + }, + "in_hour": { + "one": "dans 1 heure", + "other": "dans {count} heures" + }, + "in_day": { + "one": "dans 1 jour", + "other": "dans {count} jours" + } + } + } +} diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..0efda533237b284fd56368240436800381f176b3 --- /dev/null +++ b/i18n/locales/ja.json @@ -0,0 +1,242 @@ +{ + "language": "ja", + "messages": { + "common": { + "yes": "はい", + "no": "いいえ", + "cancel": "キャンセル", + "ok": "OK", + "save": "保存", + "delete": "削除", + "archive": "アーカイブ", + "back": "戻る", + "next": "次へ", + "previous": "前へ", + "loading": "読み込み中...", + "error": "エラー", + "success": "成功" + }, + "composer": { + "title": "新規メール作成", + "from": "差出人", + "to_placeholder": "受信者のメールアドレスを入力してください。", + "cc_placeholder": "CCの受信者。", + "bcc_placeholder": "BCCの受信者。", + "subject_placeholder": "件名", + "body_placeholder": "メッセージを作成...", + "signature": "署名", + "signature_placeholder": "メール署名。", + "attachments": "添付ファイル", + "attachments_none": "なし", + "enter_to_add": "Enterで追加", + "encrypt_smime": "メールを暗号化 (S/MIME)", + "send": "送信", + "switchable": "切替可能", + "enter_to_switch": "Enterで切替", + "no_account": "アカウントが設定されていません", + "send_confirm": "Enterキーを押してメールを送信します。", + "help": "Markdown/HTML • tab/shift+tab: 移動 • ctrl+e: $EDITOR • esc: 下書きを保存して終了", + "exit_confirm": "終了してもよろしいですか?この下書きは保存されます", + "sending": "メール送信中...", + "sent": "メールが正常に送信されました", + "draft_saved": "下書きを保存しました" + }, + "inbox": { + "title": "受信トレイ", + "all_accounts": "すべてのアカウント", + "sent": "送信済み", + "trash": "ゴミ箱", + "archive": "アーカイブ", + "empty": "メールがありません", + "loading": "メールを読み込み中...", + "refreshing": "更新中...", + "visual_mode": "ビジュアルモード", + "delete": "削除", + "archive": "アーカイブ", + "refresh": "更新", + "reply": "返信", + "forward": "転送", + "move": "移動", + "mark_read": "既読にする", + "mark_unread": "未読にする", + "help_visual": "v: ビジュアルモード • d: 削除 • a: アーカイブ", + "help_navigation": "j/k: 移動 • enter: 開く • r: 更新" + }, + "choice": { + "what_to_do": "何をしますか?", + "compose": "メール作成", + "inbox": "受信トレイを表示", + "calendar": "カレンダーを表示", + "settings": "設定", + "marketplace": "プラグインマーケットプレイス", + "drafts": "下書き", + "help": "↑/↓で移動、Enterで選択、ctrl+cで終了します。", + "unknown": "不明", + "update_available": "アップデート利用可能: {latest} (インストール済み: {current}) — `matcha update`を実行してアップグレード" + }, + "folder_inbox": { + "folders_title": "フォルダ", + "move_to_folder": "フォルダに移動:", + "move_single": "メールをフォルダに移動:", + "move_multiple": { + "other": "{count}件のメールをフォルダに移動:" + }, + "help": "j/k: 移動 enter: 移動 esc: キャンセル", + "help_folders": "tab: 次のフォルダ • shift+tab: 前のフォルダ • m: 移動" + }, + "login": { + "title": "メールアカウント", + "add_account": "アカウントを追加", + "edit_account": "アカウントを編集", + "description": "メールアカウントの認証情報を入力してください。", + "protocol_label": "プロトコル", + "protocol_placeholder": "プロトコル (imap, jmap, または pop3)", + "email_label": "メール", + "email_placeholder": "your.email@example.com", + "password_label": "パスワード", + "password_placeholder": "パスワード / アプリパスワード", + "display_name_label": "表示名", + "display_name_placeholder": "あなたの名前", + "imap_server_label": "IMAPサーバー", + "smtp_server_label": "SMTPサーバー", + "port_label": "ポート", + "save": "アカウントを保存", + "delete": "アカウントを削除", + "delete_confirm": "このアカウントを削除しますか?", + "tip_protocol": "プロトコルを選択: imap (デフォルト)、jmap、または pop3。", + "tip_app_password": "Gmailの場合、通常のパスワードではなくアプリパスワードを使用してください。" + }, + "settings": { + "title": "設定", + "category_general": "一般", + "category_accounts": "アカウント", + "category_theme": "テーマ", + "category_mailing_lists": "メーリングリスト", + "category_encryption": "アプリの暗号化", + "help_menu": "↑/↓: 移動 • 右/enter: 選択 • esc: 戻る", + "help_content": "esc: メニューに戻る" + }, + "settings_accounts": { + "title": "アカウント設定", + "no_accounts": "アカウントが設定されていません。", + "add_account": "新しいアカウントを追加", + "help": "↑/↓: 移動 • enter: 暗号化設定を編集 • e: サーバーを編集 • d: 削除" + }, + "settings_theme": { + "title": "テーマ", + "current": "アクティブ", + "help": "↑/↓: 移動 • enter/スペース: テーマを適用" + }, + "settings_mailing_lists": { + "title": "メーリングリスト", + "no_lists": "メーリングリストが設定されていません。", + "add_list": "新しいメーリングリストを追加", + "delete_confirm": "メーリングリストを削除しますか?", + "address_count": { + "other": "{count}個のアドレス" + }, + "help": "↑/↓: 移動 • enter: 選択 • e: 編集 • d: 削除" + }, + "settings_general": { + "title": "一般設定", + "disable_images": "画像表示を無効化", + "hide_tips": "コンテキストヒントを非表示", + "disable_notifications": "通知を無効化", + "date_format": "日付形式", + "language": "言語", + "signature": "署名を編集", + "signature_configured": "設定済み", + "signature_not_configured": "未設定", + "on": "オン", + "off": "オフ", + "restart_required": "言語変更を適用するには再起動が必要です" + }, + "settings_encryption": { + "title": "アプリの暗号化", + "enabled": "暗号化は現在有効です。", + "disabled": "すべてのデータを暗号化するにはパスワードを設定してください。", + "password_label": "パスワード:", + "confirm_label": "パスワード確認:", + "enable_button": "暗号化を有効にする", + "disable_button": "Enterを押して暗号化を無効にする", + "disable_confirm": "暗号化を無効にしますか?", + "disable_warning": "すべてのデータは暗号化されずに保存されます。", + "encrypting": "データを暗号化中...", + "error_empty": "パスワードを空にすることはできません", + "error_mismatch": "パスワードが一致しません", + "help": "tab: 次へ • enter: 保存" + }, + "password_prompt": { + "title": "Matchaはロックされています", + "enter_password": "パスワードを入力してください", + "error_empty": "パスワードを空にすることはできません", + "error_incorrect": "パスワードが正しくありません", + "help": "enter: ロック解除 • ctrl+c: 終了" + }, + "email_view": { + "from": "差出人", + "to": "宛先", + "cc": "CC", + "bcc": "BCC", + "subject": "件名", + "date": "日付", + "attachments": "添付ファイル", + "download": "ダウンロード", + "save": "保存", + "reply": "返信", + "reply_all": "全員に返信", + "forward": "転送", + "delete": "削除", + "archive": "アーカイブ", + "help": "r: 返信 • f: 転送 • d: 削除 • a: アーカイブ • esc: 戻る" + }, + "calendar": { + "title": "カレンダー", + "meeting": "会議", + "event": "イベント", + "accept": "承諾", + "decline": "辞退", + "tentative": "仮承諾", + "rsvp_sent": "RSVP送信済み: {response}" + }, + "marketplace": { + "title": "プラグインマーケットプレイス", + "installing": "インストール中...", + "installed": "インストール済み", + "install": "インストール", + "error": "インストール失敗", + "help": "j/k: 移動 • enter: インストール • esc: 戻る" + }, + "time": { + "just_now": "たった今", + "minute_ago": { + "other": "{count}分前" + }, + "hour_ago": { + "other": "{count}時間前" + }, + "day_ago": { + "other": "{count}日前" + }, + "week_ago": { + "other": "{count}週間前" + }, + "month_ago": { + "other": "{count}ヶ月前" + }, + "year_ago": { + "other": "{count}年前" + }, + "in_moment": "まもなく", + "in_minute": { + "other": "{count}分後" + }, + "in_hour": { + "other": "{count}時間後" + }, + "in_day": { + "other": "{count}日後" + } + } + } +} diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..3ffa97c751fe03948cc28ea80ab6dffe75e4013e --- /dev/null +++ b/i18n/locales/pl.json @@ -0,0 +1,275 @@ +{ + "language": "pl", + "messages": { + "common": { + "yes": "Tak", + "no": "Nie", + "cancel": "Anuluj", + "ok": "OK", + "save": "Zapisz", + "delete": "Usuń", + "archive": "Archiwizuj", + "back": "Wstecz", + "next": "Dalej", + "previous": "Poprzedni", + "loading": "Ładowanie...", + "error": "Błąd", + "success": "Sukces" + }, + "composer": { + "title": "Napisz Nową Wiadomość", + "from": "Od", + "to_placeholder": "Wprowadź adresy e-mail odbiorców.", + "cc_placeholder": "Odbiorcy kopii.", + "bcc_placeholder": "Odbiorcy ukrytej kopii.", + "subject_placeholder": "Temat", + "body_placeholder": "Napisz swoją wiadomość...", + "signature": "Podpis", + "signature_placeholder": "Twój podpis e-mail.", + "attachments": "Załączniki", + "attachments_none": "Brak", + "enter_to_add": "Enter aby dodać", + "encrypt_smime": "Zaszyfruj Wiadomość (S/MIME)", + "send": "Wyślij", + "switchable": "przełączalny", + "enter_to_switch": "Enter aby przełączyć", + "no_account": "brak skonfigurowanego konta", + "send_confirm": "Naciśnij Enter, aby wysłać wiadomość.", + "help": "Markdown/HTML • tab/shift+tab: nawigacja • ctrl+e: $EDITOR • esc: zapisz szkic i wyjdź", + "exit_confirm": "Czy na pewno chcesz wyjść? Ten szkic zostanie zapisany", + "sending": "Wysyłanie wiadomości...", + "sent": "Wiadomość wysłana pomyślnie", + "draft_saved": "Szkic zapisany" + }, + "inbox": { + "title": "Skrzynka odbiorcza", + "all_accounts": "Wszystkie Konta", + "sent": "Wysłane", + "trash": "Kosz", + "archive": "Archiwum", + "empty": "Brak wiadomości", + "loading": "Ładowanie wiadomości...", + "refreshing": "Odświeżanie...", + "visual_mode": "tryb wizualny", + "delete": "usuń", + "archive": "archiwizuj", + "refresh": "odśwież", + "reply": "odpowiedz", + "forward": "przekaż", + "move": "przenieś", + "mark_read": "oznacz jako przeczytane", + "mark_unread": "oznacz jako nieprzeczytane", + "help_visual": "v: tryb wizualny • d: usuń • a: archiwizuj", + "help_navigation": "j/k: nawigacja • enter: otwórz • r: odśwież" + }, + "choice": { + "what_to_do": "Co chciałbyś zrobić?", + "compose": "Napisz Wiadomość", + "inbox": "Zobacz Skrzynkę Odbiorczą", + "calendar": "Zobacz Kalendarz", + "settings": "Ustawienia", + "marketplace": "Sklep z Wtyczkami", + "drafts": "Szkice", + "help": "Użyj ↑/↓ do nawigacji, enter do wyboru i ctrl+c aby wyjść.", + "unknown": "nieznany", + "update_available": "Dostępna aktualizacja: {latest} (zainstalowana: {current}) — uruchom `matcha update` aby zaktualizować" + }, + "folder_inbox": { + "folders_title": "Foldery", + "move_to_folder": "Przenieś do folderu:", + "move_single": "Przenieś wiadomość do folderu:", + "move_multiple": { + "one": "Przenieś {count} wiadomość do folderu:", + "few": "Przenieś {count} wiadomości do folderu:", + "many": "Przenieś {count} wiadomości do folderu:", + "other": "Przenieś {count} wiadomości do folderu:" + }, + "help": "j/k: nawigacja enter: przenieś esc: anuluj", + "help_folders": "tab: następny folder • shift+tab: poprzedni folder • m: przenieś" + }, + "login": { + "title": "Konta E-mail", + "add_account": "Dodaj Konto", + "edit_account": "Edytuj Konto", + "description": "Wprowadź dane logowania do swojego konta e-mail.", + "protocol_label": "Protokół", + "protocol_placeholder": "Protokół (imap, jmap lub pop3)", + "email_label": "E-mail", + "email_placeholder": "twoj.email@przyklad.pl", + "password_label": "Hasło", + "password_placeholder": "Hasło / Hasło Aplikacji", + "display_name_label": "Wyświetlana Nazwa", + "display_name_placeholder": "Twoje Imię", + "imap_server_label": "Serwer IMAP", + "smtp_server_label": "Serwer SMTP", + "port_label": "Port", + "save": "Zapisz Konto", + "delete": "Usuń Konto", + "delete_confirm": "Usunąć to konto?", + "tip_protocol": "Wybierz protokół: imap (domyślny), jmap lub pop3.", + "tip_app_password": "Dla Gmaila użyj Hasła Aplikacji zamiast zwykłego hasła." + }, + "settings": { + "title": "Ustawienia", + "category_general": "Ogólne", + "category_accounts": "Konta", + "category_theme": "Motyw", + "category_mailing_lists": "Listy Mailingowe", + "category_encryption": "Szyfrowanie Aplikacji", + "help_menu": "↑/↓: nawigacja • prawo/enter: wybierz • esc: wstecz", + "help_content": "esc: powrót do menu" + }, + "settings_accounts": { + "title": "Ustawienia Kont", + "no_accounts": "Brak skonfigurowanych kont.", + "add_account": "Dodaj Nowe Konto", + "help": "↑/↓: nawigacja • enter: edytuj konfigurację szyfrowania • e: edytuj serwer • d: usuń" + }, + "settings_theme": { + "title": "Motyw", + "current": "aktywny", + "help": "↑/↓: nawigacja • enter/spacja: zastosuj motyw" + }, + "settings_mailing_lists": { + "title": "Listy Mailingowe", + "no_lists": "Brak skonfigurowanych list mailingowych.", + "add_list": "Dodaj Nową Listę Mailingową", + "delete_confirm": "Usunąć listę mailingową?", + "address_count": { + "one": "{count} adres", + "few": "{count} adresy", + "many": "{count} adresów", + "other": "{count} adresu" + }, + "help": "↑/↓: nawigacja • enter: wybierz • e: edytuj • d: usuń" + }, + "settings_general": { + "title": "Ustawienia Ogólne", + "disable_images": "Wyłącz Wyświetlanie Obrazów", + "hide_tips": "Ukryj Wskazówki Kontekstowe", + "disable_notifications": "Wyłącz Powiadomienia", + "date_format": "Format Daty", + "language": "Język", + "signature": "Edytuj Podpis", + "signature_configured": "skonfigurowany", + "signature_not_configured": "nieskonfigurowany", + "on": "WŁ", + "off": "WYŁ", + "restart_required": "Wymagane ponowne uruchomienie w celu zastosowania zmiany języka" + }, + "settings_encryption": { + "title": "Szyfrowanie Aplikacji", + "enabled": "Szyfrowanie jest obecnie włączone.", + "disabled": "Ustaw hasło, aby zaszyfrować wszystkie dane.", + "password_label": "Hasło:", + "confirm_label": "Potwierdź Hasło:", + "enable_button": "Włącz Szyfrowanie", + "disable_button": "Naciśnij enter, aby wyłączyć szyfrowanie", + "disable_confirm": "Wyłączyć szyfrowanie?", + "disable_warning": "Wszystkie dane będą przechowywane bez szyfrowania.", + "encrypting": "Szyfrowanie danych...", + "error_empty": "Hasło nie może być puste", + "error_mismatch": "Hasła nie pasują do siebie", + "help": "tab: następny • enter: zapisz" + }, + "password_prompt": { + "title": "Matcha jest zablokowana", + "enter_password": "Wprowadź swoje hasło", + "error_empty": "Hasło nie może być puste", + "error_incorrect": "Nieprawidłowe hasło", + "help": "enter: odblokuj • ctrl+c: wyjdź" + }, + "email_view": { + "from": "Od", + "to": "Do", + "cc": "DW", + "bcc": "UDW", + "subject": "Temat", + "date": "Data", + "attachments": "Załączniki", + "download": "Pobierz", + "save": "Zapisz", + "reply": "Odpowiedz", + "reply_all": "Odpowiedz Wszystkim", + "forward": "Przekaż", + "delete": "Usuń", + "archive": "Archiwizuj", + "help": "r: odpowiedz • f: przekaż • d: usuń • a: archiwizuj • esc: wstecz" + }, + "calendar": { + "title": "Kalendarz", + "meeting": "Spotkanie", + "event": "Wydarzenie", + "accept": "Akceptuj", + "decline": "Odrzuć", + "tentative": "Wstępnie", + "rsvp_sent": "RSVP wysłane: {response}" + }, + "marketplace": { + "title": "Sklep z Wtyczkami", + "installing": "Instalowanie...", + "installed": "Zainstalowane", + "install": "Instaluj", + "error": "Instalacja nieudana", + "help": "j/k: nawigacja • enter: instaluj • esc: wstecz" + }, + "time": { + "just_now": "właśnie teraz", + "minute_ago": { + "one": "1 minutę temu", + "few": "{count} minuty temu", + "many": "{count} minut temu", + "other": "{count} minuty temu" + }, + "hour_ago": { + "one": "1 godzinę temu", + "few": "{count} godziny temu", + "many": "{count} godzin temu", + "other": "{count} godziny temu" + }, + "day_ago": { + "one": "1 dzień temu", + "few": "{count} dni temu", + "many": "{count} dni temu", + "other": "{count} dnia temu" + }, + "week_ago": { + "one": "1 tydzień temu", + "few": "{count} tygodnie temu", + "many": "{count} tygodni temu", + "other": "{count} tygodnia temu" + }, + "month_ago": { + "one": "1 miesiąc temu", + "few": "{count} miesiące temu", + "many": "{count} miesięcy temu", + "other": "{count} miesiąca temu" + }, + "year_ago": { + "one": "1 rok temu", + "few": "{count} lata temu", + "many": "{count} lat temu", + "other": "{count} roku temu" + }, + "in_moment": "za chwilę", + "in_minute": { + "one": "za 1 minutę", + "few": "za {count} minuty", + "many": "za {count} minut", + "other": "za {count} minuty" + }, + "in_hour": { + "one": "za 1 godzinę", + "few": "za {count} godziny", + "many": "za {count} godzin", + "other": "za {count} godziny" + }, + "in_day": { + "one": "za 1 dzień", + "few": "za {count} dni", + "many": "za {count} dni", + "other": "za {count} dnia" + } + } + } +} diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json new file mode 100644 index 0000000000000000000000000000000000000000..f8cec1f42eb73bd0cd1b695fdd24591d1dec56c5 --- /dev/null +++ b/i18n/locales/pt.json @@ -0,0 +1,253 @@ +{ + "language": "pt", + "messages": { + "common": { + "yes": "Sim", + "no": "Não", + "cancel": "Cancelar", + "ok": "OK", + "save": "Salvar", + "delete": "Excluir", + "archive": "Arquivar", + "back": "Voltar", + "next": "Próximo", + "previous": "Anterior", + "loading": "Carregando...", + "error": "Erro", + "success": "Sucesso" + }, + "composer": { + "title": "Redigir Novo E-mail", + "from": "De", + "to_placeholder": "Digite os endereços de e-mail dos destinatários.", + "cc_placeholder": "Destinatários em cópia.", + "bcc_placeholder": "Destinatários em cópia oculta.", + "subject_placeholder": "Assunto", + "body_placeholder": "Redija sua mensagem...", + "signature": "Assinatura", + "signature_placeholder": "Sua assinatura de e-mail.", + "attachments": "Anexos", + "attachments_none": "Nenhum", + "enter_to_add": "Enter para adicionar", + "encrypt_smime": "Criptografar E-mail (S/MIME)", + "send": "Enviar", + "switchable": "alterável", + "enter_to_switch": "Enter para trocar", + "no_account": "nenhuma conta configurada", + "send_confirm": "Pressione Enter para enviar o e-mail.", + "help": "Markdown/HTML • tab/shift+tab: navegar • ctrl+e: $EDITOR • esc: salvar rascunho & sair", + "exit_confirm": "Tem certeza de que deseja sair? Este rascunho será salvo", + "sending": "Enviando e-mail...", + "sent": "E-mail enviado com sucesso", + "draft_saved": "Rascunho salvo" + }, + "inbox": { + "title": "Caixa de entrada", + "all_accounts": "Todas as Contas", + "sent": "Enviados", + "trash": "Lixeira", + "archive": "Arquivo", + "empty": "Sem e-mails", + "loading": "Carregando e-mails...", + "refreshing": "Atualizando...", + "visual_mode": "modo visual", + "delete": "excluir", + "archive": "arquivar", + "refresh": "atualizar", + "reply": "responder", + "forward": "encaminhar", + "move": "mover", + "mark_read": "marcar como lido", + "mark_unread": "marcar como não lido", + "help_visual": "v: modo visual • d: excluir • a: arquivar", + "help_navigation": "j/k: navegar • enter: abrir • r: atualizar" + }, + "choice": { + "what_to_do": "O que você gostaria de fazer?", + "compose": "Redigir E-mail", + "inbox": "Ver Caixa de Entrada", + "calendar": "Ver Calendário", + "settings": "Configurações", + "marketplace": "Loja de Plugins", + "drafts": "Rascunhos", + "help": "Use ↑/↓ para navegar, enter para selecionar e ctrl+c para sair.", + "unknown": "desconhecido", + "update_available": "Atualização disponível: {latest} (instalada: {current}) — execute `matcha update` para atualizar" + }, + "folder_inbox": { + "folders_title": "Pastas", + "move_to_folder": "Mover para pasta:", + "move_single": "Mover e-mail para pasta:", + "move_multiple": { + "one": "Mover {count} e-mail para pasta:", + "other": "Mover {count} e-mails para pasta:" + }, + "help": "j/k: navegar enter: mover esc: cancelar", + "help_folders": "tab: próxima pasta • shift+tab: pasta anterior • m: mover" + }, + "login": { + "title": "Contas de E-mail", + "add_account": "Adicionar Conta", + "edit_account": "Editar Conta", + "description": "Digite as credenciais da sua conta de e-mail.", + "protocol_label": "Protocolo", + "protocol_placeholder": "Protocolo (imap, jmap ou pop3)", + "email_label": "E-mail", + "email_placeholder": "seu.email@exemplo.com", + "password_label": "Senha", + "password_placeholder": "Senha / Senha de Aplicativo", + "display_name_label": "Nome de Exibição", + "display_name_placeholder": "Seu Nome", + "imap_server_label": "Servidor IMAP", + "smtp_server_label": "Servidor SMTP", + "port_label": "Porta", + "save": "Salvar Conta", + "delete": "Excluir Conta", + "delete_confirm": "Excluir esta conta?", + "tip_protocol": "Escolha o protocolo: imap (padrão), jmap ou pop3.", + "tip_app_password": "Para Gmail, use uma Senha de Aplicativo em vez de sua senha normal." + }, + "settings": { + "title": "Configurações", + "category_general": "Geral", + "category_accounts": "Contas", + "category_theme": "Tema", + "category_mailing_lists": "Listas de E-mail", + "category_encryption": "Criptografia do Aplicativo", + "help_menu": "↑/↓: navegar • direita/enter: selecionar • esc: voltar", + "help_content": "esc: voltar ao menu" + }, + "settings_accounts": { + "title": "Configurações de Contas", + "no_accounts": "Nenhuma conta configurada.", + "add_account": "Adicionar Nova Conta", + "help": "↑/↓: navegar • enter: editar config. de criptografia • e: editar servidor • d: excluir" + }, + "settings_theme": { + "title": "Tema", + "current": "ativo", + "help": "↑/↓: navegar • enter/espaço: aplicar tema" + }, + "settings_mailing_lists": { + "title": "Listas de E-mail", + "no_lists": "Nenhuma lista de e-mail configurada.", + "add_list": "Adicionar Nova Lista de E-mail", + "delete_confirm": "Excluir lista de e-mail?", + "address_count": { + "one": "{count} endereço", + "other": "{count} endereços" + }, + "help": "↑/↓: navegar • enter: selecionar • e: editar • d: excluir" + }, + "settings_general": { + "title": "Configurações Gerais", + "disable_images": "Desativar Exibição de Imagens", + "hide_tips": "Ocultar Dicas Contextuais", + "disable_notifications": "Desativar Notificações", + "date_format": "Formato de Data", + "language": "Idioma", + "signature": "Editar Assinatura", + "signature_configured": "configurada", + "signature_not_configured": "não configurada", + "on": "ATIVADO", + "off": "DESATIVADO", + "restart_required": "Reinicialização necessária para aplicar a mudança de idioma" + }, + "settings_encryption": { + "title": "Criptografia do Aplicativo", + "enabled": "A criptografia está atualmente ativada.", + "disabled": "Defina uma senha para criptografar todos os dados.", + "password_label": "Senha:", + "confirm_label": "Confirmar Senha:", + "enable_button": "Ativar Criptografia", + "disable_button": "Pressione enter para desativar a criptografia", + "disable_confirm": "Desativar criptografia?", + "disable_warning": "Todos os dados serão armazenados sem criptografia.", + "encrypting": "Criptografando dados...", + "error_empty": "A senha não pode estar vazia", + "error_mismatch": "As senhas não coincidem", + "help": "tab: próximo • enter: salvar" + }, + "password_prompt": { + "title": "Matcha está bloqueado", + "enter_password": "Digite sua senha", + "error_empty": "A senha não pode estar vazia", + "error_incorrect": "Senha incorreta", + "help": "enter: desbloquear • ctrl+c: sair" + }, + "email_view": { + "from": "De", + "to": "Para", + "cc": "Cc", + "bcc": "Cco", + "subject": "Assunto", + "date": "Data", + "attachments": "Anexos", + "download": "Baixar", + "save": "Salvar", + "reply": "Responder", + "reply_all": "Responder a Todos", + "forward": "Encaminhar", + "delete": "Excluir", + "archive": "Arquivar", + "help": "r: responder • f: encaminhar • d: excluir • a: arquivar • esc: voltar" + }, + "calendar": { + "title": "Calendário", + "meeting": "Reunião", + "event": "Evento", + "accept": "Aceitar", + "decline": "Recusar", + "tentative": "Provisório", + "rsvp_sent": "RSVP enviado: {response}" + }, + "marketplace": { + "title": "Loja de Plugins", + "installing": "Instalando...", + "installed": "Instalado", + "install": "Instalar", + "error": "Falha na instalação", + "help": "j/k: navegar • enter: instalar • esc: voltar" + }, + "time": { + "just_now": "agora mesmo", + "minute_ago": { + "one": "há 1 minuto", + "other": "há {count} minutos" + }, + "hour_ago": { + "one": "há 1 hora", + "other": "há {count} horas" + }, + "day_ago": { + "one": "há 1 dia", + "other": "há {count} dias" + }, + "week_ago": { + "one": "há 1 semana", + "other": "há {count} semanas" + }, + "month_ago": { + "one": "há 1 mês", + "other": "há {count} meses" + }, + "year_ago": { + "one": "há 1 ano", + "other": "há {count} anos" + }, + "in_moment": "em um momento", + "in_minute": { + "one": "em 1 minuto", + "other": "em {count} minutos" + }, + "in_hour": { + "one": "em 1 hora", + "other": "em {count} horas" + }, + "in_day": { + "one": "em 1 dia", + "other": "em {count} dias" + } + } + } +} diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..c4cf084947f7950fa9b63dc8045215680cfd08c1 --- /dev/null +++ b/i18n/locales/ru.json @@ -0,0 +1,275 @@ +{ + "language": "ru", + "messages": { + "common": { + "yes": "Да", + "no": "Нет", + "cancel": "Отмена", + "ok": "OK", + "save": "Сохранить", + "delete": "Удалить", + "archive": "Архивировать", + "back": "Назад", + "next": "Далее", + "previous": "Предыдущий", + "loading": "Загрузка...", + "error": "Ошибка", + "success": "Успех" + }, + "composer": { + "title": "Создать Новое Письмо", + "from": "От", + "to_placeholder": "Введите адреса электронной почты получателей.", + "cc_placeholder": "Получатели копии.", + "bcc_placeholder": "Получатели скрытой копии.", + "subject_placeholder": "Тема", + "body_placeholder": "Напишите сообщение...", + "signature": "Подпись", + "signature_placeholder": "Ваша подпись электронной почты.", + "attachments": "Вложения", + "attachments_none": "Нет", + "enter_to_add": "Enter для добавления", + "encrypt_smime": "Зашифровать Письмо (S/MIME)", + "send": "Отправить", + "switchable": "переключаемый", + "enter_to_switch": "Enter для переключения", + "no_account": "учётная запись не настроена", + "send_confirm": "Нажмите Enter для отправки письма.", + "help": "Markdown/HTML • tab/shift+tab: навигация • ctrl+e: $EDITOR • esc: сохранить черновик и выйти", + "exit_confirm": "Вы уверены, что хотите выйти? Этот черновик будет сохранён", + "sending": "Отправка письма...", + "sent": "Письмо успешно отправлено", + "draft_saved": "Черновик сохранён" + }, + "inbox": { + "title": "Входящие", + "all_accounts": "Все Учётные Записи", + "sent": "Отправленные", + "trash": "Корзина", + "archive": "Архив", + "empty": "Нет писем", + "loading": "Загрузка писем...", + "refreshing": "Обновление...", + "visual_mode": "визуальный режим", + "delete": "удалить", + "archive": "архивировать", + "refresh": "обновить", + "reply": "ответить", + "forward": "переслать", + "move": "переместить", + "mark_read": "отметить как прочитанное", + "mark_unread": "отметить как непрочитанное", + "help_visual": "v: визуальный режим • d: удалить • a: архивировать", + "help_navigation": "j/k: навигация • enter: открыть • r: обновить" + }, + "choice": { + "what_to_do": "Что вы хотите сделать?", + "compose": "Создать Письмо", + "inbox": "Просмотреть Входящие", + "calendar": "Просмотреть Календарь", + "settings": "Настройки", + "marketplace": "Магазин Плагинов", + "drafts": "Черновики", + "help": "Используйте ↑/↓ для навигации, enter для выбора и ctrl+c для выхода.", + "unknown": "неизвестно", + "update_available": "Доступно обновление: {latest} (установлено: {current}) — запустите `matcha update` для обновления" + }, + "folder_inbox": { + "folders_title": "Папки", + "move_to_folder": "Переместить в папку:", + "move_single": "Переместить письмо в папку:", + "move_multiple": { + "one": "Переместить {count} письмо в папку:", + "few": "Переместить {count} письма в папку:", + "many": "Переместить {count} писем в папку:", + "other": "Переместить {count} письма в папку:" + }, + "help": "j/k: навигация enter: переместить esc: отмена", + "help_folders": "tab: следующая папка • shift+tab: предыдущая папка • m: переместить" + }, + "login": { + "title": "Учётные Записи Электронной Почты", + "add_account": "Добавить Учётную Запись", + "edit_account": "Редактировать Учётную Запись", + "description": "Введите учётные данные вашей учётной записи электронной почты.", + "protocol_label": "Протокол", + "protocol_placeholder": "Протокол (imap, jmap или pop3)", + "email_label": "Электронная Почта", + "email_placeholder": "your.email@example.com", + "password_label": "Пароль", + "password_placeholder": "Пароль / Пароль Приложения", + "display_name_label": "Отображаемое Имя", + "display_name_placeholder": "Ваше Имя", + "imap_server_label": "IMAP Сервер", + "smtp_server_label": "SMTP Сервер", + "port_label": "Порт", + "save": "Сохранить Учётную Запись", + "delete": "Удалить Учётную Запись", + "delete_confirm": "Удалить эту учётную запись?", + "tip_protocol": "Выберите протокол: imap (по умолчанию), jmap или pop3.", + "tip_app_password": "Для Gmail используйте Пароль Приложения вместо обычного пароля." + }, + "settings": { + "title": "Настройки", + "category_general": "Общие", + "category_accounts": "Учётные Записи", + "category_theme": "Тема", + "category_mailing_lists": "Списки Рассылки", + "category_encryption": "Шифрование Приложения", + "help_menu": "↑/↓: навигация • вправо/enter: выбор • esc: назад", + "help_content": "esc: назад в меню" + }, + "settings_accounts": { + "title": "Настройки Учётных Записей", + "no_accounts": "Учётные записи не настроены.", + "add_account": "Добавить Новую Учётную Запись", + "help": "↑/↓: навигация • enter: редактировать конфигурацию шифрования • e: редактировать сервер • d: удалить" + }, + "settings_theme": { + "title": "Тема", + "current": "активная", + "help": "↑/↓: навигация • enter/пробел: применить тему" + }, + "settings_mailing_lists": { + "title": "Списки Рассылки", + "no_lists": "Списки рассылки не настроены.", + "add_list": "Добавить Новый Список Рассылки", + "delete_confirm": "Удалить список рассылки?", + "address_count": { + "one": "{count} адрес", + "few": "{count} адреса", + "many": "{count} адресов", + "other": "{count} адреса" + }, + "help": "↑/↓: навигация • enter: выбрать • e: редактировать • d: удалить" + }, + "settings_general": { + "title": "Общие Настройки", + "disable_images": "Отключить Отображение Изображений", + "hide_tips": "Скрыть Контекстные Подсказки", + "disable_notifications": "Отключить Уведомления", + "date_format": "Формат Даты", + "language": "Язык", + "signature": "Редактировать Подпись", + "signature_configured": "настроена", + "signature_not_configured": "не настроена", + "on": "ВКЛ", + "off": "ВЫКЛ", + "restart_required": "Требуется перезапуск для применения изменения языка" + }, + "settings_encryption": { + "title": "Шифрование Приложения", + "enabled": "Шифрование в настоящее время включено.", + "disabled": "Установите пароль для шифрования всех данных.", + "password_label": "Пароль:", + "confirm_label": "Подтвердите Пароль:", + "enable_button": "Включить Шифрование", + "disable_button": "Нажмите enter для отключения шифрования", + "disable_confirm": "Отключить шифрование?", + "disable_warning": "Все данные будут храниться незашифрованными.", + "encrypting": "Шифрование данных...", + "error_empty": "Пароль не может быть пустым", + "error_mismatch": "Пароли не совпадают", + "help": "tab: следующий • enter: сохранить" + }, + "password_prompt": { + "title": "Matcha заблокирован", + "enter_password": "Введите пароль", + "error_empty": "Пароль не может быть пустым", + "error_incorrect": "Неверный пароль", + "help": "enter: разблокировать • ctrl+c: выйти" + }, + "email_view": { + "from": "От", + "to": "Кому", + "cc": "Копия", + "bcc": "Скрытая Копия", + "subject": "Тема", + "date": "Дата", + "attachments": "Вложения", + "download": "Скачать", + "save": "Сохранить", + "reply": "Ответить", + "reply_all": "Ответить Всем", + "forward": "Переслать", + "delete": "Удалить", + "archive": "Архивировать", + "help": "r: ответить • f: переслать • d: удалить • a: архивировать • esc: назад" + }, + "calendar": { + "title": "Календарь", + "meeting": "Встреча", + "event": "Событие", + "accept": "Принять", + "decline": "Отклонить", + "tentative": "Предварительно", + "rsvp_sent": "RSVP отправлен: {response}" + }, + "marketplace": { + "title": "Магазин Плагинов", + "installing": "Установка...", + "installed": "Установлено", + "install": "Установить", + "error": "Не удалось установить", + "help": "j/k: навигация • enter: установить • esc: назад" + }, + "time": { + "just_now": "только что", + "minute_ago": { + "one": "1 минуту назад", + "few": "{count} минуты назад", + "many": "{count} минут назад", + "other": "{count} минуты назад" + }, + "hour_ago": { + "one": "1 час назад", + "few": "{count} часа назад", + "many": "{count} часов назад", + "other": "{count} часа назад" + }, + "day_ago": { + "one": "1 день назад", + "few": "{count} дня назад", + "many": "{count} дней назад", + "other": "{count} дня назад" + }, + "week_ago": { + "one": "1 неделю назад", + "few": "{count} недели назад", + "many": "{count} недель назад", + "other": "{count} недели назад" + }, + "month_ago": { + "one": "1 месяц назад", + "few": "{count} месяца назад", + "many": "{count} месяцев назад", + "other": "{count} месяца назад" + }, + "year_ago": { + "one": "1 год назад", + "few": "{count} года назад", + "many": "{count} лет назад", + "other": "{count} года назад" + }, + "in_moment": "через мгновение", + "in_minute": { + "one": "через 1 минуту", + "few": "через {count} минуты", + "many": "через {count} минут", + "other": "через {count} минуты" + }, + "in_hour": { + "one": "через 1 час", + "few": "через {count} часа", + "many": "через {count} часов", + "other": "через {count} часа" + }, + "in_day": { + "one": "через 1 день", + "few": "через {count} дня", + "many": "через {count} дней", + "other": "через {count} дня" + } + } + } +} diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json new file mode 100644 index 0000000000000000000000000000000000000000..bfdc3d2d9487ecf33e2527391a49947e08322691 --- /dev/null +++ b/i18n/locales/uk.json @@ -0,0 +1,264 @@ +{ + "language": "uk", + "messages": { + "common": { + "yes": "Так", + "no": "Ні", + "cancel": "Скасувати", + "ok": "Гаразд", + "save": "Зберегти", + "delete": "Видалити", + "archive": "Архівувати", + "back": "Назад", + "next": "Далі", + "previous": "Попередній", + "loading": "Завантаження...", + "error": "Помилка", + "success": "Успіх" + }, + "composer": { + "title": "Написати новий лист", + "from": "Від", + "to_placeholder": "Введіть адреси отримувачів.", + "cc_placeholder": "Копія.", + "bcc_placeholder": "Прихована копія.", + "subject_placeholder": "Тема", + "body_placeholder": "Напишіть своє повідомлення...", + "signature": "Підпис", + "signature_placeholder": "Ваш підпис електронної пошти.", + "attachments": "Вкладення", + "attachments_none": "Немає", + "enter_to_add": "Enter щоб додати", + "encrypt_smime": "Зашифрувати лист (S/MIME)", + "send": "Надіслати", + "switchable": "можна змінити", + "enter_to_switch": "Enter щоб змінити", + "no_account": "обліковий запис не налаштовано", + "send_confirm": "Натисніть Enter, щоб надіслати лист.", + "help": "Markdown/HTML • tab/shift+tab: навігація • ctrl+e: $EDITOR • esc: зберегти чернетку та вийти", + "exit_confirm": "Ви впевнені, що хочете вийти? Цей чернетку буде збережено", + "sending": "Відправлення листа...", + "sent": "Лист успішно надіслано", + "draft_saved": "Чернетку збережено" + }, + "inbox": { + "title": "Вхідні", + "all_accounts": "Всі облікові записи", + "sent": "Відправлені", + "trash": "Кошик", + "archive": "Архів", + "empty": "Немає листів", + "loading": "Завантаження листів...", + "refreshing": "Оновлення...", + "visual_mode": "візуальний режим", + "delete": "видалити", + "archive": "архівувати", + "refresh": "оновити", + "reply": "відповісти", + "forward": "переслати", + "move": "перемістити", + "mark_read": "позначити як прочитане", + "mark_unread": "позначити як непрочитане", + "help_visual": "v: візуальний режим • d: видалити • a: архівувати", + "help_navigation": "j/k: навігація • enter: відкрити • r: оновити" + }, + "choice": { + "what_to_do": "Що б ви хотіли зробити?", + "compose": "Написати лист", + "inbox": "Переглянути вхідні", + "calendar": "Переглянути календар", + "settings": "Налаштування", + "marketplace": "Магазин плагінів", + "drafts": "Чернетки", + "help": "Використовуйте ↑/↓ для навігації, enter для вибору, та ctrl+c щоб вийти.", + "unknown": "невідомо", + "update_available": "Доступне оновлення: {latest} (встановлено: {current}) — виконайте `matcha update` для оновлення" + }, + "folder_inbox": { + "folders_title": "Теки", + "move_to_folder": "Перемістити до теки:", + "move_single": "Перемістити лист до теки:", + "move_multiple": { + "one": "Перемістити {count} лист до теки:", + "few": "Перемістити {count} листи до теки:", + "other": "Перемістити {count} листів до теки:" + }, + "help": "j/k: навігація enter: перемістити esc: скасувати", + "help_folders": "tab: наступна тека • shift+tab: попередня тека • m: перемістити" + }, + "login": { + "title": "Облікові записи електронної пошти", + "add_account": "Додати обліковий запис", + "edit_account": "Редагувати обліковий запис", + "description": "Введіть облікові дані вашого облікового запису електронної пошти.", + "protocol_label": "Протокол", + "protocol_placeholder": "Протокол (imap, jmap або pop3)", + "email_label": "Електронна пошта", + "email_placeholder": "your.email@example.com", + "password_label": "Пароль", + "password_placeholder": "Пароль / Пароль додатка", + "display_name_label": "Відображуване ім'я", + "display_name_placeholder": "Ваше ім'я", + "imap_server_label": "IMAP сервер", + "smtp_server_label": "SMTP сервер", + "port_label": "Порт", + "save": "Зберегти обліковий запис", + "delete": "Видалити обліковий запис", + "delete_confirm": "Видалити цей обліковий запис?", + "tip_protocol": "Виберіть протокол: imap (за замовчуванням), jmap або pop3.", + "tip_app_password": "Для Gmail використовуйте пароль додатка замість звичайного пароля." + }, + "settings": { + "title": "Налаштування", + "category_general": "Загальні", + "category_accounts": "Облікові записи", + "category_theme": "Тема", + "category_mailing_lists": "Списки розсилки", + "category_encryption": "Шифрування додатка", + "help_menu": "↑/↓: навігація • right/enter: вибрати • esc: назад", + "help_content": "esc: назад до меню" + }, + "settings_accounts": { + "title": "Налаштування облікових записів", + "no_accounts": "Облікові записи не налаштовано.", + "add_account": "Додати новий обліковий запис", + "help": "↑/↓: навігація • enter: редагувати криптоконфіг • e: редагувати сервер • d: видалити" + }, + "settings_theme": { + "title": "Тема", + "current": "активна", + "help": "↑/↓: навігація • enter/пробіл: застосувати тему" + }, + "settings_mailing_lists": { + "title": "Списки розсилки", + "no_lists": "Списки розсилки не налаштовано.", + "add_list": "Додати новий список розсилки", + "delete_confirm": "Видалити список розсилки?", + "address_count": { + "one": "{count} адреса", + "few": "{count} адреси", + "other": "{count} адрес" + }, + "help": "↑/↓: навігація • enter: вибрати • e: редагувати • d: видалити" + }, + "settings_general": { + "title": "Загальні налаштування", + "disable_images": "Вимкнути показ зображень", + "hide_tips": "Приховати контекстні підказки", + "disable_notifications": "Вимкнути сповіщення", + "date_format": "Формат дати", + "language": "Мова", + "signature": "Редагувати підпис", + "signature_configured": "налаштовано", + "signature_not_configured": "не налаштовано", + "on": "УВІМК", + "off": "ВИМК", + "restart_required": "Для застосування зміни мови потрібен перезапуск" + }, + "settings_encryption": { + "title": "Шифрування додатка", + "enabled": "Шифрування наразі ввімкнено.", + "disabled": "Встановіть пароль для шифрування всіх даних.", + "password_label": "Пароль:", + "confirm_label": "Підтвердіть пароль:", + "enable_button": "Увімкнути шифрування", + "disable_button": "Натисніть enter, щоб вимкнути шифрування", + "disable_confirm": "Вимкнути шифрування?", + "disable_warning": "Всі дані будуть зберігатися без шифрування.", + "encrypting": "Шифрування даних...", + "error_empty": "Пароль не може бути порожнім", + "error_mismatch": "Паролі не співпадають", + "help": "tab: далі • enter: зберегти" + }, + "password_prompt": { + "title": "Matcha заблоковано", + "enter_password": "Введіть ваш пароль", + "error_empty": "Пароль не може бути порожнім", + "error_incorrect": "Неправильний пароль", + "help": "enter: розблокувати • ctrl+c: вийти" + }, + "email_view": { + "from": "Від", + "to": "Кому", + "cc": "Копія", + "bcc": "Прихована копія", + "subject": "Тема", + "date": "Дата", + "attachments": "Вкладення", + "download": "Завантажити", + "save": "Зберегти", + "reply": "Відповісти", + "reply_all": "Відповісти всім", + "forward": "Переслати", + "delete": "Видалити", + "archive": "Архівувати", + "help": "r: відповісти • f: переслати • d: видалити • a: архівувати • esc: назад" + }, + "calendar": { + "title": "Календар", + "meeting": "Зустріч", + "event": "Подія", + "accept": "Прийняти", + "decline": "Відхилити", + "tentative": "Можливо", + "rsvp_sent": "RSVP надіслано: {response}" + }, + "marketplace": { + "title": "Магазин плагінів", + "installing": "Встановлення...", + "installed": "Встановлено", + "install": "Встановити", + "error": "Не вдалося встановити", + "help": "j/k: навігація • enter: встановити • esc: назад" + }, + "time": { + "just_now": "щойно", + "minute_ago": { + "one": "1 хвилину тому", + "few": "{count} хвилини тому", + "other": "{count} хвилин тому" + }, + "hour_ago": { + "one": "1 годину тому", + "few": "{count} години тому", + "other": "{count} годин тому" + }, + "day_ago": { + "one": "1 день тому", + "few": "{count} дні тому", + "other": "{count} днів тому" + }, + "week_ago": { + "one": "1 тиждень тому", + "few": "{count} тижні тому", + "other": "{count} тижнів тому" + }, + "month_ago": { + "one": "1 місяць тому", + "few": "{count} місяці тому", + "other": "{count} місяців тому" + }, + "year_ago": { + "one": "1 рік тому", + "few": "{count} роки тому", + "other": "{count} років тому" + }, + "in_moment": "через мить", + "in_minute": { + "one": "через 1 хвилину", + "few": "через {count} хвилини", + "other": "через {count} хвилин" + }, + "in_hour": { + "one": "через 1 годину", + "few": "через {count} години", + "other": "через {count} годин" + }, + "in_day": { + "one": "через 1 день", + "few": "через {count} дні", + "other": "через {count} днів" + } + } + } +} diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json new file mode 100644 index 0000000000000000000000000000000000000000..99e01fede3df0128b82a779165a0637d5aa1a98e --- /dev/null +++ b/i18n/locales/zh.json @@ -0,0 +1,242 @@ +{ + "language": "zh", + "messages": { + "common": { + "yes": "是", + "no": "否", + "cancel": "取消", + "ok": "确定", + "save": "保存", + "delete": "删除", + "archive": "存档", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "loading": "加载中...", + "error": "错误", + "success": "成功" + }, + "composer": { + "title": "撰写新邮件", + "from": "发件人", + "to_placeholder": "输入收件人电子邮件地址。", + "cc_placeholder": "抄送收件人。", + "bcc_placeholder": "密送收件人。", + "subject_placeholder": "主题", + "body_placeholder": "撰写您的消息...", + "signature": "签名", + "signature_placeholder": "您的电子邮件签名。", + "attachments": "附件", + "attachments_none": "无", + "enter_to_add": "按Enter添加", + "encrypt_smime": "加密邮件 (S/MIME)", + "send": "发送", + "switchable": "可切换", + "enter_to_switch": "按Enter切换", + "no_account": "未配置账户", + "send_confirm": "按Enter发送邮件。", + "help": "Markdown/HTML • tab/shift+tab: 导航 • ctrl+e: $EDITOR • esc: 保存草稿并退出", + "exit_confirm": "确定要退出吗?此草稿将被保存", + "sending": "正在发送邮件...", + "sent": "邮件发送成功", + "draft_saved": "草稿已保存" + }, + "inbox": { + "title": "收件箱", + "all_accounts": "所有账户", + "sent": "已发送", + "trash": "垃圾箱", + "archive": "存档", + "empty": "没有邮件", + "loading": "正在加载邮件...", + "refreshing": "正在刷新...", + "visual_mode": "视觉模式", + "delete": "删除", + "archive": "存档", + "refresh": "刷新", + "reply": "回复", + "forward": "转发", + "move": "移动", + "mark_read": "标记为已读", + "mark_unread": "标记为未读", + "help_visual": "v: 视觉模式 • d: 删除 • a: 存档", + "help_navigation": "j/k: 导航 • enter: 打开 • r: 刷新" + }, + "choice": { + "what_to_do": "您想做什么?", + "compose": "撰写邮件", + "inbox": "查看收件箱", + "calendar": "查看日历", + "settings": "设置", + "marketplace": "插件市场", + "drafts": "草稿", + "help": "使用 ↑/↓ 导航,按 enter 选择,按 ctrl+c 退出。", + "unknown": "未知", + "update_available": "可用更新: {latest} (已安装: {current}) — 运行 `matcha update` 进行升级" + }, + "folder_inbox": { + "folders_title": "文件夹", + "move_to_folder": "移动到文件夹:", + "move_single": "将邮件移动到文件夹:", + "move_multiple": { + "other": "将 {count} 封邮件移动到文件夹:" + }, + "help": "j/k: 导航 enter: 移动 esc: 取消", + "help_folders": "tab: 下一个文件夹 • shift+tab: 上一个文件夹 • m: 移动" + }, + "login": { + "title": "电子邮件账户", + "add_account": "添加账户", + "edit_account": "编辑账户", + "description": "输入您的电子邮件账户凭据。", + "protocol_label": "协议", + "protocol_placeholder": "协议 (imap, jmap 或 pop3)", + "email_label": "电子邮件", + "email_placeholder": "your.email@example.com", + "password_label": "密码", + "password_placeholder": "密码 / 应用专用密码", + "display_name_label": "显示名称", + "display_name_placeholder": "您的姓名", + "imap_server_label": "IMAP服务器", + "smtp_server_label": "SMTP服务器", + "port_label": "端口", + "save": "保存账户", + "delete": "删除账户", + "delete_confirm": "删除此账户?", + "tip_protocol": "选择协议: imap (默认), jmap 或 pop3。", + "tip_app_password": "对于Gmail,请使用应用专用密码而不是常规密码。" + }, + "settings": { + "title": "设置", + "category_general": "常规", + "category_accounts": "账户", + "category_theme": "主题", + "category_mailing_lists": "邮件列表", + "category_encryption": "应用加密", + "help_menu": "↑/↓: 导航 • 右/enter: 选择 • esc: 返回", + "help_content": "esc: 返回菜单" + }, + "settings_accounts": { + "title": "账户设置", + "no_accounts": "未配置账户。", + "add_account": "添加新账户", + "help": "↑/↓: 导航 • enter: 编辑加密配置 • e: 编辑服务器 • d: 删除" + }, + "settings_theme": { + "title": "主题", + "current": "活动", + "help": "↑/↓: 导航 • enter/空格: 应用主题" + }, + "settings_mailing_lists": { + "title": "邮件列表", + "no_lists": "未配置邮件列表。", + "add_list": "添加新邮件列表", + "delete_confirm": "删除邮件列表?", + "address_count": { + "other": "{count} 个地址" + }, + "help": "↑/↓: 导航 • enter: 选择 • e: 编辑 • d: 删除" + }, + "settings_general": { + "title": "常规设置", + "disable_images": "禁用图片显示", + "hide_tips": "隐藏上下文提示", + "disable_notifications": "禁用通知", + "date_format": "日期格式", + "language": "语言", + "signature": "编辑签名", + "signature_configured": "已配置", + "signature_not_configured": "未配置", + "on": "开", + "off": "关", + "restart_required": "需要重新启动以应用语言更改" + }, + "settings_encryption": { + "title": "应用加密", + "enabled": "加密当前已启用。", + "disabled": "设置密码以加密所有数据。", + "password_label": "密码:", + "confirm_label": "确认密码:", + "enable_button": "启用加密", + "disable_button": "按Enter禁用加密", + "disable_confirm": "禁用加密?", + "disable_warning": "所有数据将以未加密方式存储。", + "encrypting": "正在加密数据...", + "error_empty": "密码不能为空", + "error_mismatch": "密码不匹配", + "help": "tab: 下一项 • enter: 保存" + }, + "password_prompt": { + "title": "Matcha已锁定", + "enter_password": "输入您的密码", + "error_empty": "密码不能为空", + "error_incorrect": "密码错误", + "help": "enter: 解锁 • ctrl+c: 退出" + }, + "email_view": { + "from": "发件人", + "to": "收件人", + "cc": "抄送", + "bcc": "密送", + "subject": "主题", + "date": "日期", + "attachments": "附件", + "download": "下载", + "save": "保存", + "reply": "回复", + "reply_all": "全部回复", + "forward": "转发", + "delete": "删除", + "archive": "存档", + "help": "r: 回复 • f: 转发 • d: 删除 • a: 存档 • esc: 返回" + }, + "calendar": { + "title": "日历", + "meeting": "会议", + "event": "事件", + "accept": "接受", + "decline": "拒绝", + "tentative": "暂定", + "rsvp_sent": "已发送RSVP: {response}" + }, + "marketplace": { + "title": "插件市场", + "installing": "正在安装...", + "installed": "已安装", + "install": "安装", + "error": "安装失败", + "help": "j/k: 导航 • enter: 安装 • esc: 返回" + }, + "time": { + "just_now": "刚刚", + "minute_ago": { + "other": "{count} 分钟前" + }, + "hour_ago": { + "other": "{count} 小时前" + }, + "day_ago": { + "other": "{count} 天前" + }, + "week_ago": { + "other": "{count} 周前" + }, + "month_ago": { + "other": "{count} 个月前" + }, + "year_ago": { + "other": "{count} 年前" + }, + "in_moment": "即将", + "in_minute": { + "other": "{count} 分钟后" + }, + "in_hour": { + "other": "{count} 小时后" + }, + "in_day": { + "other": "{count} 天后" + } + } + } +} diff --git a/i18n/localizer.go b/i18n/localizer.go new file mode 100644 index 0000000000000000000000000000000000000000..6f274396b4c27a977ae31a9036b46c6d4598825a --- /dev/null +++ b/i18n/localizer.go @@ -0,0 +1,104 @@ +package i18n + +// Localizer handles translation lookups for a specific language. +type Localizer struct { + lang string + bundle *Bundle + locale *Locale + cache *Cache + fallback *FallbackChain +} + +// NewLocalizer creates a new Localizer for a language. +func NewLocalizer(lang string, bundle *Bundle) *Localizer { + locale, _ := bundle.GetLocale(lang) + if locale == nil { + // Fallback to parsing locale + locale, _ = ParseLocale(lang) + } + + return &Localizer{ + lang: lang, + bundle: bundle, + locale: locale, + cache: NewCache(), + fallback: NewFallbackChain(lang, bundle.DefaultLanguage()), + } +} + +// Localize translates a message ID to text. +func (l *Localizer) Localize(messageID string) string { + // Check cache first + if cached, ok := l.cache.Get(messageID); ok { + return cached + } + + // Try fallback chain + msg, _, err := l.fallback.Resolve(l.bundle, messageID) + if err != nil { + // Return the key itself if translation not found + return messageID + } + + text := msg.GetDefault() + l.cache.Set(messageID, text) + return text +} + +// LocalizePlural translates a message with plural support. +func (l *Localizer) LocalizePlural(messageID string, count int, data map[string]interface{}) string { + // Try fallback chain + msg, _, err := l.fallback.Resolve(l.bundle, messageID) + if err != nil { + return messageID + } + + // Get plural function + pluralFunc := l.locale.PluralFunc + if pluralFunc == nil { + pluralFunc = DefaultPlural + } + + // Get appropriate plural form + text := msg.Pluralize(count, pluralFunc) + + // Interpolate variables + if data != nil { + text = Interpolate(text, data) + } + + return text +} + +// LocalizeTemplate translates a message and applies template data. +func (l *Localizer) LocalizeTemplate(messageID string, data map[string]interface{}) string { + // Try fallback chain + msg, _, err := l.fallback.Resolve(l.bundle, messageID) + if err != nil { + return messageID + } + + text := msg.GetDefault() + + // Interpolate variables + if data != nil { + text = Interpolate(text, data) + } + + return text +} + +// Language returns the localizer's language code. +func (l *Localizer) Language() string { + return l.lang +} + +// Locale returns the localizer's locale. +func (l *Localizer) Locale() *Locale { + return l.locale +} + +// ClearCache clears the localizer's cache. +func (l *Localizer) ClearCache() { + l.cache.Clear() +} diff --git a/i18n/manager.go b/i18n/manager.go new file mode 100644 index 0000000000000000000000000000000000000000..9780deebdf8c685d4b0c4bbf1720881575f62a81 --- /dev/null +++ b/i18n/manager.go @@ -0,0 +1,173 @@ +package i18n + +import ( + "fmt" + "sync" +) + +var ( + globalManager *Manager + managerOnce sync.Once +) + +// Manager is the global translation manager. +type Manager struct { + bundle *Bundle + currentLang string + localizers map[string]*Localizer + cache *Cache + mu sync.RWMutex +} + +// Init initializes the global translation manager with a default language. +func Init(defaultLang string) error { + var initErr error + + managerOnce.Do(func() { + bundle := NewBundle(defaultLang) + + // Load all embedded translations + if err := LoadTranslations(bundle); err != nil { + initErr = err + return + } + + // Register locales from registry into bundle + for _, locale := range AvailableLanguages() { + bundle.RegisterLocale(locale) + } + + globalManager = &Manager{ + bundle: bundle, + currentLang: defaultLang, + localizers: make(map[string]*Localizer), + cache: NewCache(), + } + + // Create default localizer + globalManager.localizers[defaultLang] = NewLocalizer(defaultLang, bundle) + }) + + return initErr +} + +// GetManager returns the global manager instance. +func GetManager() *Manager { + if globalManager == nil { + // Auto-initialize with English if not yet initialized + _ = Init("en") + } + return globalManager +} + +// SetLanguage changes the current language. +func (m *Manager) SetLanguage(lang string) error { + if lang == "" { + return ErrInvalidLocale + } + + lang = normalizeLanguageCode(lang) + + m.mu.Lock() + defer m.mu.Unlock() + + // Check if language is available + if !m.bundle.HasLanguage(lang) { + return fmt.Errorf("%w: %s", ErrLanguageNotFound, lang) + } + + // Create localizer if not exists + if _, ok := m.localizers[lang]; !ok { + m.localizers[lang] = NewLocalizer(lang, m.bundle) + } + + m.currentLang = lang + m.cache.Clear() // Clear cache when switching languages + + return nil +} + +// GetLanguage returns the current language code. +func (m *Manager) GetLanguage() string { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.currentLang +} + +// T translates a message key using the current language. +func (m *Manager) T(key string) string { + m.mu.RLock() + localizer := m.localizers[m.currentLang] + m.mu.RUnlock() + + if localizer == nil { + return key + } + + return localizer.Localize(key) +} + +// Tn translates a message with plural support. +func (m *Manager) Tn(key string, count int, data map[string]interface{}) string { + m.mu.RLock() + localizer := m.localizers[m.currentLang] + m.mu.RUnlock() + + if localizer == nil { + return key + } + + // Ensure count is in data + if data == nil { + data = make(map[string]interface{}) + } + if _, ok := data["count"]; !ok { + data["count"] = count + } + + return localizer.LocalizePlural(key, count, data) +} + +// Tpl translates a message and applies template variables. +func (m *Manager) Tpl(key string, data map[string]interface{}) string { + m.mu.RLock() + localizer := m.localizers[m.currentLang] + m.mu.RUnlock() + + if localizer == nil { + return key + } + + return localizer.LocalizeTemplate(key, data) +} + +// AvailableLanguages returns all loaded languages. +func (m *Manager) AvailableLanguages() []string { + return m.bundle.AvailableLanguages() +} + +// GetLocale returns the current locale. +func (m *Manager) GetLocale() *Locale { + m.mu.RLock() + defer m.mu.RUnlock() + + if localizer, ok := m.localizers[m.currentLang]; ok { + return localizer.Locale() + } + + locale, _ := ParseLocale(m.currentLang) + return locale +} + +// ClearCache clears all translation caches. +func (m *Manager) ClearCache() { + m.cache.Clear() + + m.mu.Lock() + defer m.mu.Unlock() + + for _, localizer := range m.localizers { + localizer.ClearCache() + } +} diff --git a/i18n/message.go b/i18n/message.go new file mode 100644 index 0000000000000000000000000000000000000000..7899a0e01cd32da4921406aafe6aea049fb9c280 --- /dev/null +++ b/i18n/message.go @@ -0,0 +1,88 @@ +package i18n + +// Message represents a translatable message with support for plural forms. +type Message struct { + // ID is the unique identifier for this message (e.g., "composer.title") + ID string `json:"id"` + + // Description provides context for translators + Description string `json:"description,omitempty"` + + // Hash is an optional content hash for tracking changes + Hash string `json:"hash,omitempty"` + + // Zero form is used when count is exactly 0 (optional) + Zero string `json:"zero,omitempty"` + + // One form is used for singular (count == 1) + One string `json:"one,omitempty"` + + // Two form is used for dual (count == 2) in some languages + Two string `json:"two,omitempty"` + + // Few form is used for small counts in some languages (e.g., Polish) + Few string `json:"few,omitempty"` + + // Many form is used for larger counts in some languages (e.g., Russian) + Many string `json:"many,omitempty"` + + // Other is the default form used when no specific plural form matches + Other string `json:"other,omitempty"` +} + +// MessageMap maps message IDs to Message structs. +type MessageMap map[string]*Message + +// GetText returns the appropriate text for the given plural form. +func (m *Message) GetText(form PluralForm) string { + switch form { + case Zero: + if m.Zero != "" { + return m.Zero + } + case One: + if m.One != "" { + return m.One + } + case Two: + if m.Two != "" { + return m.Two + } + case Few: + if m.Few != "" { + return m.Few + } + case Many: + if m.Many != "" { + return m.Many + } + } + // Fallback to Other or One + if m.Other != "" { + return m.Other + } + return m.One +} + +// GetDefault returns the most appropriate default text (tries Other, then One). +func (m *Message) GetDefault() string { + if m.Other != "" { + return m.Other + } + if m.One != "" { + return m.One + } + if m.Zero != "" { + return m.Zero + } + if m.Few != "" { + return m.Few + } + if m.Many != "" { + return m.Many + } + if m.Two != "" { + return m.Two + } + return "" +} diff --git a/i18n/parser.go b/i18n/parser.go new file mode 100644 index 0000000000000000000000000000000000000000..1b8467a188e2260e372ea6fa4ce4457adae07a17 --- /dev/null +++ b/i18n/parser.go @@ -0,0 +1,101 @@ +package i18n + +import ( + "encoding/json" + "fmt" +) + +// TranslationFile represents the structure of a JSON translation file. +type TranslationFile struct { + Language string `json:"language"` + Messages map[string]interface{} `json:"messages"` +} + +// ParseJSON parses a JSON translation file and returns a MessageMap. +func ParseJSON(data []byte) (MessageMap, error) { + var file TranslationFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("%w: %v", ErrParseFailed, err) + } + + messages := make(MessageMap) + parseNestedMessages("", file.Messages, messages) + + return messages, nil +} + +// parseNestedMessages recursively parses nested message structures. +// Builds dot-notation keys like "composer.title" from nested objects. +func parseNestedMessages(prefix string, data map[string]interface{}, messages MessageMap) { + for key, value := range data { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + switch v := value.(type) { + case string: + // Simple string message + messages[fullKey] = &Message{ + ID: fullKey, + Other: v, + } + + case map[string]interface{}: + // Check if this is a plural form object or nested structure + if isPluralForm(v) { + messages[fullKey] = parsePluralMessage(fullKey, v) + } else { + // Nested structure - recurse + parseNestedMessages(fullKey, v, messages) + } + + default: + // Unexpected type - treat as string + messages[fullKey] = &Message{ + ID: fullKey, + Other: fmt.Sprintf("%v", v), + } + } + } +} + +// isPluralForm checks if a map contains plural form keys. +func isPluralForm(data map[string]interface{}) bool { + pluralKeys := []string{"zero", "one", "two", "few", "many", "other"} + for _, key := range pluralKeys { + if _, ok := data[key]; ok { + return true + } + } + return false +} + +// parsePluralMessage creates a Message from plural form data. +func parsePluralMessage(id string, data map[string]interface{}) *Message { + msg := &Message{ID: id} + + if v, ok := data["zero"].(string); ok { + msg.Zero = v + } + if v, ok := data["one"].(string); ok { + msg.One = v + } + if v, ok := data["two"].(string); ok { + msg.Two = v + } + if v, ok := data["few"].(string); ok { + msg.Few = v + } + if v, ok := data["many"].(string); ok { + msg.Many = v + } + if v, ok := data["other"].(string); ok { + msg.Other = v + } + if v, ok := data["description"].(string); ok { + msg.Description = v + } + + return msg +} diff --git a/i18n/plural_rules.go b/i18n/plural_rules.go new file mode 100644 index 0000000000000000000000000000000000000000..9b4474af22ce9643de4ae3af8f3b0111eb3c3086 --- /dev/null +++ b/i18n/plural_rules.go @@ -0,0 +1,172 @@ +package i18n + +// This file contains plural rule implementations for different languages. +// Plural rules are based on Unicode CLDR plural rules. +// Reference: https://cldr.unicode.org/index/cldr-spec/plural-rules + +// EnglishPlural implements plural rules for English. +// Rule: one (n == 1), other (everything else) +func EnglishPlural(n int) PluralForm { + if n == 1 { + return One + } + return Other +} + +// SpanishPlural implements plural rules for Spanish. +// Rule: one (n == 1), other (everything else) +func SpanishPlural(n int) PluralForm { + if n == 1 { + return One + } + return Other +} + +// GermanPlural implements plural rules for German. +// Rule: one (n == 1), other (everything else) +func GermanPlural(n int) PluralForm { + if n == 1 { + return One + } + return Other +} + +// FrenchPlural implements plural rules for French. +// Rule: one (n == 0 or n == 1), other (everything else) +func FrenchPlural(n int) PluralForm { + if n == 0 || n == 1 { + return One + } + return Other +} + +// PortuguesePlural implements plural rules for Portuguese. +// Rule: one (n == 0 or n == 1), other (everything else) +func PortuguesePlural(n int) PluralForm { + if n == 0 || n == 1 { + return One + } + return Other +} + +// RussianPlural implements plural rules for Russian. +// Rule: one (n mod 10 == 1 and n mod 100 != 11) +// +// few (n mod 10 in 2..4 and n mod 100 not in 12..14) +// many (everything else) +func RussianPlural(n int) PluralForm { + mod10 := n % 10 + mod100 := n % 100 + + if mod10 == 1 && mod100 != 11 { + return One + } + if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) { + return Few + } + return Many +} + +// ArabicPlural implements plural rules for Arabic. +// Rule: zero (n == 0) +// +// one (n == 1) +// two (n == 2) +// few (n mod 100 in 3..10) +// many (n mod 100 in 11..99) +// other (everything else) +func ArabicPlural(n int) PluralForm { + if n == 0 { + return Zero + } + if n == 1 { + return One + } + if n == 2 { + return Two + } + + mod100 := n % 100 + if mod100 >= 3 && mod100 <= 10 { + return Few + } + if mod100 >= 11 && mod100 <= 99 { + return Many + } + return Other +} + +// JapanesePlural implements plural rules for Japanese. +// Rule: other (always - no plural distinction) +func JapanesePlural(n int) PluralForm { + return Other +} + +// ChinesePlural implements plural rules for Chinese. +// Rule: other (always - no plural distinction) +func ChinesePlural(n int) PluralForm { + return Other +} + +// PolishPlural implements plural rules for Polish. +// Rule: one (n == 1) +// +// few (n mod 10 in 2..4 and n mod 100 not in 12..14) +// many (everything else) +func PolishPlural(n int) PluralForm { + if n == 1 { + return One + } + + mod10 := n % 10 + mod100 := n % 100 + + if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) { + return Few + } + return Many +} + +// CzechPlural implements plural rules for Czech. +// Rule: one (n == 1) +// +// few (n in 2..4) +// many (everything else) +func CzechPlural(n int) PluralForm { + if n == 1 { + return One + } + if n >= 2 && n <= 4 { + return Few + } + return Many +} + +// ItalianPlural implements plural rules for Italian. +// Rule: one (n == 1), other (everything else) +func ItalianPlural(n int) PluralForm { + if n == 1 { + return One + } + return Other +} + +// UkrainianPlural implements plural rules for Ukrainian. +// Rule: one (n mod 10 == 1 and n mod 100 != 11) +// +// few (n mod 10 in 2..4 and n mod 100 not in 12..14) +// many (everything else) +// +// Same as Russian +func UkrainianPlural(n int) PluralForm { + mod10 := n % 10 + mod100 := n % 100 + + if mod10 == 1 && mod100 != 11 { + return One + } + if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) { + return Few + } + return Many +} diff --git a/i18n/pluralizer.go b/i18n/pluralizer.go new file mode 100644 index 0000000000000000000000000000000000000000..4a67e18f9b0f155e75ff0e1ac9a5336233d24174 --- /dev/null +++ b/i18n/pluralizer.go @@ -0,0 +1,59 @@ +package i18n + +// PluralForm represents the different plural categories. +type PluralForm int + +const ( + // Zero is used when count is exactly 0 + Zero PluralForm = iota + // One is used for singular (typically count == 1) + One + // Two is used for dual (count == 2) in some languages + Two + // Few is used for small counts in some languages + Few + // Many is used for larger counts in some languages + Many + // Other is the default/fallback form + Other +) + +// String returns the string representation of the plural form. +func (p PluralForm) String() string { + switch p { + case Zero: + return "zero" + case One: + return "one" + case Two: + return "two" + case Few: + return "few" + case Many: + return "many" + case Other: + return "other" + default: + return "other" + } +} + +// PluralFunc is a function that returns the appropriate plural form for a count. +type PluralFunc func(n int) PluralForm + +// Pluralize returns the appropriate text from a message based on count and plural rules. +func (m *Message) Pluralize(count int, pluralFunc PluralFunc) string { + if pluralFunc == nil { + pluralFunc = DefaultPlural + } + form := pluralFunc(count) + return m.GetText(form) +} + +// DefaultPlural is a simple plural function (English-like: 1 = one, else = other). +func DefaultPlural(n int) PluralForm { + if n == 1 { + return One + } + return Other +} diff --git a/i18n/registry.go b/i18n/registry.go new file mode 100644 index 0000000000000000000000000000000000000000..5cc0d4d129ff770d0a28195334702921c52fa652 --- /dev/null +++ b/i18n/registry.go @@ -0,0 +1,68 @@ +package i18n + +import "sync" + +var registry = &Registry{ + languages: make(map[string]*Locale), +} + +// Registry holds all registered language locales. +type Registry struct { + languages map[string]*Locale + mu sync.RWMutex +} + +// RegisterLanguage registers a locale in the global registry. +// This is typically called from init() functions in language files. +func RegisterLanguage(locale *Locale) { + if locale == nil || locale.Code == "" { + return + } + + registry.mu.Lock() + defer registry.mu.Unlock() + + registry.languages[locale.Code] = locale +} + +// GetLanguage retrieves a registered locale by code. +func GetLanguage(code string) (*Locale, bool) { + registry.mu.RLock() + defer registry.mu.RUnlock() + + locale, ok := registry.languages[code] + return locale, ok +} + +// AvailableLanguages returns all registered locales. +func AvailableLanguages() []*Locale { + registry.mu.RLock() + defer registry.mu.RUnlock() + + locales := make([]*Locale, 0, len(registry.languages)) + for _, locale := range registry.languages { + locales = append(locales, locale) + } + return locales +} + +// LanguageCodes returns all registered language codes. +func LanguageCodes() []string { + registry.mu.RLock() + defer registry.mu.RUnlock() + + codes := make([]string, 0, len(registry.languages)) + for code := range registry.languages { + codes = append(codes, code) + } + return codes +} + +// HasLanguage checks if a language code is registered. +func HasLanguage(code string) bool { + registry.mu.RLock() + defer registry.mu.RUnlock() + + _, ok := registry.languages[code] + return ok +} diff --git a/i18n/template.go b/i18n/template.go new file mode 100644 index 0000000000000000000000000000000000000000..6df067fcd771f03daedf2304f8d91e87c3d2edc8 --- /dev/null +++ b/i18n/template.go @@ -0,0 +1,91 @@ +package i18n + +import "strings" + +// Template represents a parsed template string with placeholders. +type Template struct { + raw string + parts []templatePart +} + +type templatePart struct { + isVar bool + value string +} + +// NewTemplate parses a template string and returns a Template. +func NewTemplate(s string) *Template { + t := &Template{ + raw: s, + parts: parseTemplate(s), + } + return t +} + +// Execute applies data to the template and returns the result. +func (t *Template) Execute(data map[string]interface{}) string { + if len(t.parts) == 0 { + return t.raw + } + + var result strings.Builder + for _, part := range t.parts { + if part.isVar { + if val, ok := data[part.value]; ok { + result.WriteString(formatValue(val)) + } else { + // Keep placeholder if no value provided + result.WriteString("{") + result.WriteString(part.value) + result.WriteString("}") + } + } else { + result.WriteString(part.value) + } + } + return result.String() +} + +// parseTemplate breaks a template string into parts (literal text and variables). +func parseTemplate(s string) []templatePart { + var parts []templatePart + var current strings.Builder + inVar := false + var varName strings.Builder + + for i := 0; i < len(s); i++ { + ch := s[i] + + if ch == '{' && !inVar { + // Start of variable + if current.Len() > 0 { + parts = append(parts, templatePart{isVar: false, value: current.String()}) + current.Reset() + } + inVar = true + varName.Reset() + } else if ch == '}' && inVar { + // End of variable + if varName.Len() > 0 { + parts = append(parts, templatePart{isVar: true, value: varName.String()}) + } + inVar = false + } else if inVar { + varName.WriteByte(ch) + } else { + current.WriteByte(ch) + } + } + + // Add remaining text + if current.Len() > 0 { + parts = append(parts, templatePart{isVar: false, value: current.String()}) + } + + return parts +} + +// String returns the raw template string. +func (t *Template) String() string { + return t.raw +} diff --git a/i18n/validator.go b/i18n/validator.go new file mode 100644 index 0000000000000000000000000000000000000000..a1009981f5fc38c868188fbfd25830de8b72c650 --- /dev/null +++ b/i18n/validator.go @@ -0,0 +1,159 @@ +package i18n + +import ( + "fmt" + "sort" +) + +// ValidationResult contains the results of validating translation files. +type ValidationResult struct { + Valid bool + Errors []ValidationError + Missing map[string][]string // lang -> missing keys + Extra map[string][]string // lang -> extra keys +} + +// ValidationError represents a validation issue. +type ValidationError struct { + Language string + Key string + Message string +} + +// ValidateTranslations validates all translations against a base language. +// Checks for missing keys, extra keys, and consistency. +func ValidateTranslations(bundle *Bundle, baseLang string) *ValidationResult { + result := &ValidationResult{ + Valid: true, + Errors: []ValidationError{}, + Missing: make(map[string][]string), + Extra: make(map[string][]string), + } + + // Get base language messages + baseMessages, err := getMessages(bundle, baseLang) + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Language: baseLang, + Message: fmt.Sprintf("Failed to load base language: %v", err), + }) + return result + } + + // Get all available languages + languages := bundle.AvailableLanguages() + + // Validate each language against base + for _, lang := range languages { + if lang == baseLang { + continue + } + + langMessages, err := getMessages(bundle, lang) + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Language: lang, + Message: fmt.Sprintf("Failed to load language: %v", err), + }) + continue + } + + // Find missing and extra keys + missing, extra := compareKeys(baseMessages, langMessages) + + if len(missing) > 0 { + result.Valid = false + result.Missing[lang] = missing + } + + if len(extra) > 0 { + result.Extra[lang] = extra + } + } + + return result +} + +// getMessages retrieves all message keys for a language. +func getMessages(bundle *Bundle, lang string) (MessageMap, error) { + bundle.mu.RLock() + defer bundle.mu.RUnlock() + + messages, ok := bundle.messages[lang] + if !ok { + return nil, fmt.Errorf("language not found: %s", lang) + } + + return messages, nil +} + +// compareKeys compares two message maps and returns missing and extra keys. +func compareKeys(base, target MessageMap) (missing, extra []string) { + // Find missing keys (in base but not in target) + for key := range base { + if _, ok := target[key]; !ok { + missing = append(missing, key) + } + } + + // Find extra keys (in target but not in base) + for key := range target { + if _, ok := base[key]; !ok { + extra = append(extra, key) + } + } + + sort.Strings(missing) + sort.Strings(extra) + + return missing, extra +} + +// String returns a human-readable validation report. +func (v *ValidationResult) String() string { + if v.Valid { + return "✓ All translations are valid" + } + + var report string + + // Report errors + if len(v.Errors) > 0 { + report += "Errors:\n" + for _, err := range v.Errors { + if err.Key != "" { + report += fmt.Sprintf(" [%s] %s: %s\n", err.Language, err.Key, err.Message) + } else { + report += fmt.Sprintf(" [%s] %s\n", err.Language, err.Message) + } + } + report += "\n" + } + + // Report missing keys + if len(v.Missing) > 0 { + report += "Missing translations:\n" + for lang, keys := range v.Missing { + report += fmt.Sprintf(" [%s] %d missing keys:\n", lang, len(keys)) + for _, key := range keys { + report += fmt.Sprintf(" - %s\n", key) + } + } + report += "\n" + } + + // Report extra keys + if len(v.Extra) > 0 { + report += "Extra translations (not in base):\n" + for lang, keys := range v.Extra { + report += fmt.Sprintf(" [%s] %d extra keys:\n", lang, len(keys)) + for _, key := range keys { + report += fmt.Sprintf(" - %s\n", key) + } + } + } + + return report +} diff --git a/main.go b/main.go index 786037fdd42b87f2147ef9854766700e79f923a5..ef5afcbe2600c65850ef20d2601f187104247af2 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,8 @@ import ( "github.com/floatpane/matcha/daemonclient" "github.com/floatpane/matcha/daemonrpc" "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/i18n" + _ "github.com/floatpane/matcha/i18n/languages" "github.com/floatpane/matcha/notify" "github.com/floatpane/matcha/plugin" "github.com/floatpane/matcha/sender" @@ -947,9 +949,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Password verified — set session key and load config config.SetSessionKey(msg.Key) cfg, err := config.LoadConfig() - if err == nil && cfg.Theme != "" { - theme.SetTheme(cfg.Theme) - tui.RebuildStyles() + if err == nil { + if cfg.Theme != "" { + theme.SetTheme(cfg.Theme) + tui.RebuildStyles() + } + // Set language from config + lang := i18n.DetectLanguage(cfg) + log.Printf("Detected language: %s", lang) + if err := i18n.GetManager().SetLanguage(lang); err != nil { + log.Printf("Failed to set language %s: %v", lang, err) + } else { + log.Printf("Language set to: %s", i18n.GetManager().GetLanguage()) + log.Printf("Test translation: %s", i18n.GetManager().T("composer.title")) + } } _ = config.EnsurePGPDir() if err != nil { @@ -3433,6 +3446,11 @@ func main() { // Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed _ = config.MigrateCacheFiles() + // Initialize i18n + if err := i18n.Init("en"); err != nil { + log.Printf("Failed to initialize i18n: %v", err) + } + var initialModel *mainModel if config.IsSecureModeEnabled() { @@ -3442,8 +3460,15 @@ func main() { initialModel.current = tui.NewPasswordPrompt() } else { cfg, err := config.LoadConfig() - if err == nil && cfg.Theme != "" { - theme.SetTheme(cfg.Theme) + if err == nil { + if cfg.Theme != "" { + theme.SetTheme(cfg.Theme) + } + // Set language from config + lang := i18n.DetectLanguage(cfg) + if err := i18n.GetManager().SetLanguage(lang); err != nil { + log.Printf("Failed to set language %s: %v", lang, err) + } } tui.RebuildStyles() diff --git a/tui/choice.go b/tui/choice.go index 65789dc321e4c136069d3cef2137b3afb91ad9f0..ecbba92ccee8d61a67b18b0a968c80f10d33450b 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -43,12 +43,15 @@ type Choice struct { func NewChoice() Choice { hasSavedDrafts := config.HasDrafts() - choices := []string{"\ueb1c Inbox", "\ueb1b Compose Email"} + choices := []string{ + "\ueb1c " + t("choice.inbox"), + "\ueb1b " + t("choice.compose"), + } if hasSavedDrafts { - choices = append(choices, "\uec0e Drafts") + choices = append(choices, "\uec0e "+t("choice.drafts")) } - choices = append(choices, "\uf487 Marketplace") - choices = append(choices, "\uf013 Settings") + choices = append(choices, "\uf487 "+t("choice.marketplace")) + choices = append(choices, "\uf013 "+t("choice.settings")) return Choice{ choices: choices, hasSavedDrafts: hasSavedDrafts, @@ -80,17 +83,22 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor++ } case "enter": - selectedChoice := m.choices[m.cursor] - switch selectedChoice { - case "\ueb1c Inbox": + // Use cursor index instead of string comparison + idx := m.cursor + if idx == 0 { + // Inbox return m, func() tea.Msg { return GoToInboxMsg{} } - case "\ueb1b Compose Email": + } else if idx == 1 { + // Compose return m, func() tea.Msg { return GoToSendMsg{} } - case "\uec0e Drafts": + } else if m.hasSavedDrafts && idx == 2 { + // Drafts return m, func() tea.Msg { return GoToDraftsMsg{} } - case "\uf487 Marketplace": + } else if (m.hasSavedDrafts && idx == 3) || (!m.hasSavedDrafts && idx == 2) { + // Marketplace return m, func() tea.Msg { return GoToMarketplaceMsg{} } - case "\uf013 Settings": + } else if (m.hasSavedDrafts && idx == 4) || (!m.hasSavedDrafts && idx == 3) { + // Settings return m, func() tea.Msg { return GoToSettingsMsg{} } } @@ -126,7 +134,7 @@ func (m Choice) View() tea.View { b.WriteString(logoStyle.Render(choiceLogo)) b.WriteString("\n") - b.WriteString(listHeader.Render("What would you like to do?")) + b.WriteString(listHeader.Render(t("choice.what_to_do"))) b.WriteString("\n\n") // If we detected an update, show a short message under the header. @@ -134,9 +142,12 @@ func (m Choice) View() tea.View { updateStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Warning).Padding(0, 1) cur := m.CurrentVersion if cur == "" { - cur = "unknown" + cur = t("choice.unknown") } - msg := fmt.Sprintf("Update available: %s (installed: %s) — run `matcha update` to upgrade", m.LatestVersion, cur) + msg := tpl("choice.update_available", map[string]interface{}{ + "latest": m.LatestVersion, + "current": cur, + }) b.WriteString(updateStyle.Render(msg)) b.WriteString("\n\n") } @@ -151,7 +162,7 @@ func (m Choice) View() tea.View { } mainContent := b.String() - helpView := helpStyle.Render("Use ↑/↓ to navigate, enter to select, and ctrl+c to quit.") + helpView := helpStyle.Render(t("choice.help")) if m.height > 0 { currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) diff --git a/tui/composer.go b/tui/composer.go index 2ce24d393132a1af29a416f8a00536bb6bb00e26..27f75bc95d76bf5adff065da7806f578db596d52 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -25,8 +25,6 @@ var ( blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) noStyle = lipgloss.NewStyle() helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - focusedButton = focusedStyle.Copy().Render("[ Send ]") - blurredButton = blurredStyle.Copy().Render("[ Send ]") emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245")) fromSelectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) @@ -104,40 +102,40 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer { taStyles := ThemedTextAreaStyles() m.toInput = textinput.New() - m.toInput.Placeholder = "To" + m.toInput.Placeholder = t("composer.to_placeholder") m.toInput.SetValue(to) m.toInput.Prompt = "> " m.toInput.CharLimit = 256 m.toInput.SetStyles(tiStyles) m.ccInput = textinput.New() - m.ccInput.Placeholder = "Cc" + m.ccInput.Placeholder = t("composer.cc_placeholder") m.ccInput.Prompt = "> " m.ccInput.CharLimit = 256 m.ccInput.SetStyles(tiStyles) m.bccInput = textinput.New() - m.bccInput.Placeholder = "Bcc" + m.bccInput.Placeholder = t("composer.bcc_placeholder") m.bccInput.Prompt = "> " m.bccInput.CharLimit = 256 m.bccInput.SetStyles(tiStyles) m.subjectInput = textinput.New() - m.subjectInput.Placeholder = "Subject" + m.subjectInput.Placeholder = t("composer.subject_placeholder") m.subjectInput.SetValue(subject) m.subjectInput.Prompt = "> " m.subjectInput.CharLimit = 256 m.subjectInput.SetStyles(tiStyles) m.bodyInput = textarea.New() - m.bodyInput.Placeholder = "Body (Markdown supported)..." + m.bodyInput.Placeholder = t("composer.body_placeholder") m.bodyInput.SetValue(body) m.bodyInput.Prompt = "> " m.bodyInput.SetHeight(10) m.bodyInput.SetStyles(taStyles) m.signatureInput = textarea.New() - m.signatureInput.Placeholder = "Signature (optional)..." + m.signatureInput.Placeholder = t("composer.signature_placeholder") m.signatureInput.Prompt = "> " m.signatureInput.SetHeight(3) m.signatureInput.SetStyles(taStyles) @@ -499,9 +497,9 @@ func (m *Composer) View() tea.View { var button string if m.focusIndex == focusSend { - button = focusedButton + button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]") } else { - button = blurredButton + button = blurredStyle.Copy().Render("[ " + t("composer.send") + " ]") } // From field with account selector @@ -509,23 +507,23 @@ func (m *Composer) View() tea.View { var fromField string if len(m.accounts) > 1 { if m.focusIndex == focusFrom { - fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr)) + fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch"))) } else { - fromField = blurredStyle.Render(fmt.Sprintf(" From: %s [switchable]", fromAddr)) + fromField = blurredStyle.Render(fmt.Sprintf(" %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable"))) } } else if fromAddr != "" { - fromField = " From: " + emailRecipientStyle.Render(fromAddr) + fromField = " " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr) } else { - fromField = blurredStyle.Render(" From: (no account configured)") + fromField = blurredStyle.Render(fmt.Sprintf(" %s (%s)", t("composer.from"), t("composer.no_account"))) } var attachmentField string if len(m.attachmentPaths) == 0 { - attachmentText := "None (Enter to add)" + attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add")) if m.focusIndex == focusAttachment { - attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments: %s", attachmentText)) + attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText)) } else { - attachmentField = blurredStyle.Render(fmt.Sprintf(" Attachments: %s", attachmentText)) + attachmentField = blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.attachments"), attachmentText)) } } else { var names []string @@ -534,9 +532,9 @@ func (m *Composer) View() tea.View { } attachmentText := strings.Join(names, ", ") if m.focusIndex == focusAttachment { - attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments (%d): %s", len(m.attachmentPaths), attachmentText)) + attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText)) } else { - attachmentField = blurredStyle.Render(fmt.Sprintf(" Attachments (%d): %s", len(m.attachmentPaths), attachmentText)) + attachmentField = blurredStyle.Render(fmt.Sprintf(" %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText)) } } @@ -544,9 +542,9 @@ func (m *Composer) View() tea.View { if m.encryptSMIME { encToggle = "[x]" } - encField := blurredStyle.Render(fmt.Sprintf(" Encrypt Email (S/MIME): %s", encToggle)) + encField := blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.encrypt_smime"), encToggle)) if m.focusIndex == focusEncryptSMIME { - encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle)) + encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle)) } // Build To field with suggestions @@ -570,9 +568,9 @@ func (m *Composer) View() tea.View { // Signature field label var signatureLabel string if m.focusIndex == focusSignature { - signatureLabel = focusedStyle.Render("Signature:") + signatureLabel = focusedStyle.Render(t("composer.signature") + ":") } else { - signatureLabel = blurredStyle.Render("Signature:") + signatureLabel = blurredStyle.Render(t("composer.signature") + ":") } tip := "" @@ -600,7 +598,7 @@ func (m *Composer) View() tea.View { } composerViewElements := []string{ - "Compose New Email", + t("composer.title"), fromField, toFieldView, m.ccInput.View(), @@ -620,7 +618,7 @@ func (m *Composer) View() tea.View { } mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...) - helpText := "Markdown/HTML • tab/shift+tab: navigate • ctrl+e: $EDITOR • esc: save draft & exit" + helpText := t("composer.help") for _, pk := range m.pluginKeyBindings { helpText += " • " + pk.Key + ": " + pk.Description } @@ -684,7 +682,7 @@ func (m *Composer) View() tea.View { if m.confirmingExit { dialog := DialogBoxStyle.Render( lipgloss.JoinVertical(lipgloss.Center, - "Are you sure you want to exit? This draft will be saved", + t("composer.exit_confirm"), HelpStyle.Render("\n(y/n)"), ), ) diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 750714ebb6eca71e1dcd12464a12632c5f126d11..f09ccc2b16b07a17f9013a82bfa58bfda64aef68 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -383,7 +383,7 @@ func (m *FolderInbox) renderSidebar() string { var b strings.Builder // Account name as title - title := "Folders" + title := t("folder_inbox.folders_title") if len(m.accounts) > 0 { acc := m.accounts[0] if acc.Name != "" { @@ -435,9 +435,11 @@ func (m *FolderInbox) renderWithMoveOverlay(content string) string { } var b strings.Builder - title := "Move to folder:" + title := t("folder_inbox.move_to_folder") if len(m.moveUIDs) > 1 { - title = fmt.Sprintf("Move %d emails to folder:", len(m.moveUIDs)) + title = tn("folder_inbox.move_multiple", len(m.moveUIDs), map[string]interface{}{ + "count": len(m.moveUIDs), + }) } b.WriteString(moveOverlayTitleStyle.Render(title)) b.WriteString("\n") @@ -455,7 +457,7 @@ func (m *FolderInbox) renderWithMoveOverlay(content string) string { } b.WriteString("\n\n") - b.WriteString(helpStyle.Render("j/k: navigate enter: move esc: cancel")) + b.WriteString(helpStyle.Render(t("folder_inbox.help"))) overlay := moveOverlayStyle.Render(b.String()) diff --git a/tui/i18n_helper.go b/tui/i18n_helper.go new file mode 100644 index 0000000000000000000000000000000000000000..4d81021f1e64a53b3d8266840d65d3dab7fc8c58 --- /dev/null +++ b/tui/i18n_helper.go @@ -0,0 +1,21 @@ +package tui + +import "github.com/floatpane/matcha/i18n" + +// t translates a message key to the current language. +// Example: t("composer.title") -> "Compose New Email" +func t(key string) string { + return i18n.GetManager().T(key) +} + +// tn translates a message with plural support. +// Example: tn("inbox.emails", 5, nil) -> "5 emails" +func tn(key string, count int, data map[string]interface{}) string { + return i18n.GetManager().Tn(key, count, data) +} + +// tpl translates a message and applies template variables. +// Example: tpl("welcome.message", map[string]interface{}{"name": "John"}) -> "Welcome, John!" +func tpl(key string, data map[string]interface{}) string { + return i18n.GetManager().Tpl(key, data) +} diff --git a/tui/inbox.go b/tui/inbox.go index ca0a08e2f41c4138dc8696acd55a8f5625955476..e0c653a862629220cadf81854cd5894ba1da041b 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -165,43 +165,34 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list // formatRelativeDate formats a time as relative if within the last week, // otherwise as an absolute date using the caller-supplied Go time layout. // When layout is empty, falls back to the built-in short/long defaults. -func formatRelativeDate(t time.Time, layout string) string { - if t.IsZero() { +func formatRelativeDate(timestamp time.Time, layout string) string { + if timestamp.IsZero() { return "" } now := time.Now() - d := now.Sub(t) + d := now.Sub(timestamp) switch { case d < time.Minute: - return "just now" + return t("time.just_now") case d < time.Hour: mins := int(d.Minutes()) - if mins == 1 { - return "1 min ago" - } - return fmt.Sprintf("%d min ago", mins) + return tn("time.minute_ago", mins, map[string]interface{}{"count": mins}) case d < 24*time.Hour: hours := int(d.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) + return tn("time.hour_ago", hours, map[string]interface{}{"count": hours}) case d < 7*24*time.Hour: days := int(d.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) + return tn("time.day_ago", days, map[string]interface{}{"count": days}) default: - t = t.Local() + timestamp = timestamp.Local() if layout != "" { - return t.Format(layout) + return timestamp.Format(layout) } - if t.Year() == now.Year() { - return t.Format("Jan 02") + if timestamp.Year() == now.Year() { + return timestamp.Format("Jan 02") } - return t.Format("Jan 02, 2006") + return timestamp.Format("Jan 02, 2006") } } @@ -413,10 +404,10 @@ func (m *Inbox) updateList() { l.SetStatusBarItemName("email", "emails") l.AdditionalShortHelpKeys = func() []key.Binding { bindings := []key.Binding{ - key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual mode")), - key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")), - key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")), - key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")), + key.NewBinding(key.WithKeys("v"), key.WithHelp("v", t("inbox.visual_mode"))), + key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", t("inbox.delete"))), + key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", t("inbox.archive"))), + key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", t("inbox.refresh"))), } if len(m.tabs) > 1 { bindings = append(bindings, @@ -459,7 +450,7 @@ func (m *Inbox) updateList() { func (m *Inbox) getTitle() string { var title string if m.currentAccountID == "" { - title = m.getBaseTitle() + " - All Accounts" + title = m.getBaseTitle() + " - " + t("inbox.all_accounts") } else { title = m.getBaseTitle() for _, acc := range m.accounts { diff --git a/tui/password_prompt.go b/tui/password_prompt.go index 4f5ed3ef6eaeeeb576ed37c7cc8c5ebdd6683372..c083ca3185fa755bb01e191c1d21fb958528163c 100644 --- a/tui/password_prompt.go +++ b/tui/password_prompt.go @@ -21,7 +21,7 @@ type PasswordPrompt struct { // NewPasswordPrompt creates a new password prompt screen. func NewPasswordPrompt() *PasswordPrompt { ti := textinput.New() - ti.Placeholder = "Enter your password" + ti.Placeholder = t("password_prompt.enter_password") ti.EchoMode = textinput.EchoPassword ti.EchoCharacter = '*' ti.Prompt = "> " @@ -50,7 +50,7 @@ func (m *PasswordPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": password := m.input.Value() if password == "" { - m.err = "Password cannot be empty" + m.err = t("password_prompt.error_empty") return m, nil } m.verifying = true @@ -90,7 +90,7 @@ func (m *PasswordPrompt) View() tea.View { Foreground(lipgloss.Color("#FFFDF5")). Background(lipgloss.Color("#25A065")). Padding(0, 1). - Render("Matcha is locked") + Render(t("password_prompt.title")) b.WriteString(lockTitle) b.WriteString("\n\n") @@ -107,7 +107,7 @@ func (m *PasswordPrompt) View() tea.View { } mainContent := b.String() - helpView := helpStyle.Render("enter: unlock • ctrl+c: quit") + helpView := helpStyle.Render(t("password_prompt.help")) if m.height > 0 { currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) diff --git a/tui/settings.go b/tui/settings.go index 9f1a8f369c91c0c4a8162b7355df8cf24ac299a5..2d6e4bdd9274ce8d4bed5a353890460ad66728b8 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -253,9 +253,15 @@ func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m *Settings) View() tea.View { // Left pane var left strings.Builder - left.WriteString(titleStyle.Render("Settings") + "\n\n") - - categories := []string{"General", "Accounts", "Theme", "Mailing Lists", "App Encryption"} + left.WriteString(titleStyle.Render(t("settings.title")) + "\n\n") + + categories := []string{ + t("settings.category_general"), + t("settings.category_accounts"), + t("settings.category_theme"), + t("settings.category_mailing_lists"), + t("settings.category_encryption"), + } for i, c := range categories { cursor := " " if m.menuCursor == i { @@ -303,9 +309,9 @@ func (m *Settings) View() tea.View { content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) - helpText := "esc: back to menu" + helpText := t("settings.help_content") if m.activePane == PaneMenu { - helpText = "↑/↓: navigate • right/enter: select • esc: go back" + helpText = t("settings.help_menu") } helpView := helpStyle.Render(helpText) diff --git a/tui/settings_accounts.go b/tui/settings_accounts.go index 844174ef0dcca63ef2646f07adf3e4a48b124b49..8a233a3e8b4e524cbb6626e2c304893703b5d0c9 100644 --- a/tui/settings_accounts.go +++ b/tui/settings_accounts.go @@ -116,10 +116,10 @@ func (m *Settings) viewAccounts() string { } var b strings.Builder - b.WriteString(titleStyle.Render("Account Settings") + "\n\n") + b.WriteString(titleStyle.Render(t("settings_accounts.title")) + "\n\n") if len(m.cfg.Accounts) == 0 { - b.WriteString(accountEmailStyle.Render(" No accounts configured.\n\n")) + b.WriteString(accountEmailStyle.Render(" " + t("settings_accounts.no_accounts") + "\n\n")) } for i, account := range m.cfg.Accounts { @@ -159,9 +159,9 @@ func (m *Settings) viewAccounts() string { cursor = "> " style = selectedAccountItemStyle } - b.WriteString(style.Render(cursor+"Add New Account") + "\n\n") + b.WriteString(style.Render(cursor+t("settings_accounts.add_account")) + "\n\n") - b.WriteString(helpStyle.Render("↑/↓: navigate • enter: edit crypto config • e: edit server • d: delete")) + b.WriteString(helpStyle.Render(t("settings_accounts.help"))) if m.confirmingDelete { accountName := m.cfg.Accounts[m.accountsCursor].Email diff --git a/tui/settings_encryption.go b/tui/settings_encryption.go index 2be7ccb2d8a852f59519fe61442284a14c0824c4..63e1e51416fce3a03a45536e7b3eb467c64c9270 100644 --- a/tui/settings_encryption.go +++ b/tui/settings_encryption.go @@ -79,11 +79,11 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { password := m.encPasswordInput.Value() confirm := m.encConfirmInput.Value() if password == "" { - m.encError = "Password cannot be empty" + m.encError = t("settings_encryption.error_empty") return m, nil } if password != confirm { - m.encError = "Passwords do not match" + m.encError = t("settings_encryption.error_mismatch") return m, nil } m.encEnabling = true @@ -111,41 +111,41 @@ func (m *Settings) viewEncryption() string { var b strings.Builder isEnabled := config.IsSecureModeEnabled() - b.WriteString(titleStyle.Render("App Encryption") + "\n\n") + b.WriteString(titleStyle.Render(t("settings_encryption.title")) + "\n\n") if isEnabled { if m.confirmingDisable { dialog := DialogBoxStyle.Render( lipgloss.JoinVertical(lipgloss.Center, - dangerStyle.Render("Disable encryption?"), - accountEmailStyle.Render("All data will be stored unencrypted."), + dangerStyle.Render(t("settings_encryption.disable_confirm")), + accountEmailStyle.Render(t("settings_encryption.disable_warning")), HelpStyle.Render("\n(y/n)"), ), ) b.WriteString(dialog + "\n") } else { - b.WriteString(settingsFocusedStyle.Render(" Encryption is currently enabled.") + "\n\n") - b.WriteString(accountEmailStyle.Render(" Press enter to disable encryption.") + "\n\n") + b.WriteString(settingsFocusedStyle.Render(" "+t("settings_encryption.enabled")) + "\n\n") + b.WriteString(accountEmailStyle.Render(" "+t("settings_encryption.disable_button")) + "\n\n") b.WriteString(helpStyle.Render("enter: disable")) } } else { - b.WriteString(accountEmailStyle.Render("Set a password to encrypt all data.") + "\n\n") + b.WriteString(accountEmailStyle.Render(t("settings_encryption.disabled")) + "\n\n") if m.encFocusIndex == 0 { - b.WriteString(settingsFocusedStyle.Render("Password:\n")) + b.WriteString(settingsFocusedStyle.Render(t("settings_encryption.password_label") + "\n")) } else { - b.WriteString(settingsBlurredStyle.Render("Password:\n")) + b.WriteString(settingsBlurredStyle.Render(t("settings_encryption.password_label") + "\n")) } b.WriteString(m.encPasswordInput.View() + "\n\n") if m.encFocusIndex == 1 { - b.WriteString(settingsFocusedStyle.Render("Confirm Password:\n")) + b.WriteString(settingsFocusedStyle.Render(t("settings_encryption.confirm_label") + "\n")) } else { - b.WriteString(settingsBlurredStyle.Render("Confirm Password:\n")) + b.WriteString(settingsBlurredStyle.Render(t("settings_encryption.confirm_label") + "\n")) } b.WriteString(m.encConfirmInput.View() + "\n\n") - saveBtn := "[ Enable Encryption ]" + saveBtn := "[ " + t("settings_encryption.enable_button") + " ]" if m.encFocusIndex == 2 { b.WriteString(settingsFocusedStyle.Render(saveBtn) + "\n") } else { @@ -153,10 +153,10 @@ func (m *Settings) viewEncryption() string { } if m.encEnabling { - b.WriteString("\n" + accountEmailStyle.Render(" Encrypting data...") + "\n") + b.WriteString("\n" + accountEmailStyle.Render(" "+t("settings_encryption.encrypting")) + "\n") } - b.WriteString("\n" + helpStyle.Render("tab: next • enter: save")) + b.WriteString("\n" + helpStyle.Render(t("settings_encryption.help"))) } if m.encError != "" { diff --git a/tui/settings_general.go b/tui/settings_general.go index 5cf88b0200f10d3554a913c890069ac0228beae9..a7177c82d42c8ba964a902a12b369c7ae89afe58 100644 --- a/tui/settings_general.go +++ b/tui/settings_general.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/i18n" ) func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { @@ -15,7 +16,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.generalCursor-- } case "down", "j": - if m.generalCursor < 4 { + if m.generalCursor < 5 { m.generalCursor++ } case "enter", "space", "right", "l": @@ -39,7 +40,21 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.cfg.DateFormat = config.DateFormatEU } _ = config.SaveConfig(m.cfg) - case 4: // Edit Signature + case 4: // Language + // Cycle through available languages + langs := i18n.LanguageCodes() + currentLang := m.cfg.GetLanguage() + currentIdx := -1 + for i, lang := range langs { + if lang == currentLang { + currentIdx = i + break + } + } + nextIdx := (currentIdx + 1) % len(langs) + m.cfg.Language = langs[nextIdx] + _ = config.SaveConfig(m.cfg) + case 5: // Edit Signature if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" { return m, func() tea.Msg { return GoToSignatureEditorMsg{} } } @@ -54,15 +69,16 @@ func (m *Settings) viewGeneral() string { b.WriteString(titleStyle.Render("General Settings") + "\n\n") options := []struct { - label string - value string - tip string + labelKey string + value string + tip string }{ - {"Disable Image Display", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails."}, - {"Hide Contextual Tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen."}, - {"Disable Notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail."}, - {"Date Format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed."}, - {"Signature", getSignatureStatus(), "Configure the signature appended to your outgoing emails."}, + {"settings_general.disable_images", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails."}, + {"settings_general.hide_tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen."}, + {"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail."}, + {"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed."}, + {"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Restart required."}, + {"settings_general.signature", getSignatureStatus(), "Configure the signature appended to your outgoing emails."}, } for i, opt := range options { @@ -73,9 +89,10 @@ func (m *Settings) viewGeneral() string { style = selectedAccountItemStyle } - text := fmt.Sprintf("%s: %s", opt.label, opt.value) - if opt.label == "Signature" { - text = fmt.Sprintf("Edit Signature (%s)", opt.value) + label := t(opt.labelKey) + text := fmt.Sprintf("%s: %s", label, opt.value) + if opt.labelKey == "settings_general.signature" { + text = fmt.Sprintf("%s (%s)", label, opt.value) } b.WriteString(style.Render(cursor+text) + "\n") @@ -92,9 +109,9 @@ func (m *Settings) viewGeneral() string { func onOff(b bool) string { if b { - return "ON" + return t("settings_general.on") } - return "OFF" + return t("settings_general.off") } func getDateFormatLabel(f string) string { @@ -113,7 +130,14 @@ func getDateFormatLabel(f string) string { func getSignatureStatus() string { if config.HasSignature() { - return "configured" + return t("settings_general.signature_configured") + } + return t("settings_general.signature_not_configured") +} + +func getLanguageLabel(langCode string) string { + if locale, ok := i18n.GetLanguage(langCode); ok { + return fmt.Sprintf("%s (%s)", locale.NativeName, locale.Code) } - return "not configured" + return langCode } diff --git a/tui/settings_lists.go b/tui/settings_lists.go index e6a401391d39bd8e86a0ddd56e08de3f397ebc51..abc44c2b7520e7875627b1a4f39fa5b5988bc854 100644 --- a/tui/settings_lists.go +++ b/tui/settings_lists.go @@ -64,17 +64,16 @@ func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) func (m *Settings) viewMailingLists() string { var b strings.Builder - b.WriteString(titleStyle.Render("Mailing Lists") + "\n\n") + b.WriteString(titleStyle.Render(t("settings_mailing_lists.title")) + "\n\n") if len(m.cfg.MailingLists) == 0 { - b.WriteString(accountEmailStyle.Render(" No mailing lists configured.\n\n")) + b.WriteString(accountEmailStyle.Render(" " + t("settings_mailing_lists.no_lists") + "\n\n")) } for i, list := range m.cfg.MailingLists { - addrCount := fmt.Sprintf("%d address", len(list.Addresses)) - if len(list.Addresses) != 1 { - addrCount += "es" - } + addrCount := tn("settings_mailing_lists.address_count", len(list.Addresses), map[string]interface{}{ + "count": len(list.Addresses), + }) line := fmt.Sprintf("%s - %s", list.Name, accountEmailStyle.Render(addrCount)) cursor := " " @@ -92,15 +91,15 @@ func (m *Settings) viewMailingLists() string { cursor = "> " style = selectedAccountItemStyle } - b.WriteString(style.Render(cursor+"Add New Mailing List") + "\n\n") + b.WriteString(style.Render(cursor+t("settings_mailing_lists.add_list")) + "\n\n") - b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete")) + b.WriteString(helpStyle.Render(t("settings_mailing_lists.help"))) if m.confirmingDelete { listName := m.cfg.MailingLists[m.listsCursor].Name dialog := DialogBoxStyle.Render( lipgloss.JoinVertical(lipgloss.Center, - dangerStyle.Render("Delete mailing list?"), + dangerStyle.Render(t("settings_mailing_lists.delete_confirm")), accountEmailStyle.Render(listName), HelpStyle.Render("\n(y/n)"), ), diff --git a/tui/settings_theme.go b/tui/settings_theme.go index 862056cf88c84a330c44bfdb36b795ea0c4ed40b..d3cc2e292b561129dc41a93d65cda47b2284bb6d 100644 --- a/tui/settings_theme.go +++ b/tui/settings_theme.go @@ -37,13 +37,13 @@ func (m *Settings) viewTheme() string { themes := theme.AllThemes() var b strings.Builder - b.WriteString(titleStyle.Render("Theme") + "\n\n") + b.WriteString(titleStyle.Render(t("settings_theme.title")) + "\n\n") - for i, t := range themes { - isActive := t.Name == theme.ActiveTheme.Name - label := t.Name + for i, thm := range themes { + isActive := thm.Name == theme.ActiveTheme.Name + label := thm.Name if isActive { - label += " (active)" + label += " (" + t("settings_theme.current") + ")" } cursor := " " @@ -77,7 +77,7 @@ func (m *Settings) viewTheme() string { b.WriteString(TipStyle.Render("Tip: Custom themes can be added as JSON files in ~/.config/matcha/themes/") + "\n\n") } - b.WriteString(helpStyle.Render("↑/↓: navigate • enter/space: apply theme")) + b.WriteString(helpStyle.Render(t("settings_theme.help"))) return b.String() } diff --git a/tui/theme.go b/tui/theme.go index ab6ac47d74eec4bc38afadb6a06cdbb4048efe6b..85274c313fb767b20f980714e09bc5641034fdbc 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -61,8 +61,6 @@ func RebuildStyles() { blurredStyle = lipgloss.NewStyle().Foreground(t.Secondary) noStyle = lipgloss.NewStyle() helpStyle = lipgloss.NewStyle().Foreground(t.SubtleText) - focusedButton = focusedStyle.Render("[ Send ]") - blurredButton = blurredStyle.Render("[ Send ]") emailRecipientStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(t.Secondary) fromSelectorStyle = lipgloss.NewStyle().Foreground(t.Accent)