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 if String.starts_with?(line, "msgid ") do process_msgid_line([line | rest], acc) else # Regular line (comments, metadata, blank lines) process_lines(rest, [line | acc]) end end defp process_msgid_line(lines, acc) do {msgid_lines, remaining} = collect_string_lines(lines, []) # Check if this is a plural form (msgid_plural follows) if plural_form?(remaining) do process_plural_form(msgid_lines, remaining, acc) else process_singular_form(msgid_lines, remaining, acc) end end defp process_plural_form(msgid_lines, remaining, acc) do {plural_lines, remaining2} = skip_plural_translation(remaining, []) new_acc = Enum.reverse(plural_lines, Enum.reverse(msgid_lines, acc)) process_lines(remaining2, new_acc) end defp process_singular_form(msgid_lines, remaining, acc) do {msgstr_lines, remaining2} = collect_msgstr_lines(remaining, []) msgid_value = extract_string_value(msgid_lines) 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 # 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 plural_form?([]), do: false defp 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