diff --git a/lib/mix/tasks/populate_english.ex b/lib/mix/tasks/populate_english.ex new file mode 100644 index 00000000..78d87330 --- /dev/null +++ b/lib/mix/tasks/populate_english.ex @@ -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 diff --git a/lib/towerops_web.ex b/lib/towerops_web.ex index 8a989a24..1a839f20 100644 --- a/lib/towerops_web.ex +++ b/lib/towerops_web.ex @@ -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 diff --git a/lib/towerops_web/gettext_helpers.ex b/lib/towerops_web/gettext_helpers.ex new file mode 100644 index 00000000..8bf34c90 --- /dev/null +++ b/lib/towerops_web/gettext_helpers.ex @@ -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 diff --git a/priv/gettext/admin.pot b/priv/gettext/admin.pot new file mode 100644 index 00000000..cee8296c --- /dev/null +++ b/priv/gettext/admin.pot @@ -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" diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot new file mode 100644 index 00000000..c6c7f389 --- /dev/null +++ b/priv/gettext/auth.pot @@ -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" diff --git a/priv/gettext/emails.pot b/priv/gettext/emails.pot new file mode 100644 index 00000000..a9563ba7 --- /dev/null +++ b/priv/gettext/emails.pot @@ -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" diff --git a/priv/gettext/en/LC_MESSAGES/admin.po b/priv/gettext/en/LC_MESSAGES/admin.po new file mode 100644 index 00000000..0dde1207 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/admin.po @@ -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" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po new file mode 100644 index 00000000..0dde1207 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -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" diff --git a/priv/gettext/en/LC_MESSAGES/emails.po b/priv/gettext/en/LC_MESSAGES/emails.po new file mode 100644 index 00000000..0dde1207 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/emails.po @@ -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" diff --git a/priv/gettext/en/LC_MESSAGES/equipment.po b/priv/gettext/en/LC_MESSAGES/equipment.po new file mode 100644 index 00000000..0dde1207 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/equipment.po @@ -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" diff --git a/priv/gettext/equipment.pot b/priv/gettext/equipment.pot new file mode 100644 index 00000000..0c30115c --- /dev/null +++ b/priv/gettext/equipment.pot @@ -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"