diff --git a/assets/css/_phoenix_kit_sources.css b/assets/css/_phoenix_kit_sources.css index bcfaa20..e7ba1f7 100644 --- a/assets/css/_phoenix_kit_sources.css +++ b/assets/css/_phoenix_kit_sources.css @@ -1,3 +1,3 @@ /* Auto-generated by PhoenixKit — do not edit manually. Regenerated on each compilation from css_sources/0 callbacks. */ - +@source "../../deps/phoenix_kit_document_creator"; diff --git a/lib/phoenix_kit_document_creator.ex b/lib/phoenix_kit_document_creator.ex index ef456b7..ec72552 100644 --- a/lib/phoenix_kit_document_creator.ex +++ b/lib/phoenix_kit_document_creator.ex @@ -183,7 +183,7 @@ defmodule PhoenixKitDocumentCreator do label: "Categories", icon: "hero-folder", path: "document-creator/categories", - priority: 647, + priority: 651, level: :admin, permission: module_key(), parent: :admin_document_creator, diff --git a/lib/phoenix_kit_document_creator/documents.ex b/lib/phoenix_kit_document_creator/documents.ex index 05983a9..cb83488 100644 --- a/lib/phoenix_kit_document_creator/documents.ex +++ b/lib/phoenix_kit_document_creator/documents.ex @@ -195,6 +195,7 @@ defmodule PhoenixKitDocumentCreator.Documents do |> Enum.map(fn {"default_width_px", v} -> {"default_width_px", parse_integer(v)} {"max_count", v} -> {"max_count", parse_integer_or_nil(v)} + {"columns", v} -> {"columns", parse_columns(v)} {k, v} -> {k, v} end) |> Enum.reject(fn {_k, v} -> v == :skip end) @@ -225,6 +226,21 @@ defmodule PhoenixKitDocumentCreator.Documents do defp parse_integer_or_nil(_), do: :skip + @max_columns 4 + + # Parses a columns value, clamping to 1..@max_columns. Falls back to 1 on + # invalid input so callers always get a usable integer (never :skip). + defp parse_columns(v) when is_integer(v), do: max(1, min(v, @max_columns)) + + defp parse_columns(v) when is_binary(v) do + case Integer.parse(v) do + {n, _} -> max(1, min(n, @max_columns)) + _ -> 1 + end + end + + defp parse_columns(_), do: 1 + @doc "List templates from the local DB. Returns maps compatible with the LiveView." @spec list_templates_from_db() :: [map()] def list_templates_from_db do @@ -306,6 +322,7 @@ defmodule PhoenixKitDocumentCreator.Documents do |> maybe_put_field(record, :type_uuid, "type_uuid") |> maybe_put_field(record, :created_by_uuid, "created_by_uuid") |> maybe_put_field(record, :inserted_at, "inserted_at") + |> maybe_put_field(record, :data, "data") end defp maybe_put_field(map, record, field, key) do @@ -1630,7 +1647,7 @@ defmodule PhoenixKitDocumentCreator.Documents do """ @spec delete_document(String.t(), keyword()) :: :ok | {:error, term()} def delete_document(file_id, opts \\ []) when is_binary(file_id) do - case move_to_deleted_folder(file_id, :deleted_documents_folder_id) do + case move_to_deleted_folder(file_id, :deleted_documents_folder_id, opts[:actor_uuid]) do :ok -> log_activity(%{ action: "document.deleted", @@ -1657,7 +1674,7 @@ defmodule PhoenixKitDocumentCreator.Documents do """ @spec delete_template(String.t(), keyword()) :: :ok | {:error, term()} def delete_template(file_id, opts \\ []) when is_binary(file_id) do - case move_to_deleted_folder(file_id, :deleted_templates_folder_id) do + case move_to_deleted_folder(file_id, :deleted_templates_folder_id, opts[:actor_uuid]) do :ok -> log_activity(%{ action: "template.deleted", @@ -1675,7 +1692,7 @@ defmodule PhoenixKitDocumentCreator.Documents do end end - defp move_to_deleted_folder(file_id, folder_key) do + defp move_to_deleted_folder(file_id, folder_key, actor_uuid) do with {:ok, folder_id} <- resolve_deleted_folder_id(folder_key), :ok <- GoogleDocsClient.move_file(file_id, folder_id) do update_file_by_google_doc_id(file_id, %{ @@ -1684,10 +1701,45 @@ defmodule PhoenixKitDocumentCreator.Documents do path: deleted_folder_path(folder_key) }) + stamp_deleted_data(file_id, actor_uuid) + :ok end end + defp stamp_deleted_data(google_doc_id, actor_uuid) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + deleted_entry = %{"at" => DateTime.to_iso8601(now), "by_uuid" => actor_uuid} + + from(t in Template, + where: t.google_doc_id == ^google_doc_id, + update: [ + set: [ + data: + fragment( + "COALESCE(data, '{}'::jsonb) || jsonb_build_object('deleted', ?::jsonb)", + ^deleted_entry + ) + ] + ] + ) + |> repo().update_all([]) + + from(d in Document, + where: d.google_doc_id == ^google_doc_id, + update: [ + set: [ + data: + fragment( + "COALESCE(data, '{}'::jsonb) || jsonb_build_object('deleted', ?::jsonb)", + ^deleted_entry + ) + ] + ] + ) + |> repo().update_all([]) + end + defp resolve_deleted_folder_id(folder_key) do case get_folder_ids() do %{^folder_key => id} when is_binary(id) -> @@ -1758,6 +1810,8 @@ defmodule PhoenixKitDocumentCreator.Documents do path: location.path }) + clear_deleted_data(file_id) + :ok else %{folder_id: nil} -> {:error, :live_folder_not_found} @@ -1766,6 +1820,20 @@ defmodule PhoenixKitDocumentCreator.Documents do end end + defp clear_deleted_data(google_doc_id) do + from(t in Template, + where: t.google_doc_id == ^google_doc_id, + update: [set: [data: fragment("COALESCE(data, '{}'::jsonb) - 'deleted'")]] + ) + |> repo().update_all([]) + + from(d in Document, + where: d.google_doc_id == ^google_doc_id, + update: [set: [data: fragment("COALESCE(data, '{}'::jsonb) - 'deleted'")]] + ) + |> repo().update_all([]) + end + # =========================================================================== # Variables # =========================================================================== @@ -1833,22 +1901,58 @@ defmodule PhoenixKitDocumentCreator.Documents do Fetches the current document text via the Google Docs client and extracts all `{{ image: name }}` / `{{ images: name }}` tags, returning a list of - `%{name: String.t(), kind: :image | :image_list}` maps sorted by name. + `%{name: String.t(), kind: :image | :image_list, config: map()}` maps sorted + by name. + + The `:config` map is sourced from the saved variable in `template.variables` + (if present); otherwise it falls back to `Variable.default_image_config(kind)`. Returns `{:error, :not_found}` if no template exists for the given UUID. """ @spec image_slots_for_template(UUIDv7.t()) :: - {:ok, [%{name: String.t(), kind: :image | :image_list}]} + {:ok, [%{name: String.t(), kind: :image | :image_list, config: map()}]} | {:error, :not_found | term()} def image_slots_for_template(template_uuid) do case repo().get(Template, template_uuid) do - nil -> - {:error, :not_found} + nil -> {:error, :not_found} + template -> fetch_slots_for_template(template) + end + end - template -> - with {:ok, text} <- docs_client().get_document_text(template.google_doc_id) do - {:ok, PhoenixKitDocumentCreator.Variable.extract_image_variables(text)} - end + defp fetch_slots_for_template(template) do + with {:ok, text} <- docs_client().get_document_text(template.google_doc_id) do + vars = template.variables || [] + + slots = + text + |> PhoenixKitDocumentCreator.Variable.extract_image_variables() + |> Enum.map(fn %{name: name, kind: kind} = slot -> + Map.put(slot, :config, resolve_slot_config(vars, name, kind)) + end) + + {:ok, slots} + end + end + + # Looks up the saved variable config for a slot by name. Falls back to the + # kind-specific default when no saved variable is found or it has no config. + defp resolve_slot_config(variables, name, kind) do + saved_config = + case Enum.find(variables, &(&1["name"] == name)) do + nil -> nil + var -> var["config"] + end + + # Stringify atom keys from the default so the merged map is uniformly + # string-keyed. Saved configs from the DB jsonb column are already + # string-keyed; the default has atom keys. + string_default = + PhoenixKitDocumentCreator.Variable.default_image_config(kind) + |> Map.new(fn {k, v} -> {to_string(k), v} end) + + case saved_config do + nil -> string_default + config when is_map(config) -> Map.merge(string_default, config) end end diff --git a/lib/phoenix_kit_document_creator/documents/composer.ex b/lib/phoenix_kit_document_creator/documents/composer.ex index 832027d..2bcc656 100644 --- a/lib/phoenix_kit_document_creator/documents/composer.ex +++ b/lib/phoenix_kit_document_creator/documents/composer.ex @@ -162,6 +162,8 @@ defmodule PhoenixKitDocumentCreator.Documents.Composer do defp insert_document_and_sections(gdoc_id, sorted_sections, created_by, name, opts, repo) do category_uuid = Keyword.get(opts, :category_uuid) + data = Keyword.get(opts, :data, %{}) + Multi.new() |> Multi.insert(:document, fn _ -> Document.changeset(%Document{}, %{ @@ -170,7 +172,8 @@ defmodule PhoenixKitDocumentCreator.Documents.Composer do # legacy column not used for composed docs; nullable in DB template_uuid: nil, created_by_uuid: created_by, - category_uuid: category_uuid + category_uuid: category_uuid, + data: data }) end) |> Multi.run(:sections, fn _, %{document: doc} -> diff --git a/lib/phoenix_kit_document_creator/errors.ex b/lib/phoenix_kit_document_creator/errors.ex index d1eec8b..97426ad 100644 --- a/lib/phoenix_kit_document_creator/errors.ex +++ b/lib/phoenix_kit_document_creator/errors.ex @@ -28,6 +28,7 @@ defmodule PhoenixKitDocumentCreator.Errors do | :create_folder_failed | :deleted_folder_not_found | :documents_folder_not_found + | :drive_file_not_found | :file_trashed | :folder_not_found | :folder_search_failed @@ -66,6 +67,13 @@ defmodule PhoenixKitDocumentCreator.Errors do def message(:create_folder_failed), do: gettext("Failed to create the Drive folder") def message(:deleted_folder_not_found), do: gettext("Deleted folder not found") def message(:documents_folder_not_found), do: gettext("Documents folder not found") + + def message(:drive_file_not_found), + do: + gettext( + "File is missing in Google Drive — it cannot be restored. You can permanently delete this record." + ) + def message(:file_trashed), do: gettext("File is in the Drive trash") def message(:folder_not_found), do: gettext("Folder not found") def message(:folder_search_failed), do: gettext("Failed to search Drive for the folder") diff --git a/lib/phoenix_kit_document_creator/google_docs_client.ex b/lib/phoenix_kit_document_creator/google_docs_client.ex index dad38e4..3acabc8 100644 --- a/lib/phoenix_kit_document_creator/google_docs_client.ex +++ b/lib/phoenix_kit_document_creator/google_docs_client.ex @@ -885,6 +885,88 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do # Google rejects with `INVALID_ARGUMENT` (`google.apps.docs.v1.Unit`), so every # `insertInlineImage` batch failed. @px_to_pt 0.75 + @image_gap_pt 8.0 + @default_content_width_pt 468.0 + @max_columns 4 + + @doc """ + Page content width in points = pageSize.width − marginLeft − marginRight. + Falls back to 468pt (US Letter with 1" margins) if anything is missing. + """ + def content_width_pt(document) when is_map(document) do + ds = Map.get(document, "documentStyle") || %{} + width = magnitude(get_in(ds, ["pageSize", "width"])) + margin_l = magnitude(Map.get(ds, "marginLeft")) || 72.0 + margin_r = magnitude(Map.get(ds, "marginRight")) || 72.0 + + case width do + nil -> @default_content_width_pt + w -> w - margin_l - margin_r + end + end + + defp magnitude(%{"magnitude" => m}) when is_number(m), do: m * 1.0 + defp magnitude(_), do: nil + + @doc """ + Per-image width in points for N columns sharing `content_width_pt`. + """ + def image_width_for_columns(content_width_pt, columns) + when is_number(content_width_pt) do + n = columns |> max(1) |> min(@max_columns) + (content_width_pt - @image_gap_pt * (n - 1)) / n + end + + @doc """ + Phase A — emits batchUpdate requests that delete the placeholder range and + create a Google Docs table at its start index. After a doc re-fetch, + `fill_table_cells/3` populates the table. + """ + def table_image_inserts(%{start_index: s, end_index: e}, media, opts) + when is_list(media) do + cols = (opts[:columns] || 1) |> max(1) |> min(@max_columns) + rows = (length(media) / cols) |> Float.ceil() |> trunc() |> max(1) + + [ + %{"deleteContentRange" => %{"range" => %{"startIndex" => s, "endIndex" => e}}}, + %{"insertTable" => %{"rows" => rows, "columns" => cols, "location" => %{"index" => s}}} + ] + end + + @doc """ + Phase B — emits insertInlineImage requests for each cell, last-first so + earlier inserts don't shift later indices. One image per cell; extra cells + beyond the media list are ignored. + """ + def fill_table_cells(cells, media, %{image_width_pt: w_pt}) + when is_list(cells) do + cells + |> Enum.zip(media) + |> Enum.reverse() + |> Enum.map(fn {%{insert_index: idx}, media_item} -> + uri = Map.get(media_item, :uri) || Map.get(media_item, "uri") + src_w = Map.get(media_item, :width_px) || Map.get(media_item, "width_px") + src_h = Map.get(media_item, :height_px) || Map.get(media_item, "height_px") + + # scale_height mixes units here: target is PT, src dims are PX. The + # resulting height is w_pt * (h_px / w_px), which is numerically in PT + # because the PX ratio cancels. Google Docs renders the correct aspect; + # absolute height value is not in PT when src dims are absent (falls back + # to w_pt, a square). + scaled_h_pt = scale_height(w_pt, src_w, src_h) || w_pt + + %{ + "insertInlineImage" => %{ + "location" => %{"index" => idx}, + "uri" => uri, + "objectSize" => %{ + "width" => %{"magnitude" => w_pt * 1.0, "unit" => "PT"}, + "height" => %{"magnitude" => scaled_h_pt * 1.0, "unit" => "PT"} + } + } + } + end) + end @doc """ Builds the list of `batchUpdate` request maps to substitute image tags. @@ -897,6 +979,11 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do """ @spec build_image_batch_requests([map()], map()) :: [map()] def build_image_batch_requests(ranges, fills) do + build_image_batch_requests(ranges, fills, @default_content_width_pt) + end + + @spec build_image_batch_requests([map()], map(), number()) :: [map()] + def build_image_batch_requests(ranges, fills, content_width_pt) do ranges |> Enum.sort_by(& &1.start_index, :desc) |> Enum.flat_map(fn %{name: name, start_index: s, end_index: e} -> @@ -906,7 +993,7 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do inserts = case fill.kind do :image -> single_image_inserts(fill, s) - :image_list -> list_image_inserts(fill, s) + :image_list -> list_image_inserts(fill, s, content_width_pt) end [delete | inserts] @@ -945,17 +1032,45 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do [image_request(media, w, index, fill, media[:uri])] end - defp list_image_inserts(%{media: []}, _index), do: [] + defp list_image_inserts(%{media: []}, _index, _content_width_pt), do: [] + + # Column-aware path: dispatch on columns count. + # columns >= 2 → insertTable only. The outer build_image_batch_requests/3 + # already emits the deleteContentRange for the placeholder; emitting + # another one here would produce a zero-width (invalid) delete that the + # Google Docs API rejects with INVALID_ARGUMENT. + # columns == 1 → inline inserts using content-width-based PT width. + defp list_image_inserts(fill, index, content_width_pt) do + cols = Map.get(fill, :columns, 1) + + if cols >= 2 do + rows = (length(fill.media) / cols) |> Float.ceil() |> trunc() |> max(1) - defp list_image_inserts(fill, index) do - %{media: media, default_width_px: w, separator: sep} = fill + [ + %{ + "insertTable" => %{ + "rows" => rows, + "columns" => cols, + "location" => %{"index" => index} + } + } + ] + else + w_pt = image_width_for_columns(content_width_pt, 1) + inline_image_inserts_pt(fill, index, w_pt) + end + end + + # Inline inserts using PT width directly (for image_list columns=1 path) + defp inline_image_inserts_pt(fill, index, w_pt) do + %{media: media, separator: sep} = fill reversed = Enum.reverse(media) last_idx = length(reversed) - 1 reversed |> Enum.with_index() |> Enum.flat_map(fn {m, i} -> - img = image_request(m, w, index, fill, m[:uri]) + img = insert_inline_image_request_pt(m, w_pt, index) if i < last_idx, do: [img, separator_request(sep, index)], else: [img] end) |> Enum.reject(&is_nil/1) @@ -1012,6 +1127,28 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do } end + # Like insert_inline_image_request/3 but takes width in PT directly (no px→pt conversion). + # Used for image_list slots where width comes from image_width_for_columns/2. + defp insert_inline_image_request_pt(media, width_pt, index) when is_map(media) do + uri = Map.get(media, :uri) || Map.get(media, "uri") + w_px = Map.get(media, :width_px) || Map.get(media, "width_px") + h_px = Map.get(media, :height_px) || Map.get(media, "height_px") + + # scale_height mixes units here: target is PT, src dims are PX; the ratio cancels + scaled_height_pt = scale_height(width_pt, w_px, h_px) || width_pt + + %{ + insertInlineImage: %{ + location: %{index: index}, + uri: uri, + objectSize: %{ + width: %{magnitude: width_pt * 1.0, unit: "PT"}, + height: %{magnitude: scaled_height_pt * 1.0, unit: "PT"} + } + } + } + end + defp scale_height(target_width, src_width, src_height) when src_width in [nil, 0], do: src_height || target_width @@ -1069,7 +1206,13 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do @doc "Move a file to a different folder in Google Drive." @spec move_file(String.t(), String.t()) :: - :ok | {:error, :invalid_file_id | :move_failed | :get_file_parents_failed | term()} + :ok + | {:error, + :invalid_file_id + | :move_failed + | :get_file_parents_failed + | :drive_file_not_found + | term()} def move_file(file_id, to_folder_id) do with {:ok, fid} <- validate_file_id(file_id), {:ok, _tid} <- validate_file_id(to_folder_id) do @@ -1091,6 +1234,9 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do {:ok, %{status: status}} when status in 200..299 -> :ok + {:ok, %{status: 404}} -> + {:error, :drive_file_not_found} + {:ok, %{body: body}} -> log_drive_error("move failed", body) {:error, :move_failed} @@ -1099,6 +1245,9 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do err end + {:ok, %{status: 404}} -> + {:error, :drive_file_not_found} + {:ok, %{body: body}} -> log_drive_error("get file parents failed", body) {:error, :get_file_parents_failed} @@ -1433,26 +1582,124 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do if all_image_fills == [] do :ok else - # Build a flat fills map for find_image_tag_ranges, then filter each result - # to its section's range before building the batch. fills_map = Map.new(all_image_fills, fn {name, fill, _} -> {name, fill} end) range_by_name = Map.new(all_image_fills, fn {name, _, range} -> {name, range} end) + content_width_pt = content_width_pt(doc2) - requests = + filtered_ranges = doc2 |> find_image_tag_ranges(Map.keys(fills_map)) |> Enum.filter(fn %{name: name, start_index: s} -> in_section_range?(range_by_name, name, s) end) - |> build_image_batch_requests(fills_map) - case maybe_batch(&batch_update/2, doc_id, requests) do - {:ok, _} -> :ok - {:error, _} = err -> err + # Partition into table slots (image_list + columns >= 2) and inline slots. + {table_ranges, inline_ranges} = + Enum.split_with(filtered_ranges, fn %{name: name} -> + fill = Map.fetch!(fills_map, name) + fill.kind == :image_list and Map.get(fill, :columns, 1) >= 2 + end) + + # Phase 1 batch: inline slot deletes+inserts + table slot delete+insertTable. + # Sort all requests descending by start_index so earlier inserts don't shift later ones. + phase1_requests = + (table_ranges ++ inline_ranges) + |> build_image_batch_requests(fills_map, content_width_pt) + + # Snapshot pre-existing table start_indices from doc2 before Phase 1 so + # Phase 2 can identify the newly inserted tables by set-difference. + pre_existing_table_starts = + doc2 |> collect_tables() |> MapSet.new(& &1["table"]["startIndex"]) + + with {:ok, _} <- maybe_batch(&batch_update/2, doc_id, phase1_requests) do + if table_ranges == [] do + :ok + else + do_fill_table_cells( + doc_id, + table_ranges, + fills_map, + content_width_pt, + pre_existing_table_starts + ) + end + end + end + end + + # Phase 2: re-fetch the doc after table creation, locate the new tables by + # set-difference against pre-existing table start_indices, and fill cells. + # + # Using "last K tables" would fail when the document has pre-existing tables + # at indices below the placeholder positions — Phase 1 inserts shift those + # pre-existing tables to higher indices, making them the "last K" instead of + # the newly created ones. + defp do_fill_table_cells( + doc_id, + table_ranges, + fills_map, + content_width_pt, + pre_existing_table_starts + ) do + with {:ok, %{body: doc3}} <- get_document(doc_id) do + table_slots_asc = Enum.sort_by(table_ranges, & &1.start_index, :asc) + + # Newly inserted tables are those whose start_index was not present in + # doc2, sorted ascending so they match table_slots_asc in document order. + new_tables = + doc3 + |> collect_tables() + |> Enum.reject(fn el -> + MapSet.member?(pre_existing_table_starts, el["table"]["startIndex"]) + end) + |> Enum.sort_by(fn el -> el["table"]["startIndex"] end, :asc) + + if length(new_tables) != length(table_slots_asc) do + Logger.warning( + "substitute_all_images: expected #{length(table_slots_asc)} new tables " <> + "but found #{length(new_tables)} in doc #{doc_id}; skipping Phase 2" + ) + + :ok + else + phase2_requests = + Enum.zip(table_slots_asc, new_tables) + |> Enum.flat_map(fn {%{name: name}, table_el} -> + fill = Map.fetch!(fills_map, name) + cols = Map.get(fill, :columns, 1) + image_width_pt = image_width_for_columns(content_width_pt, cols) + cells = extract_table_cells(table_el) + fill_table_cells(cells, fill.media, %{image_width_pt: image_width_pt}) + end) + + case maybe_batch(&batch_update/2, doc_id, phase2_requests) do + {:ok, _} -> :ok + {:error, _} = err -> err + end end end end + # Walk doc body content and collect all table elements in document order. + defp collect_tables(doc) do + (get_in(doc, ["body", "content"]) || []) + |> Enum.filter(&Map.has_key?(&1, "table")) + end + + # Extract cell insert indices from a table element returned by the Docs API. + # Each cell's first paragraph provides the startIndex; we insert at startIndex + 1 + # (one position inside the paragraph, before any existing content). + defp extract_table_cells(%{"table" => %{"tableRows" => rows}}) do + for row <- rows, + %{"tableCells" => cells} = row, + cell <- cells do + cell_start = get_in(cell, ["startIndex"]) || 0 + %{insert_index: cell_start + 1} + end + end + + defp extract_table_cells(_), do: [] + defp in_section_range?(range_by_name, name, s) do case Map.get(range_by_name, name) do nil -> false @@ -1554,6 +1801,7 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do fill = %{ kind: kind, + columns: normalize_columns(Map.get(params, "columns")), default_width_px: Map.get(params, "width_px") || 400, opacity: Map.get(params, "opacity") || 1.0, z_index: Map.get(params, "z_index") || 0, @@ -1565,6 +1813,17 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do end) end + defp normalize_columns(n) when is_integer(n), do: n |> max(1) |> min(@max_columns) + + defp normalize_columns(n) when is_binary(n) do + case Integer.parse(n) do + {i, _} -> normalize_columns(i) + :error -> 1 + end + end + + defp normalize_columns(_), do: 1 + defp build_media_items(%{"media" => media}) when is_list(media) do Enum.map(media, fn m -> %{uri: Map.get(m, "uri", ""), width_px: Map.get(m, "width_px"), height_px: nil} diff --git a/lib/phoenix_kit_document_creator/variable.ex b/lib/phoenix_kit_document_creator/variable.ex index 6f85baf..3b89130 100644 --- a/lib/phoenix_kit_document_creator/variable.ex +++ b/lib/phoenix_kit_document_creator/variable.ex @@ -160,5 +160,12 @@ defmodule PhoenixKitDocumentCreator.Variable do def default_image_config(:image), do: %{default_width_px: 400, opacity: 1.0, z_index: 0} def default_image_config(:image_list), - do: %{default_width_px: 400, opacity: 1.0, z_index: 0, separator: :newline, max_count: nil} + do: %{ + default_width_px: 400, + opacity: 1.0, + z_index: 0, + separator: :newline, + max_count: nil, + columns: 1 + } end diff --git a/lib/phoenix_kit_document_creator/web/categories_live.ex b/lib/phoenix_kit_document_creator/web/categories_live.ex index 7c3c5e6..9972d51 100644 --- a/lib/phoenix_kit_document_creator/web/categories_live.ex +++ b/lib/phoenix_kit_document_creator/web/categories_live.ex @@ -27,8 +27,10 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do selected: nil, types: [], presets: [], - categories_trash: false, - types_trash: false + categories_status_mode: "active", + types_status_mode: "active", + trashed_categories_count: 0, + trashed_types_count: 0 )} end @@ -51,29 +53,39 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do with_category(socket, uuid, fn category -> {:noreply, socket - |> assign(selected: category, types_trash: false) + |> assign(selected: category, types_status_mode: "active") |> reload_types()} end) end - def handle_event("toggle_categories_trash", _params, socket) do - trash = !socket.assigns.categories_trash - + def handle_event( + "switch_status", + %{"target" => "categories", "mode" => mode}, + socket + ) + when mode in ["active", "trashed"] do {:noreply, socket - |> assign(categories_trash: trash, selected: nil, types: []) + |> assign(categories_status_mode: mode, selected: nil, types: []) |> reload_categories()} end - def handle_event("toggle_types_trash", _params, socket) do - trash = !socket.assigns.types_trash - + def handle_event( + "switch_status", + %{"target" => "types", "mode" => mode}, + socket + ) + when mode in ["active", "trashed"] do {:noreply, socket - |> assign(types_trash: trash) + |> assign(types_status_mode: mode) |> reload_types()} end + def handle_event("switch_status", _params, socket) do + {:noreply, socket} + end + def handle_event("trash_category", %{"uuid" => uuid}, socket) do with_category(socket, uuid, fn category -> case Taxonomy.trash_category(category, Helpers.actor_opts(socket)) do @@ -273,29 +285,18 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do <%!-- Active / Trash sub-tabs --%> -
- - -
+ <.status_subtabs + target="categories" + status_mode={@categories_status_mode} + trashed_count={@trashed_categories_count} + /> <%!-- Category list --%> @@ -340,7 +343,7 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do

