README.md

  1# i18n - Custom Internationalization Library
  2
  3Custom-built internationalization system for matcha email client. Zero external i18n dependencies - everything implemented from scratch.
  4
  5## Table of Contents
  6
  7- [Architecture Overview](#architecture-overview)
  8- [Directory Structure](#directory-structure)
  9- [Core Components](#core-components)
 10  - [Translation Manager](#translation-manager)
 11  - [Bundle System](#bundle-system)
 12  - [Localizer](#localizer)
 13  - [Plural Rules Engine](#plural-rules-engine)
 14  - [Template System](#template-system)
 15  - [Language Registry](#language-registry)
 16- [Data Flow](#data-flow)
 17- [Translation File Format](#translation-file-format)
 18- [Plural Forms](#plural-forms)
 19- [Caching Strategy](#caching-strategy)
 20- [Language Detection](#language-detection)
 21- [Adding/Editing Languages](#addingediting-languages)
 22
 23## Architecture Overview
 24
 25```
 26β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 27β”‚                      Application Layer                      β”‚
 28β”‚              (TUI components call t(), tn(), tpl())         β”‚
 29β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 30                           β”‚
 31β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 32β”‚                    Global Manager                           β”‚
 33β”‚  β€’ Singleton instance                                       β”‚
 34β”‚  β€’ Current language state                                   β”‚
 35β”‚  β€’ Localizer instances                                      β”‚
 36β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 37                           β”‚
 38         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 39         β”‚                 β”‚                 β”‚
 40β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
 41β”‚     Bundle      β”‚ β”‚  Localizer β”‚ β”‚     Cache      β”‚
 42β”‚  All messages   β”‚ β”‚  Per-lang  β”‚ β”‚  Translations  β”‚
 43β”‚  All locales    β”‚ β”‚  instance  β”‚ β”‚  Rendered text β”‚
 44β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 45         β”‚                β”‚
 46         β”‚         β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 47         β”‚         β”‚   Pluralizer    β”‚
 48         β”‚         β”‚  Plural rules   β”‚
 49         β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 50         β”‚
 51β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 52β”‚                   Translation Files                         β”‚
 53β”‚            locales/en.json, locales/uk.json, ...            β”‚
 54β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 55```
 56
 57## Directory Structure
 58
 59```
 60i18n/
 61β”œβ”€β”€ manager.go           # Global singleton manager
 62β”œβ”€β”€ bundle.go            # Translation bundle storage
 63β”œβ”€β”€ locale.go            # Locale representation
 64β”œβ”€β”€ message.go           # Message structure (with plural forms)
 65β”œβ”€β”€ localizer.go         # Per-language translation engine
 66β”œβ”€β”€ loader.go            # Load & embed translation files
 67β”œβ”€β”€ parser.go            # JSON parser for translation files
 68β”œβ”€β”€ plural_rules.go      # Plural rule functions for all languages
 69β”œβ”€β”€ pluralizer.go        # Plural form selection logic
 70β”œβ”€β”€ template.go          # Simple {var} template engine
 71β”œβ”€β”€ interpolator.go      # Variable interpolation in strings
 72β”œβ”€β”€ cache.go             # In-memory translation cache
 73β”œβ”€β”€ fallback.go          # Fallback chain (uk β†’ en)
 74β”œβ”€β”€ detector.go          # Auto-detect user language
 75β”œβ”€β”€ registry.go          # Available languages registry
 76β”œβ”€β”€ formatter.go         # Number formatting per locale
 77β”œβ”€β”€ date_formatter.go    # Date/time formatting per locale
 78β”œβ”€β”€ context.go           # Context keys for locale passing
 79β”œβ”€β”€ errors.go            # Error types
 80β”œβ”€β”€ init.go              # Package initialization
 81β”œβ”€β”€ embed.go             # Embed translation files in binary
 82β”œβ”€β”€ languages/           # Language-specific implementations
 83β”‚   β”œβ”€β”€ base.go          # Base language interface
 84β”‚   β”œβ”€β”€ en.go            # English locale registration
 85β”‚   β”œβ”€β”€ uk.go            # Ukrainian locale registration
 86β”‚   β”œβ”€β”€ es.go            # Spanish locale registration
 87β”‚   └── ...              # Other languages
 88└── locales/             # Translation JSON files
 89    β”œβ”€β”€ en.json          # English translations (base)
 90    β”œβ”€β”€ uk.json          # Ukrainian translations
 91    └── ...              # Other language files
 92```
 93
 94## Core Components
 95
 96### Translation Manager
 97
 98**File:** `manager.go`
 99
100Global singleton that coordinates all i18n operations. Provides three main translation functions:
101
102- `T(key)` - Simple translation lookup
103- `Tn(key, count, data)` - Translation with pluralization
104- `Tpl(key, data)` - Translation with template variables
105
106```go
107// Usage in TUI components
108import "github.com/floatpane/matcha/i18n"
109
110func render() string {
111    title := i18n.GetManager().T("composer.title")
112    count := i18n.GetManager().Tn("inbox.unread", 5, map[string]interface{}{
113        "count": 5,
114    })
115    return title + " - " + count
116}
117```
118
119Maintains:
120- Current active language
121- Bundle with all loaded translations
122- Localizer instances per language
123- Translation cache
124
125### Bundle System
126
127**File:** `bundle.go`
128
129Central storage for all translation messages across all languages. Thread-safe with RWMutex.
130
131```go
132type Bundle struct {
133    defaultLang string
134    messages    map[string]MessageMap  // lang β†’ message ID β†’ Message
135    locales     map[string]*Locale     // lang β†’ Locale info
136    mu          sync.RWMutex
137}
138```
139
140Responsibilities:
141- Store translations for all languages
142- Retrieve messages by language + key
143- Register locale definitions
144- List available languages
145
146### Localizer
147
148**File:** `localizer.go`
149
150Per-language translation engine. Each active language gets one localizer instance.
151
152```go
153type Localizer struct {
154    lang    string
155    bundle  *Bundle
156    locale  *Locale
157    cache   *Cache
158}
159```
160
161Core methods:
162- `Localize(messageID)` - Basic lookup
163- `LocalizePlural(messageID, count, data)` - With plural rules
164- `LocalizeTemplate(messageID, data)` - With variable substitution
165
166Handles:
167- Message lookup with fallback
168- Plural form selection
169- Template interpolation
170- Result caching
171
172### Plural Rules Engine
173
174**Files:** `plural_rules.go`, `pluralizer.go`
175
176Implements CLDR plural rules for all supported languages.
177
178```go
179type PluralForm int
180
181const (
182    Zero PluralForm = iota  // 0 items
183    One                     // 1 item
184    Two                     // 2 items (Arabic, Welsh)
185    Few                     // 2-4 items (Slavic languages)
186    Many                    // 5+ items (Slavic languages)
187    Other                   // Default/fallback
188)
189```
190
191Each language has specific rules:
192
193**English/Spanish:** Simple (one/other)
194```go
195func EnglishPlural(n int) PluralForm {
196    if n == 1 {
197        return One
198    }
199    return Other
200}
201```
202
203**Ukrainian/Russian:** Complex (one/few/many)
204```go
205func UkrainianPlural(n int) PluralForm {
206    mod10 := n % 10
207    mod100 := n % 100
208    
209    if mod10 == 1 && mod100 != 11 {
210        return One     // 1, 21, 31, 41...
211    }
212    if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
213        return Few     // 2-4, 22-24, 32-34...
214    }
215    return Many        // 0, 5-20, 25-30...
216}
217```
218
219**Arabic:** Very complex (zero/one/two/few/many/other)
220
221### Template System
222
223**Files:** `template.go`, `interpolator.go`
224
225Simple variable substitution using `{variable}` syntax.
226
227```go
228template := "Update available: {latest} (current: {current})"
229data := map[string]interface{}{
230    "latest": "1.5.0",
231    "current": "1.4.0",
232}
233result := Interpolate(template, data)
234// β†’ "Update available: 1.5.0 (current: 1.4.0)"
235```
236
237**Design philosophy:** Keep simple - no complex logic, just variable replacement. Complex formatting done in Go code before passing to templates.
238
239### Language Registry
240
241**File:** `registry.go`
242
243Global registry of all available languages. Languages self-register on init.
244
245```go
246// In languages/uk.go
247func init() {
248    i18n.RegisterLanguage(&i18n.Locale{
249        Code:       "uk",
250        Name:       "Ukrainian",
251        NativeName: "Π£ΠΊΡ€Π°Ρ—Π½ΡΡŒΠΊΠ°",
252        Direction:  "ltr",
253        PluralFunc: i18n.UkrainianPlural,
254    })
255}
256```
257
258Registry provides:
259- `GetLanguage(code)` - Lookup locale by code
260- `AvailableLanguages()` - List all registered locales
261- `LanguageCodes()` - List all language codes
262
263## Data Flow
264
265### Translation Lookup Flow
266
267```
2681. TUI calls t("composer.title")
269              ↓
2702. Manager.T() β†’ GetLocalizer(currentLang)
271              ↓
2723. Localizer checks cache
273   β€’ Cache hit? β†’ return cached value
274   β€’ Cache miss? β†’ continue
275              ↓
2764. Bundle.GetMessage(lang, "composer.title")
277   β€’ Found? β†’ return Message
278   β€’ Not found? β†’ try fallback chain (uk β†’ en)
279              ↓
2805. Return message.One (singular form)
281              ↓
2826. Cache result for future lookups
283              ↓
2847. Return translated string to TUI
285```
286
287### Pluralization Flow
288
289```
2901. TUI calls tn("inbox.unread", 5, {"count": 5})
291              ↓
2922. Manager.Tn() β†’ GetLocalizer(currentLang)
293              ↓
2943. Get Message from bundle
295              ↓
2964. Call locale.PluralFunc(5)
297   β€’ English: 5 β†’ Other
298   β€’ Ukrainian: 5 β†’ Many
299   β€’ Arabic: 5 β†’ Few
300              ↓
3015. Select appropriate form from Message:
302   β€’ message.One (n=1)
303   β€’ message.Few (n=2-4 in Ukrainian)
304   β€’ message.Many (n=5+ in Ukrainian)
305   β€’ message.Other (fallback)
306              ↓
3076. Interpolate {count} β†’ "5"
308              ↓
3097. Return "5 листів" (Ukrainian) or "5 emails" (English)
310```
311
312## Translation File Format
313
314**Location:** `locales/*.json`
315
316**Structure:**
317
318```json
319{
320  "language": "uk",
321  "messages": {
322    "common": {
323      "yes": "Π’Π°ΠΊ",
324      "no": "Ні",
325      "save": "Π—Π±Π΅Ρ€Π΅Π³Ρ‚ΠΈ"
326    },
327    "composer": {
328      "title": "Написати Π½ΠΎΠ²ΠΈΠΉ лист",
329      "send": "Надіслати",
330      "from": "Π’Ρ–Π΄"
331    },
332    "inbox": {
333      "unread": {
334        "one": "{count} Π½Π΅ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½ΠΈΠΉ лист",
335        "few": "{count} Π½Π΅ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½Ρ– листи",
336        "other": "{count} Π½Π΅ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½ΠΈΡ… листів"
337      }
338    }
339  }
340}
341```
342
343**Nested structure:** Dot notation for keys (`composer.title`, `inbox.unread`)
344
345**Plural objects:** When value is object with `one`/`few`/`many`/`other`, it's treated as plural form.
346
347## Plural Forms
348
349Languages use different plural categories:
350
351| Language | Forms Used | Example (emails) |
352|----------|-----------|------------------|
353| English | one, other | 1 email, 2 emails |
354| Spanish | one, other | 1 correo, 2 correos |
355| German | one, other | 1 E-Mail, 2 E-Mails |
356| French | one, other | 0 courriel, 1 courriel, 2 courriels |
357| Ukrainian | one, few, other | 1 лист, 2 листи, 5 листів |
358| Russian | one, few, other | 1 письмо, 2 письма, 5 писСм |
359| Polish | one, few, many, other | 1 e-mail, 2 e-maile, 5 e-maili |
360| Arabic | zero, one, two, few, many, other | Complex rules |
361| Japanese | other | All numbers use same form |
362| Chinese | other | All numbers use same form |
363
364## Caching Strategy
365
366**File:** `cache.go`
367
368Simple in-memory string cache with RWMutex for thread safety.
369
370**Cache key format:** `{lang}:{messageID}:{count}:{hash(data)}`
371
372**Why cache?**
373- Translation lookup involves: map lookups, plural calculation, interpolation
374- Typical UI has ~100 translated strings
375- Redrawing UI on every keypress = wasted CPU
376- Cache hit = instant string return
377
378**Cache invalidation:** Cache cleared when language changes via `SetLanguage()`.
379
380**No size limit:** Translation cache is small (~KB for entire UI) and only grows to unique message combinations actually used.
381
382## Language Detection
383
384**File:** `detector.go`
385
386Detection priority:
387
3881. **Config file:** `language = "uk"` in `~/.config/matcha/config.toml`
3892. **Environment:** `LANG`, `LC_ALL`, `LC_MESSAGES`
3903. **System locale:** OS-specific detection
3914. **Default:** Falls back to `"en"`
392
393```go
394func DetectLanguage(cfg *config.Config) string {
395    // 1. Check config
396    if cfg.Language != "" && isValidLanguage(cfg.Language) {
397        return cfg.Language
398    }
399    
400    // 2. Check environment
401    if lang := detectFromEnv(); lang != "" {
402        return lang
403    }
404    
405    // 3. Check system
406    if lang := detectFromSystem(); lang != "" {
407        return lang
408    }
409    
410    // 4. Default
411    return "en"
412}
413```
414
415Normalizes language codes: `en_US.UTF-8` β†’ `en`, `uk_UA` β†’ `uk`
416
417## Adding/Editing Languages
418
419### Adding a New Language
420
421**1. Create translation file:**
422
423```bash
424cd i18n/locales
425cp en.json xx.json  # Replace 'xx' with language code (ISO 639-1)
426```
427
428**2. Update language code:**
429
430```json
431{
432  "language": "xx",
433  "messages": { ... }
434}
435```
436
437**3. Translate all strings:**
438
439- Keep JSON structure identical to `en.json`
440- Translate all message values
441- Preserve placeholders: `{count}`, `{latest}`, `{current}`
442- Don't translate technical terms: S/MIME, PGP, IMAP, SMTP
443- Don't translate commands: `matcha update`
444
445**4. Handle plural forms:**
446
447Check plural rules for your language at [CLDR Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules).
448
449Add plural forms as needed:
450
451```json
452"hours_ago": {
453  "one": "{count} hour ago",
454  "other": "{count} hours ago"
455}
456```
457
458For complex plurals (Ukrainian, Arabic, Polish):
459
460```json
461"address_count": {
462  "one": "{count} адрСса",
463  "few": "{count} адрСси",
464  "other": "{count} адрСс"
465}
466```
467
468**5. Register language (if not already in `languages/`):**
469
470Create `languages/xx.go`:
471
472```go
473package languages
474
475import "github.com/floatpane/matcha/i18n"
476
477func init() {
478    i18n.RegisterLanguage(&i18n.Locale{
479        Code:       "xx",
480        Name:       "YourLanguage",
481        NativeName: "YourLanguageInNativeScript",
482        Direction:  "ltr",  // or "rtl" for Arabic, Hebrew, etc.
483        PluralFunc: i18n.YourLanguagePlural,
484    })
485}
486```
487
488If plural function doesn't exist in `plural_rules.go`, add it:
489
490```go
491// In plural_rules.go
492func YourLanguagePlural(n int) PluralForm {
493    // Implement CLDR rules for your language
494    if n == 1 {
495        return One
496    }
497    return Other
498}
499```
500
501**6. Test:**
502
503```bash
504go build
505# Edit config: language = "xx"
506./matcha
507```
508
509Verify:
510- All UI elements translated
511- Plural forms work (test with 0, 1, 2, 5, 21 items)
512- Variables interpolate correctly
513- No English leaks through
514
515### Editing Existing Translations
516
517**1. Open translation file:**
518
519```bash
520vi i18n/locales/uk.json
521```
522
523**2. Find key using dot notation:**
524
525Translation keys follow UI structure:
526- `composer.*` - Email composer
527- `inbox.*` - Inbox view  
528- `settings_general.*` - General settings
529- `common.*` - Shared elements
530
531**3. Update translation:**
532
533```json
534{
535  "composer": {
536    "title": "Old translation"  // Change this
537  }
538}
539```
540
541**4. Rebuild and test:**
542
543```bash
544go build
545./matcha
546```
547
548Language changes apply instantly (no restart needed).
549
550### Translation Guidelines
551
552**Do translate:**
553- All visible UI text
554- Button labels
555- Menu items
556- Help text
557- Tips
558- Error messages shown to user
559- Status messages
560
561**Don't translate:**
562- Backend error logs
563- Debug messages
564- Protocol names (IMAP, SMTP, S/MIME, PGP)
565- File paths (`~/.config/matcha/`)
566- Environment variables (`$EDITOR`)
567- Commands (`matcha update`)
568- Technical identifiers
569
570**Variable placeholders:**
571
572Always keep variables unchanged:
573
574```json
575// βœ… Correct
576"update_available": "ДоступнС оновлСння: {latest} (встановлСно: {current})"
577
578// ❌ Wrong - renamed variable
579"update_available": "ДоступнС оновлСння: {останній} (встановлСно: {ΠΏΠΎΡ‚ΠΎΡ‡Π½ΠΈΠΉ})"
580```
581
582### Testing Checklist
583
584- [ ] All screens display in target language
585- [ ] Test plural forms with different counts (0, 1, 2, 5, 21)
586- [ ] Variables interpolate correctly
587- [ ] No untranslated English text (except technical terms)
588- [ ] Text fits in UI (not truncated)
589- [ ] Special characters render properly
590- [ ] RTL languages display correctly (Arabic, Hebrew)
591- [ ] Date/time formats work
592- [ ] Help text makes sense in context
593
594### Contributing Translations
595
5961. Fork repository
5972. Add/edit translation file in `i18n/locales/`
5983. Test thoroughly with checklist above
5994. Submit pull request with:
600   - Translation file
601   - Screenshots of translated UI
602   - Note about plural form testing
603   - List any technical challenges
604
605**Translation quality > completeness.** Better to have 80% high-quality translation than 100% machine-translated text.
606
607---
608
609**For more details on using translations in code, see:** [Documentation](https://docs.matcha.email/localization).