feat: added a gallery quick action and fleshed out builtin macros

This commit is contained in:
2026-05-28 17:19:49 +02:00
parent 1914b05f39
commit f99e139fa5
31 changed files with 5907 additions and 4316 deletions

View File

@@ -12,6 +12,17 @@ defmodule BDS.Desktop.FilePicker do
end
end
def choose_files(prompt, opts \\ []) when is_binary(prompt) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do
{:unix, :darwin} -> choose_files_macos(prompt, opts)
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
end
end
end
defp choose_file_macos(prompt) do
script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"
@@ -21,6 +32,50 @@ defmodule BDS.Desktop.FilePicker do
end
end
defp choose_files_macos(prompt, opts) do
multiple = Keyword.get(opts, :multiple, false)
image_only = Keyword.get(opts, :image_only, false)
script_parts = ["POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\""]
script_parts =
if image_only do
script_parts ++ [" of type {\"public.image\"}"]
else
script_parts
end
script_parts =
if multiple do
script_parts ++ [" with multiple selections allowed"]
else
script_parts
end
script = Enum.join(script_parts, "") <> ")"
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
{output, 0} -> parse_choose_files_result(String.trim(output), multiple)
{output, _status} -> normalize_picker_failure(output)
end
end
@doc false
def parse_choose_files_result(output, true = _multiple) do
paths =
output
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{:ok, paths}
end
@doc false
def parse_choose_files_result(output, false = _multiple) do
{:ok, output}
end
defp normalize_picker_failure(output) do
message = String.trim(output)

View File

@@ -35,7 +35,8 @@ defmodule BDS.Desktop.Overlay do
title: Map.get(context, :insert_media_title, "Insert Media"),
search_query: "",
results: Enum.map(media, &to_insert_media_result/1),
all_media: media
all_media: media,
post_id: current_id(context)
}
end

View File

@@ -5,13 +5,14 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.{AI, BoundedAtoms}
alias BDS.{AI, BoundedAtoms, Metadata}
alias BDS.CliSync.Watcher
alias BDS.Desktop.{ExternalLinks, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.ShellLive.{
Bridges,
ChatEditor,
GalleryImport,
ImportEditor,
MediaEditor,
MenuEditor,
@@ -399,6 +400,41 @@ defmodule BDS.Desktop.ShellLive do
def handle_event("overlay_lightbox_next", params, socket),
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
if socket.assigns.offline_mode do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)}
else
project_id = socket.assigns.projects.active_project_id
{:ok, metadata} = Metadata.get_project_metadata(project_id)
concurrency_limit = metadata.image_import_concurrency
language = metadata.main_language || "en"
parent = self()
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
image_only: true, multiple: true) do
{:ok, paths} when is_list(paths) and paths != [] ->
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
:cancel ->
send(parent, {:add_images_cancelled})
{:error, reason} ->
send(parent, {:add_images_error, reason})
end
end)
{:noreply, assign(socket, :gallery_import_post_id, post_id)}
end
end
def handle_event("toggle_project_menu", _params, socket) do
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
end
@@ -580,6 +616,68 @@ defmodule BDS.Desktop.ShellLive do
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
end
def handle_info({:add_image_processed, title}, socket) do
{:noreply,
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), dgettext("ui", "Added %{title}", title: title), nil, "info")}
end
def handle_info({:add_images_complete, count}, socket) do
post_id = socket.assigns[:gallery_import_post_id]
socket =
if is_binary(post_id) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :insert_content,
content: "\n[[gallery]]\n"
)
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :refresh
)
socket
|> assign(:gallery_import_post_id, nil)
else
socket
end
{:noreply,
socket
|> append_output_entry(
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Added %{count} images to post", count: count),
nil,
"info"
)}
end
def handle_info({:add_images_error, reason}, socket) do
{:noreply,
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), inspect(reason), nil, "error")}
end
def handle_info({:add_image_error, path, reason}, socket) do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Failed to process %{path}: %{reason}", path: Path.basename(path), reason: inspect(reason)),
nil,
"error"
)}
end
def handle_info({:add_images_cancelled}, socket) do
{:noreply, socket}
end
def handle_info({:test_ping, caller, ref}, socket) do
send(caller, {:test_pong, ref})
{:noreply, socket}
end
def handle_info(message, socket) do
Bridges.handle_info(message, socket, bridges_callbacks())
end

View File

