feat: implement gettext infrastructure (Phase 1)

- Create domain-specific .pot template files (auth, equipment, admin, emails)
- Create corresponding English .po files for all domains
- Add GettextHelpers module with domain-specific helper functions:
  * t() for default domain (common UI)
  * t_auth() for authentication flows
  * t_equipment() for equipment/device management
  * t_admin() for admin features
  * t_email() for email templates
- Import GettextHelpers in html_helpers() for automatic availability in LiveViews/components
- Add mix populate_english task to auto-fill English translations (msgstr = msgid)
- Task properly handles plural forms and multi-line strings

Phase 1 complete - infrastructure ready for module-by-module migration.
This commit is contained in:
Graham McIntire 2026-02-02 09:40:32 -06:00
parent 6c8e670dae
commit cfb60cd186
No known key found for this signature in database
11 changed files with 495 additions and 2 deletions

View file

@ -0,0 +1,226 @@
defmodule Mix.Tasks.PopulateEnglish do
@shortdoc "Populates English .po files with msgstr = msgid"
@moduledoc """
Populates English translation files by setting msgstr = msgid.
For English-only applications using Gettext, this task automatically
fills in translation strings so that the original English text is used.
## Usage
mix populate_english
This task will:
1. Find all .po files in priv/gettext/en/LC_MESSAGES/
2. For each msgid without a msgstr, set msgstr to equal msgid
3. Preserve existing non-empty msgstr values
Run this after extracting translations with `mix gettext.extract --merge`.
"""
use Mix.Task
@impl Mix.Task
def run(_args) do
po_dir = Path.join(["priv", "gettext", "en", "LC_MESSAGES"])
if !File.dir?(po_dir) do
Mix.raise("Directory not found: #{po_dir}")
end
po_files = Path.wildcard(Path.join(po_dir, "*.po"))
if Enum.empty?(po_files) do
Mix.shell().info("No .po files found in #{po_dir}")
:ok
else
Enum.each(po_files, &process_po_file/1)
Mix.shell().info("✓ Populated #{length(po_files)} English translation file(s)")
end
end
defp process_po_file(file_path) do
content = File.read!(file_path)
updated_content = populate_translations(content)
if content == updated_content do
Mix.shell().info(" No changes: #{Path.basename(file_path)}")
else
File.write!(file_path, updated_content)
Mix.shell().info(" Updated: #{Path.basename(file_path)}")
end
end
defp populate_translations(content) do
# Match msgid followed by msgstr
# Handle both single-line and multi-line strings
content
|> String.split("\n")
|> process_lines([])
|> Enum.reverse()
|> Enum.join("\n")
end
defp process_lines([], acc), do: acc
defp process_lines([line | rest], acc) do
# Found msgid line
if String.starts_with?(line, "msgid ") do
{msgid_lines, remaining} = collect_string_lines([line | rest], [])
# Check if this is a plural form (msgid_plural follows)
if is_plural_form?(remaining) do
# Skip plural forms - they need special handling
{plural_lines, remaining2} = skip_plural_translation(remaining, [])
new_acc = Enum.reverse(plural_lines, Enum.reverse(msgid_lines, acc))
process_lines(remaining2, new_acc)
else
{msgstr_lines, remaining2} = collect_msgstr_lines(remaining, [])
msgid_value = extract_string_value(msgid_lines)
# If msgstr is empty and msgid is not empty, populate it
updated_msgstr =
if should_populate?(msgid_value, msgstr_lines) do
populate_msgstr(msgid_lines, msgid_value)
else
msgstr_lines
end
new_acc = Enum.reverse(updated_msgstr, Enum.reverse(msgid_lines, acc))
process_lines(remaining2, new_acc)
end
else
# Regular line (comments, metadata, blank lines)
process_lines(rest, [line | acc])
end
end
# Collect lines that are part of a msgid string (including continuation lines)
defp collect_string_lines([], acc), do: {Enum.reverse(acc), []}
defp collect_string_lines([line | rest] = lines, acc) do
cond do
String.starts_with?(line, "msgid ") ->
collect_string_lines(rest, [line | acc])
String.starts_with?(String.trim_leading(line), "\"") and acc != [] ->
# Continuation line
collect_string_lines(rest, [line | acc])
true ->
{Enum.reverse(acc), lines}
end
end
# Collect msgstr lines
defp collect_msgstr_lines([], acc), do: {Enum.reverse(acc), []}
defp collect_msgstr_lines([line | rest] = lines, acc) do
cond do
String.starts_with?(line, "msgstr ") ->
collect_msgstr_lines(rest, [line | acc])
String.starts_with?(String.trim_leading(line), "\"") and acc != [] ->
# Continuation line
collect_msgstr_lines(rest, [line | acc])
true ->
{Enum.reverse(acc), lines}
end
end
# Extract the actual string value from msgid/msgstr lines
defp extract_string_value(lines) do
Enum.map_join(lines, &extract_line_value/1)
end
defp extract_line_value(line) do
# Remove msgid/msgstr prefix and extract quoted content
line
|> String.replace(~r/^msgid\s+/, "")
|> String.replace(~r/^msgstr\s+/, "")
|> String.trim()
|> extract_quoted_string()
end
defp extract_quoted_string(s) do
if String.starts_with?(s, "\"") and String.ends_with?(s, "\"") do
s
|> String.slice(1..-2//1)
|> unescape_string()
else
""
end
end
defp unescape_string(s) do
s
|> String.replace("\\n", "\n")
|> String.replace("\\t", "\t")
|> String.replace("\\\"", "\"")
|> String.replace("\\\\", "\\")
end
defp format_string_value(value) do
escaped =
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
|> String.replace("\t", "\\t")
"\"#{escaped}\""
end
defp msgstr_empty?([]), do: true
defp msgstr_empty?(lines) do
value = extract_string_value(lines)
String.trim(value) == ""
end
defp should_populate?(msgid_value, msgstr_lines) do
String.trim(msgid_value) != "" and msgstr_empty?(msgstr_lines)
end
defp populate_msgstr(msgid_lines, msgid_value) do
if length(msgid_lines) > 1 do
create_multiline_msgstr(msgid_lines)
else
["msgstr #{format_string_value(msgid_value)}"]
end
end
defp create_multiline_msgstr(msgid_lines) do
msgid_lines
|> Enum.with_index()
|> Enum.map(fn {line, index} ->
if index == 0 do
String.replace(line, ~r/^msgid\s+/, "msgstr ")
else
line
end
end)
end
defp is_plural_form?([]), do: false
defp is_plural_form?([line | _rest]) do
String.starts_with?(line, "msgid_plural ")
end
defp skip_plural_translation([], acc), do: {Enum.reverse(acc), []}
defp skip_plural_translation([line | rest] = lines, acc) do
# Collect msgid_plural and msgstr[N] lines
if String.starts_with?(line, "msgid_plural ") or
String.starts_with?(line, "msgstr[") or
(String.starts_with?(String.trim_leading(line), "\"") and acc != []) do
skip_plural_translation(rest, [line | acc])
else
{Enum.reverse(acc), lines}
end
end
end

View file

@ -82,10 +82,12 @@ defmodule ToweropsWeb do
# Translation
use Gettext, backend: ToweropsWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import ToweropsWeb.CoreComponents
import ToweropsWeb.GettextHelpers
# HTML escaping functionality
# Core UI components
# Common modules used in templates
alias Phoenix.LiveView.JS

View file

@ -0,0 +1,157 @@
defmodule ToweropsWeb.GettextHelpers do
@moduledoc """
Domain-specific helper functions for Gettext translations.
Provides shorter syntax for common translation patterns:
- `t/1` - Common UI elements (default domain)
- `t_auth/1` - Authentication flows
- `t_equipment/1` - Equipment/device management
- `t_admin/1` - Admin features
- `t_email/1` - Email templates
Error messages use the standard `gettext/1` function.
## Examples
# Common UI
t("Save")
t("Cancel")
# Authentication
t_auth("Log in")
t_auth("Sign up")
# Equipment
t_equipment("Add Device")
t_equipment("Last polled: %{time}", time: formatted_time)
# Admin
t_admin("Impersonate User")
# Emails
t_email("Reset password instructions")
# Errors (use standard gettext)
gettext("can't be blank")
"""
@doc """
Translates a string in the default domain.
Used for common UI elements, navigation, generic actions.
"""
defmacro t(msgid) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("default", unquote(msgid))
end
end
@doc """
Translates a string with interpolation in the default domain.
"""
defmacro t(msgid, bindings) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("default", unquote(msgid), unquote(bindings))
end
end
@doc """
Translates a string in the auth domain.
Used for authentication flows, user management, login/signup.
"""
defmacro t_auth(msgid) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("auth", unquote(msgid))
end
end
@doc """
Translates a string with interpolation in the auth domain.
"""
defmacro t_auth(msgid, bindings) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("auth", unquote(msgid), unquote(bindings))
end
end
@doc """
Translates a string in the equipment domain.
Used for equipment/device management, SNMP, monitoring, alerts.
"""
defmacro t_equipment(msgid) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("equipment", unquote(msgid))
end
end
@doc """
Translates a string with interpolation in the equipment domain.
"""
defmacro t_equipment(msgid, bindings) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("equipment", unquote(msgid), unquote(bindings))
end
end
@doc """
Translates a string in the admin domain.
Used for admin-specific features like impersonation, GeoIP, audit logs.
"""
defmacro t_admin(msgid) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("admin", unquote(msgid))
end
end
@doc """
Translates a string with interpolation in the admin domain.
"""
defmacro t_admin(msgid, bindings) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("admin", unquote(msgid), unquote(bindings))
end
end
@doc """
Translates a string in the emails domain.
Used for email templates and subjects.
"""
defmacro t_email(msgid) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("emails", unquote(msgid))
end
end
@doc """
Translates a string with interpolation in the emails domain.
"""
defmacro t_email(msgid, bindings) do
quote do
require ToweropsWeb.Gettext
ToweropsWeb.Gettext.dgettext("emails", unquote(msgid), unquote(bindings))
end
end
end

15
priv/gettext/admin.pot Normal file
View file

@ -0,0 +1,15 @@
## This file is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (.po) files instead.
##
## Domain: admin
## Purpose: Admin-specific features, impersonation, GeoIP, audit logs
msgid ""
msgstr ""
"Language: en\n"

15
priv/gettext/auth.pot Normal file
View file

@ -0,0 +1,15 @@
## This file is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (.po) files instead.
##
## Domain: auth
## Purpose: Authentication flows, user management, TOTP, login/signup
msgid ""
msgstr ""
"Language: en\n"

15
priv/gettext/emails.pot Normal file
View file

@ -0,0 +1,15 @@
## This file is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (.po) files instead.
##
## Domain: emails
## Purpose: Email templates and subjects
msgid ""
msgstr ""
"Language: en\n"

View file

@ -0,0 +1,12 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

View file

@ -0,0 +1,12 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

View file

@ -0,0 +1,12 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

View file

@ -0,0 +1,12 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

View file

@ -0,0 +1,15 @@
## This file is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (.po) files instead.
##
## Domain: equipment
## Purpose: Equipment/device management, SNMP, monitoring, alerts
msgid ""
msgstr ""
"Language: en\n"