refactor: move pure post-editor logic to BDS.UI.PostEditor for TUI reuse (issue #26, phase 3)

This commit is contained in:
2026-07-14 19:41:43 +02:00
parent 16f45d77b2
commit 3a8fafd2fd
9 changed files with 198 additions and 264 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -1,140 +0,0 @@
defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
@moduledoc false
alias BDS.{Metadata, Tags}
require Logger
@spec field_key(term()) :: term()
def field_key(:tags), do: "tags"
def field_key(:categories), do: "categories"
@spec tag_values(term()) :: term()
def tag_values(form), do: csv_to_list(Map.get(form, "tags", ""))
@spec category_values(term()) :: term()
def category_values(form), do: csv_to_list(Map.get(form, "categories", ""))
@spec tag_suggestions(term(), term(), term()) :: term()
def tag_suggestions(form, options, query) do
selected = MapSet.new(tag_values(form))
filter_suggestions(options, query, fn option -> option.name end, selected)
end
@spec tag_chips(term(), term()) :: term()
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
@spec category_suggestions(term(), term(), term()) :: term()
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
@spec query_addable?(term(), term(), term(), term()) :: term()
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
@spec normalize_list_entry(term()) :: term()
def normalize_list_entry(value) do
value
|> to_string()
|> String.trim()
|> String.downcase()
end
@spec ensure_list_value(term(), term(), term()) :: term()
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 ->
Logger.warning("swallowed list_values ensure_list_value error: #{inspect(error)}")
:ok
end
@spec csv_to_list(term()) :: term()
def csv_to_list(value) do
value
|> to_string()
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
@spec tag_chip_style(term()) :: term()
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
# nil is handled by tag_chip_style/1 before this is reached.
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
<<r::binary-size(2), g::binary-size(2), b::binary-size(2)>> = 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"
@spec ai_overlay_fields(term()) :: term()
def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted)
end

View File

@@ -1,119 +0,0 @@
defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
@moduledoc false
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata}
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)
if DraftManagement.editing_canonical_language?(
translations,
active_language,
canonical_language
) do
post
|> save_canonical_draft(draft, action)
|> 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
@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)
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
@spec has_published_version?(term()) :: term()
def has_published_version?(%Post{} = post),
do: not is_nil(post.published_at) or post.file_path not in [nil, ""]
@spec discard_label(term()) :: term()
def discard_label(%Post{} = post) do
if has_published_version?(post),
do: dgettext("ui", "Discard Changes"),
else: dgettext("ui", "Discard Draft")
end
@spec discard_title(term()) :: term()
def discard_title(%Post{} = post) do
if has_published_version?(post),
do: dgettext("ui", "Discard changes and restore the published version"),
else: dgettext("ui", "Delete this unpublished draft")
end
defp save_canonical_draft(%Post{id: post_id}, draft, action) 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"))
},
auto_translate: action != :auto_save
)
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
end

View File

@@ -1,227 +0,0 @@
defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
@moduledoc false
import Ecto.Query
alias BDS.{I18n, Media, Metadata, PostLinks, Posts, Preview, Repo, Templates}
alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.{Post, PostMedia}
use Gettext, backend: BDS.Gettext
@spec project_metadata(term()) :: term()
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
@spec canonical_language(term(), term()) :: term()
def canonical_language(post, metadata) do
BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language(
post.language,
metadata.main_language || "en"
)
end
@spec translations(term()) :: term()
def translations(post_id) do
{:ok, translations} = Posts.list_post_translations(post_id)
Map.new(translations, fn translation -> {translation.language, translation} end)
end
@spec languages(term()) :: term()
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
@spec template_options(term()) :: term()
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
@spec linked_media(term()) :: term()
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 Media.get_media(media_id) do
%MediaRecord{} = 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
@spec post_links(term()) :: term()
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 Posts.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
@spec translation_flags(term(), term(), term(), term()) :: term()
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
@spec footer(term(), term(), term(), term()) :: term()
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
@spec display_title(term(), term(), term()) :: term()
def display_title(title, slug, fallback_id) do
blank_to_nil(title) || blank_to_nil(slug) || fallback_id || dgettext("ui", "Untitled")
end
@spec gallery_count(term()) :: term()
def gallery_count(form) do
content = form |> Map.get("content", "") |> to_string()
image_count =
content
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|> length()
gallery_macro_count =
content
|> then(&Regex.scan(~r/\[\[gallery\]\]/i, &1))
|> length()
max(image_count, gallery_macro_count)
end
@spec preview_url(term(), term(), term(), term()) :: term()
def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview,
do: nil
@spec preview_url(term(), term(), term(), term()) :: term()
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}/#{BDS.Strings.pad2(datetime.month)}/#{BDS.Strings.pad2(datetime.day)}/#{slug || ""}"
end
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)
@spec truthy?(term()) :: boolean()
def truthy?(value), do: BDS.Values.truthy?(value)
@spec blank?(term()) :: term()
def blank?(value), do: blank_to_nil(value) == nil
@spec blank_to_nil(term()) :: term()
def blank_to_nil(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
end