{if @selected, do: @selected.name, else: gettext("Types")}

- <%= if @selected && not @categories_trash do %> + <%= if @selected && @categories_status_mode == "active" do %> <%!-- Active / Trash sub-tabs for types --%> -
- - -
+ <.status_subtabs + target="types" + status_mode={@types_status_mode} + trashed_count={@trashed_types_count} + /> @@ -410,7 +404,7 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do - <%= if @selected && not @categories_trash do %> + <%= if @selected && @categories_status_mode == "active" do %>
@@ -470,6 +464,35 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do # ── Private components ───────────────────────────────────────────────────── + attr(:target, :string, required: true) + attr(:status_mode, :string, required: true) + attr(:trashed_count, :integer, required: true) + + defp status_subtabs(assigns) do + ~H""" +
0 or @status_mode == "trashed"} class="flex mb-3 border-b border-base-200"> + + +
+ """ + end + defp category_row_menu(assigns) do ~H""" + <%!-- Warning --%> +
+ + {@warning} +
+ <%!-- Error --%>
@@ -1189,6 +1206,8 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do |> Enum.flat_map(fn {_cat_uuid, opts} -> opts end) |> Map.new() + deleted_by_names = build_deleted_by_names(files) + %{ files: files, view_mode: assigns.view_mode, @@ -1200,10 +1219,52 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do category_names: cat_names, cat_options: cat_options, types_by_category: types_by_category, - type_names: type_names + type_names: type_names, + deleted_by_names: deleted_by_names } end + # Collect distinct by_uuid values from trashed files and resolve them to + # display names in one query. Returns a %{uuid => display_name} map. + defp build_deleted_by_names(files) do + uuids = extract_deleted_by_uuids(files) + + case uuids do + [] -> + %{} + + _ -> + uuids + |> Auth.get_users_by_uuids() + |> Map.new(&{&1.uuid, user_display_name(&1)}) + end + end + + defp extract_deleted_by_uuids(files) do + files + |> Enum.flat_map(fn file -> + case get_in(file, ["data", "deleted", "by_uuid"]) do + nil -> [] + uuid -> [uuid] + end + end) + |> Enum.uniq() + end + + defp user_display_name(%{first_name: first, last_name: last}) + when is_binary(first) and first != "" and is_binary(last) and last != "", + do: "#{first} #{last}" + + defp user_display_name(%{username: username}) + when is_binary(username) and username != "", + do: username + + defp user_display_name(%{email: email}) + when is_binary(email) and email != "", + do: email + + defp user_display_name(_), do: "unknown" + defp render_file_grid(assigns) do ~H"""
@@ -1297,6 +1358,10 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do

