fix: issue #7 with broken macro renderings for photo archive and tag cloud

This commit is contained in:
2026-07-05 14:11:12 +02:00
parent 28398b17f4
commit 8e60fe31a5
13 changed files with 503 additions and 41 deletions

View File

@@ -167,18 +167,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
translate_targets,
parent
) do
# Linking is mandatory and MUST happen regardless of AI availability
# (airplane mode / no local model), mirroring the drag-drop chain and the
# old app: import -> link -> (optional) AI enrichment. Gating the link
# behind AI is what left galleries empty.
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
title = enrich_media(media, language, translate_targets)
send(parent, {:add_image_processed, title})
else
false ->
@@ -190,6 +186,32 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
end
end
# Best-effort AI enrichment: analysis + metadata update + translations. Any
# failure (offline, no model) is logged and never unlinks the image. Returns
# the display title used for progress reporting.
defp enrich_media(media, language, translate_targets) do
case AI.analyze_image(media.id, language: language) do
{:ok, result} ->
case Media.update_media(media.id, %{
title: result.title,
alt: result.alt,
caption: result.caption
}) do
{:ok, _updated} ->
translate_media_translations(media.id, translate_targets)
result.title || media.original_name
{:error, reason} ->
Logger.warning("Media metadata update failed for #{media.id}: #{inspect(reason)}")
media.original_name
end
{:error, reason} ->
Logger.warning("AI image analysis skipped for #{media.id}: #{inspect(reason)}")
media.original_name
end
end
defp translate_media_translations(_media_id, []), do: :ok
defp translate_media_translations(media_id, [target | rest]) do

View File

@@ -20,6 +20,7 @@ defmodule BDS.Maintenance.DiffReports do
]
alias BDS.DocumentFields
alias BDS.Media.Linking
alias BDS.Media.Media
alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Metadata
@@ -163,7 +164,12 @@ defmodule BDS.Maintenance.DiffReports do
diff_field("language", media.language, Map.get(fields, "language")),
diff_field("created_at", media.created_at, DocumentFields.get(fields, "createdAt")),
diff_field("updated_at", media.updated_at, DocumentFields.get(fields, "updatedAt")),
diff_field("tags", media.tags, Map.get(fields, "tags", []))
diff_field("tags", media.tags, Map.get(fields, "tags", [])),
diff_field(
"linked_post_ids",
Linking.linked_post_ids(media.id),
DocumentFields.get(fields, "linkedPostIds", [])
)
]
|> Enum.reject(&is_nil/1)

View File

@@ -114,6 +114,53 @@ defmodule BDS.Media.Linking do
)
end
@doc """
Restore `PostMedia` links for `media_id` from the list of `post_ids` persisted
in the media sidecar (`linkedPostIds`).
Used by rebuild-from-filesystem so gallery associations survive a rebuild (and
so projects whose link data lives only in sidecars still populate galleries).
Idempotent: skips links that already exist and post ids that are not present
in the database. Does not re-write sidecars.
"""
@spec restore_post_links(String.t(), String.t(), [String.t()] | term()) :: :ok
def restore_post_links(project_id, media_id, post_ids)
when is_binary(project_id) and is_binary(media_id) and is_list(post_ids) do
post_ids
|> Enum.filter(&is_binary/1)
|> Enum.uniq()
|> Enum.each(fn post_id -> ensure_link(project_id, media_id, post_id) end)
end
def restore_post_links(_project_id, _media_id, _post_ids), do: :ok
defp ensure_link(project_id, media_id, post_id) do
already_linked? =
Repo.exists?(
from pm in PostMedia,
where: pm.media_id == ^media_id and pm.post_id == ^post_id
)
post_exists? = Repo.exists?(from p in BDS.Posts.Post, where: p.id == ^post_id)
if not already_linked? and post_exists? do
%PostMedia{}
|> PostMedia.changeset(%{
id: Ecto.UUID.generate(),
project_id: project_id,
post_id: post_id,
media_id: media_id,
sort_order: next_sort_order(media_id),
created_at: Persistence.now_ms()
})
|> Repo.insert!()
:ok
else
:ok
end
end
defp log_sidecar_error(:ok, _media_id), do: :ok
defp log_sidecar_error({:error, reason}, media_id) do

