towerops/docs/plans/2026-02-02-gettext-internationalization-design.md
Graham McIntire 6c8e670dae
docs: add gettext internationalization design
- Add comprehensive i18n design document with domain-based organization
  (default, errors, auth, equipment, admin, emails domains)
- Document helper functions, implementation patterns, and migration workflow
- Fix multiple test suite issues:
  * user_settings_live_test.exs: Update all tests to match current UI structure
    - Fix user.name references (use first_name/last_name)
    - Fix TOTP device creation (use create_totp_device/2)
    - Update tab URLs and form IDs
    - Update tab names (Account, API, Notifications)
    - Remove obsolete tabs (Organizations, Activity)
    - Fix password tests (pattern match redirect without flash token)
    - Update sudo mode behavior
  * account_data_controller_test.exs: Fix user profile assertions
  * rate_limit_test.exs: Fix unused variable warning
  * settings_live_test.exs: Fix organization form validation test
2026-02-02 09:33:01 -06:00

10 KiB

Gettext Internationalization Design

Date: 2026-02-02 Status: Approved Scope: Comprehensive i18n for entire Towerops web application Initial Language: English only (prepared for future multilingual support)

Overview

Move all user-facing text in Towerops to Gettext for internationalization. This enables future language support while maintaining English as the primary language. The implementation uses domain-based organization for better maintainability as the application grows.

Goals

  1. Extract all user-facing text to Gettext translation files
  2. Organize translations by feature domain for maintainability
  3. Maintain English-only translations initially
  4. Prepare infrastructure for future language additions
  5. Ensure no functional regressions during migration

Architecture

Gettext Domain Structure

Organize translations into 6 domains:

Domain Purpose Examples
default Common UI elements, navigation, generic actions "Dashboard", "Save", "Cancel", "Loading..."
errors Validation errors, system errors, API errors "can't be blank", "Not found", "Unauthorized"
auth Authentication flows, user management "Log in", "Sign up", "Reset password", "TOTP"
equipment Equipment/device management, SNMP, monitoring "Add device", "SNMP settings", "Last polled", "Alerts"
admin Admin-specific features "Impersonate user", "GeoIP import", "Audit logs"
emails Email templates and subjects "Reset password instructions", email bodies

Rationale:

  • Smaller .po files are easier to review and maintain
  • Clear ownership - developers know which domain to use
  • Enables assigning different translators to different areas
  • Standard pattern in mature i18n applications

Helper Module Pattern

Create domain-specific helper functions to reduce verbosity:

# lib/towerops_web/gettext_helpers.ex
defmodule ToweropsWeb.GettextHelpers do
  import ToweropsWeb.Gettext

  def t(msgid), do: dgettext("default", msgid)
  def t_auth(msgid), do: dgettext("auth", msgid)
  def t_equipment(msgid), do: dgettext("equipment", msgid)
  def t_admin(msgid), do: dgettext("admin", msgid)
  def t_email(msgid), do: dgettext("emails", msgid)
  # Errors use existing gettext/1
end

Benefits:

  • Shorter syntax: t("Save") vs dgettext("default", "Save")
  • Clear domain intent: t_auth("Log in")
  • Easy refactoring if domain structure changes

Import Strategy

Web Layer (LiveViews, Components, Controllers):

  • Add GettextHelpers to html_helpers/0 in ToweropsWeb
  • All LiveViews and components get helpers automatically via use ToweropsWeb, :live_view

Non-Web Layer (Emails, Background Jobs):

  • Explicit import: import ToweropsWeb.GettextHelpers
  • Use in notifier modules, workers, contexts

Implementation Patterns

Template Patterns

Basic string replacement:

<!-- Before -->
<h1>Equipment List</h1>
<.button>Add Device</.button>

<!-- After -->
<h1>{t_equipment("Equipment List")}</h1>
<.button>{t_equipment("Add Device")}</.button>

String interpolation:

<p>{t_equipment("Last polled: %{time}", time: format_timestamp(@last_polled))}</p>
<p>{t_equipment("Found %{count} devices", count: @device_count)}</p>

Pluralization:

ngettext("1 device", "%{count} devices", device_count)
dngettext("equipment", "1 alert", "%{count} alerts", alert_count)

Flash Messages

# Before
put_flash(socket, :info, "Device created successfully")

# After
put_flash(socket, :info, t_equipment("Device created successfully"))

Email Templates

# Before
subject = "Reset password instructions"
body = """
Hi #{user.email},

You can reset your password by visiting the URL below:
#{url}
"""

# After
subject = t_email("Reset password instructions")
body = t_email("""
Hi %{email},

You can reset your password by visiting the URL below:
%{url}

If you didn't request this change, please ignore this.
""", email: user.email, url: url)

Validation Messages

Ecto changeset validations already use Gettext via the errors domain automatically. Ensure custom error messages use dgettext("errors", ...).

What NOT to Translate

  • Log messages (internal, for developers)
  • API response keys (machine-readable)
  • Database column names
  • Configuration keys
  • Code comments
  • Developer-facing error details (stack traces, debug info)

Migration Workflow