@@ -0,0 +1,172 @@
defmodule BDS.Desktop.ShellLive.GalleryImport do
@moduledoc false
require Logger
alias BDS.{AI, Media, Metadata}
@doc """
Starts the image import pipeline: for each selected path, imports the file,
runs AI analysis, updates metadata, links to the post, and translates to
all configured blog languages.
Processes images with a concurrency cap via a sliding window.
"""
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
main_language = metadata.main_language || language
blog_languages = metadata.blog_languages || []
translate_targets =
[main_language | blog_languages]
|> Enum.reject(&(&1 == language or is_nil(&1)))
|> Enum.uniq()
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
tasks =
Enum.map(in_flight, fn path ->
Task.async(fn ->
process_single_image(path, project_id, post_id, language, translate_targets, parent)
end)
end)
known_refs = MapSet.new(tasks, & &1.ref)
drain_tasks(
remaining, tasks, known_refs, project_id, post_id, language, translate_targets, parent
)
send(parent, {:add_images_complete, length(paths)})
end
defp drain_tasks(
[], tasks, _known_refs, _project_id, _post_id, _language, _translate_targets, _parent
) do
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
end
defp drain_tasks(
[next_path | rest],
tasks,
known_refs,
project_id,
post_id,
language,
translate_targets,
parent
) do
receive do
{ref, _result} when is_reference(ref) ->
if MapSet.member?(known_refs, ref) do
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
new_task =
Task.async(fn ->
process_single_image(
next_path, project_id, post_id, language, translate_targets, parent
)
end)
drain_tasks(
rest,
[new_task | remaining_tasks],
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
project_id,
post_id,
language,
translate_targets,
parent
)
else
drain_tasks(
[next_path | rest], tasks, known_refs,
project_id, post_id, language, translate_targets, parent
)
end
{:DOWN, ref, :process, _pid, _reason} when is_reference(ref) ->
if MapSet.member?(known_refs, ref) do
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
new_task =
Task.async(fn ->
process_single_image(
next_path, project_id, post_id, language, translate_targets, parent
)
end)
drain_tasks(
rest,
[new_task | remaining_tasks],
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
project_id,
post_id,
language,
translate_targets,
parent
)
else
drain_tasks(
[next_path | rest], tasks, known_refs,
project_id, post_id, language, translate_targets, parent
)
end
end
end
defp pop_task_by_ref(tasks, ref) do
Enum.reduce(tasks, {nil, []}, fn
%{ref: ^ref} = task, {nil, rest} -> {task, rest}
task, {found, rest} -> {found, [task | rest]}
end)
end
defp process_single_image(
path, project_id, post_id, language, translate_targets, parent
) do
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
true <- String.starts_with?(media.mime_type || "", "image/"),
{:ok, result} <- AI.analyze_image(media.id, language: language),
{:ok, _updated} <- Media.update_media(media.id, %{
title: result.title,
alt: result.alt,
caption: result.caption
}),
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
translate_media_translations(media.id, translate_targets)
title = result.title || media.original_name
send(parent, {:add_image_processed, title})
else
false ->
:ok
{:error, reason} ->
Logger.warning("Image pipeline error for #{path}: #{inspect(reason)}")
send(parent, {:add_image_error, path, reason})
end
end
defp translate_media_translations(_media_id, []), do: :ok
defp translate_media_translations(media_id, [target | rest]) do
case AI.translate_media(media_id, target) do
{:ok, translation} ->
Media.upsert_media_translation(media_id, target, %{
title: translation.title,
alt: translation.alt,
caption: translation.caption
})
translate_media_translations(media_id, rest)
{:error, reason} ->
Logger.warning(
"Media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
)
translate_media_translations(media_id, rest)
end
end
end

View File

@@ -168,11 +168,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
@spec gallery_count(term()) :: term()
def gallery_count(form) do
form
|> Map.get("content", "")
|> to_string()
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|> length()
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()

View File

@@ -362,6 +362,14 @@
>
<%= dgettext("ui", "Insert Media") %>
</button>
<button
class="add-gallery-images-button"
type="button"
phx-click="add_gallery_images"
phx-value-post-id={@post_editor.id}
>
<%= dgettext("ui", "Add Gallery Images") %>
</button>
<% end %>
<%= if @post_editor.gallery_count > 0 do %>

View File

