From 1643a979585ca1dea473e1ac8457eb4e315258a9 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Mon, 18 May 2026 19:16:08 +0300 Subject: [PATCH 01/12] Persist composed-document creation settings in Document.data Composer now writes a caller-supplied :data opt into the Document changeset, and schema_to_file_map/1 exposes the data field. This lets the host app store the template/image selection ("recipe") that produced a document so it can be re-created later. --- lib/phoenix_kit_document_creator/documents.ex | 1 + .../documents/composer.ex | 5 +++- test/documents/composed_document_test.exs | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_kit_document_creator/documents.ex b/lib/phoenix_kit_document_creator/documents.ex index 05983a9..bd50a9c 100644 --- a/lib/phoenix_kit_document_creator/documents.ex +++ b/lib/phoenix_kit_document_creator/documents.ex @@ -306,6 +306,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 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/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} = From ee0b312da01d73385bc29f1742dbce76609bb4ce Mon Sep 17 00:00:00 2001 From: Timujeen Date: Wed, 20 May 2026 10:05:04 +0300 Subject: [PATCH 02/12] Add page content width and column width helpers --- .../google_docs_client.ex | 31 ++++++++++ .../google_docs_client_width_test.exs | 57 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 test/phoenix_kit_document_creator/google_docs_client_width_test.exs diff --git a/lib/phoenix_kit_document_creator/google_docs_client.ex b/lib/phoenix_kit_document_creator/google_docs_client.ex index dad38e4..7fc9356 100644 --- a/lib/phoenix_kit_document_creator/google_docs_client.ex +++ b/lib/phoenix_kit_document_creator/google_docs_client.ex @@ -885,6 +885,37 @@ 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 """ Builds the list of `batchUpdate` request maps to substitute image tags. 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 From 5d13816c05902b3a5150923546673ef24361e7b5 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Wed, 20 May 2026 10:40:07 +0300 Subject: [PATCH 03/12] Add table-based image insertion helpers for multi-column slots --- .../google_docs_client.ex | 50 +++++++++++ .../google_docs_client_table_test.exs | 82 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 test/phoenix_kit_document_creator/google_docs_client_table_test.exs diff --git a/lib/phoenix_kit_document_creator/google_docs_client.ex b/lib/phoenix_kit_document_creator/google_docs_client.ex index 7fc9356..bcd6357 100644 --- a/lib/phoenix_kit_document_creator/google_docs_client.ex +++ b/lib/phoenix_kit_document_creator/google_docs_client.ex @@ -917,6 +917,56 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do (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 works on any consistent unit; using PT values directly + # preserves the aspect ratio. When src dimensions are absent it falls + # back to w_pt (a square), which the API will then scale to fit. + 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. 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..7077b6e --- /dev/null +++ b/test/phoenix_kit_document_creator/google_docs_client_table_test.exs @@ -0,0 +1,82 @@ +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 From 064c0d29b70f6fb30a9d46830b13912ea0146286 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Wed, 20 May 2026 11:12:59 +0300 Subject: [PATCH 04/12] Unify Active/Trash sub-tabs on Categories page with Documents style Replace boolean categories_trash/types_trash with string status_mode assigns (active/trashed); replace toggle_categories_trash and toggle_types_trash with a single switch_status event using phx-value-target and phx-value-mode, matching DocumentsLive. Extract status_subtabs/1 component shared by Categories and Types columns; render in the DocumentsLive visual style (border-b-2 with border-primary for Active and border-error for Trash). Auto-hide the entire sub-tab row when the Trash list is empty and the current mode is active. Track trashed_categories_count and trashed_types_count, reusing the loaded list as count when viewing trash to avoid a second query. --- .../web/categories_live.ex | 165 +++++++++++------- 1 file changed, 103 insertions(+), 62 deletions(-) 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 --%>
    <%= if @categories == [] do %>
  • - {if @categories_trash, do: gettext("No trashed categories."), else: gettext("No categories yet.")} + {if @categories_status_mode == "trashed", + do: gettext("No trashed categories."), + else: gettext("No categories yet.")}
  • <% end %> <%= for cat <- @categories do %> @@ -312,7 +315,7 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do data-id={cat.uuid} > @@ -326,7 +329,7 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do > {cat.name} - <.category_row_menu category={cat} trash_view={@categories_trash} /> + <.category_row_menu category={cat} trash_view={@categories_status_mode == "trashed"} /> <% end %>
@@ -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} + />
    <%= if @types == [] do %>
  • - {if @types_trash, do: gettext("No trashed types."), else: gettext("No types yet.")} + {if @types_status_mode == "trashed", + do: gettext("No trashed types."), + else: gettext("No types yet.")}
  • <% end %> <%= for type <- @types do %> @@ -390,14 +384,14 @@ defmodule PhoenixKitDocumentCreator.Web.CategoriesLive do data-id={type.uuid} > {type.name} - <.type_row_menu type={type} trash_view={@types_trash} /> + <.type_row_menu type={type} trash_view={@types_status_mode == "trashed"} /> <% end %>
@@ -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,45 @@ 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 + files + |> extract_deleted_by_uuids() + |> Auth.get_users_by_uuids() + |> Map.new(&{&1.uuid, user_display_name(&1)}) + 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 +1351,10 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do

{format_time(file["modifiedTime"])}

+

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

<%!-- Actions --%> @@ -1357,6 +1415,7 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do {gettext("Name")} {gettext("Status")} + {gettext("Deleted")} {gettext("Modified")} {gettext("Actions")} @@ -1364,7 +1423,7 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do <%= if MapSet.member?(@pending_files, file["id"]) do %> - + <% else %> @@ -1415,6 +1474,9 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do {gettext("unfiled")} + + {format_deleted_info(file["data"]["deleted"], @deleted_by_names)} + {format_time(file["modifiedTime"])}
@@ -1653,6 +1715,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/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..5965390 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,52 @@ 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}} + }) + ) + + 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") + 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}} + }) + ) + + 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") + 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 +395,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` → From 6944db2c8280c59ef2a9b38fa33ae696a04d9d49 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Wed, 20 May 2026 14:52:18 +0300 Subject: [PATCH 09/12] Address review carryovers: NULL-safe JSONB, restore preservation test, empty-uuid short-circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap data column with COALESCE(data, '{}'::jsonb) in both fragments of stamp_deleted_data/2 and clear_deleted_data/1, so a row with a NULL data column still gets the deleted metadata written / cleared correctly. The data column is nullable in the migration. - Extend restore_document/2 and restore_template/2 tests to include a sibling 'recipe' key in data alongside 'deleted', and assert it survives restore — previously only the delete path verified preservation. - Skip Auth.get_users_by_uuids/1 when the trashed UUID list is empty (active tab, or trash tab with no by_uuid stamped rows). --- lib/phoenix_kit_document_creator/documents.ex | 20 ++++- .../google_docs_client.ex | 79 +++++++++++++------ .../web/documents_live.ex | 15 +++- test/integration/drive_bound_actions_test.exs | 12 ++- .../google_docs_client_phase_test.exs | 17 ++-- 5 files changed, 103 insertions(+), 40 deletions(-) diff --git a/lib/phoenix_kit_document_creator/documents.ex b/lib/phoenix_kit_document_creator/documents.ex index b3eeaff..5f01727 100644 --- a/lib/phoenix_kit_document_creator/documents.ex +++ b/lib/phoenix_kit_document_creator/documents.ex @@ -1698,7 +1698,13 @@ defmodule PhoenixKitDocumentCreator.Documents do from(t in Template, where: t.google_doc_id == ^google_doc_id, update: [ - set: [data: fragment("data || jsonb_build_object('deleted', ?::jsonb)", ^deleted_entry)] + set: [ + data: + fragment( + "COALESCE(data, '{}'::jsonb) || jsonb_build_object('deleted', ?::jsonb)", + ^deleted_entry + ) + ] ] ) |> repo().update_all([]) @@ -1706,7 +1712,13 @@ defmodule PhoenixKitDocumentCreator.Documents do from(d in Document, where: d.google_doc_id == ^google_doc_id, update: [ - set: [data: fragment("data || jsonb_build_object('deleted', ?::jsonb)", ^deleted_entry)] + set: [ + data: + fragment( + "COALESCE(data, '{}'::jsonb) || jsonb_build_object('deleted', ?::jsonb)", + ^deleted_entry + ) + ] ] ) |> repo().update_all([]) @@ -1795,13 +1807,13 @@ defmodule PhoenixKitDocumentCreator.Documents do defp clear_deleted_data(google_doc_id) do from(t in Template, where: t.google_doc_id == ^google_doc_id, - update: [set: [data: fragment("data - 'deleted'")]] + 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("data - 'deleted'")]] + update: [set: [data: fragment("COALESCE(data, '{}'::jsonb) - 'deleted'")]] ) |> repo().update_all([]) end diff --git a/lib/phoenix_kit_document_creator/google_docs_client.ex b/lib/phoenix_kit_document_creator/google_docs_client.ex index 2ac02f3..3acabc8 100644 --- a/lib/phoenix_kit_document_creator/google_docs_client.ex +++ b/lib/phoenix_kit_document_creator/google_docs_client.ex @@ -1035,18 +1035,26 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do defp list_image_inserts(%{media: []}, _index, _content_width_pt), do: [] # Column-aware path: dispatch on columns count. - # columns >= 2 → table creation requests (Phase 1 of two-phase insertion). + # 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 - placeholder = %{start_index: index, end_index: index} + rows = (length(fill.media) / cols) |> Float.ceil() |> trunc() |> max(1) - table_image_inserts(placeholder, fill.media, %{ - columns: cols, - content_width_pt: content_width_pt - }) + [ + %{ + "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) @@ -1598,41 +1606,62 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClient do (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) + 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, and - # fill their cells with images. - defp do_fill_table_cells(doc_id, table_ranges, fills_map, content_width_pt) do + # 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 - # Count pre-existing tables in doc2 is unnecessary — instead we rely on - # document order: table_ranges sorted asc by original start_index correspond - # one-to-one with the newly inserted tables in document order. - table_slots_asc = - Enum.sort_by(table_ranges, & &1.start_index, :asc) - - all_tables = collect_tables(doc3) + 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(all_tables) < length(table_slots_asc) do + if length(new_tables) != length(table_slots_asc) do Logger.warning( - "substitute_all_images: expected #{length(table_slots_asc)} tables " <> - "but found #{length(all_tables)} in doc #{doc_id}; skipping Phase 2" + "substitute_all_images: expected #{length(table_slots_asc)} new tables " <> + "but found #{length(new_tables)} in doc #{doc_id}; skipping Phase 2" ) :ok else - # Take the LAST K tables (the newly inserted ones appear in document order - # matching the ascending-sorted table_slots). - k = length(table_slots_asc) - new_tables = Enum.take(all_tables, -k) - phase2_requests = Enum.zip(table_slots_asc, new_tables) |> Enum.flat_map(fn {%{name: name}, table_el} -> diff --git a/lib/phoenix_kit_document_creator/web/documents_live.ex b/lib/phoenix_kit_document_creator/web/documents_live.ex index 09c7b9a..ccbc54d 100644 --- a/lib/phoenix_kit_document_creator/web/documents_live.ex +++ b/lib/phoenix_kit_document_creator/web/documents_live.ex @@ -1227,10 +1227,17 @@ defmodule PhoenixKitDocumentCreator.Web.DocumentsLive do # 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 - files - |> extract_deleted_by_uuids() - |> Auth.get_users_by_uuids() - |> Map.new(&{&1.uuid, user_display_name(&1)}) + 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 diff --git a/test/integration/drive_bound_actions_test.exs b/test/integration/drive_bound_actions_test.exs index 5965390..4f2ab55 100644 --- a/test/integration/drive_bound_actions_test.exs +++ b/test/integration/drive_bound_actions_test.exs @@ -251,7 +251,10 @@ defmodule PhoenixKitDocumentCreator.Integration.DriveBoundActionsTest do TestRepo.update!( Document.changeset(doc, %{ - data: %{"deleted" => %{"at" => "2025-01-01T00:00:00Z", "by_uuid" => actor_uuid}} + data: %{ + "deleted" => %{"at" => "2025-01-01T00:00:00Z", "by_uuid" => actor_uuid}, + "recipe" => "preserved" + } }) ) @@ -262,6 +265,7 @@ defmodule PhoenixKitDocumentCreator.Integration.DriveBoundActionsTest do record = TestRepo.get_by!(Document, google_doc_id: file_id) refute Map.has_key?(record.data, "deleted") + assert record.data["recipe"] == "preserved" end end @@ -274,7 +278,10 @@ defmodule PhoenixKitDocumentCreator.Integration.DriveBoundActionsTest do TestRepo.update!( Template.changeset(tpl, %{ - data: %{"deleted" => %{"at" => "2025-01-01T00:00:00Z", "by_uuid" => actor_uuid}} + data: %{ + "deleted" => %{"at" => "2025-01-01T00:00:00Z", "by_uuid" => actor_uuid}, + "recipe" => "preserved" + } }) ) @@ -285,6 +292,7 @@ defmodule PhoenixKitDocumentCreator.Integration.DriveBoundActionsTest do record = TestRepo.get_by!(Template, google_doc_id: file_id) refute Map.has_key?(record.data, "deleted") + assert record.data["recipe"] == "preserved" end end 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 index b8c4946..4131de4 100644 --- a/test/phoenix_kit_document_creator/google_docs_client_phase_test.exs +++ b/test/phoenix_kit_document_creator/google_docs_client_phase_test.exs @@ -75,7 +75,7 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClientPhaseTest do # --------------------------------------------------------------------------- describe "list_image_inserts/3 with columns >= 2" do - test "produces deleteContentRange + insertTable, no insertInlineImage" do + test "produces exactly one deleteContentRange + one insertTable, no insertInlineImage" do fill = %{ kind: :image_list, columns: 2, @@ -87,12 +87,19 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClientPhaseTest do fills = %{"grid" => fill} requests = GoogleDocsClient.build_image_batch_requests(ranges, fills, 468.0) - # Overall delete for the slot (from build_image_batch_requests), then - # table_image_inserts also emits a deleteContentRange + insertTable. - # The slot-level delete comes from build_image_batch_requests itself; - # list_image_inserts for columns>=2 returns delete+insertTable directly. + # 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 From 849111dc787e9f8647100fb0cf368181d5b5f29a Mon Sep 17 00:00:00 2001 From: Timujeen Date: Wed, 20 May 2026 14:54:14 +0300 Subject: [PATCH 10/12] Fix double deleteContentRange and pre-existing table matching --- .../google_docs_client_phase_test.exs | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) 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 index 4131de4..8652ba0 100644 --- a/test/phoenix_kit_document_creator/google_docs_client_phase_test.exs +++ b/test/phoenix_kit_document_creator/google_docs_client_phase_test.exs @@ -152,21 +152,28 @@ defmodule PhoenixKitDocumentCreator.GoogleDocsClientPhaseTest do requests = GoogleDocsClient.build_image_batch_requests(ranges, fills, 468.0) - # logo (higher start_index) processed first (desc order) - logo_requests = - Enum.take_while(requests, fn r -> - not (Map.has_key?(r, "deleteContentRange") and - get_in(r, ["deleteContentRange", "range", "startIndex"]) == 50) + # 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) - gallery_requests = requests -- logo_requests + assert total_deletes == 2, "expected exactly 2 deleteContentRange (one per slot)" - # logo slot: atom-key delete + atom-key insert - assert Enum.any?(logo_requests, &match?(%{deleteContentRange: _}, &1)) - assert Enum.any?(logo_requests, &match?(%{insertInlineImage: _}, &1)) + # 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")) - # gallery slot: string-key table requests - assert Enum.any?(gallery_requests, &Map.has_key?(&1, "insertTable")) + assert gallery_string_deletes == 0, + "list_image_inserts must not emit its own deleteContentRange for table slots" end end From 6fbefce422c80fc8467a09e33e4eb223e1e1fe6d Mon Sep 17 00:00:00 2001 From: Timujeen Date: Wed, 20 May 2026 16:35:39 +0300 Subject: [PATCH 11/12] Sidebar: move Categories tab last Bump the :admin_document_creator_categories Tab priority from 647 to 651 so it sorts after Documents (648) and Templates (649). Children of an admin module tab are ordered by ascending priority, so Categories previously appeared first; now it appears last as intended. --- lib/phoenix_kit_document_creator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From c8b01284d8d13d8602963faa1e27ccf7190ab0f2 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Wed, 20 May 2026 16:58:11 +0300 Subject: [PATCH 12/12] Image columns: per-slot config in template editor + Andi propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'columns' field (1..4) to image_list variable configs in the Document Creator template editor and propagates the saved config to consumers via image_slots_for_template/1. - variable.ex: default_image_config(:image_list) now includes columns: 1 so new variables get a sensible default. - documents.ex: coerce_config/1 parses and clamps 'columns' to 1..4 (mirrors the existing max_count handling). image_slots_for_template/1 now returns %{name, kind, config} — config is merged from the saved variable.config with defaults, normalised to string keys so consumers do not need to handle both atom and string forms. - variable_config_form.ex: adds a Columns select (1..4) to the :image_list variant of the form, matching the existing separator/ max_count fields visually. - _phoenix_kit_sources.css: regenerated by the compile step. --- assets/css/_phoenix_kit_sources.css | 2 +- lib/phoenix_kit_document_creator/documents.ex | 68 ++++++++++++++++--- lib/phoenix_kit_document_creator/variable.ex | 9 ++- .../web/components/variable_config_form.ex | 24 ++++++- 4 files changed, 92 insertions(+), 11 deletions(-) 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/documents.ex b/lib/phoenix_kit_document_creator/documents.ex index 5f01727..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 @@ -1885,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/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/components/variable_config_form.ex b/lib/phoenix_kit_document_creator/web/components/variable_config_form.ex index 00d6618..704dc57 100644 --- a/lib/phoenix_kit_document_creator/web/components/variable_config_form.ex +++ b/lib/phoenix_kit_document_creator/web/components/variable_config_form.ex @@ -35,7 +35,14 @@ defmodule PhoenixKitDocumentCreator.Web.Components.VariableConfigForm do def config_form(%{variable: %{type: :image_list}} = assigns) do current = assigns.variable.config[:separator] || assigns.variable.config["separator"] current_separator = if current, do: to_string(current), else: "newline" - assigns = assign(assigns, :current_separator, current_separator) + + current_columns = + assigns.variable.config[:columns] || assigns.variable.config["columns"] || 1 + + current_columns = to_string(current_columns) + + assigns = + assign(assigns, current_separator: current_separator, current_columns: current_columns) ~H"""
@@ -66,6 +73,21 @@ defmodule PhoenixKitDocumentCreator.Web.Components.VariableConfigForm do
+
+ + +