456 lines
13 KiB
Elixir
456 lines
13 KiB
Elixir
defmodule Mv.Membership.MembersPDF do
|
|
@moduledoc """
|
|
Exports members to PDF using Typst templates and Imprintor.
|
|
|
|
Uses the same data structure as `MemberExport.Build` and converts it
|
|
to the format expected by the Typst template. Handles internationalization
|
|
for PDF-specific labels (title, metadata) and membership fee status.
|
|
|
|
Ensures deterministic output by maintaining column and row order.
|
|
|
|
Creates a temporary directory per request and copies the template there
|
|
to avoid symlink issues and ensure isolation.
|
|
"""
|
|
|
|
require Logger
|
|
|
|
use Gettext, backend: MvWeb.Gettext
|
|
|
|
alias Mv.Config
|
|
|
|
@template_filename "members_export.typ"
|
|
|
|
@doc """
|
|
Renders export data to PDF binary.
|
|
|
|
- `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
|
|
- `opts` - Keyword list with `:locale` (default: "en") and `:club_name` (default: "Club")
|
|
|
|
Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`.
|
|
|
|
The PDF binary starts with "%PDF" (PDF magic bytes).
|
|
|
|
Validates row count against configured limit before processing.
|
|
"""
|
|
@spec render(map(), keyword()) :: {:ok, binary()} | {:error, term()}
|
|
def render(export_data, opts \\ []) do
|
|
row_count = length(export_data.rows)
|
|
max_rows = Config.pdf_export_row_limit()
|
|
|
|
if row_count > max_rows do
|
|
Logger.warning(
|
|
"PDF export rejected: row count exceeds limit (rows: #{row_count}, max: #{max_rows})",
|
|
error_type: :row_limit_exceeded
|
|
)
|
|
|
|
{:error, {:row_limit_exceeded, row_count, max_rows}}
|
|
else
|
|
Logger.info(
|
|
"Starting PDF export (rows: #{row_count}, columns: #{length(export_data.columns)})"
|
|
)
|
|
|
|
locale = Keyword.get(opts, :locale, "en")
|
|
club_name = Keyword.get(opts, :club_name, "Club")
|
|
create_and_use_temp_directory(export_data, locale, club_name)
|
|
end
|
|
end
|
|
|
|
defp create_and_use_temp_directory(export_data, locale, club_name) do
|
|
case create_temp_directory() do
|
|
{:ok, temp_dir} ->
|
|
try do
|
|
with {:ok, template_content} <- load_template(),
|
|
{:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content),
|
|
{:ok, template_data} <-
|
|
convert_to_template_format(export_data, locale, club_name),
|
|
{:ok, config} <-
|
|
build_imprintor_config(template_content, template_data, temp_dir),
|
|
{:ok, pdf_binary} <- compile_to_pdf(config) do
|
|
Logger.info("PDF export completed successfully (rows: #{length(export_data.rows)})")
|
|
|
|
{:ok, pdf_binary}
|
|
else
|
|
{:error, reason} = error ->
|
|
Logger.error("PDF export failed: #{inspect(reason)}",
|
|
error_type: :pdf_export_failed
|
|
)
|
|
|
|
error
|
|
end
|
|
after
|
|
cleanup_temp_directory(temp_dir)
|
|
end
|
|
|
|
{:error, reason} = error ->
|
|
Logger.error("Failed to create temp directory: #{inspect(reason)}",
|
|
error_type: :temp_dir_creation_failed
|
|
)
|
|
|
|
error
|
|
end
|
|
end
|
|
|
|
defp create_temp_directory do
|
|
# Create unique temp directory per request
|
|
temp_base = System.tmp_dir!()
|
|
temp_dir = Path.join(temp_base, "mv_pdf_export_#{System.unique_integer([:positive])}")
|
|
|
|
case File.mkdir_p(temp_dir) do
|
|
:ok -> {:ok, temp_dir}
|
|
{:error, reason} -> {:error, {:temp_dir_creation_failed, reason}}
|
|
end
|
|
end
|
|
|
|
defp load_template do
|
|
# Try multiple paths: compiled app path and source path (for tests/dev)
|
|
template_paths = [
|
|
Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/#{@template_filename}"),
|
|
Path.join([File.cwd!(), "priv", "pdf_templates", @template_filename])
|
|
]
|
|
|
|
Enum.reduce_while(template_paths, nil, fn path, _acc ->
|
|
case File.read(path) do
|
|
{:ok, content} -> {:halt, {:ok, content}}
|
|
{:error, _reason} -> {:cont, nil}
|
|
end
|
|
end)
|
|
|> case do
|
|
{:ok, content} -> {:ok, content}
|
|
nil -> {:error, {:template_not_found, :enoent}}
|
|
end
|
|
end
|
|
|
|
defp copy_template_to_temp(temp_dir, template_content) do
|
|
# Write template to temp directory (no symlinks, actual file copy)
|
|
template_path = Path.join(temp_dir, @template_filename)
|
|
|
|
case File.write(template_path, template_content) do
|
|
:ok -> {:ok, template_path}
|
|
{:error, reason} -> {:error, {:template_copy_failed, reason}}
|
|
end
|
|
end
|
|
|
|
defp cleanup_temp_directory(temp_dir) do
|
|
# Clean up temp directory and all contents
|
|
case File.rm_rf(temp_dir) do
|
|
{:ok, _} ->
|
|
:ok
|
|
|
|
{:error, reason, _} ->
|
|
Logger.warning("Failed to cleanup temp directory: #{temp_dir}, error: #{inspect(reason)}")
|
|
end
|
|
end
|
|
|
|
defp convert_to_template_format(export_data, locale, club_name) do
|
|
# Set locale for translations
|
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
|
|
|
headers = Enum.map(export_data.columns, & &1.label)
|
|
column_count = length(export_data.columns)
|
|
|
|
meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{}
|
|
|
|
generated_at_raw =
|
|
Map.get(meta, :generated_at) ||
|
|
Map.get(meta, "generated_at") ||
|
|
DateTime.utc_now() |> DateTime.to_iso8601()
|
|
|
|
generated_at = format_datetime(generated_at_raw, locale)
|
|
|
|
member_count =
|
|
Map.get(meta, :member_count) ||
|
|
Map.get(meta, "member_count") ||
|
|
length(export_data.rows)
|
|
|
|
# Translate membership fee status and format dates in rows
|
|
rows =
|
|
export_data.rows
|
|
|> translate_membership_fee_status_in_rows(export_data.columns)
|
|
|> format_dates_in_rows(export_data.columns, locale)
|
|
|
|
# Build title based on locale
|
|
title = build_title(locale, club_name)
|
|
|
|
# Build translated labels for metadata
|
|
created_at_label = gettext("Created at:")
|
|
member_count_label = gettext("Member count:")
|
|
|
|
template_data = %{
|
|
"title" => title,
|
|
"created_at_label" => created_at_label,
|
|
"member_count_label" => member_count_label,
|
|
"generated_at" => generated_at,
|
|
"column_count" => column_count,
|
|
"headers" => headers,
|
|
"rows" => rows,
|
|
"columns" =>
|
|
Enum.map(export_data.columns, fn col ->
|
|
%{
|
|
"key" => to_string(col.key),
|
|
"kind" => to_string(col.kind),
|
|
"label" => col.label
|
|
}
|
|
end),
|
|
"meta" => %{
|
|
"generated_at" => generated_at,
|
|
"member_count" => member_count
|
|
},
|
|
"locale" => locale
|
|
}
|
|
|
|
{:ok, template_data}
|
|
end
|
|
|
|
defp build_title(_locale, club_name) do
|
|
gettext("Member %{club_name}", club_name: club_name)
|
|
end
|
|
|
|
defp format_datetime(iso8601_string, locale) when is_binary(iso8601_string) do
|
|
# Try to parse as DateTime first
|
|
case DateTime.from_iso8601(iso8601_string) do
|
|
{:ok, datetime, _offset} ->
|
|
format_datetime(datetime, locale)
|
|
|
|
{:ok, datetime} ->
|
|
format_datetime(datetime, locale)
|
|
|
|
{:error, _} ->
|
|
# Try NaiveDateTime if DateTime parsing fails
|
|
case NaiveDateTime.from_iso8601(iso8601_string) do
|
|
{:ok, naive_dt} ->
|
|
# Convert to DateTime in UTC
|
|
datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
|
|
format_datetime(datetime, locale)
|
|
|
|
{:error, _} ->
|
|
# If both fail, return original string
|
|
iso8601_string
|
|
end
|
|
end
|
|
end
|
|
|
|
defp format_datetime(%DateTime{} = datetime, locale) do
|
|
# Format as readable date and time (locale-specific)
|
|
case locale do
|
|
"de" ->
|
|
# German format: dd.mm.yyyy - HH:MM Uhr
|
|
Calendar.strftime(datetime, "%d.%m.%Y - %H:%M Uhr")
|
|
|
|
_ ->
|
|
# English format: MM/DD/YYYY HH:MM AM/PM
|
|
Calendar.strftime(datetime, "%m/%d/%Y %I:%M %p")
|
|
end
|
|
end
|
|
|
|
defp format_datetime(_, _), do: ""
|
|
|
|
defp format_date(%Date{} = date, locale) do
|
|
# Format as readable date (locale-specific)
|
|
case locale do
|
|
"de" ->
|
|
# German format: dd.mm.yyyy
|
|
Calendar.strftime(date, "%d.%m.%Y")
|
|
|
|
_ ->
|
|
# English format: MM/DD/YYYY
|
|
Calendar.strftime(date, "%m/%d/%Y")
|
|
end
|
|
end
|
|
|
|
defp format_date(_, _), do: ""
|
|
|
|
defp format_dates_in_rows(rows, columns, locale) do
|
|
date_indices = find_date_column_indices(columns)
|
|
|
|
if date_indices == [] do
|
|
rows
|
|
else
|
|
format_rows_dates(rows, date_indices, locale)
|
|
end
|
|
end
|
|
|
|
defp find_date_column_indices(columns) do
|
|
columns
|
|
|> Enum.with_index()
|
|
|> Enum.filter(fn {col, _idx} -> date_column?(col) end)
|
|
|> Enum.map(fn {_col, idx} -> idx end)
|
|
end
|
|
|
|
defp format_rows_dates(rows, date_indices, locale) do
|
|
Enum.map(rows, fn row -> format_row_dates(row, date_indices, locale) end)
|
|
end
|
|
|
|
defp format_row_dates(row, date_indices, locale) do
|
|
Enum.with_index(row)
|
|
|> Enum.map(fn {cell_value, idx} ->
|
|
if idx in date_indices do
|
|
format_cell_date(cell_value, locale)
|
|
else
|
|
cell_value
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp date_column?(%{kind: :member_field, key: key}) do
|
|
key_atom = key_to_atom_safe(key)
|
|
key_atom in [:join_date, :exit_date, :membership_fee_start_date]
|
|
end
|
|
|
|
defp date_column?(_), do: false
|
|
|
|
defp key_to_atom_safe(key) when is_binary(key) do
|
|
try do
|
|
String.to_existing_atom(key)
|
|
rescue
|
|
ArgumentError -> key
|
|
end
|
|
end
|
|
|
|
defp key_to_atom_safe(key), do: key
|
|
|
|
defp format_cell_date(cell_value, locale) when is_binary(cell_value) do
|
|
format_cell_date_iso8601(cell_value, locale)
|
|
end
|
|
|
|
defp format_cell_date(cell_value, _locale), do: cell_value
|
|
|
|
defp format_cell_date_iso8601(cell_value, locale) do
|
|
case Date.from_iso8601(cell_value) do
|
|
{:ok, date} -> format_date(date, locale)
|
|
_ -> format_cell_date_datetime(cell_value, locale)
|
|
end
|
|
end
|
|
|
|
defp format_cell_date_datetime(cell_value, locale) do
|
|
case DateTime.from_iso8601(cell_value) do
|
|
{:ok, datetime} -> format_datetime(datetime, locale)
|
|
_ -> format_cell_date_naive(cell_value, locale)
|
|
end
|
|
end
|
|
|
|
defp format_cell_date_naive(cell_value, locale) do
|
|
case NaiveDateTime.from_iso8601(cell_value) do
|
|
{:ok, naive_dt} ->
|
|
datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
|
|
format_datetime(datetime, locale)
|
|
|
|
_ ->
|
|
cell_value
|
|
end
|
|
end
|
|
|
|
defp translate_membership_fee_status_in_rows(rows, columns) do
|
|
status_col_index = find_membership_fee_status_index(columns)
|
|
|
|
if status_col_index do
|
|
translate_rows_status(rows, status_col_index)
|
|
else
|
|
rows
|
|
end
|
|
end
|
|
|
|
defp find_membership_fee_status_index(columns) do
|
|
Enum.find_index(columns, fn col ->
|
|
col.kind == :computed && col.key == :membership_fee_status
|
|
end)
|
|
end
|
|
|
|
defp translate_rows_status(rows, status_col_index) do
|
|
Enum.map(rows, fn row ->
|
|
List.update_at(row, status_col_index, &translate_membership_fee_status/1)
|
|
end)
|
|
end
|
|
|
|
defp translate_membership_fee_status("paid"), do: gettext("Paid")
|
|
defp translate_membership_fee_status("unpaid"), do: gettext("Unpaid")
|
|
defp translate_membership_fee_status("suspended"), do: gettext("Suspended")
|
|
defp translate_membership_fee_status(value), do: value
|
|
|
|
defp build_imprintor_config(template_content, template_data, root_directory) do
|
|
# Imprintor.Config.new(source_document, inputs, options)
|
|
# inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template
|
|
# options: set root_directory to temp dir to ensure no symlink issues
|
|
# extra_fonts: list of font file paths for Typst to use
|
|
extra_fonts = get_extra_fonts()
|
|
options = [root_directory: root_directory, extra_fonts: extra_fonts]
|
|
|
|
config = Imprintor.Config.new(template_content, template_data, options)
|
|
{:ok, config}
|
|
end
|
|
|
|
defp get_extra_fonts do
|
|
font_paths = get_font_paths()
|
|
|
|
Enum.reduce_while(font_paths, [], &find_fonts_in_path/2)
|
|
|> normalize_fonts_result()
|
|
end
|
|
|
|
defp get_font_paths do
|
|
[
|
|
Path.join(Application.app_dir(:mv, "priv"), "fonts"),
|
|
Path.join([File.cwd!(), "priv", "fonts"])
|
|
]
|
|
end
|
|
|
|
defp find_fonts_in_path(base_path, _acc) do
|
|
case File.ls(base_path) do
|
|
{:ok, files} -> process_font_files(files, base_path)
|
|
{:error, _reason} -> {:cont, []}
|
|
end
|
|
end
|
|
|
|
defp process_font_files(files, base_path) do
|
|
fonts =
|
|
files
|
|
|> Enum.filter(&String.ends_with?(&1, ".ttf"))
|
|
|> Enum.map(&Path.join(base_path, &1))
|
|
|> Enum.sort()
|
|
|
|
if fonts != [] do
|
|
{:halt, fonts}
|
|
else
|
|
{:cont, []}
|
|
end
|
|
end
|
|
|
|
defp normalize_fonts_result([]), do: []
|
|
defp normalize_fonts_result(fonts), do: fonts
|
|
|
|
defp compile_to_pdf(config) do
|
|
case Imprintor.compile_to_pdf(config) do
|
|
{:ok, pdf_binary} when is_binary(pdf_binary) ->
|
|
# Verify PDF magic bytes
|
|
if String.starts_with?(pdf_binary, "%PDF") do
|
|
{:ok, pdf_binary}
|
|
else
|
|
Logger.error(
|
|
"PDF compilation returned invalid format (start: #{String.slice(pdf_binary, 0, 20)})"
|
|
)
|
|
|
|
{:error, :invalid_pdf_format}
|
|
end
|
|
|
|
{:error, reason} ->
|
|
Logger.error("PDF compilation failed",
|
|
error: inspect(reason),
|
|
error_type: :imprintor_compile_error
|
|
)
|
|
|
|
{:error, {:compile_error, reason}}
|
|
|
|
other ->
|
|
Logger.error("PDF compilation returned unexpected result: #{inspect(other)}",
|
|
error_type: :unexpected_result
|
|
)
|
|
|
|
{:error, {:unexpected_result, other}}
|
|
end
|
|
rescue
|
|
e ->
|
|
Logger.error("PDF compilation raised exception: #{inspect(e)}",
|
|
error_type: :compile_exception
|
|
)
|
|
|
|
{:error, {:compile_exception, e}}
|
|
end
|
|
end
|