View File

@@ -132,6 +132,12 @@ defmodule BDS.Media.Sidecars do
|> Media.changeset(attrs)
|> Repo.insert_or_update!()
Linking.restore_post_links(
project.id,
media.id,
DocumentFields.get(sidecar.fields, "linkedPostIds", [])
)
if Keyword.get(opts, :sync_search, true) do
:ok = Search.sync_media(media)
end

View File

@@ -335,27 +335,40 @@ defmodule BDS.Rendering.Filters do
language = macro_language(context)
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
words_json = if project_id, do: build_tag_cloud_data(project_id), else: nil
render_macro_template(
"macros/tag-cloud",
%{
"orientation" => Map.get(params, "orientation", "horizontal"),
"orientation" => normalize_tag_cloud_orientation(Map.get(params, "orientation")),
"words_json" => words_json,
"width" => Map.get(params, "width", width),
"height" => Map.get(params, "height", height),
"aria_label" => "Tag cloud",
"width" => tag_cloud_dimension(Map.get(params, "width"), 320, 1600, 900),
"height" => tag_cloud_dimension(Map.get(params, "height"), 180, 900, 420),
"aria_label" => BDS.Gettext.lgettext(language, "render", "Tag cloud"),
"empty_label" => BDS.Gettext.lgettext(language, "render", "No tags")
},
context
)
end
# Mirror the old app's normalizeTagCloudOrientation so `orientation=diagonal`
# (and the other aliases) map to the runtime's `mixed-diagonal`/`mixed-hv`
# rotation modes instead of silently falling back to horizontal.
defp normalize_tag_cloud_orientation(value) do
case value |> to_string() |> String.trim() |> String.downcase() do
v when v in ["mixed_hv", "mixed-hv", "hv", "horizontal_vertical"] -> "mixed-hv"
v when v in ["mixed_diagonal", "mixed-diagonal", "diagonal", "diag"] -> "mixed-diagonal"
_ -> "horizontal"
end
end
defp tag_cloud_dimension(value, min, max, default) do
case value |> to_string() |> Integer.parse() do
{n, _rest} when n >= min and n <= max -> n
_other -> default
end
end
defp render_video_macro(kind, params, language, translation, context) do
render_macro_template(
"macros/#{kind}",
@@ -382,14 +395,16 @@ defmodule BDS.Rendering.Filters do
end
defp gallery_items(post_id) do
group_name = "gallery-#{post_id}"
post_id
|> linked_media_images()
|> Enum.map(fn media ->
%{
"media_path" => "/#{media.file_path}",
"title" => media.title || media.original_name,
"title" => media.caption || media.title || media.original_name,
"alt" => media.alt || media.title || media.original_name,
"group_name" => post_id
"group_name" => group_name
}
end)
end
@@ -403,7 +418,7 @@ defmodule BDS.Rendering.Filters do
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],
order_by: [desc: m.created_at, asc: m.id],
select: m
)
end
@@ -505,6 +520,7 @@ defmodule BDS.Rendering.Filters do
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
FROM posts, json_each(posts.tags) je
WHERE posts.project_id = ?1
AND posts.status = 'published'
AND trim(je.value) != ''
GROUP BY tag
ORDER BY cnt DESC, lower(tag) ASC
@@ -518,29 +534,66 @@ defmodule BDS.Rendering.Filters do
end)
if tag_entries == [] do
{nil, 0, 0}
nil
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)
counts = Enum.map(tag_entries, & &1.count)
max_count = Enum.max(counts)
min_count = Enum.min(counts)
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)"}
base = %{
"text" => tag,
"size" => tag_cloud_font_size(count, min_count, max_count),
"count" => count,
"url" => "/tag/#{URI.encode(to_string(tag), &URI.char_unreserved?/1)}/"
}
if is_binary(color) and color != "", do: Map.put(base, "color", color), else: base
end)
{Jason.encode!(words), 800, 400}
escape_html(Jason.encode!(words))
end
end
# Match the old app's tag-cloud sizing (14px..56px, integer point sizes) so the
# d3 word-cloud runtime lays tags out identically.
defp tag_cloud_font_size(_count, min_count, max_count) when max_count == min_count,
do: round((14 + 56) / 2)
defp tag_cloud_font_size(count, min_count, max_count),
do: round(14 + (count - min_count) / (max_count - min_count) * (56 - 14))
# The tag-cloud JSON is embedded in a double-quoted HTML data attribute, so it
# must be HTML-escaped (matching the old app) or the first `"` closes the
# attribute and the client-side JSON.parse silently fails.
defp escape_html(value) do
value
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&#39;")
end
# ── Helpers ────────────────────────────────────────────────────────────────
defp macro_language(context), do: Access.get(context, "language") || "en"
defp project_id_from_context(context) do
context_private_project_id(context) ||
context_post_project_id(context) ||
project_id_from_post(context)
end
defp context_private_project_id(%Liquex.Context{private: private}) when is_map(private),
do: Map.get(private, :project_id)
defp context_private_project_id(_context), do: nil
defp context_post_project_id(context) do
post = Access.get(context, "post") || %{}
post["project_id"] || Access.get(post, :project_id) || project_id_from_post(context)
post["project_id"] || Access.get(post, :project_id)
end
defp project_id_from_post(context) do

