gettext_ops

Targeted operations for Phoenix Gettext translations. Quick edits, bulk updates, and search for .po files.

gettext_ops provides Mix tasks for searching, listing, and updating Phoenix Gettext translation files without loading entire files into memory. Built on Expo for reliable .po file parsing.

Why gettext_ops?

Working with large .po files (1000+ lines) is painful for both humans and AI coding agents:

gettext_ops solves this with targeted operations:

# List untranslated entries (no file reading needed!)
mix gettext_ops.list_untranslated --locale sv --json
# Search for specific translations
mix gettext_ops.search "Welcome" --locale sv
# Bulk update translations from a file
mix gettext_ops.translate translations.txt --locale sv
# Update all .po files at once and it's source text everywhere it appears
mix gettext_ops.change_msgid "Sign In" "Log In"

Features

Installation

Add gettext_ops to your mix.exs dependencies:

def deps do
[
{:gettext_ops, "~> 0.1.0"}
]
end

Then run:

mix deps.get

Quick Start

# List all untranslated entries for Swedish
mix gettext_ops.list_untranslated --locale sv
# Get JSON output (perfect for piping to other tools or LLMs)
mix gettext_ops.list_untranslated --locale sv --json
# Search for entries containing "Welcome"
mix gettext_ops.search "Welcome" --locale sv
# Apply translations (format: msgid = msgstr)
mix gettext_ops.translate --locale sv <<EOF
Sign In = Logga in
Sign Out = Logga ut
Welcome = Välkommen
EOF
# Change msgid "Sign In" to "Log In" everywhere (all .po files, .pot templates, and source code)
mix gettext_ops.change_msgid "Sign In" "Log In"

Usage Examples

Working with Untranslated Strings

# Show untranslated Swedish strings
mix gettext_ops.list_untranslated --locale sv --json --limit 10
# Check how many translations are missing
mix gettext_ops.list_untranslated --locale sv | wc -l
# Get entries ready for translation
mix gettext_ops.list_untranslated --locale sv --json --limit 20 > to_translate.json

Token savings for AI agents: Instead of reading a 5000-line .po file (consuming ~15k tokens), get exactly the 10 entries needed (~500 tokens).

Searching Translations

# Find all login-related source strings (searches msgid)
mix gettext_ops.search "login" --locale sv --json
# Find error messages in source strings
mix gettext_ops.search "error" --locale en --json | jq .
# Search in translations (searches msgstr)
mix gettext_ops.search_value "Välkommen" --locale sv

Bulk Translation Updates

# Apply translations from a file
mix gettext_ops.translate --locale sv translations.txt
# Apply translations from stdin
mix gettext_ops.translate --locale sv <<EOF
Sign In = Logga in
Sign Out = Logga ut
Welcome = Välkommen
EOF
# Batch translate with LLM assistance
mix gettext_ops.list_untranslated --locale sv --json --limit 20 | \
llm "translate to Swedish" | \
mix gettext_ops.translate --locale sv

Changing Source Text

# Update msgid everywhere (all .po files, .pot templates, and source code references)
mix gettext_ops.change_msgid "Sign In" "Log In"
# Preview changes first
mix gettext_ops.change_msgid --dry-run "Sign In" "Log In"

Development Workflow

Typical Translation Process

# 1. Extract new strings from code
mix gettext.extract --merge
# 2. See what needs translation
mix gettext_ops.list_untranslated --locale sv
# 3. Translate (manually, with LLM, or with translation service)
mix gettext_ops.list_untranslated --locale sv --json --limit 10 | \
your_translation_tool | \
mix gettext_ops.translate --locale sv
# 4. Verify
mix gettext_ops.list_untranslated --locale sv

Updating Copy

# 1. Find current usage
mix gettext_ops.search "Old Text" --json
# 2. Update msgid everywhere (keeps existing translations)
mix gettext_ops.change_msgid "Old Text" "New Text"
# 3. Re-extract from source code (updates references)
mix gettext.extract --merge

Commands

mix gettext_ops.list_untranslated

List all entries with empty translations (missing msgstr values). Use this to see what still needs translation.

Options:

Examples:

# Plain text output
mix gettext_ops.list_untranslated --locale sv
# JSON output (one entry per line)
mix gettext_ops.list_untranslated --locale sv --json
# First 10 untranslated entries
mix gettext_ops.list_untranslated --locale sv --limit 10