@@ -22,6 +22,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
"main_language" => metadata.main_language || "en",
"default_author" => metadata.default_author || "",
"max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
"image_import_concurrency" => Integer.to_string(metadata.image_import_concurrency),
"blogmark_category" =>
metadata.blogmark_category ||
List.first(metadata.categories) || "article",
@@ -71,6 +72,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
main_language: blank_to_nil(Map.get(draft, "main_language")),
default_author: blank_to_nil(Map.get(draft, "default_author")),
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
image_import_concurrency: parse_integer(Map.get(draft, "image_import_concurrency"), 4),
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
blog_languages: Map.get(draft, "blog_languages", []),
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
@@ -85,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
"main_language" => Map.get(params, "main_language", "en"),
"default_author" => Map.get(params, "default_author", ""),
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
"image_import_concurrency" => Map.get(params, "image_import_concurrency", "4"),
"blogmark_category" => Map.get(params, "blogmark_category", "article"),
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))

View File

@@ -82,6 +82,10 @@
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div>
<div class="setting-control"><input class="ui-input" type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Image Import Concurrency") %></label></div>
<div class="setting-control"><input class="ui-input" type="number" min="1" max="8" name="settings_project[image_import_concurrency]" value={@settings_editor.project["image_import_concurrency"]} /></div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div>
<div class="setting-control">

View File