Phase 1: Setup (Infrastructure)

  1. Create domain-specific .pot template files:

    • priv/gettext/auth.pot
    • priv/gettext/equipment.pot
    • priv/gettext/admin.pot
    • priv/gettext/emails.pot
    • Keep existing default.pot and errors.pot
  2. Create corresponding English .po files:

    • priv/gettext/en/LC_MESSAGES/auth.po
    • priv/gettext/en/LC_MESSAGES/equipment.po
    • priv/gettext/en/LC_MESSAGES/admin.po
    • priv/gettext/en/LC_MESSAGES/emails.po
  3. Create lib/towerops_web/gettext_helpers.ex

  4. Update lib/towerops_web.ex to import helpers in html_helpers/0

  5. Create Mix task to auto-populate English translations (msgstr = msgid)

Phase 2: Module-by-Module Migration

Order of migration (smallest to largest):

  1. Emails (lib/towerops/accounts/user_notifier.ex)

    • Self-contained, minimal dependencies
    • Domain: emails
  2. Auth Flows

    • lib/towerops_web/controllers/user_*_html.ex
    • lib/towerops_web/live/user_settings_live/
    • Domain: auth
  3. Core Components (lib/towerops_web/components/)

    • core_components.ex - Generic labels
    • layouts.ex - Navigation, user menu
    • Domain: default
  4. Equipment Features

    • lib/towerops_web/live/device_live/
    • lib/towerops_web/live/equipment_live/
    • lib/towerops_web/live/alert_live/
    • Domain: equipment
  5. Admin Features

    • lib/towerops_web/live/admin/
    • Domain: admin
  6. Error Messages

    • Review all put_flash(:error, ...) calls
    • Ecto validation messages
    • Domain: errors

Per-module process:

  1. Update module to use gettext calls
  2. Run mix gettext.extract --merge
  3. Run mix populate_english to fill English translations
  4. Compile and verify no errors
  5. Manual QA - check pages render correctly
  6. Run mix test to verify no regressions
  7. Commit changes for that module

Phase 3: Verification

  1. Run mix gettext.extract --check-up-to-date (fails if untranslated strings)
  2. Add to CI pipeline to catch missing translations
  3. Visual QA pass on all major pages
  4. Test all flash messages, error states, email previews

Gettext Commands Reference

# Extract new strings from code
mix gettext.extract

# Merge extracted strings into existing .po files (preserves translations)
mix gettext.merge priv/gettext

# Extract and merge in one step
mix gettext.extract --merge

# Check for unused translations (fail if out of date)
mix gettext.extract --check-up-to-date

Testing Strategy

  1. Unit Tests: Continue to work unchanged (test behavior, not strings)
  2. Integration Tests: May need updates if asserting on specific text
  3. CI Check: Add mix gettext.extract --check-up-to-date to CI pipeline
  4. Manual QA: Visual inspection of key pages after migration
  5. Email Preview: Use Swoosh mailbox (/dev/mailbox) to verify email translations

Future Enhancements

Adding New Languages

When ready to add a new language (e.g., Spanish):

  1. Create priv/gettext/es/LC_MESSAGES/*.po files
  2. Copy structure from English .po files
  3. Replace msgstr values with Spanish translations
  4. Recompile application
  5. Set user locale via session/cookie

Locale Selection

Add user preference for language:

  1. Add locale field to users table (default: "en")
  2. Set Gettext.put_locale/1 in authentication pipeline
  3. Add language selector in user settings
  4. Persist choice in session/database

Dynamic Content

For database-stored content that needs translation:

  1. Consider using a translation table pattern
  2. Or use JSONB columns with locale keys
  3. Libraries like trans can help with database translations

File Structure

priv/gettext/
├── default.pot         # Common UI template
├── errors.pot          # Error messages template
├── auth.pot            # Auth flows template
├── equipment.pot       # Equipment features template
├── admin.pot           # Admin features template
├── emails.pot          # Email templates template
└── en/
    └── LC_MESSAGES/
        ├── default.po      # English common UI
        ├── errors.po       # English errors
        ├── auth.po         # English auth
        ├── equipment.po    # English equipment
        ├── admin.po        # English admin
        └── emails.po       # English emails

lib/
├── towerops_web/
│   ├── gettext.ex              # Existing Gettext backend
│   ├── gettext_helpers.ex      # NEW: Domain helper functions
│   └── ...
└── mix/
    └── tasks/
        └── populate_english.exs  # NEW: Auto-fill English translations

Risks & Mitigations

Risk Mitigation
Forgetting to translate new strings Add mix gettext.extract --check-up-to-date to CI
Breaking existing tests Run test suite after each module migration
Inconsistent domain choices Document examples in CLAUDE.md after implementation
Performance impact Gettext compiles translations at build time (no runtime cost)
Large PR review burden Migrate module-by-module with separate commits

Success Criteria

  • All user-facing text extracted to Gettext
  • All English .po files populated (msgstr = msgid)
  • All tests passing
  • CI check for untranslated strings
  • No functional regressions
  • Documentation updated in CLAUDE.md
  • Infrastructure ready for adding new languages

Estimated Scope

  • 34 LiveView modules + 22 HEEx templates
  • ~8 controller HTML modules
  • 1 email notifier module (5 email templates)
  • Dozens of flash messages across modules
  • Core components with embedded strings

Implementation Time:

  • Setup phase: ~2 hours
  • Migration work: ~1-2 days (incremental, module-by-module)
  • Testing & QA: ~4 hours

References