Output format (plain text):

msgid "Sign In"
msgstr ""
msgid "Sign Out"
msgstr ""

Output format (JSON):

{"msgid":"Sign In","msgstr":"","references":["lib/my_app_web/controllers/auth_controller.ex:12"]}
{"msgid":"Sign Out","msgstr":"","references":["lib/my_app_web/controllers/auth_controller.ex:18"]}

mix gettext_ops.search

Search for entries where msgid (source text) matches a pattern. Use this to find entries by their English/source strings.

Options:

Examples:

# Find entries containing "Welcome"
mix gettext_ops.search "Welcome" --locale sv
# Regex search (entries starting with "Error")
mix gettext_ops.search "^Error" --locale sv --regex
# JSON output
mix gettext_ops.search "button" --locale sv --json

mix gettext_ops.search_value

Search for entries where msgstr (translated text) matches a pattern. Use this to find entries by their translated strings.

Options: Same as search

Examples:

# Find Swedish translations containing "Välkommen"
mix gettext_ops.search_value "Välkommen" --locale sv
# Find all translations with "fel" (Swedish for error/wrong)
mix gettext_ops.search_value "fel" --locale sv --json

mix gettext_ops.translate

Apply translations from a text file or stdin to .po files. This updates msgstr (translation values) for given msgids.

Input format:

msgid text = msgstr translation
msgid text = msgstr translation

Options:

Entries with a msgctxt

Gettext identifies an entry by the pair {msgctxt, msgid}, so a catalogue can hold several entries with the same msgid but different contexts:

msgid "Active"
msgstr "Aktiv"
msgctxt "token status"
msgid "Active"
msgstr "Giltig"

The input format carries only a msgid, so translate resolves it as follows:

To target a context-carrying entry directly, use the programmatic API with a {msgctxt, msgid} key:

GettextOps.Writer.update_translations(path, %{{"token status", "Active"} => "Giltig"})

Examples:

# From file
mix gettext_ops.translate --locale sv translations.txt
# From stdin (heredoc)
mix gettext_ops.translate --locale sv <<EOF
Sign In = Logga in
Sign Out = Logga ut
EOF
# From pipe
echo "Welcome = Välkommen" | mix gettext_ops.translate --locale sv
# With file flag (explicit)
mix gettext_ops.translate --locale sv --file translations.txt

Input file format (translations.txt):

Sign In = Logga in
Sign Out = Logga ut
Welcome = Välkommen
Error: Invalid input = Fel: Ogiltig inmatning

mix gettext_ops.change_msgid

Update a msgid (source text) across all locale files, .pot templates, and source code references. This changes the source text everywhere while preserving existing translations.

Arguments:

Options:

Examples:

# Update msgid everywhere
mix gettext_ops.change_msgid "Sign In" "Log In"
# Preview changes first
mix gettext_ops.change_msgid --dry-run "Sign In" "Log In"