{format_time(file["modifiedTime"])}

+

+ + {format_deleted_info(file["data"]["deleted"], @deleted_by_names)} +

<%!-- Actions --%> @@ -1357,6 +1422,7 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do {gettext("Name")} {gettext("Status")} + {gettext("Deleted")} {gettext("Modified")} {gettext("Actions")} @@ -1364,7 +1430,7 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do <%= if MapSet.member?(@pending_files, file["id"]) do %> - + <% else %> @@ -1415,6 +1481,9 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do {gettext("unfiled")} + + {format_deleted_info(file["data"]["deleted"], @deleted_by_names)} + {format_time(file["modifiedTime"])}
@@ -1653,6 +1722,25 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do end end + # Formats the "Deleted" display: " · " or "—" when + # no deletion metadata is present. + defp format_deleted_info(nil, _names), do: "—" + + defp format_deleted_info(%{"at" => at_iso} = deleted, names) do + by_uuid = Map.get(deleted, "by_uuid") + name = if by_uuid, do: Map.get(names, by_uuid, gettext("unknown")), else: gettext("unknown") + + formatted_at = + case DateTime.from_iso8601(at_iso) do + {:ok, dt, _} -> Calendar.strftime(dt, "%b %d, %Y") + _ -> at_iso + end + + "#{formatted_at} · #{name}" + end + + defp format_deleted_info(_deleted, _names), do: "—" + defp sanitize_filename(name) do name |> to_string() diff --git a/test/documents/composed_document_test.exs b/test/documents/composed_document_test.exs index 47fc9fa..4093121 100644 --- a/test/documents/composed_document_test.exs +++ b/test/documents/composed_document_test.exs @@ -134,6 +134,36 @@ defmodule PhoenixKitDocumentCreator.Documents.ComposedDocumentTest do end end + describe "create_composed_document/2 — data field" do + test "data: opt is stored on the created Document" do + t1 = insert_template!(google_doc_id: "tmpl-data1", published: true) + section = %{template_uuid: t1.uuid, position: 0, variable_values: %{}, image_params: %{}} + recipe = %{"category_uuid" => "cat-1", "template_uuids" => [t1.uuid]} + + assert {:ok, %Document{} = doc} = + Documents.create_composed_document([section], + created_by_uuid: Ecto.UUID.generate(), + name: "With data", + data: %{"recreate_recipe" => recipe} + ) + + assert doc.data == %{"recreate_recipe" => recipe} + end + + test "data defaults to empty map when opt is omitted" do + t1 = insert_template!(google_doc_id: "tmpl-nodata1", published: true) + section = %{template_uuid: t1.uuid, position: 0, variable_values: %{}, image_params: %{}} + + assert {:ok, %Document{} = doc} = + Documents.create_composed_document([section], + created_by_uuid: Ecto.UUID.generate(), + name: "No data" + ) + + assert doc.data == %{} + end + end + describe "create_composed_document/2 — validation" do test "returns error for empty sections" do assert {:error, :empty_sections} = diff --git a/test/errors_test.exs b/test/errors_test.exs index 6591c8b..b741488 100644 --- a/test/errors_test.exs +++ b/test/errors_test.exs @@ -17,6 +17,8 @@ defmodule PhoenixKitDocumentCreator.ErrorsTest do {:create_folder_failed, "Failed to create the Drive folder"}, {:deleted_folder_not_found, "Deleted folder not found"}, {:documents_folder_not_found, "Documents folder not found"}, + {:drive_file_not_found, + "File is missing in Google Drive — it cannot be restored. You can permanently delete this record."}, {:file_trashed, "File is in the Drive trash"}, {:folder_not_found, "Folder not found"}, {:folder_search_failed, "Failed to search Drive for the folder"}, diff --git a/test/integration/drive_bound_actions_test.exs b/test/integration/drive_bound_actions_test.exs index fb503b0..4f2ab55 100644 --- a/test/integration/drive_bound_actions_test.exs +++ b/test/integration/drive_bound_actions_test.exs @@ -12,6 +12,8 @@ defmodule PhoenixKitDocumentCreator.Integration.DriveBoundActionsTest do use PhoenixKitDocumentCreator.DataCase, async: false alias PhoenixKitDocumentCreator.Documents + alias PhoenixKitDocumentCreator.Schemas.Document + alias PhoenixKitDocumentCreator.Schemas.Template alias PhoenixKitDocumentCreator.Test.Repo, as: TestRepo alias PhoenixKitDocumentCreator.Test.StubIntegrations @@ -136,6 +138,78 @@ defmodule PhoenixKitDocumentCreator.Integration.DriveBoundActionsTest do end end + describe "delete_document/2 — data[\"deleted\"] stamping" do + test "stamps data[deleted] with at and by_uuid when a DB record exists" do + actor_uuid = Ecto.UUID.generate() + file_id = "drv-doc-del-data-1" + + {:ok, _} = Documents.register_existing_document(%{google_doc_id: file_id, name: "Doc"}) + + stub_folder_resolution!() + stub_drive_move!(file_id) + + assert :ok = Documents.delete_document(file_id, actor_uuid: actor_uuid) + + record = TestRepo.get_by!(Document, google_doc_id: file_id) + assert %{"deleted" => deleted} = record.data + assert deleted["by_uuid"] == actor_uuid + assert is_binary(deleted["at"]) + assert {:ok, _dt, _} = DateTime.from_iso8601(deleted["at"]) + end + + test "stamps data[deleted] with nil by_uuid when no actor_uuid given" do + file_id = "drv-doc-del-data-2" + + {:ok, _} = Documents.register_existing_document(%{google_doc_id: file_id, name: "Doc"}) + + stub_folder_resolution!() + stub_drive_move!(file_id) + + assert :ok = Documents.delete_document(file_id) + + record = TestRepo.get_by!(Document, google_doc_id: file_id) + assert %{"deleted" => deleted} = record.data + assert is_nil(deleted["by_uuid"]) + end + + test "preserves other data keys when stamping deleted" do + file_id = "drv-doc-del-data-3" + actor_uuid = Ecto.UUID.generate() + + {:ok, doc} = Documents.register_existing_document(%{google_doc_id: file_id, name: "Doc"}) + # Manually set an existing data key that must survive the delete stamp. + TestRepo.update!(Document.changeset(doc, %{data: %{"recipe" => "preserved"}})) + + stub_folder_resolution!() + stub_drive_move!(file_id) + + assert :ok = Documents.delete_document(file_id, actor_uuid: actor_uuid) + + record = TestRepo.get_by!(Document, google_doc_id: file_id) + assert record.data["recipe"] == "preserved" + assert is_map(record.data["deleted"]) + end + end + + describe "delete_template/2 — data[\"deleted\"] stamping" do + test "stamps data[deleted] with at and by_uuid when a DB record exists" do + actor_uuid = Ecto.UUID.generate() + file_id = "drv-tpl-del-data-1" + + {:ok, _} = Documents.register_existing_template(%{google_doc_id: file_id, name: "Tpl"}) + + stub_folder_resolution!() + stub_drive_move!(file_id) + + assert :ok = Documents.delete_template(file_id, actor_uuid: actor_uuid) + + record = TestRepo.get_by!(Template, google_doc_id: file_id) + assert %{"deleted" => deleted} = record.data + assert deleted["by_uuid"] == actor_uuid + assert is_binary(deleted["at"]) + end + end + describe "restore_document/2 — happy path" do test "logs document.restored on :ok" do actor_uuid = Ecto.UUID.generate() @@ -168,6 +242,60 @@ defmodule PhoenixKitDocumentCreator.Integration.DriveBoundActionsTest do end end + describe "restore_document/2 — data[\"deleted\"] clearing" do + test "clears data[deleted] on restore when DB record exists" do + actor_uuid = Ecto.UUID.generate() + file_id = "drv-doc-restore-data-1" + + {:ok, doc} = Documents.register_existing_document(%{google_doc_id: file_id, name: "Doc"}) + + TestRepo.update!( + Document.changeset(doc, %{ + data: %{ + "deleted" => %{"at" => "2025-01-01T00:00:00Z", "by_uuid" => actor_uuid}, + "recipe" => "preserved" + } + }) + ) + + stub_folder_resolution!() + stub_drive_move!(file_id) + + assert :ok = Documents.restore_document(file_id, actor_uuid: actor_uuid) + + record = TestRepo.get_by!(Document, google_doc_id: file_id) + refute Map.has_key?(record.data, "deleted") + assert record.data["recipe"] == "preserved" + end + end + + describe "restore_template/2 — data[\"deleted\"] clearing" do + test "clears data[deleted] on restore when DB record exists" do + actor_uuid = Ecto.UUID.generate() + file_id = "drv-tpl-restore-data-1" + + {:ok, tpl} = Documents.register_existing_template(%{google_doc_id: file_id, name: "Tpl"}) + + TestRepo.update!( + Template.changeset(tpl, %{ + data: %{ + "deleted" => %{"at" => "2025-01-01T00:00:00Z", "by_uuid" => actor_uuid}, + "recipe" => "preserved" + } + }) + ) + + stub_folder_resolution!() + stub_drive_move!(file_id) + + assert :ok = Documents.restore_template(file_id, actor_uuid: actor_uuid) + + record = TestRepo.get_by!(Template, google_doc_id: file_id) + refute Map.has_key?(record.data, "deleted") + assert record.data["recipe"] == "preserved" + end + end + describe "export_pdf/2 — happy path" do test "logs document.exported_pdf on :ok with size_bytes" do actor_uuid = Ecto.UUID.generate() @@ -275,6 +403,63 @@ defmodule PhoenixKitDocumentCreator.Integration.DriveBoundActionsTest do end end + # ── Drive 404 handling ───────────────────────────────────────────────── + + describe "restore_document/2 — Drive 404 (file deleted from Drive)" do + test "returns {:error, :drive_file_not_found} when GET parents returns 404" do + file_id = "drv-doc-404-get" + stub_folder_resolution!() + + StubIntegrations.stub_request( + :get, + ~r{/drive/v3/files/#{Regex.escape(file_id)}(\?|$)}, + {:ok, + %{status: 404, body: %{"error" => %{"code" => 404, "message" => "File not found."}}}} + ) + + assert {:error, :drive_file_not_found} = + Documents.restore_document(file_id, actor_uuid: Ecto.UUID.generate()) + end + + test "returns {:error, :drive_file_not_found} when PATCH move returns 404" do + file_id = "drv-doc-404-patch" + stub_folder_resolution!() + + StubIntegrations.stub_request( + :get, + ~r{/drive/v3/files/#{Regex.escape(file_id)}(\?|$)}, + {:ok, %{status: 200, body: %{"id" => file_id, "parents" => ["old-parent"]}}} + ) + + StubIntegrations.stub_request( + :patch, + "/drive/v3/files/#{file_id}", + {:ok, + %{status: 404, body: %{"error" => %{"code" => 404, "message" => "File not found."}}}} + ) + + assert {:error, :drive_file_not_found} = + Documents.restore_document(file_id, actor_uuid: Ecto.UUID.generate()) + end + end + + describe "restore_template/2 — Drive 404 (file deleted from Drive)" do + test "returns {:error, :drive_file_not_found} when Drive returns 404" do + file_id = "drv-tpl-404" + stub_folder_resolution!() + + StubIntegrations.stub_request( + :get, + ~r{/drive/v3/files/#{Regex.escape(file_id)}(\?|$)}, + {:ok, + %{status: 404, body: %{"error" => %{"code" => 404, "message" => "File not found."}}}} + ) + + assert {:error, :drive_file_not_found} = + Documents.restore_template(file_id, actor_uuid: Ecto.UUID.generate()) + end + end + # ── Test stub helpers ───────────────────────────────────────────────── # `Documents.create_template/2` (and friends) call `get_folder_ids/0` → diff --git a/test/phoenix_kit_document_creator/google_docs_client_phase_test.exs b/test/phoenix_kit_document_creator/google_docs_client_phase_test.exs new file mode 100644 index 0000000..8652ba0 --- /dev/null +++ b/test/phoenix_kit_document_creator/google_docs_client_phase_test.exs @@ -0,0 +1,259 @@ +defmodule PhoenixKitDocumentCreator.GoogleDocsClientPhaseTest do + use ExUnit.Case, async: true + alias PhoenixKitDocumentCreator.GoogleDocsClient + + # --------------------------------------------------------------------------- + # list_image_inserts/3 — columns=1 uses inline PT-width path + # --------------------------------------------------------------------------- + + describe "list_image_inserts/3 with columns=1" do + test "produces insertInlineImage requests, no insertTable" do + fill = %{ + kind: :image_list, + columns: 1, + media: [ + %{uri: "u1", width_px: nil, height_px: nil}, + %{uri: "u2", width_px: nil, height_px: nil} + ], + separator: :newline + } + + # Invoke via build_image_batch_requests/3 (the public API). + ranges = [%{name: "photos", start_index: 10, end_index: 30}] + fills = %{"photos" => fill} + requests = GoogleDocsClient.build_image_batch_requests(ranges, fills, 468.0) + + refute Enum.any?(requests, &Map.has_key?(&1, "insertTable")) + assert Enum.any?(requests, &Map.has_key?(&1, :insertInlineImage)) + end + + test "width uses content-width-based PT (image_width_for_columns(cw, 1) = cw)" do + fill = %{ + kind: :image_list, + columns: 1, + media: [%{uri: "u1", width_px: nil, height_px: nil}], + separator: :newline + } + + ranges = [%{name: "img", start_index: 5, end_index: 25}] + fills = %{"img" => fill} + content_width_pt = 468.0 + requests = GoogleDocsClient.build_image_batch_requests(ranges, fills, content_width_pt) + + [_delete | inserts] = requests + [insert] = inserts + width_magnitude = get_in(insert, [:insertInlineImage, :objectSize, :width, :magnitude]) + # image_width_for_columns(468.0, 1) == 468.0 + assert width_magnitude == content_width_pt + end + + test "inserts are ordered last-first with newline separators" do + fill = %{ + kind: :image_list, + columns: 1, + media: [ + %{uri: "first", width_px: nil, height_px: nil}, + %{uri: "second", width_px: nil, height_px: nil} + ], + separator: :newline + } + + ranges = [%{name: "p", start_index: 5, end_index: 24}] + fills = %{"p" => fill} + requests = GoogleDocsClient.build_image_batch_requests(ranges, fills, 468.0) + + # delete + second_img + newline + first_img + uris = + for %{insertInlineImage: %{uri: u}} <- requests, do: u + + assert uris == ["second", "first"] + end + end + + # --------------------------------------------------------------------------- + # list_image_inserts/3 — columns >= 2 uses table path + # --------------------------------------------------------------------------- + + describe "list_image_inserts/3 with columns >= 2" do + test "produces exactly one deleteContentRange + one insertTable, no insertInlineImage" do + fill = %{ + kind: :image_list, + columns: 2, + media: [%{uri: "u1"}, %{uri: "u2"}, %{uri: "u3"}], + separator: :newline + } + + ranges = [%{name: "grid", start_index: 50, end_index: 70}] + fills = %{"grid" => fill} + requests = GoogleDocsClient.build_image_batch_requests(ranges, fills, 468.0) + + # build_image_batch_requests/3 emits the deleteContentRange (atom keys). + # list_image_inserts for columns>=2 emits ONLY insertTable (string keys). + # There must be exactly ONE deleteContentRange — a second one would be + # zero-width (startIndex == endIndex) and rejected by the Google Docs API. + delete_count = + Enum.count(requests, fn r -> + Map.has_key?(r, :deleteContentRange) or Map.has_key?(r, "deleteContentRange") + end) + + assert delete_count == 1, "expected exactly 1 deleteContentRange, got #{delete_count}" + assert Enum.any?(requests, &Map.has_key?(&1, "insertTable")) + refute Enum.any?(requests, &Map.has_key?(&1, :insertInlineImage)) + refute Enum.any?(requests, &Map.has_key?(&1, "insertInlineImage")) + end + + test "insertTable has correct row count ceil(media / columns)" do + fill = %{ + kind: :image_list, + columns: 2, + media: [%{uri: "u1"}, %{uri: "u2"}, %{uri: "u3"}], + separator: :newline + } + + ranges = [%{name: "grid", start_index: 50, end_index: 70}] + fills = %{"grid" => fill} + requests = GoogleDocsClient.build_image_batch_requests(ranges, fills, 468.0) + + table_req = Enum.find(requests, &Map.has_key?(&1, "insertTable")) + assert get_in(table_req, ["insertTable", "rows"]) == 2 + assert get_in(table_req, ["insertTable", "columns"]) == 2 + end + end + + # --------------------------------------------------------------------------- + # build_image_batch_requests/3 — mixed slots + # --------------------------------------------------------------------------- + + describe "build_image_batch_requests/3 mixed slots" do + test "single :image slot gets delete + inline insert; image_list columns=2 gets delete + insertTable" do + ranges = [ + %{name: "logo", start_index: 100, end_index: 120}, + %{name: "gallery", start_index: 50, end_index: 70} + ] + + fills = %{ + "logo" => %{ + kind: :image, + columns: 1, + default_width_px: 400, + opacity: 1.0, + z_index: 0, + separator: nil, + media: [%{uri: "logo.png", width_px: 800, height_px: 400}] + }, + "gallery" => %{ + kind: :image_list, + columns: 2, + default_width_px: 400, + separator: :newline, + media: [%{uri: "g1"}, %{uri: "g2"}] + } + } + + requests = GoogleDocsClient.build_image_batch_requests(ranges, fills, 468.0) + + # logo (higher start_index) processed first (desc order); gallery second. + # Verify exactly one deleteContentRange per slot (two slots → two deletes total). + total_deletes = + Enum.count(requests, fn r -> + Map.has_key?(r, :deleteContentRange) or Map.has_key?(r, "deleteContentRange") + end) + + assert total_deletes == 2, "expected exactly 2 deleteContentRange (one per slot)" + + # logo slot: atom-key delete + atom-key inline insert + assert Enum.any?(requests, &match?(%{deleteContentRange: %{range: %{startIndex: 100}}}, &1)) + assert Enum.any?(requests, &match?(%{insertInlineImage: %{location: %{index: 100}}}, &1)) + + # gallery slot: atom-key delete (from outer loop) + string-key insertTable only + assert Enum.any?(requests, &match?(%{deleteContentRange: %{range: %{startIndex: 50}}}, &1)) + assert Enum.any?(requests, &Map.has_key?(&1, "insertTable")) + # No second deleteContentRange for the gallery slot (would be zero-width and API-rejected) + gallery_string_deletes = + Enum.count(requests, &Map.has_key?(&1, "deleteContentRange")) + + assert gallery_string_deletes == 0, + "list_image_inserts must not emit its own deleteContentRange for table slots" + end + end + + # --------------------------------------------------------------------------- + # Cell-finder helper — extract_table_cells equivalent + # --------------------------------------------------------------------------- + + describe "fill_table_cells/3 with extracted cell structure" do + test "extracts insert indices from a known Google Docs table element" do + # Simulate what the Docs API returns for a 2x2 table after insertTable. + # The private extract_table_cells/1 uses startIndex + 1 as the insert + # position. We verify the contract by constructing cells manually. + # cell.startIndex values: 10, 30, 50, 70 → insert indices: 11, 31, 51, 71. + cells = [ + %{insert_index: 11}, + %{insert_index: 31}, + %{insert_index: 51}, + %{insert_index: 71} + ] + + media = [%{uri: "a"}, %{uri: "b"}, %{uri: "c"}] + reqs = GoogleDocsClient.fill_table_cells(cells, media, %{image_width_pt: 230.0}) + + # fill_table_cells zips cells with media (3 items), reverses → last-first + indices = for %{"insertInlineImage" => %{"location" => %{"index" => i}}} <- reqs, do: i + assert indices == [51, 31, 11] + + uris = for %{"insertInlineImage" => %{"uri" => u}} <- reqs, do: u + assert uris == ["c", "b", "a"] + end + + test "table startIndex + 1 gives correct insert position" do + # Mirrors the strategy used in extract_table_cells/1: + # cell.startIndex + 1 = first position inside the cell paragraph. + cell_start = 100 + expected_insert = cell_start + 1 + + cells = [%{insert_index: expected_insert}] + media = [%{uri: "img"}] + [req] = GoogleDocsClient.fill_table_cells(cells, media, %{image_width_pt: 230.0}) + + assert get_in(req, ["insertInlineImage", "location", "index"]) == expected_insert + end + end + + # --------------------------------------------------------------------------- + # build_image_fills columns pass-through + # --------------------------------------------------------------------------- + + describe "build_image_fills columns integration (via build_image_batch_requests/3)" do + test "columns from image_params is respected — 2 columns → insertTable" do + # Simulate image_params as built by build_image_fills/1 with columns key. + fill = %{ + kind: :image_list, + columns: 2, + default_width_px: 400, + separator: :newline, + media: [%{uri: "a"}, %{uri: "b"}, %{uri: "c"}, %{uri: "d"}] + } + + ranges = [%{name: "slot", start_index: 10, end_index: 30}] + requests = GoogleDocsClient.build_image_batch_requests(ranges, %{"slot" => fill}, 468.0) + + assert Enum.any?(requests, &match?(%{"insertTable" => %{"rows" => 2, "columns" => 2}}, &1)) + end + + test "missing columns defaults to 1 → inline inserts" do + fill = %{ + kind: :image_list, + columns: 1, + default_width_px: 400, + separator: :newline, + media: [%{uri: "a"}, %{uri: "b"}] + } + + ranges = [%{name: "slot", start_index: 10, end_index: 30}] + requests = GoogleDocsClient.build_image_batch_requests(ranges, %{"slot" => fill}, 468.0) + + refute Enum.any?(requests, &Map.has_key?(&1, "insertTable")) + assert Enum.any?(requests, &Map.has_key?(&1, :insertInlineImage)) + end + end +end diff --git a/test/phoenix_kit_document_creator/google_docs_client_table_test.exs b/test/phoenix_kit_document_creator/google_docs_client_table_test.exs new file mode 100644 index 0000000..760809e --- /dev/null +++ b/test/phoenix_kit_document_creator/google_docs_client_table_test.exs @@ -0,0 +1,97 @@ +defmodule PhoenixKitDocumentCreator.GoogleDocsClientTableTest do + use ExUnit.Case, async: true + alias PhoenixKitDocumentCreator.GoogleDocsClient + + describe "table_image_inserts/3 — phase A (table creation)" do + test "returns a deleteContentRange + insertTable for placeholder range" do + placeholder = %{start_index: 100, end_index: 120} + media = [%{uri: "u1"}, %{uri: "u2"}, %{uri: "u3"}] + opts = %{columns: 2, content_width_pt: 468.0} + + reqs = GoogleDocsClient.table_image_inserts(placeholder, media, opts) + + assert [ + %{"deleteContentRange" => %{"range" => %{"startIndex" => 100, "endIndex" => 120}}}, + %{"insertTable" => %{"rows" => 2, "columns" => 2, "location" => %{"index" => 100}}} + ] = reqs + end + + test "rows = ceil(count / columns)" do + placeholder = %{start_index: 0, end_index: 10} + media = List.duplicate(%{uri: "u"}, 5) + opts = %{columns: 2, content_width_pt: 468.0} + + reqs = GoogleDocsClient.table_image_inserts(placeholder, media, opts) + + assert Enum.any?( + reqs, + &match?( + %{"insertTable" => %{"rows" => 3, "columns" => 2}}, + &1 + ) + ) + end + + test "clamps columns to 1..4" do + placeholder = %{start_index: 0, end_index: 10} + media = [%{uri: "u"}] + + reqs = + GoogleDocsClient.table_image_inserts(placeholder, media, %{ + columns: 99, + content_width_pt: 468.0 + }) + + assert Enum.any?(reqs, &match?(%{"insertTable" => %{"columns" => 4}}, &1)) + end + end + + describe "fill_table_cells/3 — phase B (image insertion into cells)" do + test "inserts one image per cell, left-to-right top-to-bottom" do + # Mock the doc-after-table-creation: cells with known startIndices. + # Each cell has a paragraph startIndex we insert into. + cells = [ + %{insert_index: 200}, + %{insert_index: 220}, + %{insert_index: 240}, + %{insert_index: 260} + ] + + media = [%{uri: "a"}, %{uri: "b"}, %{uri: "c"}] + opts = %{image_width_pt: 230.0} + + reqs = GoogleDocsClient.fill_table_cells(cells, media, opts) + + uris = for %{"insertInlineImage" => %{"uri" => u}} <- reqs, do: u + assert uris == ["c", "b", "a"], "insert last-first to avoid index drift" + + indices = + for %{"insertInlineImage" => %{"location" => %{"index" => i}}} <- + reqs, + do: i + + # last-first by original cell order + assert indices == [240, 220, 200] + end + + test "ignores extra cells when media is shorter" do + cells = [ + %{insert_index: 200}, + %{insert_index: 220}, + %{insert_index: 240}, + %{insert_index: 260} + ] + + media = [%{uri: "a"}] + reqs = GoogleDocsClient.fill_table_cells(cells, media, %{image_width_pt: 230.0}) + assert length(reqs) == 1 + end + + test "objectSize uses provided image_width_pt for width magnitude" do + cells = [%{insert_index: 100}] + media = [%{uri: "a"}] + [req] = GoogleDocsClient.fill_table_cells(cells, media, %{image_width_pt: 230.0}) + assert get_in(req, ["insertInlineImage", "objectSize", "width", "magnitude"]) == 230.0 + end + end +end diff --git a/test/phoenix_kit_document_creator/google_docs_client_width_test.exs b/test/phoenix_kit_document_creator/google_docs_client_width_test.exs new file mode 100644 index 0000000..73472a6 --- /dev/null +++ b/test/phoenix_kit_document_creator/google_docs_client_width_test.exs @@ -0,0 +1,57 @@ +defmodule PhoenixKitDocumentCreator.GoogleDocsClientWidthTest do + use ExUnit.Case, async: true + alias PhoenixKitDocumentCreator.GoogleDocsClient + + describe "content_width_pt/1" do + test "default A4-ish page with default margins (72pt each side)" do + doc = %{ + "documentStyle" => %{ + "pageSize" => %{"width" => %{"magnitude" => 612.0, "unit" => "PT"}}, + "marginLeft" => %{"magnitude" => 72.0, "unit" => "PT"}, + "marginRight" => %{"magnitude" => 72.0, "unit" => "PT"} + } + } + + assert GoogleDocsClient.content_width_pt(doc) == 468.0 + end + + test "falls back to 468pt when documentStyle missing" do + assert GoogleDocsClient.content_width_pt(%{}) == 468.0 + end + + test "uses pageSize but missing margins → 72pt default each side" do + doc = %{ + "documentStyle" => %{ + "pageSize" => %{"width" => %{"magnitude" => 595.0, "unit" => "PT"}} + } + } + + assert GoogleDocsClient.content_width_pt(doc) == 451.0 + end + end + + describe "image_width_for_columns/2" do + test "N=1 → full content width" do + assert GoogleDocsClient.image_width_for_columns(468.0, 1) == 468.0 + end + + test "N=2 → half minus single gap" do + # gap=8pt → (468 - 8)/2 = 230 + assert GoogleDocsClient.image_width_for_columns(468.0, 2) == 230.0 + end + + test "N=4 → quarter minus three gaps" do + # (468 - 24)/4 = 111 + assert GoogleDocsClient.image_width_for_columns(468.0, 4) == 111.0 + end + + test "clamps N below 1 to 1" do + assert GoogleDocsClient.image_width_for_columns(468.0, 0) == 468.0 + end + + test "clamps N above 4 to 4" do + # (468 - 24)/4 = 111 + assert GoogleDocsClient.image_width_for_columns(468.0, 99) == 111.0 + end + end +end