diff --git a/docs/docs/index.md b/docs/docs/index.md index 60ad988ee30ee154d90bdc6ab290ef34bf835a7e..3880575e7afc13c7238225ad22265a1f6d825699 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -19,6 +19,7 @@ Ready to dive in? Here are a few places to start: - πŸš€ [Installation Guide](.//installation.md) - Get Matcha running on your machine - πŸ“– [Usage & Shortcuts](./usage.md) - Learn how to navigate the interface - βš™οΈ [Configuration](./Configuration.md) - Set up your accounts and preferences +- 🌍 [Localization](./localization.md) - Add or edit translations for your language ## Core Features diff --git a/docs/docs/localization.md b/docs/docs/localization.md new file mode 100644 index 0000000000000000000000000000000000000000..867a62081a7778ed4a32096e9e3e6d0a70247ac0 --- /dev/null +++ b/docs/docs/localization.md @@ -0,0 +1,388 @@ +# Localization Guide + +Matcha uses a custom i18n (internationalization) system to support multiple languages. This guide explains how to add new translations or edit existing ones. + +## Changing Language + +Set your preferred language in the config file (`~/.config/matcha/config.json`): + +```toml +language = "uk" # or "es", "de", "fr", etc. +``` + +Or in Matcha Settings menu β†’ General β†’ Language. + +## File Structure + +``` +i18n/ +β”œβ”€β”€ locales/ +β”‚ β”œβ”€β”€ en.json # English (base) +β”‚ β”œβ”€β”€ uk.json # Ukrainian +β”‚ β”œβ”€β”€ es.json # Spanish +β”‚ └── ... +└── languages/ + β”œβ”€β”€ en.go # English plural rules + β”œβ”€β”€ uk.go # Ukrainian plural rules + └── ... +``` + +## Adding a New Translation + +### 1. Create Translation File + +Copy `i18n/locales/en.json` to `i18n/locales/[lang].json`: + +```bash +cp i18n/locales/en.json i18n/locales/es.json +``` + +### 2. Update Language Code + +Change the `language` field: + +```json +{ + "language": "es", + "messages": { + ... + } +} +``` + +### 3. Translate All Strings + +Translate all message values while preserving: +- JSON structure +- Placeholder variables: `{count}`, `{latest}`, `{current}`, etc. +- Technical terms: S/MIME, PGP, IMAP, SMTP, etc. +- Commands and file paths + +**Example:** + +```json +"composer": { + "title": "Redactar nuevo correo", + "from": "De", + "to_placeholder": "Ingrese direcciones de correo de destinatarios.", + "send": "Enviar" +} +``` + +### 4. Handle Plural Forms + +Different languages have different plural rules. Matcha supports: + +- `one` - Singular (1) +- `few` - Few items (2-4 in some languages) +- `many` - Many items (5+ in some languages) +- `other` - Default/all other counts + +**English (simple):** +```json +"address_count": { + "one": "{count} address", + "other": "{count} addresses" +} +``` + +**Ukrainian (complex):** +```json +"address_count": { + "one": "{count} адрСса", + "few": "{count} адрСси", + "other": "{count} адрСс" +} +``` + +**Arabic (very complex):** +```json +"hours_ago": { + "zero": "Ω…Ω†Ψ° {count} Ψ³Ψ§ΨΉΨ©", + "one": "Ω…Ω†Ψ° Ψ³Ψ§ΨΉΨ© واحدة", + "two": "Ω…Ω†Ψ° Ψ³Ψ§ΨΉΨͺΩŠΩ†", + "few": "Ω…Ω†Ψ° {count} Ψ³Ψ§ΨΉΨ§Ψͺ", + "many": "Ω…Ω†Ψ° {count} Ψ³Ψ§ΨΉΨ©", + "other": "Ω…Ω†Ψ° {count} Ψ³Ψ§ΨΉΨ©" +} +``` + +### 5. Register Language (Optional) + +If adding a completely new language not in `i18n/languages/`, create the plural rules file: + +**i18n/languages/es.go:** +```go +package languages + +import "github.com/floatpane/matcha/i18n" + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Code: "es", + Name: "Spanish", + NativeName: "EspaΓ±ol", + Direction: "ltr", + PluralFunc: i18n.SpanishPlural, + }) +} +``` + +Plural function already exists in `i18n/plural_rules.go` for common languages. + +### 6. Test Translation + +1. Build matcha: `go build` +2. Set language in config: `language = "es"` +3. Restart matcha +4. Verify all UI elements display translated text + +## Editing Existing Translations + +### 1. Find Translation File + +Open `i18n/locales/[lang].json` for your language. + +### 2. Locate Translation Key + +Translation keys follow dot notation matching UI structure: + +- `composer.*` - Email composer screen +- `inbox.*` - Inbox view +- `settings.*` - Settings menu +- `settings_general.*` - General settings +- `settings_accounts.*` - Account settings +- `choice.*` - Main menu +- `common.*` - Shared UI elements + +**Example key paths:** +``` +composer.title +inbox.all_accounts +settings_general.language +settings_encryption.password_label +``` + +### 3. Update Translation + +Edit the string value: + +```json +"composer": { + "title": "Redactar correo nuevo" // Old + "title": "Escribir nuevo correo" // New +} +``` + +### 4. Rebuild and Test + +```bash +go build +./matcha +``` + +## Translation Guidelines + +### Do Translate: +βœ… All UI text visible to users +βœ… Help text and tips +βœ… Button labels +βœ… Menu items +βœ… Error messages shown in UI +βœ… Status messages + +### Don't Translate: +❌ Error logs (backend) +❌ Debug messages +❌ Protocol names (IMAP, SMTP, PGP, S/MIME) +❌ File paths +❌ Environment variables +❌ Command names (`matcha update`) +❌ Code/technical identifiers + +### Placeholder Variables + +Keep variables intact: + +```json +// βœ… Correct +"update_available": "Mise Γ  jour disponible: {latest} (installΓ©: {current})" + +// ❌ Wrong - renamed variable +"update_available": "Mise Γ  jour disponible: {derniere} (installΓ©: {actuel})" + +// ❌ Wrong - removed variable +"update_available": "Mise Γ  jour disponible (installΓ©)" +``` + +### Context-Aware Translation + +Some keys need context: + +```json +// Button in composer +"send": "Enviar" + +// Status message +"sent": "Enviado correctamente" + +// Different contexts, different translations +``` + +## Common Translation Keys + +### Navigation +```json +"common": { + "yes": "SΓ­", + "no": "No", + "cancel": "Cancelar", + "save": "Guardar", + "delete": "Eliminar", + "back": "Volver" +} +``` + +### Relative Time +```json +"inbox": { + "just_now": "Ahora mismo", + "minute_ago": { + "one": "Hace {count} minuto", + "other": "Hace {count} minutos" + }, + "hour_ago": { + "one": "Hace {count} hora", + "other": "Hace {count} horas" + } +} +``` + +## Plural Rules Reference + +### English, Spanish, Portuguese +``` +one: 1 +other: 0, 2-∞ +``` + +### French +``` +one: 0, 1 +other: 2-∞ +``` + +### German +``` +one: 1 +other: 0, 2-∞ +``` + +### Russian, Ukrainian +``` +one: 1, 21, 31, 41... +few: 2-4, 22-24, 32-34... +other: 0, 5-20, 25-30... +``` + +### Polish +``` +one: 1 +few: 2-4, 22-24, 32-34... (not 12-14) +many: 0, 5-21, 25-31... +other: fractions +``` + +### Arabic +``` +zero: 0 +one: 1 +two: 2 +few: 3-10 +many: 11-99 +other: 100+, fractions +``` + +### Japanese, Chinese +``` +other: all numbers (no plural distinction) +``` + +## Testing Checklist + +When adding/editing translations: + +- [ ] All UI screens display in target language +- [ ] Plural forms work correctly (test with 0, 1, 2, 5, 21 items) +- [ ] Variable interpolation works (`{count}`, `{latest}`, etc.) +- [ ] No English text visible (except technical terms) +- [ ] Help text fits in UI (not truncated) +- [ ] Special characters display correctly +- [ ] RTL languages render properly (Arabic) + +## Contributing Translations + +1. Fork the repository +2. Add/edit translation file in `i18n/locales/` +3. Test thoroughly +4. Submit pull request with: + - Translation file changes + - Screenshots showing translated UI + - Note about plural form testing + +## Dynamic Language Switching + +Language changes currently require restart. To make dynamic: + +1. Save language to config +2. Call `i18n.GetManager().SetLanguage(lang)` +3. Trigger full UI re-render + +**Implementation:** + +```go +// In settings handler +func (m *Settings) changeLanguage(newLang string) tea.Cmd { + m.cfg.Language = newLang + config.SaveConfig(m.cfg) + i18n.GetManager().SetLanguage(newLang) + + // Force complete UI rebuild + return func() tea.Msg { + return LanguageChangedMsg{Language: newLang} + } +} +``` + +Full dynamic switching requires rebuilding all TUI models with new translations. + +## Troubleshooting + +### Translation Not Showing + +1. Check language code matches file name (`uk.json` β†’ `language = "uk"`) +2. Verify JSON syntax is valid +3. Rebuild: `go build` +4. Clear cache: `rm -rf ~/.cache/matcha` +5. Restart matcha + +### Missing Translations + +If key missing, falls back to: +1. Base language (English) +2. Translation key itself (e.g., `composer.title`) + +Check logs for fallback warnings. + +### Plural Forms Not Working + +1. Verify plural rules defined for language in `i18n/plural_rules.go` +2. Check JSON structure matches expected forms (`one`, `few`, `many`, `other`) +3. Use `tn()` function in code, not `t()` + +## Reference + +- Translation files: `i18n/locales/*.json` +- Plural rules: `i18n/plural_rules.go` +- Language registry: `i18n/languages/*.go` +- Unicode CLDR: https://cldr.unicode.org/index/cldr-spec/plural-rules diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 0000000000000000000000000000000000000000..44578744cc9b0b7f5a32ac3607a2ef3d56372421 --- /dev/null +++ b/i18n/README.md @@ -0,0 +1,609 @@ +# i18n - Custom Internationalization Library + +Custom-built internationalization system for matcha email client. Zero external i18n dependencies - everything implemented from scratch. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Directory Structure](#directory-structure) +- [Core Components](#core-components) + - [Translation Manager](#translation-manager) + - [Bundle System](#bundle-system) + - [Localizer](#localizer) + - [Plural Rules Engine](#plural-rules-engine) + - [Template System](#template-system) + - [Language Registry](#language-registry) +- [Data Flow](#data-flow) +- [Translation File Format](#translation-file-format) +- [Plural Forms](#plural-forms) +- [Caching Strategy](#caching-strategy) +- [Language Detection](#language-detection) +- [Adding/Editing Languages](#addingediting-languages) + +## Architecture Overview + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Application Layer β”‚ +β”‚ (TUI components call t(), tn(), tpl()) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Global Manager β”‚ +β”‚ β€’ Singleton instance β”‚ +β”‚ β€’ Current language state β”‚ +β”‚ β€’ Localizer instances β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Bundle β”‚ β”‚ Localizer β”‚ β”‚ Cache β”‚ +β”‚ All messages β”‚ β”‚ Per-lang β”‚ β”‚ Translations β”‚ +β”‚ All locales β”‚ β”‚ instance β”‚ β”‚ Rendered text β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ Pluralizer β”‚ + β”‚ β”‚ Plural rules β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Translation Files β”‚ +β”‚ locales/en.json, locales/uk.json, ... β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Directory Structure + +``` +i18n/ +β”œβ”€β”€ manager.go # Global singleton manager +β”œβ”€β”€ bundle.go # Translation bundle storage +β”œβ”€β”€ locale.go # Locale representation +β”œβ”€β”€ message.go # Message structure (with plural forms) +β”œβ”€β”€ localizer.go # Per-language translation engine +β”œβ”€β”€ loader.go # Load & embed translation files +β”œβ”€β”€ parser.go # JSON parser for translation files +β”œβ”€β”€ plural_rules.go # Plural rule functions for all languages +β”œβ”€β”€ pluralizer.go # Plural form selection logic +β”œβ”€β”€ template.go # Simple {var} template engine +β”œβ”€β”€ interpolator.go # Variable interpolation in strings +β”œβ”€β”€ cache.go # In-memory translation cache +β”œβ”€β”€ fallback.go # Fallback chain (uk β†’ en) +β”œβ”€β”€ detector.go # Auto-detect user language +β”œβ”€β”€ registry.go # Available languages registry +β”œβ”€β”€ formatter.go # Number formatting per locale +β”œβ”€β”€ date_formatter.go # Date/time formatting per locale +β”œβ”€β”€ context.go # Context keys for locale passing +β”œβ”€β”€ errors.go # Error types +β”œβ”€β”€ init.go # Package initialization +β”œβ”€β”€ embed.go # Embed translation files in binary +β”œβ”€β”€ languages/ # Language-specific implementations +β”‚ β”œβ”€β”€ base.go # Base language interface +β”‚ β”œβ”€β”€ en.go # English locale registration +β”‚ β”œβ”€β”€ uk.go # Ukrainian locale registration +β”‚ β”œβ”€β”€ es.go # Spanish locale registration +β”‚ └── ... # Other languages +└── locales/ # Translation JSON files + β”œβ”€β”€ en.json # English translations (base) + β”œβ”€β”€ uk.json # Ukrainian translations + └── ... # Other language files +``` + +## Core Components + +### Translation Manager + +**File:** `manager.go` + +Global singleton that coordinates all i18n operations. Provides three main translation functions: + +- `T(key)` - Simple translation lookup +- `Tn(key, count, data)` - Translation with pluralization +- `Tpl(key, data)` - Translation with template variables + +```go +// Usage in TUI components +import "github.com/floatpane/matcha/i18n" + +func render() string { + title := i18n.GetManager().T("composer.title") + count := i18n.GetManager().Tn("inbox.unread", 5, map[string]interface{}{ + "count": 5, + }) + return title + " - " + count +} +``` + +Maintains: +- Current active language +- Bundle with all loaded translations +- Localizer instances per language +- Translation cache + +### Bundle System + +**File:** `bundle.go` + +Central storage for all translation messages across all languages. Thread-safe with RWMutex. + +```go +type Bundle struct { + defaultLang string + messages map[string]MessageMap // lang β†’ message ID β†’ Message + locales map[string]*Locale // lang β†’ Locale info + mu sync.RWMutex +} +``` + +Responsibilities: +- Store translations for all languages +- Retrieve messages by language + key +- Register locale definitions +- List available languages + +### Localizer + +**File:** `localizer.go` + +Per-language translation engine. Each active language gets one localizer instance. + +```go +type Localizer struct { + lang string + bundle *Bundle + locale *Locale + cache *Cache +} +``` + +Core methods: +- `Localize(messageID)` - Basic lookup +- `LocalizePlural(messageID, count, data)` - With plural rules +- `LocalizeTemplate(messageID, data)` - With variable substitution + +Handles: +- Message lookup with fallback +- Plural form selection +- Template interpolation +- Result caching + +### Plural Rules Engine + +**Files:** `plural_rules.go`, `pluralizer.go` + +Implements CLDR plural rules for all supported languages. + +```go +type PluralForm int + +const ( + Zero PluralForm = iota // 0 items + One // 1 item + Two // 2 items (Arabic, Welsh) + Few // 2-4 items (Slavic languages) + Many // 5+ items (Slavic languages) + Other // Default/fallback +) +``` + +Each language has specific rules: + +**English/Spanish:** Simple (one/other) +```go +func EnglishPlural(n int) PluralForm { + if n == 1 { + return One + } + return Other +} +``` + +**Ukrainian/Russian:** Complex (one/few/many) +```go +func UkrainianPlural(n int) PluralForm { + mod10 := n % 10 + mod100 := n % 100 + + if mod10 == 1 && mod100 != 11 { + return One // 1, 21, 31, 41... + } + if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) { + return Few // 2-4, 22-24, 32-34... + } + return Many // 0, 5-20, 25-30... +} +``` + +**Arabic:** Very complex (zero/one/two/few/many/other) + +### Template System + +**Files:** `template.go`, `interpolator.go` + +Simple variable substitution using `{variable}` syntax. + +```go +template := "Update available: {latest} (current: {current})" +data := map[string]interface{}{ + "latest": "1.5.0", + "current": "1.4.0", +} +result := Interpolate(template, data) +// β†’ "Update available: 1.5.0 (current: 1.4.0)" +``` + +**Design philosophy:** Keep simple - no complex logic, just variable replacement. Complex formatting done in Go code before passing to templates. + +### Language Registry + +**File:** `registry.go` + +Global registry of all available languages. Languages self-register on init. + +```go +// In languages/uk.go +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Code: "uk", + Name: "Ukrainian", + NativeName: "Π£ΠΊΡ€Π°Ρ—Π½ΡΡŒΠΊΠ°", + Direction: "ltr", + PluralFunc: i18n.UkrainianPlural, + }) +} +``` + +Registry provides: +- `GetLanguage(code)` - Lookup locale by code +- `AvailableLanguages()` - List all registered locales +- `LanguageCodes()` - List all language codes + +## Data Flow + +### Translation Lookup Flow + +``` +1. TUI calls t("composer.title") + ↓ +2. Manager.T() β†’ GetLocalizer(currentLang) + ↓ +3. Localizer checks cache + β€’ Cache hit? β†’ return cached value + β€’ Cache miss? β†’ continue + ↓ +4. Bundle.GetMessage(lang, "composer.title") + β€’ Found? β†’ return Message + β€’ Not found? β†’ try fallback chain (uk β†’ en) + ↓ +5. Return message.One (singular form) + ↓ +6. Cache result for future lookups + ↓ +7. Return translated string to TUI +``` + +### Pluralization Flow + +``` +1. TUI calls tn("inbox.unread", 5, {"count": 5}) + ↓ +2. Manager.Tn() β†’ GetLocalizer(currentLang) + ↓ +3. Get Message from bundle + ↓ +4. Call locale.PluralFunc(5) + β€’ English: 5 β†’ Other + β€’ Ukrainian: 5 β†’ Many + β€’ Arabic: 5 β†’ Few + ↓ +5. Select appropriate form from Message: + β€’ message.One (n=1) + β€’ message.Few (n=2-4 in Ukrainian) + β€’ message.Many (n=5+ in Ukrainian) + β€’ message.Other (fallback) + ↓ +6. Interpolate {count} β†’ "5" + ↓ +7. Return "5 листів" (Ukrainian) or "5 emails" (English) +``` + +## Translation File Format + +**Location:** `locales/*.json` + +**Structure:** + +```json +{ + "language": "uk", + "messages": { + "common": { + "yes": "Π’Π°ΠΊ", + "no": "Ні", + "save": "Π—Π±Π΅Ρ€Π΅Π³Ρ‚ΠΈ" + }, + "composer": { + "title": "Написати Π½ΠΎΠ²ΠΈΠΉ лист", + "send": "Надіслати", + "from": "Π’Ρ–Π΄" + }, + "inbox": { + "unread": { + "one": "{count} Π½Π΅ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½ΠΈΠΉ лист", + "few": "{count} Π½Π΅ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½Ρ– листи", + "other": "{count} Π½Π΅ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½ΠΈΡ… листів" + } + } + } +} +``` + +**Nested structure:** Dot notation for keys (`composer.title`, `inbox.unread`) + +**Plural objects:** When value is object with `one`/`few`/`many`/`other`, it's treated as plural form. + +## Plural Forms + +Languages use different plural categories: + +| Language | Forms Used | Example (emails) | +|----------|-----------|------------------| +| English | one, other | 1 email, 2 emails | +| Spanish | one, other | 1 correo, 2 correos | +| German | one, other | 1 E-Mail, 2 E-Mails | +| French | one, other | 0 courriel, 1 courriel, 2 courriels | +| Ukrainian | one, few, other | 1 лист, 2 листи, 5 листів | +| Russian | one, few, other | 1 письмо, 2 письма, 5 писСм | +| Polish | one, few, many, other | 1 e-mail, 2 e-maile, 5 e-maili | +| Arabic | zero, one, two, few, many, other | Complex rules | +| Japanese | other | All numbers use same form | +| Chinese | other | All numbers use same form | + +## Caching Strategy + +**File:** `cache.go` + +Simple in-memory string cache with RWMutex for thread safety. + +**Cache key format:** `{lang}:{messageID}:{count}:{hash(data)}` + +**Why cache?** +- Translation lookup involves: map lookups, plural calculation, interpolation +- Typical UI has ~100 translated strings +- Redrawing UI on every keypress = wasted CPU +- Cache hit = instant string return + +**Cache invalidation:** Cache cleared when language changes via `SetLanguage()`. + +**No size limit:** Translation cache is small (~KB for entire UI) and only grows to unique message combinations actually used. + +## Language Detection + +**File:** `detector.go` + +Detection priority: + +1. **Config file:** `language = "uk"` in `~/.config/matcha/config.toml` +2. **Environment:** `LANG`, `LC_ALL`, `LC_MESSAGES` +3. **System locale:** OS-specific detection +4. **Default:** Falls back to `"en"` + +```go +func DetectLanguage(cfg *config.Config) string { + // 1. Check config + if cfg.Language != "" && isValidLanguage(cfg.Language) { + return cfg.Language + } + + // 2. Check environment + if lang := detectFromEnv(); lang != "" { + return lang + } + + // 3. Check system + if lang := detectFromSystem(); lang != "" { + return lang + } + + // 4. Default + return "en" +} +``` + +Normalizes language codes: `en_US.UTF-8` β†’ `en`, `uk_UA` β†’ `uk` + +## Adding/Editing Languages + +### Adding a New Language + +**1. Create translation file:** + +```bash +cd i18n/locales +cp en.json xx.json # Replace 'xx' with language code (ISO 639-1) +``` + +**2. Update language code:** + +```json +{ + "language": "xx", + "messages": { ... } +} +``` + +**3. Translate all strings:** + +- Keep JSON structure identical to `en.json` +- Translate all message values +- Preserve placeholders: `{count}`, `{latest}`, `{current}` +- Don't translate technical terms: S/MIME, PGP, IMAP, SMTP +- Don't translate commands: `matcha update` + +**4. Handle plural forms:** + +Check plural rules for your language at [CLDR Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules). + +Add plural forms as needed: + +```json +"hours_ago": { + "one": "{count} hour ago", + "other": "{count} hours ago" +} +``` + +For complex plurals (Ukrainian, Arabic, Polish): + +```json +"address_count": { + "one": "{count} адрСса", + "few": "{count} адрСси", + "other": "{count} адрСс" +} +``` + +**5. Register language (if not already in `languages/`):** + +Create `languages/xx.go`: + +```go +package languages + +import "github.com/floatpane/matcha/i18n" + +func init() { + i18n.RegisterLanguage(&i18n.Locale{ + Code: "xx", + Name: "YourLanguage", + NativeName: "YourLanguageInNativeScript", + Direction: "ltr", // or "rtl" for Arabic, Hebrew, etc. + PluralFunc: i18n.YourLanguagePlural, + }) +} +``` + +If plural function doesn't exist in `plural_rules.go`, add it: + +```go +// In plural_rules.go +func YourLanguagePlural(n int) PluralForm { + // Implement CLDR rules for your language + if n == 1 { + return One + } + return Other +} +``` + +**6. Test:** + +```bash +go build +# Edit config: language = "xx" +./matcha +``` + +Verify: +- All UI elements translated +- Plural forms work (test with 0, 1, 2, 5, 21 items) +- Variables interpolate correctly +- No English leaks through + +### Editing Existing Translations + +**1. Open translation file:** + +```bash +vi i18n/locales/uk.json +``` + +**2. Find key using dot notation:** + +Translation keys follow UI structure: +- `composer.*` - Email composer +- `inbox.*` - Inbox view +- `settings_general.*` - General settings +- `common.*` - Shared elements + +**3. Update translation:** + +```json +{ + "composer": { + "title": "Old translation" // Change this + } +} +``` + +**4. Rebuild and test:** + +```bash +go build +./matcha +``` + +Language changes apply instantly (no restart needed). + +### Translation Guidelines + +**Do translate:** +- All visible UI text +- Button labels +- Menu items +- Help text +- Tips +- Error messages shown to user +- Status messages + +**Don't translate:** +- Backend error logs +- Debug messages +- Protocol names (IMAP, SMTP, S/MIME, PGP) +- File paths (`~/.config/matcha/`) +- Environment variables (`$EDITOR`) +- Commands (`matcha update`) +- Technical identifiers + +**Variable placeholders:** + +Always keep variables unchanged: + +```json +// βœ… Correct +"update_available": "ДоступнС оновлСння: {latest} (встановлСно: {current})" + +// ❌ Wrong - renamed variable +"update_available": "ДоступнС оновлСння: {останній} (встановлСно: {ΠΏΠΎΡ‚ΠΎΡ‡Π½ΠΈΠΉ})" +``` + +### Testing Checklist + +- [ ] All screens display in target language +- [ ] Test plural forms with different counts (0, 1, 2, 5, 21) +- [ ] Variables interpolate correctly +- [ ] No untranslated English text (except technical terms) +- [ ] Text fits in UI (not truncated) +- [ ] Special characters render properly +- [ ] RTL languages display correctly (Arabic, Hebrew) +- [ ] Date/time formats work +- [ ] Help text makes sense in context + +### Contributing Translations + +1. Fork repository +2. Add/edit translation file in `i18n/locales/` +3. Test thoroughly with checklist above +4. Submit pull request with: + - Translation file + - Screenshots of translated UI + - Note about plural form testing + - List any technical challenges + +**Translation quality > completeness.** Better to have 80% high-quality translation than 100% machine-translated text. + +--- + +**For more details on using translations in code, see:** [Documentation](https://docs.matcha.floatpane.com/localization).