defmodule BDS.UI.Sidebar do @moduledoc false import Ecto.Query use Gettext, backend: BDS.Gettext alias BDS.AI.ChatConversation alias BDS.ImportDefinitions alias BDS.Media.Media alias BDS.Posts.Post alias BDS.Posts.Translation alias BDS.Repo alias BDS.Scripts.Script alias BDS.Tags.Tag alias BDS.Templates.Template @default_page_size 500 @spec snapshot(String.t() | nil) :: map() def snapshot(nil), do: empty_snapshot() def snapshot(project_id) when is_binary(project_id) do %{ "posts" => view(project_id, "posts"), "pages" => view(project_id, "pages"), "media" => view(project_id, "media"), "scripts" => view(project_id, "scripts"), "templates" => view(project_id, "templates"), "tags" => view(project_id, "tags"), "chat" => view(project_id, "chat"), "import" => entity_list_view( dgettext("ui", "Import"), dgettext("ui", "Import definitions"), "import", list_import_definitions(project_id) ), "git" => git_view(), "settings" => settings_nav_view() } end @spec view(String.t() | nil, String.t() | atom(), map()) :: map() def view(project_id, view_id, params \\ %{}) def view(nil, view_id, _params), do: empty_view(view_id) def view(project_id, view_id, params) when is_binary(project_id) do normalized_view = normalize_view_id(view_id) case normalized_view do "posts" -> posts_view(project_id, params, false) "pages" -> posts_view(project_id, params, true) "media" -> media_view(project_id, params) "scripts" -> entity_list_view( dgettext("ui", "Scripts"), dgettext("ui", "Automation helpers"), "scripts", list_scripts(project_id) ) "templates" -> entity_list_view( dgettext("ui", "Templates"), dgettext("ui", "Site rendering"), "templates", list_templates(project_id) ) "tags" -> tags_nav_view(tag_count(project_id)) "chat" -> entity_list_view( dgettext("ui", "Chat"), dgettext("ui", "AI conversations"), "chat", list_conversations() ) "import" -> entity_list_view( dgettext("ui", "Import"), dgettext("ui", "Import definitions"), "import", list_import_definitions(project_id) ) "git" -> git_view() "settings" -> settings_nav_view() _other -> empty_view(normalized_view) end end @spec empty_snapshot() :: map() def empty_snapshot do %{ "posts" => empty_view("posts"), "pages" => empty_view("pages"), "media" => empty_view("media"), "scripts" => entity_list_view( dgettext("ui", "Scripts"), dgettext("ui", "Automation helpers"), "scripts", [] ), "templates" => entity_list_view( dgettext("ui", "Templates"), dgettext("ui", "Site rendering"), "templates", [] ), "tags" => tags_nav_view(0), "chat" => entity_list_view( dgettext("ui", "Chat"), dgettext("ui", "AI conversations"), "chat", [] ), "import" => entity_list_view( dgettext("ui", "Import"), dgettext("ui", "Import definitions"), "import", [] ), "git" => git_view(), "settings" => settings_nav_view() } end defp empty_view("posts"), do: build_posts_view([], %{}, false, empty_filter_params(), %{}, [], [], []) defp empty_view("pages"), do: build_posts_view([], %{}, true, empty_filter_params(), %{}, [], [], []) defp empty_view("media"), do: build_media_view([], empty_filter_params(), %{}, [], [], 0) defp empty_view("scripts"), do: entity_list_view( dgettext("ui", "Scripts"), dgettext("ui", "Automation helpers"), "scripts", [] ) defp empty_view("templates"), do: entity_list_view( dgettext("ui", "Templates"), dgettext("ui", "Site rendering"), "templates", [] ) defp empty_view("tags"), do: tags_nav_view(0) defp empty_view("chat"), do: entity_list_view( dgettext("ui", "Chat"), dgettext("ui", "AI conversations"), "chat", [] ) defp empty_view("import"), do: entity_list_view( dgettext("ui", "Import"), dgettext("ui", "Import definitions"), "import", [] ) defp empty_view("git"), do: git_view() defp empty_view("settings"), do: settings_nav_view() defp empty_view(_other), do: %{ title: "", subtitle: "", layout: "entity_list", items: [], empty_message: dgettext("ui", "No items") } # --------------------------------------------------------------------------- # Posts view (SQL-level filtering) # --------------------------------------------------------------------------- defp posts_view(project_id, params, pages?) do filters = normalize_filter_params(params) translation_counts = translation_counts(project_id) tag_colors = tag_color_map(project_id) year_months = base_year_month_counts(project_id, pages?) avail_tags = base_available_tags(project_id, pages?) avail_categories = base_available_categories(project_id, pages?) filtered_query = base_posts_query(project_id, pages?) |> apply_post_query_filters(filters) total_count = Repo.one(from p in filtered_query, select: count(p.id)) limited_posts = Repo.all( from p in filtered_query, order_by: [desc: p.created_at], limit: ^filters.display_limit, select: %{ id: p.id, title: p.title, slug: p.slug, excerpt: p.excerpt, status: p.status, tags: p.tags, categories: p.categories, updated_at: p.updated_at, published_at: p.published_at, language: p.language } ) build_posts_view( limited_posts, translation_counts, pages?, filters, tag_colors, year_months, avail_tags, avail_categories, total_count ) end defp build_posts_view( limited_posts, translation_counts, pages?, filters, tag_colors, year_month_counts, available_tags, available_categories, total_count \\ 0 ) do grouped_posts = group_posts(limited_posts) loaded_count = length(limited_posts) %{ title: if(pages?, do: dgettext("ui", "Pages"), else: dgettext("ui", "Posts")), subtitle: if(pages?, do: dgettext("ui", "Standalone pages"), else: dgettext("ui", "Drafts, published entries, and archive history") ), layout: "post_list", empty_message: if(pages?, do: dgettext("ui", "No pages yet"), else: dgettext("ui", "No posts yet")), filters: %{ enabled: true, search_placeholder: if(pages?, do: dgettext("ui", "Search pages..."), else: dgettext("ui", "Search posts...") ), toggle_filters_label: dgettext("ui", "Toggle Filters"), archive_label: dgettext("render", "Archive"), tags_label: dgettext("ui", "Tags"), categories_label: dgettext("ui", "Categories"), clear_tags_label: dgettext("ui", "Clear tags"), clear_categories_label: dgettext("ui", "Clear categories"), clear_filters_label: dgettext("ui", "Clear filters"), results_label: dgettext("ui", "results"), results_for_label: dgettext("ui", "results for"), no_results_label: dgettext("ui", "No matching posts"), year_month_counts: year_month_counts, available_tags: available_tags, available_tag_colors: Map.take(tag_colors, available_tags), available_categories: available_categories, max_items: @default_page_size, display_limit: filters.display_limit, loaded_count: loaded_count, total_count: total_count, has_more: total_count > filters.display_limit, has_active_filters: filter_active?(filters), selected: %{ search: filters.search, year: filters.year, month: filters.month, tags: filters.tags, categories: filters.categories } }, sections: [ build_post_section( dgettext("ui", "Drafts"), :draft, grouped_posts.draft, translation_counts, false ), build_post_section( dgettext("ui", "Published"), :published, grouped_posts.published, translation_counts, true ), build_post_section( dgettext("ui", "Archived"), :archived, grouped_posts.archived, translation_counts, false ) ] } end defp base_posts_query(project_id, true = _pages?) do from post in Post, where: post.project_id == ^project_id, where: fragment( "EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = 'page')", post.categories ) end defp base_posts_query(project_id, false = _pages?) do from post in Post, where: post.project_id == ^project_id, where: fragment( "NOT EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = 'page')", post.categories ) end defp apply_post_query_filters(query, filters) do query |> maybe_where_search(filters.search) |> maybe_where_year(filters.year) |> maybe_where_month(filters.month) |> maybe_where_all_tags(filters.tags) |> maybe_where_all_categories(filters.categories) end defp maybe_where_search(query, nil), do: query defp maybe_where_search(query, search) do search_term = "%" <> String.downcase(search) <> "%" where( query, [p], fragment( """ lower(COALESCE(?,'') || ' ' || COALESCE(?,'') || ' ' || COALESCE(?,'') || ' ' || COALESCE(?,'[]') || ' ' || COALESCE(?,'[]')) LIKE ? """, p.title, p.slug, p.excerpt, p.tags, p.categories, ^search_term ) ) end defp maybe_where_year(query, nil), do: query defp maybe_where_year(query, year) do year_str = to_string(year) where( query, [p], fragment( "strftime('%Y', datetime(COALESCE(?, ?) / 1000, 'unixepoch')) = ?", p.published_at, p.updated_at, ^year_str ) ) end defp maybe_where_month(query, nil), do: query defp maybe_where_month(query, month) do month_str = String.pad_leading(to_string(month), 2, "0") where( query, [p], fragment( "strftime('%m', datetime(COALESCE(?, ?) / 1000, 'unixepoch')) = ?", p.published_at, p.updated_at, ^month_str ) ) end defp maybe_where_all_tags(query, []), do: query defp maybe_where_all_tags(query, tags) do Enum.reduce(tags, query, fn tag, q -> where( q, [p], fragment( "EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))", p.tags, ^tag ) ) end) end defp maybe_where_all_categories(query, []), do: query defp maybe_where_all_categories(query, categories) do Enum.reduce(categories, query, fn category, q -> where( q, [p], fragment( "EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?) AND lower(value) != 'page')", p.categories, ^category ) ) end) end defp base_year_month_counts(project_id, pages?) do is_page = if pages?, do: 1, else: 0 %{rows: rows} = Ecto.Adapters.SQL.query!( Repo, """ SELECT CAST(strftime('%Y', datetime(COALESCE(published_at, updated_at) / 1000, 'unixepoch')) AS INTEGER), CAST(strftime('%m', datetime(COALESCE(published_at, updated_at) / 1000, 'unixepoch')) AS INTEGER), COUNT(*) FROM posts WHERE project_id = ?1 AND COALESCE(published_at, updated_at) IS NOT NULL AND ( (?2 = 1 AND EXISTS (SELECT 1 FROM json_each(categories) WHERE lower(value) = 'page')) OR (?2 = 0 AND NOT EXISTS (SELECT 1 FROM json_each(categories) WHERE lower(value) = 'page')) ) GROUP BY 1, 2 ORDER BY 1 DESC, 2 DESC """, [project_id, is_page] ) Enum.map(rows, fn [year, month, count] -> %{year: year, month: month, count: count} end) end defp base_available_tags(project_id, pages?) do is_page = if pages?, do: 1, else: 0 %{rows: rows} = Ecto.Adapters.SQL.query!( Repo, """ SELECT DISTINCT trim(je.value) FROM posts, json_each(posts.tags) je WHERE posts.project_id = ?1 AND trim(je.value) != '' AND ( (?2 = 1 AND EXISTS (SELECT 1 FROM json_each(posts.categories) WHERE lower(value) = 'page')) OR (?2 = 0 AND NOT EXISTS (SELECT 1 FROM json_each(posts.categories) WHERE lower(value) = 'page')) ) ORDER BY lower(trim(je.value)) """, [project_id, is_page] ) Enum.map(rows, fn [tag] -> tag end) end defp base_available_categories(project_id, pages?) do is_page = if pages?, do: 1, else: 0 %{rows: rows} = Ecto.Adapters.SQL.query!( Repo, """ SELECT DISTINCT trim(je.value) FROM posts, json_each(posts.categories) je WHERE posts.project_id = ?1 AND trim(je.value) != '' AND lower(trim(je.value)) != 'page' AND ( (?2 = 1 AND EXISTS (SELECT 1 FROM json_each(posts.categories) jc WHERE lower(jc.value) = 'page')) OR (?2 = 0 AND NOT EXISTS (SELECT 1 FROM json_each(posts.categories) jc WHERE lower(jc.value) = 'page')) ) ORDER BY lower(trim(je.value)) """, [project_id, is_page] ) Enum.map(rows, fn [category] -> category end) end # --------------------------------------------------------------------------- # Media view (SQL-level filtering) # --------------------------------------------------------------------------- defp media_view(project_id, params) do filters = normalize_filter_params(params) tag_colors = tag_color_map(project_id) year_months = media_year_month_counts(project_id) avail_tags = media_available_tags(project_id) filtered_query = media_filtered_query(project_id, filters) total_count = Repo.one(from m in filtered_query, select: count(m.id)) limited_media = Repo.all( from m in filtered_query, order_by: [desc: m.created_at], limit: ^filters.display_limit, select: %{ id: m.id, title: m.title, original_name: m.original_name, mime_type: m.mime_type, size: m.size, tags: m.tags, alt: m.alt, caption: m.caption, updated_at: m.updated_at } ) build_media_view(limited_media, filters, tag_colors, year_months, avail_tags, total_count) end defp build_media_view(limited_media, filters, tag_colors, year_month_counts, available_tags, total_count) do loaded_count = length(limited_media) %{ title: dgettext("ui", "Media"), subtitle: dgettext("ui", "Images and files"), layout: "media_grid", empty_message: dgettext("ui", "No media files"), filters: %{ enabled: true, search_placeholder: dgettext("ui", "Search media..."), toggle_filters_label: dgettext("ui", "Toggle Filters"), archive_label: dgettext("render", "Archive"), tags_label: dgettext("ui", "Tags"), clear_tags_label: dgettext("ui", "Clear tags"), clear_filters_label: dgettext("ui", "Clear filters"), results_label: dgettext("ui", "results"), results_for_label: dgettext("ui", "results for"), no_results_label: dgettext("ui", "No media files"), year_month_counts: year_month_counts, available_tags: available_tags, available_tag_colors: Map.take(tag_colors, available_tags), available_categories: [], max_items: @default_page_size, display_limit: filters.display_limit, loaded_count: loaded_count, total_count: total_count, has_more: total_count > filters.display_limit, has_active_filters: filter_active?(filters), selected: %{ search: filters.search, year: filters.year, month: filters.month, tags: filters.tags, categories: [] } }, items: Enum.map(limited_media, fn media -> %{ id: media.id, title: display_media_title(media), meta: media_size_label(media.size), mime_type: media.mime_type, route: "media", updated_at: media.updated_at, tags: media.tags || [], search_blob: media_search_blob(media) } end) } end defp media_filtered_query(project_id, filters) do from(media in Media, where: media.project_id == ^project_id) |> maybe_where_media_search(filters.search) |> maybe_where_media_year(filters.year) |> maybe_where_media_month(filters.month) |> maybe_where_all_media_tags(filters.tags) end defp maybe_where_media_search(query, nil), do: query defp maybe_where_media_search(query, search) do search_term = "%" <> String.downcase(search) <> "%" where( query, [m], fragment( """ lower(COALESCE(?,'') || ' ' || COALESCE(?,'') || ' ' || COALESCE(?,'') || ' ' || COALESCE(?,'') || ' ' || COALESCE(?,'[]')) LIKE ? """, m.title, m.original_name, m.alt, m.caption, m.tags, ^search_term ) ) end defp maybe_where_media_year(query, nil), do: query defp maybe_where_media_year(query, year) do year_str = to_string(year) where( query, [m], fragment( "strftime('%Y', datetime(? / 1000, 'unixepoch')) = ?", m.updated_at, ^year_str ) ) end defp maybe_where_media_month(query, nil), do: query defp maybe_where_media_month(query, month) do month_str = String.pad_leading(to_string(month), 2, "0") where( query, [m], fragment( "strftime('%m', datetime(? / 1000, 'unixepoch')) = ?", m.updated_at, ^month_str ) ) end defp maybe_where_all_media_tags(query, []), do: query defp maybe_where_all_media_tags(query, tags) do Enum.reduce(tags, query, fn tag, q -> where( q, [m], fragment( "EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))", m.tags, ^tag ) ) end) end defp media_year_month_counts(project_id) do %{rows: rows} = Ecto.Adapters.SQL.query!( Repo, """ SELECT CAST(strftime('%Y', datetime(updated_at / 1000, 'unixepoch')) AS INTEGER), CAST(strftime('%m', datetime(updated_at / 1000, 'unixepoch')) AS INTEGER), COUNT(*) FROM media WHERE project_id = ?1 AND updated_at IS NOT NULL GROUP BY 1, 2 ORDER BY 1 DESC, 2 DESC """, [project_id] ) Enum.map(rows, fn [year, month, count] -> %{year: year, month: month, count: count} end) end defp media_available_tags(project_id) do %{rows: rows} = Ecto.Adapters.SQL.query!( Repo, """ SELECT DISTINCT trim(je.value) FROM media, json_each(media.tags) je WHERE media.project_id = ?1 AND trim(je.value) != '' ORDER BY lower(trim(je.value)) """, [project_id] ) Enum.map(rows, fn [tag] -> tag end) end # --------------------------------------------------------------------------- # Navigation views # --------------------------------------------------------------------------- defp tags_nav_view(count) do %{ title: dgettext("ui", "Tags"), subtitle: dgettext("ui", "Tag management"), layout: "nav_list", items: [ %{id: "tags-cloud", title: dgettext("ui", "Tag Cloud"), icon: "☁️", route: "tags"}, %{id: "tags-manage", title: dgettext("ui", "Create / Edit"), icon: "✏️", route: "tags"}, %{id: "tags-merge", title: dgettext("ui", "Merge Tags"), icon: "🔀", route: "tags"} ], summary_badge: count } end defp settings_nav_view do %{ title: dgettext("ui", "Settings"), subtitle: dgettext("ui", "Project and publishing"), layout: "nav_list", items: [ %{id: "settings-project", title: dgettext("ui", "Project"), icon: "📁", route: "settings"}, %{id: "settings-editor", title: dgettext("ui", "Editor"), icon: "📝", route: "settings"}, %{id: "settings-content", title: dgettext("ui", "Content"), icon: "📋", route: "settings"}, %{id: "settings-ai", title: dgettext("ui", "AI"), icon: "🤖", route: "settings"}, %{ id: "settings-technology", title: dgettext("ui", "Technology"), icon: "⚙️", route: "settings" }, %{ id: "settings-publishing", title: dgettext("ui", "Publishing"), icon: "🚀", route: "settings" }, %{id: "settings-data", title: dgettext("ui", "Data"), icon: "🗄️", route: "settings"}, %{id: "settings-mcp", title: dgettext("ui", "MCP"), icon: "🔌", route: "settings"}, %{id: "settings-style", title: dgettext("ui", "Style"), icon: "🎨", route: "style"} ] } end defp git_view do %{ title: dgettext("ui", "Git"), subtitle: dgettext("ui", "Working tree and history"), layout: "entity_list", empty_message: dgettext("ui", "No items"), items: [ %{ id: "git-working-tree", title: dgettext("ui", "Working tree"), meta: dgettext("ui", "Working tree and history"), route: "git_diff", updated_at: nil } ] } end defp entity_list_view(title, subtitle, route, items) do %{ title: title, subtitle: subtitle, layout: "entity_list", empty_message: dgettext("ui", "No items"), items: Enum.map(items, fn item -> %{ id: item.id, title: item.title, meta: Map.get(item, :meta), updated_at: Map.get(item, :updated_at), route: route } end) } end # --------------------------------------------------------------------------- # Post section builder # --------------------------------------------------------------------------- defp build_post_section(title, status, posts, translation_counts, published_meta?) do post_count = length(posts) %{ id: Atom.to_string(status), title: title, status: Atom.to_string(status), count: post_count, items: Enum.map(posts, fn post -> %{ id: post.id, title: display_post_title(post), categories: post.categories || [], tags: post.tags || [], status: Atom.to_string(post.status), language_count: 1 + Map.get(translation_counts, post.id, 0), meta_timestamp: if(published_meta?, do: post.published_at || post.updated_at, else: post.updated_at ), route: "post", search_blob: post_search_blob(post) } end) } end # --------------------------------------------------------------------------- # Data queries # --------------------------------------------------------------------------- defp translation_counts(project_id) do Repo.all( from translation in Translation, where: translation.project_id == ^project_id, group_by: translation.translation_for, select: {translation.translation_for, count(translation.id)} ) |> Map.new() end defp list_scripts(project_id) do Repo.all( from script in Script, where: script.project_id == ^project_id, order_by: [desc: script.updated_at, desc: script.created_at], select: %{id: script.id, title: script.title, updated_at: script.updated_at} ) end defp list_templates(project_id) do Repo.all( from template in Template, where: template.project_id == ^project_id, order_by: [desc: template.updated_at, desc: template.created_at], select: %{id: template.id, title: template.title, updated_at: template.updated_at} ) end defp list_import_definitions(project_id) do ImportDefinitions.list_definitions(project_id) end defp tag_count(project_id) do Repo.one(from tag in Tag, where: tag.project_id == ^project_id, select: count(tag.id)) end defp list_conversations do Repo.all( from conversation in ChatConversation, order_by: [desc: conversation.updated_at, desc: conversation.created_at], select: %{ id: conversation.id, title: conversation.title, updated_at: conversation.updated_at } ) end defp group_posts(posts) do grouped = Enum.group_by(posts, & &1.status) %{ draft: Map.get(grouped, :draft, []), published: Map.get(grouped, :published, []), archived: Map.get(grouped, :archived, []) } end defp tag_color_map(project_id) do Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: {tag.name, tag.color}) |> Enum.reduce(%{}, fn {name, color}, acc -> case String.trim(to_string(color || "")) do "" -> acc trimmed -> Map.put(acc, to_string(name), trimmed) end end) end # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp normalize_view_id(view_id) when is_atom(view_id), do: Atom.to_string(view_id) defp normalize_view_id(view_id) when is_binary(view_id), do: view_id defp normalize_view_id(_other), do: "" defp normalize_filter_params(params) when is_map(params) do %{ search: normalize_string(BDS.MapUtils.attr(params, :search)), year: normalize_integer(BDS.MapUtils.attr(params, :year)), month: normalize_integer(BDS.MapUtils.attr(params, :month)), tags: normalize_string_list(BDS.MapUtils.attr(params, :tags)), categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)), display_limit: max( @default_page_size, normalize_integer(BDS.MapUtils.attr(params, :display_limit)) || @default_page_size ) } end defp normalize_filter_params(_params), do: empty_filter_params() defp empty_filter_params do %{ search: nil, year: nil, month: nil, tags: [], categories: [], display_limit: @default_page_size } end defp filter_active?(filters) do present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or filters.categories != [] end defp post_search_blob(post) do [ post.title, post.slug, post.excerpt, Enum.join(post.tags || [], " "), Enum.join(post.categories || [], " ") ] |> Enum.reject(&is_nil/1) |> Enum.join(" ") end defp media_search_blob(media) do [media.title, media.original_name, media.alt, media.caption, Enum.join(media.tags || [], " ")] |> Enum.reject(&is_nil/1) |> Enum.join(" ") end defp normalize_integer(nil), do: nil defp normalize_integer(value) when is_integer(value), do: value defp normalize_integer(value) when is_binary(value) do case Integer.parse(value) do {integer, ""} -> integer _other -> nil end end defp normalize_integer(_value), do: nil defp normalize_string(value) when is_binary(value) do trimmed = String.trim(value) if trimmed == "", do: nil, else: trimmed end defp normalize_string(_value), do: nil defp normalize_string_list(values) when is_list(values) do values |> Enum.map(&normalize_string/1) |> Enum.reject(&is_nil/1) end defp normalize_string_list(_values), do: [] defp display_post_title(post) do cond do present?(post.title) -> post.title present?(post.slug) -> post.slug true -> dgettext("ui", "Untitled") end end defp display_media_title(media) do if present?(media.title), do: media.title, else: media.original_name || "" end defp media_size_label(size) when is_integer(size) and size < 1024, do: "#{size} B" defp media_size_label(size) when is_integer(size) and size < 1024 * 1024 do :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB" end defp media_size_label(size) when is_integer(size) do :erlang.float_to_binary(size / (1024 * 1024), decimals: 1) <> " MB" end defp media_size_label(_size), do: "0 B" defp present?(value), do: value not in [nil, ""] end