@@ -13,6 +13,9 @@ defmodule BDS.Metadata do
@default_categories ["article", "aside", "page", "picture"]
@min_posts_per_page 1
@max_posts_per_page 500
@default_image_import_concurrency 4
@min_image_import_concurrency 1
@max_image_import_concurrency 8
@supported_pico_themes MapSet.new([
"default",
"amber",
@@ -70,6 +73,7 @@ defmodule BDS.Metadata do
:main_language,
:default_author,
:max_posts_per_page,
:image_import_concurrency,
:blogmark_category,
:pico_theme,
:semantic_similarity_enabled,
@@ -238,6 +242,8 @@ defmodule BDS.Metadata do
default_author: Map.get(project_metadata, "default_author"),
max_posts_per_page:
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
image_import_concurrency:
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
blogmark_category: Map.get(project_metadata, "blogmark_category"),
pico_theme: Map.get(project_metadata, "pico_theme"),
semantic_similarity_enabled:
@@ -274,6 +280,8 @@ defmodule BDS.Metadata do
default_author: Map.get(project_metadata, "default_author"),
max_posts_per_page:
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
image_import_concurrency:
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
blogmark_category: Map.get(project_metadata, "blogmark_category"),
pico_theme: Map.get(project_metadata, "pico_theme"),
semantic_similarity_enabled:
@@ -293,6 +301,7 @@ defmodule BDS.Metadata do
main_language: nil,
default_author: nil,
max_posts_per_page: @default_max_posts_per_page,
image_import_concurrency: @default_image_import_concurrency,
blogmark_category: nil,
pico_theme: nil,
semantic_similarity_enabled: false,
@@ -308,6 +317,8 @@ defmodule BDS.Metadata do
main_language: normalize_optional_language(attr(attrs, :main_language)),
default_author: attr(attrs, :default_author),
max_posts_per_page: normalize_posts_per_page(attr(attrs, :max_posts_per_page)),
image_import_concurrency:
normalize_image_import_concurrency(attr(attrs, :image_import_concurrency)),
blogmark_category: attr(attrs, :blogmark_category),
pico_theme: normalize_pico_theme(attr(attrs, :pico_theme)),
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
@@ -342,6 +353,7 @@ defmodule BDS.Metadata do
"main_language" => project_metadata.main_language,
"default_author" => project_metadata.default_author,
"max_posts_per_page" => project_metadata.max_posts_per_page,
"image_import_concurrency" => project_metadata.image_import_concurrency,
"blogmark_category" => project_metadata.blogmark_category,
"pico_theme" => project_metadata.pico_theme,
"semantic_similarity_enabled" => project_metadata.semantic_similarity_enabled,
@@ -429,6 +441,8 @@ defmodule BDS.Metadata do
"main_language" => Map.get(payload, "mainLanguage"),
"default_author" => Map.get(payload, "defaultAuthor"),
"max_posts_per_page" => Map.get(payload, "maxPostsPerPage", @default_max_posts_per_page),
"image_import_concurrency" =>
Map.get(payload, "imageImportConcurrency", @default_image_import_concurrency),
"blogmark_category" => Map.get(payload, "blogmarkCategory"),
"pico_theme" => Map.get(payload, "picoTheme"),
"semantic_similarity_enabled" => Map.get(payload, "semanticSimilarityEnabled", false),
@@ -505,6 +519,8 @@ defmodule BDS.Metadata do
"defaultAuthor" => Map.get(project_metadata, "default_author"),
"maxPostsPerPage" =>
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
"imageImportConcurrency" =>
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
"blogmarkCategory" => Map.get(project_metadata, "blogmark_category"),
"picoTheme" => Map.get(project_metadata, "pico_theme"),
"semanticSimilarityEnabled" =>
@@ -576,6 +592,23 @@ defmodule BDS.Metadata do
defp normalize_posts_per_page(_value), do: @default_max_posts_per_page
defp normalize_image_import_concurrency(nil), do: @default_image_import_concurrency
defp normalize_image_import_concurrency(value) when is_integer(value) do
value
|> max(@min_image_import_concurrency)
|> min(@max_image_import_concurrency)
end
defp normalize_image_import_concurrency(value) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{integer, ""} -> normalize_image_import_concurrency(integer)
_ -> @default_image_import_concurrency
end
end
defp normalize_image_import_concurrency(_value), do: @default_image_import_concurrency
defp normalize_optional_language(nil), do: nil
defp normalize_optional_language(""), do: nil

View File

@@ -3,7 +3,14 @@ defmodule BDS.Rendering.Filters do
use Liquex.Filter
import Ecto.Query
alias BDS.Slug
alias BDS.{Repo}
alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.{Post, PostMedia}
alias BDS.Tags.Tag
require Logger
@spec i18n(term(), String.t(), Liquex.Context.t()) :: String.t()
def i18n(value, language, _context) do
@@ -28,7 +35,7 @@ defmodule BDS.Rendering.Filters do
) :: String.t()
def markdown(
value,
_post_id,
post_id,
_post_data_json_by_id,
canonical_post_paths,
canonical_media_paths,
@@ -36,15 +43,15 @@ defmodule BDS.Rendering.Filters do
_language_prefix,
context
) do
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context)
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id)
end
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t()) ::
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t(), term()) ::
String.t()
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context) do
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id \\ nil) do
value
|> to_string()
|> replace_built_in_macros(language, context)
|> replace_built_in_macros(language, context, post_id)
|> render_markdown_html()
|> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{})
end
@@ -56,7 +63,7 @@ defmodule BDS.Rendering.Filters do
|> Slug.slugify()
end
defp replace_built_in_macros(content, language, context) do
defp replace_built_in_macros(content, language, context, post_id) do
Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match,
macro_name,
raw_params ->
@@ -88,6 +95,15 @@ defmodule BDS.Rendering.Filters do
context
)
"gallery" ->
render_gallery_macro(context, params, post_id)
"photo_archive" ->
render_photo_archive_macro(context, params)
"tag_cloud" ->
render_tag_cloud_macro(context, params)
_other ->
full_match
end
@@ -127,23 +143,14 @@ defmodule BDS.Rendering.Filters do
end
defp render_macro_template(template_path, assigns, context) do
case Map.get(assigns, "id") do
"" ->
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
{:ok, template_source} ->
render_macro_source(template_path, template_source, assigns, context)
{:error, :enoent} ->
require Logger
Logger.warning("Macro template not found: #{template_path}")
""
nil ->
""
_id ->
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
{:ok, template_source} ->
render_macro_source(template_path, template_source, assigns, context)
{:error, :enoent} ->
require Logger
Logger.warning("Macro template not found: #{template_path}")
""
end
end
end
@@ -285,4 +292,307 @@ defmodule BDS.Rendering.Filters do
defp ensure_leading_slash("/" <> _rest = path), do: path
defp ensure_leading_slash(path), do: "/" <> path
# ── Built-in macro renderers ───────────────────────────────────────────────
defp render_gallery_macro(context, params, post_id) when is_binary(post_id) do
columns = normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6)
caption = Map.get(params, "caption")
items =
post_id
|> linked_media_images()
|> Enum.map(fn media ->
%{
"media_path" => "/#{media.file_path}",
"title" => media.title || media.original_name,
"alt" => media.alt || media.title || media.original_name,
"group_name" => post_id
}
end)
render_macro_template(
"macros/gallery",
%{
"columns" => columns,
"post_id" => post_id,
"items" => items,
"caption" => caption,
"empty_label" =>
BDS.Gettext.lgettext(
Access.get(context, "language") || "en",
"render",
"No images"
)
},
context
)
end
defp render_gallery_macro(context, params, _post_id) do
render_macro_template(
"macros/gallery",
%{
"columns" => normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6),
"post_id" => "",
"items" => [],
"caption" => Map.get(params, "caption"),
"empty_label" =>
BDS.Gettext.lgettext(
Access.get(context, "language") || "en",
"render",
"No images"
)
},
context
)
end
defp render_photo_archive_macro(context, params) do
language = Access.get(context, "language") || "en"
project_id = project_id_from_context(context)
months =
if project_id do
media_month_archive(project_id, Map.get(params, "year"), Map.get(params, "month"))
else
[]
end
render_macro_template(
"macros/photo-archive",
%{
"root_classes" => "macro-photo-archive",
"data_attrs" => [],
"months" => months,
"empty_label" =>
BDS.Gettext.lgettext(language, "render", "No photos found")
},
context
)
end
defp render_tag_cloud_macro(context, params) do
language = Access.get(context, "language") || "en"
project_id = project_id_from_context(context)
{words_json, width, height} =
if project_id do
build_tag_cloud_data(project_id)
else
{nil, 800, 400}
end
render_macro_template(
"macros/tag-cloud",
%{
"orientation" => Map.get(params, "orientation", "horizontal"),
"words_json" => words_json,
"width" => Map.get(params, "width", width),
"height" => Map.get(params, "height", height),
"aria_label" => "Tag cloud",
"empty_label" =>
BDS.Gettext.lgettext(language, "render", "No tags")
},
context
)
end
# ── Data queries for macros ────────────────────────────────────────────────
defp linked_media_images(post_id) do
Repo.all(
from pm in PostMedia,
join: m in MediaRecord,
on: pm.media_id == m.id,
where: pm.post_id == ^post_id,
where: like(m.mime_type, "image/%"),
order_by: [asc: pm.sort_order, asc: pm.media_id],
select: m
)
end
defp media_month_archive(project_id, year, month) do
query =
from m in MediaRecord,
where: m.project_id == ^project_id,
where: like(m.mime_type, "image/%"),
order_by: [desc: m.created_at],
select: m
query =
if year do
year_int = parse_integer(year)
if month do
month_int = parse_integer(month)
start_ts = month_start_ms(year_int, month_int)
end_ts = month_end_ms(year_int, month_int)
from m in query,
where: m.created_at >= ^start_ts and m.created_at <= ^end_ts
else
start_ts = month_start_ms(year_int, 1)
end_ts = month_end_ms(year_int, 12)
from m in query,
where: m.created_at >= ^start_ts and m.created_at <= ^end_ts
end
else
from m in query, limit: 200
end
media_records =
query
|> Repo.all()
|> group_by_media_month()
if year == nil do
Enum.take(media_records, 10)
else
media_records
end
end
defp group_by_media_month(media_records) do
month_names = %{
1 => "January", 2 => "February", 3 => "March", 4 => "April",
5 => "May", 6 => "June", 7 => "July", 8 => "August",
9 => "September", 10 => "October", 11 => "November", 12 => "December"
}
media_records
|> Enum.group_by(fn m ->
date = DateTime.from_unix!(div(m.created_at, 1000))
{date.year, date.month}
end)
|> Enum.sort_by(fn {{y, m}, _} -> {y, m} end, :desc)
|> Enum.map(fn {{year, month}, items} ->
%{
"label" => "#{Map.get(month_names, month)} #{year}",
"items" =>
Enum.map(items, fn m ->
%{
"media_path" => "/#{m.file_path}",
"title" => m.title || m.original_name,
"alt" => m.alt || m.title || m.original_name,
"group_name" => "#{year}-#{month}"
}
end)
}
end)
end
defp build_tag_cloud_data(project_id) do
tag_colors =
Repo.all(
from tag in Tag,
where: tag.project_id == ^project_id,
where: not is_nil(tag.color) and tag.color != "",
select: {tag.name, tag.color}
)
|> Map.new()
%{rows: rows} =
Ecto.Adapters.SQL.query!(
Repo,
"""
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
FROM posts, json_each(posts.tags) je
WHERE posts.project_id = ?1
AND trim(je.value) != ''
GROUP BY tag
ORDER BY cnt DESC, lower(tag) ASC
""",
[project_id]
)
tag_entries =
Enum.map(rows, fn [tag, count] ->
%{tag: tag, count: count, color: Map.get(tag_colors, tag)}
end)
if tag_entries == [] do
{nil, 0, 0}
else
max_count = Enum.map(tag_entries, & &1.count) |> Enum.max()
min_count = Enum.map(tag_entries, & &1.count) |> Enum.min()
range = max(max_count - min_count, 1)
words =
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
size = 12.0 + (count - min_count) / range * 28.0
%{"text" => tag, "size" => size, "color" => color || "var(--accent-color)"}
end)
{Jason.encode!(words), 800, 400}
end
end
# ── Helpers ────────────────────────────────────────────────────────────────
defp project_id_from_context(context) do
post = Access.get(context, "post") || %{}
post["project_id"] || Access.get(post, :project_id) || project_id_from_post(context)
end
defp project_id_from_post(context) do
post_id =
Access.get(Access.get(context, "post") || %{}, "id") ||
Access.get(Access.get(context, "post") || %{}, :id)
if is_binary(post_id) do
case Repo.one(from p in Post, where: p.id == ^post_id, select: p.project_id) do
nil -> nil
project_id -> project_id
end
end
end
defp normalize_columns(value, default, min, max) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n |> max(min) |> min(max)
_ -> default
end
end
defp normalize_columns(value, _default, min, max) when is_integer(value),
do: value |> max(min) |> min(max)
defp normalize_columns(_value, default, _min, _max), do: default
defp parse_integer(value) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n
_ -> nil
end
end
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(_value), do: nil
defp month_start_ms(year, month) do
case DateTime.from_iso8601("#{year}-#{pad(month)}-01T00:00:00Z") do
{:ok, dt, _} -> DateTime.to_unix(dt, :millisecond)
_ -> 0
end
end
defp month_end_ms(year, month) do
last_day =
if month == 12 do
31
else
case DateTime.from_iso8601("#{year}-#{pad(month + 1)}-01T00:00:00Z") do
{:ok, dt, _} ->
dt |> DateTime.add(-1, :second) |> DateTime.to_date() |> Map.get(:day)
_ ->
31
end
end
case DateTime.from_iso8601("#{year}-#{pad(month)}-#{pad(last_day)}T23:59:59.999Z") do
{:ok, dt, _} -> DateTime.to_unix(dt, :millisecond)
_ -> 0
end
end
defp pad(n) when is_integer(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0")
end

View File

@@ -10,7 +10,9 @@ defmodule BDS.Rendering.PostRendering do
alias BDS.Rendering.TemplateSelection
alias BDS.MapUtils
alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Posts.Translation
alias BDS.Media.Media, as: MediaRecord
alias BDS.Repo
@spec post_assigns(String.t(), map()) :: map()
@@ -39,7 +41,8 @@ defmodule BDS.Rendering.PostRendering do
canonical_post_paths,
canonical_media_paths,
language,
template_context
template_context,
post_id
)
incoming_links =
@@ -208,6 +211,7 @@ defmodule BDS.Rendering.PostRendering do
title: MapUtils.attr(assigns, :title),
content: MapUtils.attr(assigns, :content),
raw_content: MapUtils.attr(assigns, :raw_content),
project_id: MapUtils.attr(assigns, :project_id) || Map.get(post_record || %{}, :project_id),
excerpt:
Map.get(
assigns,
@@ -234,7 +238,7 @@ defmodule BDS.Rendering.PostRendering do
MapUtils.attr(assigns, :template_slug)
),
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
linked_media: [],
linked_media: linked_media_images(assigns),
outgoing_links: outgoing_links,
incoming_links: incoming_links
}
@@ -245,21 +249,42 @@ defmodule BDS.Rendering.PostRendering do
map(),
map(),
String.t(),
Liquex.Context.t()
Liquex.Context.t(),
term()
) :: String.t()
def render_post_content(
content,
canonical_post_paths,
canonical_media_paths,
language,
template_context
template_context,
post_id \\ nil
) do
Filters.render_markdown(
content,
canonical_post_paths,
canonical_media_paths,
language,
template_context
template_context,
post_id
)
end
defp linked_media_images(assigns) do
post_id = MapUtils.attr(assigns, :id)
if is_binary(post_id) do
Repo.all(
from pm in PostMedia,
join: m in MediaRecord,
on: pm.media_id == m.id,
where: pm.post_id == ^post_id,
where: like(m.mime_type, "image/%"),
order_by: [asc: pm.sort_order, asc: pm.media_id],
select: m
)
else
[]
end
end
end