diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex index b0aef77..feb45aa 100644 --- a/lib/bds/desktop/shell_live/post_editor.ex +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -6,11 +6,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do alias BDS.{AI, Embeddings, Metadata, Posts, Preview, Tasks} alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify} - alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata} alias BDS.Posts.Post alias BDS.Tags + alias BDS.UI.PostEditor.{Draft, ListValues, Persistence} - import DraftManagement, + import Draft, only: [ editing_canonical_language?: 3, normalize_language: 2, @@ -48,7 +48,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do use Gettext, backend: BDS.Gettext - import PostMetadata, + import BDS.UI.PostEditor.Metadata, only: [ blank?: 1, blank_to_nil: 1, diff --git a/lib/bds/desktop/shell_live/post_editor/draft_management.ex b/lib/bds/desktop/shell_live/post_editor/draft_management.ex deleted file mode 100644 index 6869356..0000000 --- a/lib/bds/desktop/shell_live/post_editor/draft_management.ex +++ /dev/null @@ -1,241 +0,0 @@ -defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do - @moduledoc false - - import Phoenix.Component, only: [assign: 3] - - alias BDS.Posts - alias BDS.Posts.{Post, Translation} - alias BDS.Desktop.ShellLive.PostEditor.PostMetadata - alias BDS.UI.Workbench - - @spec normalize_mode(term()) :: term() - def normalize_mode(mode) when mode in [:markdown, :preview], do: mode - @spec normalize_mode(term()) :: term() - def normalize_mode("visual"), do: :markdown - def normalize_mode("preview"), do: :preview - def normalize_mode(_mode), do: :markdown - - @spec normalize_language(term(), term()) :: term() - def normalize_language(value, fallback) do - case value |> to_string() |> String.trim() do - "" -> fallback - normalized -> String.downcase(normalized) - end - end - - @spec normalize_params(term(), term(), term()) :: term() - def normalize_params(params, current_language, next_language) do - %{ - "title" => Map.get(params, "title", ""), - "excerpt" => Map.get(params, "excerpt", ""), - "content" => Map.get(params, "content", ""), - "tags" => Map.get(params, "tags", ""), - "categories" => Map.get(params, "categories", ""), - "author" => Map.get(params, "author", ""), - "language" => - if(current_language == next_language, - do: normalize_language(Map.get(params, "language"), current_language), - else: next_language - ), - "do_not_translate" => truthy?(Map.get(params, "do_not_translate")), - "template_slug" => Map.get(params, "template_slug", "") - } - end - - @spec current_draft(term(), term(), term(), term()) :: term() - def current_draft(assigns, %Post{} = post, metadata, active_language) do - persisted = persisted_form(post, metadata, active_language) - - assigns.post_editor_drafts - |> Map.get(post.id, %{}) - |> Map.get(active_language, persisted) - end - - @spec persisted_form(term(), term(), term()) :: term() - def persisted_form(%Post{} = post, metadata, active_language) do - persisted_form(post, metadata, active_language, PostMetadata.translations(post.id)) - end - - @spec persisted_form(term(), term(), term(), term()) :: term() - def persisted_form(post, metadata, active_language, translations) do - canonical_language = PostMetadata.canonical_language(post, metadata) - translation = Map.get(translations, active_language) - - if active_language == canonical_language do - %{ - "title" => post.title || "", - "excerpt" => post.excerpt || "", - "content" => Posts.editor_body(post), - "tags" => Enum.join(post.tags || [], ", "), - "categories" => Enum.join(post.categories || [], ", "), - "author" => post.author || metadata.default_author || "", - "language" => canonical_language, - "do_not_translate" => post.do_not_translate || false, - "template_slug" => post.template_slug || "" - } - else - %{ - "title" => (translation && translation.title) || "", - "excerpt" => (translation && translation.excerpt) || "", - "content" => if(translation, do: Posts.editor_body(translation), else: ""), - "tags" => Enum.join(post.tags || [], ", "), - "categories" => Enum.join(post.categories || [], ", "), - "author" => post.author || metadata.default_author || "", - "language" => active_language, - "do_not_translate" => post.do_not_translate || false, - "template_slug" => post.template_slug || "" - } - end - end - - @spec maybe_update_draft(term(), term(), term(), term(), term(), term(), term()) :: term() - def maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do - workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id) - - socket - |> assign(:workbench, workbench) - |> assign( - :post_editor_drafts, - put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft) - ) - |> assign( - :post_editor_active_languages, - Map.put(socket.assigns.post_editor_active_languages, post_id, next_language) - ) - |> assign( - :post_editor_save_states, - Map.put(socket.assigns.post_editor_save_states, post_id, :dirty) - ) - |> assign( - :tab_meta, - Map.put(socket.assigns.tab_meta, {:post, post_id}, %{ - title: draft["title"], - subtitle: Atom.to_string(post.status || :draft) - }) - ) - |> maybe_drop_old_language_draft(post_id, current_language, next_language) - end - - def maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do - assign( - socket, - :post_editor_active_languages, - Map.put(socket.assigns.post_editor_active_languages, post_id, next_language) - ) - end - - @spec put_draft_field(term(), term(), term(), term(), term(), term()) :: term() - def put_draft_field(socket, post_id, post, active_language, field, value) do - metadata = PostMetadata.project_metadata(post.project_id) - draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value) - workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id) - - socket - |> assign(:workbench, workbench) - |> assign( - :post_editor_drafts, - put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft) - ) - |> assign( - :post_editor_save_states, - Map.put(socket.assigns.post_editor_save_states, post_id, :dirty) - ) - end - - @spec put_query_state(term(), term(), term(), term()) :: term() - def put_query_state(socket, post_id, kind, value) do - key = query_key(kind) - - assign( - socket, - key, - Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || "")) - ) - end - - @spec query_value(term(), term(), term()) :: term() - def query_value(assigns, kind, post_id) do - assigns - |> Map.get(query_key(kind), %{}) - |> Map.get(post_id, "") - end - - defp query_key(:tags), do: :post_editor_tag_queries - defp query_key(:categories), do: :post_editor_category_queries - - defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) - when current_language == next_language, - do: socket - - defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do - assign( - socket, - :post_editor_drafts, - delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language) - ) - end - - @spec toggled_sections(term(), term(), term()) :: term() - def toggled_sections(expanded_by_post, post_id, section) do - expanded_by_post - |> Map.get(post_id, %{metadata: false, excerpt: false}) - |> Map.put_new(:metadata, false) - |> Map.put_new(:excerpt, false) - |> Map.update!(section, &(not &1)) - end - - @spec put_nested_map(term(), term(), term(), term()) :: term() - def put_nested_map(map, key, nested_key, value) do - Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) - end - - @spec delete_nested_map(term(), term(), term()) :: term() - def delete_nested_map(map, key, nested_key) do - case Map.get(map, key) do - nil -> - map - - nested -> - case Map.delete(nested, nested_key) do - emptied when map_size(emptied) == 0 -> Map.delete(map, key) - remaining -> Map.put(map, key, remaining) - end - end - end - - @spec reload_with_assigned_workbench(term(), term()) :: term() - def reload_with_assigned_workbench(socket, reload), - do: reload.(socket, socket.assigns.workbench) - - @spec save_state_for_action(term()) :: term() - def save_state_for_action(:publish), do: :published - def save_state_for_action(_action), do: :saved - - @spec record_title(term(), term()) :: term() - def record_title(%Translation{title: title}, post), - do: blank_to_nil(title) || post.title || post.slug || post.id - - def record_title(%Post{title: title, slug: slug, id: id}, _post), - do: blank_to_nil(title) || blank_to_nil(slug) || id - - @spec record_status(term()) :: term() - def record_status(%Translation{status: status}), do: status || :draft - def record_status(%Post{status: status}), do: status || :draft - - @spec editing_canonical_language?(term(), term(), term()) :: term() - def editing_canonical_language?(translations, active_language, canonical_language) do - active_language == canonical_language or not Map.has_key?(translations, active_language) - end - - defp truthy?(value), do: BDS.Values.truthy?(value) - - defp blank_to_nil(value) do - value - |> to_string() - |> String.trim() - |> case do - "" -> nil - trimmed -> trimmed - end - end -end diff --git a/lib/bds/ui/post_editor/draft.ex b/lib/bds/ui/post_editor/draft.ex new file mode 100644 index 0000000..51b24e8 --- /dev/null +++ b/lib/bds/ui/post_editor/draft.ex @@ -0,0 +1,107 @@ +defmodule BDS.UI.PostEditor.Draft do + @moduledoc """ + Pure draft/form logic for the post editor (issue #26, phase 3). + + Shared by the LiveView shell and the TUI: builds the persisted form for a + post (canonical language or translation), normalizes editor params, and + provides the small pure helpers both renderers need. No socket, no + process state — callers own the draft maps. + """ + + alias BDS.Posts + alias BDS.Posts.{Post, Translation} + alias BDS.UI.PostEditor.Metadata + + def normalize_mode(mode) when mode in [:markdown, :preview], do: mode + def normalize_mode("visual"), do: :markdown + def normalize_mode("preview"), do: :preview + def normalize_mode(_mode), do: :markdown + + def normalize_language(value, fallback) do + case value |> to_string() |> String.trim() do + "" -> fallback + normalized -> String.downcase(normalized) + end + end + + def normalize_params(params, current_language, next_language) do + %{ + "title" => Map.get(params, "title", ""), + "excerpt" => Map.get(params, "excerpt", ""), + "content" => Map.get(params, "content", ""), + "tags" => Map.get(params, "tags", ""), + "categories" => Map.get(params, "categories", ""), + "author" => Map.get(params, "author", ""), + "language" => + if(current_language == next_language, + do: normalize_language(Map.get(params, "language"), current_language), + else: next_language + ), + "do_not_translate" => truthy?(Map.get(params, "do_not_translate")), + "template_slug" => Map.get(params, "template_slug", "") + } + end + + def persisted_form(%Post{} = post, metadata, active_language) do + persisted_form(post, metadata, active_language, Metadata.translations(post.id)) + end + + def persisted_form(post, metadata, active_language, translations) do + canonical_language = Metadata.canonical_language(post, metadata) + translation = Map.get(translations, active_language) + + if active_language == canonical_language do + %{ + "title" => post.title || "", + "excerpt" => post.excerpt || "", + "content" => Posts.editor_body(post), + "tags" => Enum.join(post.tags || [], ", "), + "categories" => Enum.join(post.categories || [], ", "), + "author" => post.author || metadata.default_author || "", + "language" => canonical_language, + "do_not_translate" => post.do_not_translate || false, + "template_slug" => post.template_slug || "" + } + else + %{ + "title" => (translation && translation.title) || "", + "excerpt" => (translation && translation.excerpt) || "", + "content" => if(translation, do: Posts.editor_body(translation), else: ""), + "tags" => Enum.join(post.tags || [], ", "), + "categories" => Enum.join(post.categories || [], ", "), + "author" => post.author || metadata.default_author || "", + "language" => active_language, + "do_not_translate" => post.do_not_translate || false, + "template_slug" => post.template_slug || "" + } + end + end + + def save_state_for_action(:publish), do: :published + def save_state_for_action(_action), do: :saved + + def record_title(%Translation{title: title}, post), + do: blank_to_nil(title) || post.title || post.slug || post.id + + def record_title(%Post{title: title, slug: slug, id: id}, _post), + do: blank_to_nil(title) || blank_to_nil(slug) || id + + def record_status(%Translation{status: status}), do: status || :draft + def record_status(%Post{status: status}), do: status || :draft + + def editing_canonical_language?(translations, active_language, canonical_language) do + active_language == canonical_language or not Map.has_key?(translations, active_language) + end + + defp truthy?(value), do: BDS.Values.truthy?(value) + + defp blank_to_nil(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end +end diff --git a/lib/bds/desktop/shell_live/post_editor/list_values.ex b/lib/bds/ui/post_editor/list_values.ex similarity index 98% rename from lib/bds/desktop/shell_live/post_editor/list_values.ex rename to lib/bds/ui/post_editor/list_values.ex index f187621..fe65254 100644 --- a/lib/bds/desktop/shell_live/post_editor/list_values.ex +++ b/lib/bds/ui/post_editor/list_values.ex @@ -1,4 +1,4 @@ -defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do +defmodule BDS.UI.PostEditor.ListValues do @moduledoc false alias BDS.{Metadata, Tags} diff --git a/lib/bds/desktop/shell_live/post_editor/post_metadata.ex b/lib/bds/ui/post_editor/metadata.ex similarity index 98% rename from lib/bds/desktop/shell_live/post_editor/post_metadata.ex rename to lib/bds/ui/post_editor/metadata.ex index 3963ac1..05f4f90 100644 --- a/lib/bds/desktop/shell_live/post_editor/post_metadata.ex +++ b/lib/bds/ui/post_editor/metadata.ex @@ -1,4 +1,4 @@ -defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do +defmodule BDS.UI.PostEditor.Metadata do @moduledoc false import Ecto.Query @@ -20,7 +20,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do @spec canonical_language(term(), term()) :: term() def canonical_language(post, metadata) do - BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language( + BDS.UI.PostEditor.Draft.normalize_language( post.language, metadata.main_language || "en" ) diff --git a/lib/bds/desktop/shell_live/post_editor/persistence.ex b/lib/bds/ui/post_editor/persistence.ex similarity index 83% rename from lib/bds/desktop/shell_live/post_editor/persistence.ex rename to lib/bds/ui/post_editor/persistence.ex index 28fa9c2..1a9a1ca 100644 --- a/lib/bds/desktop/shell_live/post_editor/persistence.ex +++ b/lib/bds/ui/post_editor/persistence.ex @@ -1,17 +1,21 @@ -defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do - @moduledoc false +defmodule BDS.UI.PostEditor.Persistence do + @moduledoc """ + Save/publish/discard workflow for post drafts, shared by the LiveView + shell and the TUI (issue #26, phase 3). Routes canonical-language edits + to the post itself and other languages to translations. + """ alias BDS.Posts alias BDS.Posts.Post - alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata} + alias BDS.UI.PostEditor.{Draft, Metadata} use Gettext, backend: BDS.Gettext @spec persist(term(), term(), term(), term(), term()) :: term() def persist(%Post{} = post, draft, active_language, metadata, action) do - canonical_language = PostMetadata.canonical_language(post, metadata) - translations = PostMetadata.translations(post.id) + canonical_language = Metadata.canonical_language(post, metadata) + translations = Metadata.translations(post.id) - if DraftManagement.editing_canonical_language?( + if Draft.editing_canonical_language?( translations, active_language, canonical_language @@ -28,11 +32,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do @spec discard(term(), term(), term()) :: term() def discard(%Post{} = post, active_language, metadata) do - canonical_language = PostMetadata.canonical_language(post, metadata) - current_translations = PostMetadata.translations(post.id) + canonical_language = Metadata.canonical_language(post, metadata) + current_translations = Metadata.translations(post.id) cond do - not DraftManagement.editing_canonical_language?( + not Draft.editing_canonical_language?( current_translations, active_language, canonical_language diff --git a/test/bds/gallery_pipeline_test.exs b/test/bds/gallery_pipeline_test.exs index 5364de1..ecbf6f5 100644 --- a/test/bds/gallery_pipeline_test.exs +++ b/test/bds/gallery_pipeline_test.exs @@ -55,43 +55,43 @@ defmodule BDS.GalleryPipelineTest do test "counts [[gallery]] macro as gallery content" do form = %{"content" => "Some text\n\n[[gallery]]\n\nMore text"} - assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1 + assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1 end test "counts inline images as before" do form = %{"content" => "![Alt](image.jpg)"} - assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1 + assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1 end test "counts both [[gallery]] and inline images" do form = %{"content" => "![Alt](image.jpg)\n[[gallery]]"} - assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1 + assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1 end test "returns 0 when no gallery marks present" do form = %{"content" => "Just plain text"} - assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0 + assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 0 end test "handles empty content" do form = %{} - assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0 + assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 0 end test "counts multiple [[gallery]] occurrences" do form = %{"content" => "[[gallery]] some text [[gallery]]"} - assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 2 + assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 2 end test "is case insensitive for [[gallery]]" do form = %{"content" => "[[Gallery]]"} - assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1 + assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1 end end diff --git a/test/bds/post_translations_test.exs b/test/bds/post_translations_test.exs index 3b9298a..6b73d8d 100644 --- a/test/bds/post_translations_test.exs +++ b/test/bds/post_translations_test.exs @@ -220,7 +220,7 @@ defmodule BDS.PostTranslationsTest do "template_slug" => "" } - alias BDS.Desktop.ShellLive.PostEditor.Persistence + alias BDS.UI.PostEditor.Persistence assert {:ok, _post} = Persistence.persist(post, draft, "en", metadata, :auto_save) assert ai_tasks() == [] diff --git a/test/bds/ui/post_editor_test.exs b/test/bds/ui/post_editor_test.exs new file mode 100644 index 0000000..0cde7ef --- /dev/null +++ b/test/bds/ui/post_editor_test.exs @@ -0,0 +1,64 @@ +defmodule BDS.UI.PostEditorTest do + use ExUnit.Case, async: false + + alias BDS.UI.PostEditor.{Draft, Metadata, Persistence} + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_dir = Path.join(System.tmp_dir!(), "bds-ui-pe-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "UI PostEditor", data_path: temp_dir}) + + {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "First Post", + content: "hello world", + tags: ["elixir"], + language: "en" + }) + + %{project: project, post: post} + end + + test "persisted_form builds the canonical draft", %{post: post} do + metadata = Metadata.project_metadata(post.project_id) + form = Draft.persisted_form(post, metadata, "en") + + assert form["title"] == "First Post" + assert form["content"] == "hello world" + assert form["tags"] == "elixir" + assert form["language"] == "en" + end + + test "persist saves an edited draft and publish publishes it", %{post: post} do + metadata = Metadata.project_metadata(post.project_id) + form = Draft.persisted_form(post, metadata, "en") + + draft = %{form | "title" => "Renamed Post", "content" => "updated body"} + + assert {:ok, saved} = Persistence.persist(post, draft, "en", metadata, :save) + assert saved.title == "Renamed Post" + assert saved.status == :draft + + form = Draft.persisted_form(saved, metadata, "en") + assert {:ok, published} = Persistence.persist(saved, form, "en", metadata, :publish) + assert published.status == :published + assert published.file_path != "" + end + + test "discard on an unpublished draft is a no-op success", %{post: post} do + metadata = Metadata.project_metadata(post.project_id) + assert {:ok, _post} = Persistence.discard(post, "en", metadata) + end + + test "normalize_params keeps the active language on same-language edits" do + params = %{"title" => "T", "language" => "DE "} + + assert Draft.normalize_params(params, "de", "de")["language"] == "de" + assert Draft.normalize_params(params, "de", "fr")["language"] == "fr" + end +end