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:
parent
6c8e670dae
commit
cfb60cd186
11 changed files with 495 additions and 2 deletions
226
lib/mix/tasks/populate_english.ex
Normal file
226
lib/mix/tasks/populate_english.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
157
lib/towerops_web/gettext_helpers.ex
Normal file
157
lib/towerops_web/gettext_helpers.ex
Normal 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
15
priv/gettext/admin.pot
Normal 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
15
priv/gettext/auth.pot
Normal 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
15
priv/gettext/emails.pot
Normal 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"
|
||||
12
priv/gettext/en/LC_MESSAGES/admin.po
Normal file
12
priv/gettext/en/LC_MESSAGES/admin.po
Normal 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"
|
||||
12
priv/gettext/en/LC_MESSAGES/auth.po
Normal file
12
priv/gettext/en/LC_MESSAGES/auth.po
Normal 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"
|
||||
12
priv/gettext/en/LC_MESSAGES/emails.po
Normal file
12
priv/gettext/en/LC_MESSAGES/emails.po
Normal 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"
|
||||
12
priv/gettext/en/LC_MESSAGES/equipment.po
Normal file
12
priv/gettext/en/LC_MESSAGES/equipment.po
Normal 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"
|
||||
15
priv/gettext/equipment.pot
Normal file
15
priv/gettext/equipment.pot
Normal 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"
|
||||
Loading…
Add table
Reference in a new issue