View File

@@ -156,7 +156,7 @@ defmodule BDS.Rendering.ListArchive do
id: MapUtils.attr(post, :id),
slug: MapUtils.attr(post, :slug),
title: MapUtils.attr(post, :title),
content: PostRendering.cached_post_content(ctx, raw_content, language),
content: PostRendering.cached_post_content(ctx, raw_content, language, post_id),
raw_content: raw_content,
excerpt: MapUtils.attr(post, :excerpt, record_value(post_record, :excerpt)),
author: MapUtils.attr(post, :author, record_value(post_record, :author)),

View File

@@ -64,7 +64,7 @@ defmodule BDS.Rendering.Metadata do
defp category_settings_for(_category_settings, _category), do: nil
defp category_title(settings) when is_map(settings),
do: Map.get(settings, "title") || Map.get(settings, :title)
do: BDS.MapUtils.attr(settings, :title)
defp category_title(_settings), do: nil

View File

@@ -89,7 +89,8 @@ defmodule BDS.Rendering.RenderContext do
static_environment: %{},
filter_module: Filters,
file_system: file_system
),
)
|> put_context_project_id(project_id),
record_by_id: Map.merge(Map.new(translations, &{&1.id, &1}), post_by_id),
incoming_links_by_post: incoming_links_by_post,
outgoing_links_by_post: outgoing_links_by_post,
@@ -144,6 +145,13 @@ defmodule BDS.Rendering.RenderContext do
## --- internals -----------------------------------------------------------
# Stash the project id in the context's private map (not exposed to templates)
# so built-in macros (photo archive, tag cloud, gallery) can resolve their
# project-scoped data even when rendered outside a full page context.
defp put_context_project_id(%Liquex.Context{private: private} = context, project_id) do
%{context | private: Map.put(private, :project_id, project_id)}
end
defp build_link_context_maps(project_id, post_by_id, main_language) do
links =
Repo.all(

View File

@@ -223,10 +223,13 @@ defmodule BDS.Rendering.TemplateSelection do
def template_render_context(project_id) do
project = Projects.get_project!(project_id)
Liquex.Context.new(%{},
static_environment: %{},
filter_module: Filters,
file_system: FileSystem.new(StarterTemplates.template_roots(project))
)
context =
Liquex.Context.new(%{},
static_environment: %{},
filter_module: Filters,
file_system: FileSystem.new(StarterTemplates.template_roots(project))
)
%{context | private: Map.put(context.private, :project_id, project_id)}
end
end