From f76e48e409de5a08e45706bc70d194247a78c727 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 15:05:18 +0200 Subject: [PATCH] chore: post editor refactored --- CODESMELL.md | 9 +- lib/bds/desktop/shell_live/post_editor.ex | 601 +++--------------- .../post_editor/draft_management.ex | 183 ++++++ .../shell_live/post_editor/list_values.ex | 125 ++++ .../shell_live/post_editor/persistence.ex | 105 +++ .../shell_live/post_editor/post_metadata.ex | 190 ++++++ 6 files changed, 682 insertions(+), 531 deletions(-) create mode 100644 lib/bds/desktop/shell_live/post_editor/draft_management.ex create mode 100644 lib/bds/desktop/shell_live/post_editor/list_values.ex create mode 100644 lib/bds/desktop/shell_live/post_editor/persistence.ex create mode 100644 lib/bds/desktop/shell_live/post_editor/post_metadata.ex diff --git a/CODESMELL.md b/CODESMELL.md index 309e866..0dcbfee 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -2,7 +2,7 @@ Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`). -Last refreshed: 2026-05-05. +Last refreshed: 2026-05-06. --- @@ -14,7 +14,6 @@ Last refreshed: 2026-05-05. | # | Module | Current lines | Target | Strategy | |---|---|---|---|---| -| 6 | `BDS.Desktop.ShellLive.PostEditor` | 963 | ≤ 400 | Extract `DraftManagement` (~180), `ListValues` (~160), `Persistence` (~140), `PostMetadata` (~150). | | 7 | `BDS.Desktop.ShellLive.SettingsEditor` | 872 | ≤ 350 | Extract `ProjectSettings` (~140), `AISettings` (~150), `PublishingSettings` (~80), `ManagedCategories` (~140), `StyleEditor` (~80), `MCPConfig` (~60). | | 8 | `BDS.Desktop.ShellLive.ChatEditor` | 972 | ≤ 400 | Extract `ToolSurfaces` (~280), `ToolTracking` (~140), `MessageBuild` (~160), `ModelSelection` (~100). Defer — highest internal coupling. | | 9 | `BDS.MCP` | 677 | ≤ 350 | Split tools / resources / proposals / serialization clusters. (Carried over from original priority list.) | @@ -33,6 +32,7 @@ Last refreshed: 2026-05-05. - `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %) - `BDS.Rendering` 838 → 33 (96 %) - `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %) +- `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %) --- @@ -166,6 +166,11 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search` ## Changelog +### 2026-05-06 + +- **God modules**: + - `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %). Submodules under `lib/bds/desktop/shell_live/post_editor/`: `PostMetadata` (190, project_metadata + canonical_language + translations + languages + template_options + linked_media + post_links + translation_flags + footer + format_timestamp + display_title + gallery_count + preview_url + canonical_preview_path + pad2 + maybe_put_query + truthy? + blank? + blank_to_nil), `ListValues` (125, field_key + tag_values/category_values + tag_suggestions/category_suggestions + filter_suggestions + tag_chips + query_addable? + normalize_query + normalize_list_entry + ensure_list_value + csv_to_list + tag_chip_style + normalize_color + contrast_color + ai_overlay_fields), `DraftManagement` (183, normalize_mode + normalize_language + normalize_params + current_draft + persisted_form/3,4 + maybe_update_draft + put_draft_field + put_query_state + query_value + query_key + maybe_drop_old_language_draft + toggled_sections + put_nested_map + delete_nested_map + reload_with_assigned_workbench + save_state_for_action + record_title + record_status + editing_canonical_language?), `Persistence` (105, persist + discard + has_published_version? + discard_label + discard_title + save_canonical_draft + save_translation_draft + maybe_publish_post + maybe_publish_translation). Coordinator keeps the 16 public event handlers (assign_socket, update, persist_socket, discard_socket, delete_socket, set_mode, toggle_section, select_language, toggle_quick_actions, detect_language, translate, apply_ai_suggestions, insert_content, add_list_value, remove_list_value), build/1,2, and the HEEx-callable helpers (`translated/1,2`, `post_status_label/1`, `post_editor_save_state_label/1`, `post_editor_mode_label/1`); `tag_chip_style/1` is exposed via `defdelegate` so HEEx call sites stay unchanged. Cross-submodule deps form a runtime cycle between PostMetadata.canonical_language → DraftManagement.normalize_language and DraftManagement.persisted_form → PostMetadata.translations/canonical_language (compile-safe, no compile cycle). Persistence → DraftManagement + PostMetadata; ListValues is a leaf. Each submodule that needs it duplicates the small `translated/2`, `blank_to_nil/1`, `csv_to_list/1` helpers locally per the established convention. Submodules use `Phoenix.Component.assign/3` directly (only DraftManagement needs it). The 400-line target was not reachable while keeping all 16 public event handlers + build + HEEx helpers in the coordinator. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (0 errors), `mix test` (342 tests, 0 failures, 4 skipped). + ### 2026-05-05 - **God modules**: diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex index 43bc202..17b68aa 100644 --- a/lib/bds/desktop/shell_live/post_editor.ex +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -3,14 +3,77 @@ defmodule BDS.Desktop.ShellLive.PostEditor do use Phoenix.Component - import Ecto.Query - + alias BDS.{AI, Posts, Preview, Repo} alias BDS.Desktop.ShellData - alias BDS.{AI, I18n, Metadata, PostLinks, Posts, Preview, Repo, Tags, Templates} - alias BDS.Media.Media - alias BDS.Posts.{Post, PostMedia, Translation} + alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata} + alias BDS.Posts.Post + alias BDS.Tags alias BDS.UI.Workbench + import DraftManagement, + only: [ + current_draft: 4, + delete_nested_map: 3, + editing_canonical_language?: 3, + maybe_update_draft: 7, + normalize_language: 2, + normalize_mode: 1, + normalize_params: 3, + persisted_form: 3, + put_draft_field: 6, + put_nested_map: 4, + put_query_state: 4, + query_value: 3, + record_status: 1, + record_title: 2, + reload_with_assigned_workbench: 2, + save_state_for_action: 1, + toggled_sections: 3 + ] + + import ListValues, + only: [ + category_suggestions: 3, + category_values: 1, + csv_to_list: 1, + ensure_list_value: 3, + field_key: 1, + normalize_list_entry: 1, + query_addable?: 4, + tag_chips: 2, + tag_suggestions: 3, + tag_values: 1 + ] + + import Persistence, + only: [ + discard: 3, + discard_label: 1, + discard_title: 1, + has_published_version?: 1, + persist: 5 + ] + + import PostMetadata, + only: [ + blank?: 1, + blank_to_nil: 1, + canonical_language: 2, + display_title: 3, + footer: 4, + gallery_count: 1, + languages: 1, + linked_media: 1, + post_links: 1, + preview_url: 4, + project_metadata: 1, + template_options: 1, + translation_flags: 4, + translations: 1 + ] + + defdelegate tag_chip_style(color), to: ListValues + embed_templates "post_editor_html/*" def assign_socket(socket) do @@ -363,12 +426,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor do canonical_language = canonical_language(post, metadata) active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language) translations = translations(post.id) - persisted_form = persisted_form(post, metadata, active_language, translations) + persisted = DraftManagement.persisted_form(post, metadata, active_language, translations) form = assigns.post_editor_drafts |> Map.get(post.id, %{}) - |> Map.get(active_language, persisted_form) + |> Map.get(active_language, persisted) expanded = Map.get(assigns.post_editor_expanded, post.id, %{ @@ -425,91 +488,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do def build(_assigns), do: nil - 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 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 - - def persisted_form(%Post{} = post, metadata, active_language) do - persisted_form(post, metadata, active_language, translations(post.id)) - end - - def persist(%Post{} = post, draft, active_language, metadata, action) do - canonical_language = canonical_language(post, metadata) - translations = translations(post.id) - - result = - if editing_canonical_language?(translations, active_language, canonical_language) do - post - |> save_canonical_draft(draft) - |> maybe_publish_post(post.id, action) - else - post.id - |> save_translation_draft(active_language, draft) - |> maybe_publish_translation(post.id, active_language, action) - end - - result - end - - def discard(%Post{} = post, active_language, metadata) do - canonical_language = canonical_language(post, metadata) - current_translations = translations(post.id) - - cond do - not editing_canonical_language?(current_translations, active_language, canonical_language) -> - {:ok, post} - - post.file_path not in [nil, ""] and post.status == :draft -> - Posts.discard_post_changes(post.id) - - true -> - {:ok, post} - 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 - def post_status_label(status), do: ShellData.dashboard_status_label(status) def post_editor_save_state_label(:dirty), do: translated("Unsaved") @@ -521,443 +499,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do def post_editor_mode_label(:markdown), do: translated("Markdown") def post_editor_mode_label(:preview), do: translated("Preview") - def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) - - def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted) - - def project_metadata(nil), do: %{main_language: "en", blog_languages: []} - - def project_metadata(project_id) do - {:ok, metadata} = Metadata.get_project_metadata(project_id) - metadata - rescue - _error -> %{main_language: "en", blog_languages: []} - end - - def tag_chip_style(nil), do: nil - - def tag_chip_style(color) do - normalized = normalize_color(color) - - if normalized do - "background-color: #{normalized}; color: #{contrast_color(normalized)}; border-color: #{normalized};" - end - end + def translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp assigned_project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{}) - - defp 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 - - defp 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 - - defp put_draft_field(socket, post_id, post, active_language, field, value) do - metadata = 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 - - defp 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 - - defp 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 field_key(:tags), do: "tags" - defp field_key(:categories), do: "categories" - - defp tag_values(form), do: csv_to_list(Map.get(form, "tags", "")) - defp category_values(form), do: csv_to_list(Map.get(form, "categories", "")) - - defp tag_suggestions(form, options, query) do - selected = MapSet.new(tag_values(form)) - filter_suggestions(options, query, fn option -> option.name end, selected) - end - - defp tag_chips(form, options) do - option_map = Map.new(options, fn option -> {option.name, option} end) - - Enum.map(tag_values(form), fn name -> - option = Map.get(option_map, name) - %{name: name, color: option && option.color} - end) - end - - defp category_suggestions(form, options, query) do - selected = MapSet.new(category_values(form)) - filter_suggestions(options, query, & &1, selected) - end - - defp filter_suggestions(options, query, labeler, selected) do - query = normalize_query(query) - - options - |> Enum.filter(fn option -> - label = labeler.(option) - not MapSet.member?(selected, label) and (query == "" or String.contains?(String.downcase(label), query)) - end) - |> Enum.take(8) - end - - defp query_addable?(query, selected_values, options, labeler) do - normalized = normalize_query(query) - - normalized != "" and - normalized not in Enum.map(selected_values, &String.downcase/1) and - not Enum.any?(options, fn option -> String.downcase(labeler.(option)) == normalized end) - end - - defp normalize_query(value) do - value - |> to_string() - |> String.trim() - |> String.downcase() - end - - defp normalize_list_entry(value) do - value - |> to_string() - |> String.trim() - |> String.downcase() - end - - defp ensure_list_value(project_id, :tags, value) do - if Enum.any?(Tags.list_tags(project_id), &(String.downcase(&1.name) == value)) do - :ok - else - _ = Tags.create_tag(%{project_id: project_id, name: value}) - :ok - end - end - - defp ensure_list_value(project_id, :categories, value) do - {:ok, metadata} = Metadata.get_project_metadata(project_id) - - if value in (metadata.categories || []) do - :ok - else - _ = Metadata.add_category(project_id, value) - :ok - end - rescue - _error -> :ok - end - - defp normalize_color(nil), do: nil - defp normalize_color(""), do: nil - - defp normalize_color("#" <> rest = color) when byte_size(rest) == 6 do - if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil - end - - defp normalize_color(_color), do: nil - - defp contrast_color("#" <> rgb) do - <> = rgb - {red, _} = Integer.parse(r, 16) - {green, _} = Integer.parse(g, 16) - {blue, _} = Integer.parse(b, 16) - luminance = (red * 299 + green * 587 + blue * 114) / 1000 - if luminance > 150, do: "#1e1e1e", else: "#ffffff" - end - - defp contrast_color(_color), do: "#ffffff" - - defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench) - - defp persisted_form(post, metadata, active_language, translations) do - canonical_language = 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 - - defp canonical_language(post, metadata) do - normalize_language(post.language, metadata.main_language || "en") - end - - defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true - defp truthy?(_value), do: false - - defp blank?(value), do: blank_to_nil(value) == nil - - defp blank_to_nil(value) do - value - |> to_string() - |> String.trim() - |> case do - "" -> nil - trimmed -> trimmed - end - end - - defp csv_to_list(value) do - value - |> to_string() - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - end - - defp translations(post_id) do - {:ok, translations} = Posts.list_post_translations(post_id) - Map.new(translations, fn translation -> {translation.language, translation} end) - end - - defp languages(metadata) do - (([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ Enum.map(I18n.supported_languages(), & &1.code)) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - end - - defp template_options(project_id) do - Repo.all( - from template in Templates.Template, - where: template.project_id == ^project_id, - order_by: [asc: template.title, asc: template.slug], - select: %{slug: template.slug, title: fragment("COALESCE(?, ?)", template.title, template.slug)} - ) - rescue - _error -> [] - end - - defp linked_media(post_id) do - rows = - Repo.all( - from pm in PostMedia, - where: pm.post_id == ^post_id, - order_by: [asc: pm.sort_order, asc: pm.media_id], - select: {pm.media_id, pm.sort_order} - ) - - Enum.map(rows, fn {media_id, sort_order} -> - case Repo.get(Media, media_id) do - %Media{} = media -> - %{ - media_id: media.id, - has_thumbnail: String.starts_with?(to_string(media.mime_type || ""), "image/"), - name: media.title || media.original_name || media.id, - sort_order: sort_order || 0 - } - - _other -> - nil - end - end) - |> Enum.reject(&is_nil/1) - rescue - _error -> [] - end - - defp post_links(post_id) do - %{ - backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id), - outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id) - } - end - - defp related_posts(links, key) do - Enum.map(links, fn link -> - case Repo.get(Post, Map.fetch!(link, key)) do - %Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} - _other -> nil - end - end) - |> Enum.reject(&is_nil/1) - end - - defp translation_flags(post, canonical_language, active_language, translations) do - canonical = %{language: canonical_language, flag: I18n.flag(canonical_language), status: Atom.to_string(post.status || :draft), active: active_language == canonical_language, label: canonical_language} - - others = - translations - |> Map.values() - |> Enum.sort_by(& &1.language) - |> Enum.map(fn translation -> - %{ - language: translation.language, - flag: I18n.flag(translation.language), - status: Atom.to_string(translation.status || :draft), - active: active_language == translation.language, - label: translation.language - } - end) - - [canonical | others] - end - - defp footer(post, translation, active_language, canonical_language) do - if active_language == canonical_language do - %{ - created_at: format_timestamp(post.created_at), - updated_at: format_timestamp(post.updated_at), - published_at: format_timestamp(post.published_at) - } - else - %{ - created_at: format_timestamp(translation && translation.created_at || post.created_at), - updated_at: format_timestamp(translation && translation.updated_at || post.updated_at), - published_at: format_timestamp(translation && translation.published_at) - } - end - end - - defp format_timestamp(nil), do: "" - - defp format_timestamp(timestamp) do - timestamp - |> DateTime.from_unix!(:millisecond) - |> Calendar.strftime("%x") - end - - defp display_title(title, slug, fallback_id) do - blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled") - end - - defp has_published_version?(%Post{} = post), do: not is_nil(post.published_at) or post.file_path not in [nil, ""] - - defp discard_label(%Post{} = post) do - if has_published_version?(post), do: translated("Discard Changes"), else: translated("Discard Draft") - end - - defp discard_title(%Post{} = post) do - if has_published_version?(post), do: translated("Discard changes and restore the published version"), else: translated("Delete this unpublished draft") - end - - defp gallery_count(form) do - form - |> Map.get("content", "") - |> to_string() - |> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1)) - |> length() - end - - defp preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil - - defp preview_url(%Post{} = post, active_language, canonical_language, :preview) do - query = - %{} - |> maybe_put_query("draft", "true") - |> maybe_put_query("post_id", post.id) - |> maybe_put_query("lang", active_language != canonical_language && active_language) - - Preview.base_url() <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query) - end - - defp canonical_preview_path(created_at_ms, slug) do - datetime = DateTime.from_unix!(created_at_ms, :millisecond) - "/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{slug || ""}" - end - - defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") - - defp maybe_put_query(query, _key, false), do: query - defp maybe_put_query(query, _key, nil), do: query - defp maybe_put_query(query, key, value), do: Map.put(query, key, value) - - defp save_canonical_draft(%Post{id: post_id}, draft) do - Posts.update_post(post_id, %{ - title: blank_to_nil(Map.get(draft, "title")), - excerpt: blank_to_nil(Map.get(draft, "excerpt")), - content: blank_to_nil(Map.get(draft, "content")), - tags: csv_to_list(Map.get(draft, "tags")), - categories: csv_to_list(Map.get(draft, "categories")), - author: blank_to_nil(Map.get(draft, "author")), - language: blank_to_nil(Map.get(draft, "language")), - do_not_translate: Map.get(draft, "do_not_translate", false), - template_slug: blank_to_nil(Map.get(draft, "template_slug")) - }) - end - - defp save_translation_draft(post_id, language, draft) do - Posts.upsert_post_translation(post_id, language, %{ - title: Map.get(draft, "title", ""), - excerpt: blank_to_nil(Map.get(draft, "excerpt")), - content: blank_to_nil(Map.get(draft, "content")) - }) - end - - defp maybe_publish_post({:ok, %Post{}}, post_id, :publish), do: Posts.publish_post(post_id) - defp maybe_publish_post(result, _post_id, _action), do: result - - defp maybe_publish_translation({:ok, _translation}, post_id, language, :publish), do: Posts.publish_post_translation(post_id, language) - defp maybe_publish_translation(result, _post_id, _language, _action), do: result - - 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 - - defp 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, ¬ &1) - end - - defp put_nested_map(map, key, nested_key, value) do - Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) - end - - defp 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 end diff --git a/lib/bds/desktop/shell_live/post_editor/draft_management.ex b/lib/bds/desktop/shell_live/post_editor/draft_management.ex new file mode 100644 index 0000000..2050a2a --- /dev/null +++ b/lib/bds/desktop/shell_live/post_editor/draft_management.ex @@ -0,0 +1,183 @@ +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 + + 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 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 + + def persisted_form(%Post{} = post, metadata, active_language) do + persisted_form(post, metadata, active_language, PostMetadata.translations(post.id)) + end + + 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 + + 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 + + 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 + + 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 + + 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 + + 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, ¬ &1) + end + + def put_nested_map(map, key, nested_key, value) do + Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) + end + + 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 + + def reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench) + + 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) when value in [true, "true", "on", 1, "1"], do: true + defp truthy?(_value), do: false + + 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/desktop/shell_live/post_editor/list_values.ex new file mode 100644 index 0000000..0be75ad --- /dev/null +++ b/lib/bds/desktop/shell_live/post_editor/list_values.ex @@ -0,0 +1,125 @@ +defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do + @moduledoc false + + alias BDS.{Metadata, Tags} + + def field_key(:tags), do: "tags" + def field_key(:categories), do: "categories" + + def tag_values(form), do: csv_to_list(Map.get(form, "tags", "")) + def category_values(form), do: csv_to_list(Map.get(form, "categories", "")) + + def tag_suggestions(form, options, query) do + selected = MapSet.new(tag_values(form)) + filter_suggestions(options, query, fn option -> option.name end, selected) + end + + def tag_chips(form, options) do + option_map = Map.new(options, fn option -> {option.name, option} end) + + Enum.map(tag_values(form), fn name -> + option = Map.get(option_map, name) + %{name: name, color: option && option.color} + end) + end + + def category_suggestions(form, options, query) do + selected = MapSet.new(category_values(form)) + filter_suggestions(options, query, & &1, selected) + end + + defp filter_suggestions(options, query, labeler, selected) do + query = normalize_query(query) + + options + |> Enum.filter(fn option -> + label = labeler.(option) + not MapSet.member?(selected, label) and (query == "" or String.contains?(String.downcase(label), query)) + end) + |> Enum.take(8) + end + + def query_addable?(query, selected_values, options, labeler) do + normalized = normalize_query(query) + + normalized != "" and + normalized not in Enum.map(selected_values, &String.downcase/1) and + not Enum.any?(options, fn option -> String.downcase(labeler.(option)) == normalized end) + end + + defp normalize_query(value) do + value + |> to_string() + |> String.trim() + |> String.downcase() + end + + def normalize_list_entry(value) do + value + |> to_string() + |> String.trim() + |> String.downcase() + end + + def ensure_list_value(project_id, :tags, value) do + if Enum.any?(Tags.list_tags(project_id), &(String.downcase(&1.name) == value)) do + :ok + else + _ = Tags.create_tag(%{project_id: project_id, name: value}) + :ok + end + end + + def ensure_list_value(project_id, :categories, value) do + {:ok, metadata} = Metadata.get_project_metadata(project_id) + + if value in (metadata.categories || []) do + :ok + else + _ = Metadata.add_category(project_id, value) + :ok + end + rescue + _error -> :ok + end + + def csv_to_list(value) do + value + |> to_string() + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + def tag_chip_style(nil), do: nil + + def tag_chip_style(color) do + normalized = normalize_color(color) + + if normalized do + "background-color: #{normalized}; color: #{contrast_color(normalized)}; border-color: #{normalized};" + end + end + + defp normalize_color(nil), do: nil + defp normalize_color(""), do: nil + + defp normalize_color("#" <> rest = color) when byte_size(rest) == 6 do + if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil + end + + defp normalize_color(_color), do: nil + + defp contrast_color("#" <> rgb) do + <> = rgb + {red, _} = Integer.parse(r, 16) + {green, _} = Integer.parse(g, 16) + {blue, _} = Integer.parse(b, 16) + luminance = (red * 299 + green * 587 + blue * 114) / 1000 + if luminance > 150, do: "#1e1e1e", else: "#ffffff" + end + + defp contrast_color(_color), do: "#ffffff" + + def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted) +end diff --git a/lib/bds/desktop/shell_live/post_editor/persistence.ex b/lib/bds/desktop/shell_live/post_editor/persistence.ex new file mode 100644 index 0000000..800a30a --- /dev/null +++ b/lib/bds/desktop/shell_live/post_editor/persistence.ex @@ -0,0 +1,105 @@ +defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do + @moduledoc false + + alias BDS.Posts + alias BDS.Posts.Post + alias BDS.Desktop.ShellData + alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata} + + def persist(%Post{} = post, draft, active_language, metadata, action) do + canonical_language = PostMetadata.canonical_language(post, metadata) + translations = PostMetadata.translations(post.id) + + if DraftManagement.editing_canonical_language?(translations, active_language, canonical_language) do + post + |> save_canonical_draft(draft) + |> maybe_publish_post(post.id, action) + else + post.id + |> save_translation_draft(active_language, draft) + |> maybe_publish_translation(post.id, active_language, action) + end + end + + def discard(%Post{} = post, active_language, metadata) do + canonical_language = PostMetadata.canonical_language(post, metadata) + current_translations = PostMetadata.translations(post.id) + + cond do + not DraftManagement.editing_canonical_language?(current_translations, active_language, canonical_language) -> + {:ok, post} + + post.file_path not in [nil, ""] and post.status == :draft -> + Posts.discard_post_changes(post.id) + + true -> + {:ok, post} + end + end + + def has_published_version?(%Post{} = post), + do: not is_nil(post.published_at) or post.file_path not in [nil, ""] + + def discard_label(%Post{} = post) do + if has_published_version?(post), + do: translated("Discard Changes"), + else: translated("Discard Draft") + end + + def discard_title(%Post{} = post) do + if has_published_version?(post), + do: translated("Discard changes and restore the published version"), + else: translated("Delete this unpublished draft") + end + + defp save_canonical_draft(%Post{id: post_id}, draft) do + Posts.update_post(post_id, %{ + title: blank_to_nil(Map.get(draft, "title")), + excerpt: blank_to_nil(Map.get(draft, "excerpt")), + content: blank_to_nil(Map.get(draft, "content")), + tags: csv_to_list(Map.get(draft, "tags")), + categories: csv_to_list(Map.get(draft, "categories")), + author: blank_to_nil(Map.get(draft, "author")), + language: blank_to_nil(Map.get(draft, "language")), + do_not_translate: Map.get(draft, "do_not_translate", false), + template_slug: blank_to_nil(Map.get(draft, "template_slug")) + }) + end + + defp save_translation_draft(post_id, language, draft) do + Posts.upsert_post_translation(post_id, language, %{ + title: Map.get(draft, "title", ""), + excerpt: blank_to_nil(Map.get(draft, "excerpt")), + content: blank_to_nil(Map.get(draft, "content")) + }) + end + + defp maybe_publish_post({:ok, %Post{}}, post_id, :publish), do: Posts.publish_post(post_id) + defp maybe_publish_post(result, _post_id, _action), do: result + + defp maybe_publish_translation({:ok, _translation}, post_id, language, :publish), + do: Posts.publish_post_translation(post_id, language) + + defp maybe_publish_translation(result, _post_id, _language, _action), do: result + + defp blank_to_nil(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + + defp csv_to_list(value) do + value + |> to_string() + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/post_editor/post_metadata.ex b/lib/bds/desktop/shell_live/post_editor/post_metadata.ex new file mode 100644 index 0000000..4b409d4 --- /dev/null +++ b/lib/bds/desktop/shell_live/post_editor/post_metadata.ex @@ -0,0 +1,190 @@ +defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do + @moduledoc false + + import Ecto.Query + + alias BDS.{I18n, Metadata, PostLinks, Posts, Preview, Repo, Templates} + alias BDS.Desktop.ShellData + alias BDS.Media.Media + alias BDS.Posts.{Post, PostMedia} + + def project_metadata(nil), do: %{main_language: "en", blog_languages: []} + + def project_metadata(project_id) do + {:ok, metadata} = Metadata.get_project_metadata(project_id) + metadata + rescue + _error -> %{main_language: "en", blog_languages: []} + end + + def canonical_language(post, metadata) do + BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language( + post.language, + metadata.main_language || "en" + ) + end + + def translations(post_id) do + {:ok, translations} = Posts.list_post_translations(post_id) + Map.new(translations, fn translation -> {translation.language, translation} end) + end + + def languages(metadata) do + (([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ Enum.map(I18n.supported_languages(), & &1.code)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + def template_options(project_id) do + Repo.all( + from template in Templates.Template, + where: template.project_id == ^project_id, + order_by: [asc: template.title, asc: template.slug], + select: %{slug: template.slug, title: fragment("COALESCE(?, ?)", template.title, template.slug)} + ) + rescue + _error -> [] + end + + def linked_media(post_id) do + rows = + Repo.all( + from pm in PostMedia, + where: pm.post_id == ^post_id, + order_by: [asc: pm.sort_order, asc: pm.media_id], + select: {pm.media_id, pm.sort_order} + ) + + Enum.map(rows, fn {media_id, sort_order} -> + case Repo.get(Media, media_id) do + %Media{} = media -> + %{ + media_id: media.id, + has_thumbnail: String.starts_with?(to_string(media.mime_type || ""), "image/"), + name: media.title || media.original_name || media.id, + sort_order: sort_order || 0 + } + + _other -> + nil + end + end) + |> Enum.reject(&is_nil/1) + rescue + _error -> [] + end + + def post_links(post_id) do + %{ + backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id), + outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id) + } + end + + defp related_posts(links, key) do + Enum.map(links, fn link -> + case Repo.get(Post, Map.fetch!(link, key)) do + %Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} + _other -> nil + end + end) + |> Enum.reject(&is_nil/1) + end + + def translation_flags(post, canonical_language, active_language, translations) do + canonical = %{language: canonical_language, flag: I18n.flag(canonical_language), status: Atom.to_string(post.status || :draft), active: active_language == canonical_language, label: canonical_language} + + others = + translations + |> Map.values() + |> Enum.sort_by(& &1.language) + |> Enum.map(fn translation -> + %{ + language: translation.language, + flag: I18n.flag(translation.language), + status: Atom.to_string(translation.status || :draft), + active: active_language == translation.language, + label: translation.language + } + end) + + [canonical | others] + end + + def footer(post, translation, active_language, canonical_language) do + if active_language == canonical_language do + %{ + created_at: format_timestamp(post.created_at), + updated_at: format_timestamp(post.updated_at), + published_at: format_timestamp(post.published_at) + } + else + %{ + created_at: format_timestamp(translation && translation.created_at || post.created_at), + updated_at: format_timestamp(translation && translation.updated_at || post.updated_at), + published_at: format_timestamp(translation && translation.published_at) + } + end + end + + defp format_timestamp(nil), do: "" + + defp format_timestamp(timestamp) do + timestamp + |> DateTime.from_unix!(:millisecond) + |> Calendar.strftime("%x") + end + + def display_title(title, slug, fallback_id) do + blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled") + end + + def gallery_count(form) do + form + |> Map.get("content", "") + |> to_string() + |> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1)) + |> length() + end + + def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil + + def preview_url(%Post{} = post, active_language, canonical_language, :preview) do + query = + %{} + |> maybe_put_query("draft", "true") + |> maybe_put_query("post_id", post.id) + |> maybe_put_query("lang", active_language != canonical_language && active_language) + + Preview.base_url() <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query) + end + + defp canonical_preview_path(created_at_ms, slug) do + datetime = DateTime.from_unix!(created_at_ms, :millisecond) + "/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{slug || ""}" + end + + defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") + + defp maybe_put_query(query, _key, false), do: query + defp maybe_put_query(query, _key, nil), do: query + defp maybe_put_query(query, key, value), do: Map.put(query, key, value) + + def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true + def truthy?(_value), do: false + + def blank?(value), do: blank_to_nil(value) == nil + + def blank_to_nil(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) +end