What it does:

  1. Finds all .po files in priv/gettext/*/LC_MESSAGES/
  2. Finds .pot template files
  3. Updates the msgid in all matching entries across all locales, including context-carrying ones — a typo in the source text is a typo in each of its contexts
  4. Preserves all translations (msgstr values remain intact)
  5. Updates source code references if applicable
  6. Shows summary of changes

Example output:

priv/gettext/sv/LC_MESSAGES/default.po (1 entry)
priv/gettext/en/LC_MESSAGES/default.po (1 entry)
priv/gettext/default.pot (1 entry)
Updated 3 file(s) with 3 total entries

Configuration

By default, gettext_ops looks for translations in:

priv/gettext/{locale}/LC_MESSAGES/{domain}.po

This matches Phoenix's default Gettext structure.

Custom Configuration

If your project uses a different structure, configure in config/config.exs:

config :gettext_ops,
gettext_path: "translations", # Custom base path
default_domain: "messages" # Custom default domain

AI Agent Configuration

Add this prompt to your CLAUDE.md, .github/agents.md, or AI agent configuration to help agents work efficiently with translations:

# Working with Gettext Translations
This project uses **gettext_ops** for managing translations. NEVER read `.po` files directly - they are large (1000+ lines) and waste tokens.
## Available Commands
### List untranslated entries
```bash
# See what needs translation
mix gettext_ops.list_untranslated --locale LOCALE --json --limit 10

Search translations

# Search by msgid (source text)
mix gettext_ops.search "pattern" --locale LOCALE --json
# Search by msgstr (translated text)
mix gettext_ops.search_value "pattern" --locale LOCALE --json

Apply translations

# Update translations (format: msgid = msgstr)
mix gettext_ops.translate --locale LOCALE <<EOF
English text = Translated text
Another string = Another translation
EOF

Change source text globally

# Updates msgid in all .po files, .pot templates, and source code
mix gettext_ops.change_msgid "Old Text" "New Text"

Workflow

  1. Finding work: Use list_untranslated to see what needs translation
  2. Searching: Use search (msgid) or search_value (msgstr) to find specific entries
  3. Translating: Get strings with list_untranslated --json, translate them, then apply with translate
  4. Changing copy: Use change_msgid to update source text everywhere

Key Points

## LLM Integration Workflow
### Example: Translate with Claude/GPT
```bash
# 1. Get untranslated entries as JSON
mix gettext_ops.list_untranslated --locale sv --json --limit 20 > to_translate.json
# 2. Send to LLM (via API or copy-paste)
cat to_translate.json | llm "Translate these English strings to Swedish. \
Output format: 'English = Swedish' (one per line)"
# 3. Save LLM output to file
# (LLM outputs: Sign In = Logga in, etc.)
# 4. Apply translations
mix gettext_ops.translate --locale sv translations.txt

Example: Change copy with AI assistance

# Find the current text
mix gettext_ops.search "Sign In" --json
# Ask LLM for better alternative
# LLM suggests: "Log In" is more standard
# Update msgid everywhere (all .po files, .pot templates, and source code)
mix gettext_ops.change_msgid "Sign In" "Log In"

How It Works

Built on Expo

gettext_ops uses the Expo library for .po file parsing and writing. Expo is the same library used by Phoenix's Gettext module, ensuring compatibility and reliability.

Streaming Operations

Commands like list_untranslated and search stream through .po files entry-by-entry, extracting only matching entries. This means:

File Updates

Commands like translate and change_msgid:

  1. Parse the original .po file using Expo
  2. Update matching entries
  3. Write back using Expo's composer
  4. Preserve all formatting, comments, and metadata

Comparison with Existing Tools

ToolPurposeRelation to gettext_ops
mix gettext.extractExtract translatable strings from source codeComplementary - run before using gettext_ops
mix gettext.mergeMerge extracted strings into .po filesComplementary - creates files that gettext_ops works with
gettext_llmBulk translate entire .po files via LLM APIsDifferent - automated translation vs. targeted operations
gettext_checkCheck for missing translationsSimilar goal, but gettext_ops provides actionable output
ExpoLow-level .po parser/writer libraryFoundation - gettext_ops builds on Expo

gettext_ops fills a gap: It provides targeted, scriptable operations for working with individual translation entries, designed for both human and AI workflows.

Troubleshooting

"No .po file found"

Check that your locale directory exists:

ls priv/gettext/sv/LC_MESSAGES/default.po

Run mix gettext.extract --merge to create initial files.

"msgid not found" when translating

The msgid in your translation file must exactly match the msgid in the .po file. Use --force to see warnings:

mix gettext_ops.translate --locale sv --force translations.txt

Check for:

"ambiguous msgid" when translating

The msgid exists in the .po file only under two or more different msgctxt values, so a bare msgid cannot say which entry you mean. The command lists the contexts it found. Use the programmatic API with a {msgctxt, msgid} key to target one, or --force to skip it.

"refusing to write: would produce duplicate entries"

The operation would have produced a .po file holding two entries with the same {msgctxt, msgid} pair — a file gettext and Expo.PO.parse_file!/1 cannot read. The file is left untouched. This usually means a change_msgid rename collided with a msgid the file already uses.

JSON output is malformed

Each command outputs line-delimited JSON (one JSON object per line):

# ✅ Correct - one object per line
{"msgid":"A","msgstr":""}
{"msgid":"B","msgstr":""}
# ❌ Incorrect - not a JSON array
[{"msgid":"A"},{"msgid":"B"}]

This format is designed for streaming and piping. To parse as JSON array:

mix gettext_ops.list_untranslated --locale sv --json | jq -s '.'

Roadmap

Future features under consideration:

Contributing

Contributions welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Write tests for new functionality
  4. Submit a pull request

License

MIT License - see LICENSE file for details.


gettext_ops - Targeted Mix tasks for Phoenix Gettext translations