Detailed changes
@@ -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,
@@ -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
+}
@@ -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
+}
@@ -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 ""
+}
@@ -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"
+ }
+}
@@ -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)
+}
@@ -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
@@ -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")
+)
@@ -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
+}
@@ -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])
+}
@@ -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")
+// }
@@ -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)
+ }
+}
@@ -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,
+ })
+}
@@ -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.
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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,
+ })
+}
@@ -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)
+}
@@ -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"
+}
@@ -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} يوم"
+ }
+ }
+ }
+}
@@ -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"
+ }
+ }
+ }
+}
@@ -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"
+ }
+ }
+ }
+}
@@ -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"
+ }
+ }
+ }
+}
@@ -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"
+ }
+ }
+ }
+}
@@ -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}日後"
+ }
+ }
+ }
+}
@@ -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"
+ }
+ }
+ }
+}
@@ -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"
+ }
+ }
+ }
+}
@@ -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} дня"
+ }
+ }
+ }
+}
@@ -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} днів"
+ }
+ }
+ }
+}
@@ -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} 天后"
+ }
+ }
+ }
+}
@@ -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()
+}
@@ -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()
+ }
+}
@@ -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 ""
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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()
@@ -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))
@@ -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)"),
),
)
@@ -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())
@@ -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)
+}
@@ -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 {
@@ -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))
@@ -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)
@@ -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
@@ -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 != "" {
@@ -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
}
@@ -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)"),
),
@@ -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()
}
@@ -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)