fix: issue #7 with broken macro renderings for photo archive and tag cloud
This commit is contained in:
@@ -167,18 +167,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
translate_targets,
|
translate_targets,
|
||||||
parent
|
parent
|
||||||
) do
|
) 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}),
|
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
||||||
true <- String.starts_with?(media.mime_type || "", "image/"),
|
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
|
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||||
translate_media_translations(media.id, translate_targets)
|
title = enrich_media(media, language, translate_targets)
|
||||||
title = result.title || media.original_name
|
|
||||||
send(parent, {:add_image_processed, title})
|
send(parent, {:add_image_processed, title})
|
||||||
else
|
else
|
||||||
false ->
|
false ->
|
||||||
@@ -190,6 +186,32 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
end
|
end
|
||||||
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, []), do: :ok
|
||||||
|
|
||||||
defp translate_media_translations(media_id, [target | rest]) do
|
defp translate_media_translations(media_id, [target | rest]) do
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ defmodule BDS.Maintenance.DiffReports do
|
|||||||
]
|
]
|
||||||
|
|
||||||
alias BDS.DocumentFields
|
alias BDS.DocumentFields
|
||||||
|
alias BDS.Media.Linking
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.Media.Translation, as: MediaTranslation
|
alias BDS.Media.Translation, as: MediaTranslation
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
@@ -163,7 +164,12 @@ defmodule BDS.Maintenance.DiffReports do
|
|||||||
diff_field("language", media.language, Map.get(fields, "language")),
|
diff_field("language", media.language, Map.get(fields, "language")),
|
||||||
diff_field("created_at", media.created_at, DocumentFields.get(fields, "createdAt")),
|
diff_field("created_at", media.created_at, DocumentFields.get(fields, "createdAt")),
|
||||||
diff_field("updated_at", media.updated_at, DocumentFields.get(fields, "updatedAt")),
|
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)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,53 @@ defmodule BDS.Media.Linking do
|
|||||||
)
|
)
|
||||||
end
|
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(:ok, _media_id), do: :ok
|
||||||
|
|
||||||
defp log_sidecar_error({:error, reason}, media_id) do
|
defp log_sidecar_error({:error, reason}, media_id) do
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ defmodule BDS.Media.Sidecars do
|
|||||||
|> Media.changeset(attrs)
|
|> Media.changeset(attrs)
|
||||||
|> Repo.insert_or_update!()
|
|> 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
|
if Keyword.get(opts, :sync_search, true) do
|
||||||
:ok = Search.sync_media(media)
|
:ok = Search.sync_media(media)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -335,27 +335,40 @@ defmodule BDS.Rendering.Filters do
|
|||||||
language = macro_language(context)
|
language = macro_language(context)
|
||||||
project_id = project_id_from_context(context)
|
project_id = project_id_from_context(context)
|
||||||
|
|
||||||
{words_json, width, height} =
|
words_json = if project_id, do: build_tag_cloud_data(project_id), else: nil
|
||||||
if project_id do
|
|
||||||
build_tag_cloud_data(project_id)
|
|
||||||
else
|
|
||||||
{nil, 800, 400}
|
|
||||||
end
|
|
||||||
|
|
||||||
render_macro_template(
|
render_macro_template(
|
||||||
"macros/tag-cloud",
|
"macros/tag-cloud",
|
||||||
%{
|
%{
|
||||||
"orientation" => Map.get(params, "orientation", "horizontal"),
|
"orientation" => normalize_tag_cloud_orientation(Map.get(params, "orientation")),
|
||||||
"words_json" => words_json,
|
"words_json" => words_json,
|
||||||
"width" => Map.get(params, "width", width),
|
"width" => tag_cloud_dimension(Map.get(params, "width"), 320, 1600, 900),
|
||||||
"height" => Map.get(params, "height", height),
|
"height" => tag_cloud_dimension(Map.get(params, "height"), 180, 900, 420),
|
||||||
"aria_label" => "Tag cloud",
|
"aria_label" => BDS.Gettext.lgettext(language, "render", "Tag cloud"),
|
||||||
"empty_label" => BDS.Gettext.lgettext(language, "render", "No tags")
|
"empty_label" => BDS.Gettext.lgettext(language, "render", "No tags")
|
||||||
},
|
},
|
||||||
context
|
context
|
||||||
)
|
)
|
||||||
end
|
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
|
defp render_video_macro(kind, params, language, translation, context) do
|
||||||
render_macro_template(
|
render_macro_template(
|
||||||
"macros/#{kind}",
|
"macros/#{kind}",
|
||||||
@@ -382,14 +395,16 @@ defmodule BDS.Rendering.Filters do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp gallery_items(post_id) do
|
defp gallery_items(post_id) do
|
||||||
|
group_name = "gallery-#{post_id}"
|
||||||
|
|
||||||
post_id
|
post_id
|
||||||
|> linked_media_images()
|
|> linked_media_images()
|
||||||
|> Enum.map(fn media ->
|
|> Enum.map(fn media ->
|
||||||
%{
|
%{
|
||||||
"media_path" => "/#{media.file_path}",
|
"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,
|
"alt" => media.alt || media.title || media.original_name,
|
||||||
"group_name" => post_id
|
"group_name" => group_name
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -403,7 +418,7 @@ defmodule BDS.Rendering.Filters do
|
|||||||
on: pm.media_id == m.id,
|
on: pm.media_id == m.id,
|
||||||
where: pm.post_id == ^post_id,
|
where: pm.post_id == ^post_id,
|
||||||
where: like(m.mime_type, "image/%"),
|
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
|
select: m
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -505,6 +520,7 @@ defmodule BDS.Rendering.Filters do
|
|||||||
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
|
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
|
||||||
FROM posts, json_each(posts.tags) je
|
FROM posts, json_each(posts.tags) je
|
||||||
WHERE posts.project_id = ?1
|
WHERE posts.project_id = ?1
|
||||||
|
AND posts.status = 'published'
|
||||||
AND trim(je.value) != ''
|
AND trim(je.value) != ''
|
||||||
GROUP BY tag
|
GROUP BY tag
|
||||||
ORDER BY cnt DESC, lower(tag) ASC
|
ORDER BY cnt DESC, lower(tag) ASC
|
||||||
@@ -518,29 +534,66 @@ defmodule BDS.Rendering.Filters do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
if tag_entries == [] do
|
if tag_entries == [] do
|
||||||
{nil, 0, 0}
|
nil
|
||||||
else
|
else
|
||||||
max_count = Enum.map(tag_entries, & &1.count) |> Enum.max()
|
counts = Enum.map(tag_entries, & &1.count)
|
||||||
min_count = Enum.map(tag_entries, & &1.count) |> Enum.min()
|
max_count = Enum.max(counts)
|
||||||
range = max(max_count - min_count, 1)
|
min_count = Enum.min(counts)
|
||||||
|
|
||||||
words =
|
words =
|
||||||
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
|
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
|
||||||
size = 12.0 + (count - min_count) / range * 28.0
|
base = %{
|
||||||
%{"text" => tag, "size" => size, "color" => color || "var(--accent-color)"}
|
"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)
|
end)
|
||||||
|
|
||||||
{Jason.encode!(words), 800, 400}
|
escape_html(Jason.encode!(words))
|
||||||
end
|
end
|
||||||
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("&", "&")
|
||||||
|
|> String.replace("<", "<")
|
||||||
|
|> String.replace(">", ">")
|
||||||
|
|> String.replace("\"", """)
|
||||||
|
|> String.replace("'", "'")
|
||||||
|
end
|
||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp macro_language(context), do: Access.get(context, "language") || "en"
|
defp macro_language(context), do: Access.get(context, "language") || "en"
|
||||||
|
|
||||||
defp project_id_from_context(context) do
|
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 = 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
|
end
|
||||||
|
|
||||||
defp project_id_from_post(context) do
|
defp project_id_from_post(context) do
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ defmodule BDS.Rendering.ListArchive do
|
|||||||
id: MapUtils.attr(post, :id),
|
id: MapUtils.attr(post, :id),
|
||||||
slug: MapUtils.attr(post, :slug),
|
slug: MapUtils.attr(post, :slug),
|
||||||
title: MapUtils.attr(post, :title),
|
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,
|
raw_content: raw_content,
|
||||||
excerpt: MapUtils.attr(post, :excerpt, record_value(post_record, :excerpt)),
|
excerpt: MapUtils.attr(post, :excerpt, record_value(post_record, :excerpt)),
|
||||||
author: MapUtils.attr(post, :author, record_value(post_record, :author)),
|
author: MapUtils.attr(post, :author, record_value(post_record, :author)),
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ defmodule BDS.Rendering.Metadata do
|
|||||||
defp category_settings_for(_category_settings, _category), do: nil
|
defp category_settings_for(_category_settings, _category), do: nil
|
||||||
|
|
||||||
defp category_title(settings) when is_map(settings),
|
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
|
defp category_title(_settings), do: nil
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ defmodule BDS.Rendering.RenderContext do
|
|||||||
static_environment: %{},
|
static_environment: %{},
|
||||||
filter_module: Filters,
|
filter_module: Filters,
|
||||||
file_system: file_system
|
file_system: file_system
|
||||||
),
|
)
|
||||||
|
|> put_context_project_id(project_id),
|
||||||
record_by_id: Map.merge(Map.new(translations, &{&1.id, &1}), post_by_id),
|
record_by_id: Map.merge(Map.new(translations, &{&1.id, &1}), post_by_id),
|
||||||
incoming_links_by_post: incoming_links_by_post,
|
incoming_links_by_post: incoming_links_by_post,
|
||||||
outgoing_links_by_post: outgoing_links_by_post,
|
outgoing_links_by_post: outgoing_links_by_post,
|
||||||
@@ -144,6 +145,13 @@ defmodule BDS.Rendering.RenderContext do
|
|||||||
|
|
||||||
## --- internals -----------------------------------------------------------
|
## --- 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
|
defp build_link_context_maps(project_id, post_by_id, main_language) do
|
||||||
links =
|
links =
|
||||||
Repo.all(
|
Repo.all(
|
||||||
|
|||||||
@@ -223,10 +223,13 @@ defmodule BDS.Rendering.TemplateSelection do
|
|||||||
def template_render_context(project_id) do
|
def template_render_context(project_id) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
|
|
||||||
Liquex.Context.new(%{},
|
context =
|
||||||
static_environment: %{},
|
Liquex.Context.new(%{},
|
||||||
filter_module: Filters,
|
static_environment: %{},
|
||||||
file_system: FileSystem.new(StarterTemplates.template_roots(project))
|
filter_module: Filters,
|
||||||
)
|
file_system: FileSystem.new(StarterTemplates.template_roots(project))
|
||||||
|
)
|
||||||
|
|
||||||
|
%{context | private: Map.put(context.private, :project_id, project_id)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -71,19 +71,31 @@ surface BuiltinMacroSurface {
|
|||||||
|
|
||||||
rule ExpandBuiltinMacros {
|
rule ExpandBuiltinMacros {
|
||||||
when: MacroEncountered(macro, language, post_id)
|
when: MacroEncountered(macro, language, post_id)
|
||||||
|
-- Project-scoped macros (photo_archive, tag_cloud) resolve the owning
|
||||||
|
-- project id from the render context (carried in the Liquid context's
|
||||||
|
-- private data), so they populate correctly even when content is
|
||||||
|
-- pre-rendered outside a full page context.
|
||||||
-- Unknown macro names are left verbatim in the source.
|
-- Unknown macro names are left verbatim in the source.
|
||||||
if macro.name = "youtube" or macro.name = "vimeo":
|
if macro.name = "youtube" or macro.name = "vimeo":
|
||||||
-- Embeds video by `id`; localized default title when title param absent.
|
-- Embeds video by `id`; localized default title when title param absent.
|
||||||
ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name))
|
ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name))
|
||||||
if macro.name = "gallery":
|
if macro.name = "gallery":
|
||||||
-- Image gallery from the post's linked image media (ordered by sort).
|
-- Image gallery from the post's linked image media (PostMedia links,
|
||||||
|
-- restored from media sidecars on rebuild). Linking on import is
|
||||||
|
-- independent of AI enrichment. Ordered newest-first (created_at desc);
|
||||||
|
-- lightbox group is "gallery-{post_id}"; title is caption|title|name.
|
||||||
-- `columns` clamped to 1..6 (default 3). Empty when no post_id/images.
|
-- `columns` clamped to 1..6 (default 3). Empty when no post_id/images.
|
||||||
ensures: MacroTemplateRendered("macros/gallery")
|
ensures: MacroTemplateRendered("macros/gallery")
|
||||||
if macro.name = "photo_archive":
|
if macro.name = "photo_archive":
|
||||||
-- Month-grouped image archive for the project (optional year/month filter).
|
-- Month-grouped image archive for the project (optional year/month filter).
|
||||||
ensures: MacroTemplateRendered("macros/photo-archive")
|
ensures: MacroTemplateRendered("macros/photo-archive")
|
||||||
if macro.name = "tag_cloud":
|
if macro.name = "tag_cloud":
|
||||||
-- Weighted tag cloud from post tag counts; per-tag colours from Tag rows.
|
-- Weighted tag cloud from PUBLISHED post tag counts; per-tag colours
|
||||||
|
-- from Tag rows. Words carry {text, size, count, url}; the JSON is
|
||||||
|
-- HTML-escaped into the data-tag-cloud-words attribute so the client
|
||||||
|
-- runtime can JSON.parse it. `orientation` aliases normalize to
|
||||||
|
-- horizontal | mixed-hv | mixed-diagonal; width (320..1600, def 900)
|
||||||
|
-- and height (180..900, def 420) clamp to defaults when out of range.
|
||||||
ensures: MacroTemplateRendered("macros/tag-cloud")
|
ensures: MacroTemplateRendered("macros/tag-cloud")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
test/bds/desktop/shell_live/gallery_import_test.exs
Normal file
57
test/bds/desktop/shell_live/gallery_import_test.exs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.GalleryImportTest do
|
||||||
|
@moduledoc """
|
||||||
|
Guards the regression where gallery images were imported but never linked to
|
||||||
|
the post when AI analysis was unavailable (offline / no local model), leaving
|
||||||
|
galleries empty. Linking must happen regardless of AI, matching the old app.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellLive.GalleryImport
|
||||||
|
alias BDS.Posts.PostMedia
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-galimport-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "GalImport", data_path: temp_dir})
|
||||||
|
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Gallery Post",
|
||||||
|
content: "[[gallery]]",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
%{project: project, post: post, temp_dir: temp_dir}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "imports link every image to the post even without AI analysis", %{
|
||||||
|
project: project,
|
||||||
|
post: post,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
paths =
|
||||||
|
for i <- 1..3 do
|
||||||
|
path = Path.join(temp_dir, "img_#{i}.jpg")
|
||||||
|
File.write!(path, "fake jpeg #{i}")
|
||||||
|
path
|
||||||
|
end
|
||||||
|
|
||||||
|
GalleryImport.start(paths, project.id, post.id, "en", 2, self())
|
||||||
|
|
||||||
|
assert_received {:add_images_complete, 3}
|
||||||
|
|
||||||
|
link_count =
|
||||||
|
Repo.aggregate(from(pm in PostMedia, where: pm.post_id == ^post.id), :count)
|
||||||
|
|
||||||
|
assert link_count == 3
|
||||||
|
end
|
||||||
|
end
|
||||||
72
test/bds/media/link_restore_test.exs
Normal file
72
test/bds/media/link_restore_test.exs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
defmodule BDS.Media.LinkRestoreTest do
|
||||||
|
@moduledoc """
|
||||||
|
Guards the regression where gallery associations (PostMedia links) were lost
|
||||||
|
on rebuild-from-filesystem: the media sidecar persists `linkedPostIds`, but
|
||||||
|
the rebuild never restored them, so galleries rendered empty afterwards.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Media
|
||||||
|
alias BDS.Posts.PostMedia
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-linkrestore-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "LinkRestore", data_path: temp_dir})
|
||||||
|
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Gallery Post",
|
||||||
|
content: "[[gallery]]",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
source = Path.join(temp_dir, "linked.jpg")
|
||||||
|
File.write!(source, "fake jpeg")
|
||||||
|
{:ok, media} = Media.import_media(%{project_id: project.id, source_path: source})
|
||||||
|
{:ok, _} = Media.link_media_to_post(media.id, post.id)
|
||||||
|
|
||||||
|
%{project: project, post: post, media: media}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp link_count(post_id, media_id) do
|
||||||
|
Repo.aggregate(
|
||||||
|
from(pm in PostMedia, where: pm.post_id == ^post_id and pm.media_id == ^media_id),
|
||||||
|
:count
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "media rebuild-from-filesystem restores PostMedia links from the sidecar", %{
|
||||||
|
project: project,
|
||||||
|
post: post,
|
||||||
|
media: media
|
||||||
|
} do
|
||||||
|
assert link_count(post.id, media.id) == 1
|
||||||
|
|
||||||
|
# Simulate the DB losing gallery links (e.g. a prior rebuild).
|
||||||
|
Repo.delete_all(from pm in PostMedia, where: pm.media_id == ^media.id)
|
||||||
|
assert link_count(post.id, media.id) == 0
|
||||||
|
|
||||||
|
assert {:ok, _} = BDS.Media.rebuild_media_from_files(project.id)
|
||||||
|
|
||||||
|
assert link_count(post.id, media.id) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "restore is idempotent and does not duplicate existing links", %{
|
||||||
|
project: project,
|
||||||
|
post: post,
|
||||||
|
media: media
|
||||||
|
} do
|
||||||
|
assert {:ok, _} = BDS.Media.rebuild_media_from_files(project.id)
|
||||||
|
assert link_count(post.id, media.id) == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
176
test/bds/rendering/macro_rendering_test.exs
Normal file
176
test/bds/rendering/macro_rendering_test.exs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
defmodule BDS.Rendering.MacroRenderingTest do
|
||||||
|
@moduledoc """
|
||||||
|
End-to-end coverage for built-in macros through the *real* render pipeline
|
||||||
|
(`RenderContext.build/1`). These guard the regression where photo archive and
|
||||||
|
tag cloud rendered empty because the project id was never threaded into the
|
||||||
|
macro context, and where galleries rendered empty on list pages because the
|
||||||
|
post id was dropped.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias BDS.Media
|
||||||
|
alias BDS.Rendering
|
||||||
|
alias BDS.Rendering.PostRendering
|
||||||
|
alias BDS.Rendering.RenderContext
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
||||||
|
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-macros-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "MacroBlog", data_path: temp_dir})
|
||||||
|
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Macro Post",
|
||||||
|
content: "seed",
|
||||||
|
language: "en",
|
||||||
|
tags: ["elixir", "phoenix"]
|
||||||
|
})
|
||||||
|
|
||||||
|
source = Path.join(temp_dir, "macro_image.jpg")
|
||||||
|
File.write!(source, "fake jpeg")
|
||||||
|
{:ok, media} = Media.import_media(%{project_id: project.id, source_path: source})
|
||||||
|
{:ok, _} = Media.update_media(media.id, %{title: "Macro Image", alt: "Macro Alt"})
|
||||||
|
{:ok, _} = Media.link_media_to_post(media.id, post.id)
|
||||||
|
|
||||||
|
{:ok, _} = BDS.Posts.publish_post(post.id)
|
||||||
|
|
||||||
|
%{project: project, post: post}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "photo archive and tag cloud resolve project data on single-post render", %{
|
||||||
|
project: project,
|
||||||
|
post: post
|
||||||
|
} do
|
||||||
|
ctx = RenderContext.build(project.id)
|
||||||
|
|
||||||
|
rendered =
|
||||||
|
PostRendering.cached_post_content(
|
||||||
|
ctx,
|
||||||
|
"[[photo_archive]]\n\n[[tag_cloud]]",
|
||||||
|
"en",
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rendered =~ "photo-archive-item"
|
||||||
|
refute rendered =~ "photo-archive-empty"
|
||||||
|
|
||||||
|
assert rendered =~ "data-tag-cloud-words"
|
||||||
|
refute rendered =~ "tag-cloud-empty"
|
||||||
|
|
||||||
|
# The JSON is embedded in a double-quoted HTML attribute, so it must be
|
||||||
|
# HTML-escaped or the client-side JSON.parse silently fails (empty cloud).
|
||||||
|
assert rendered =~ """
|
||||||
|
|
||||||
|
words = extract_tag_cloud_words(rendered)
|
||||||
|
texts = Enum.map(words, &Map.get(&1, "text"))
|
||||||
|
assert "elixir" in texts
|
||||||
|
assert "phoenix" in texts
|
||||||
|
|
||||||
|
# Matches the old app's word shape so the d3 runtime can size/link tags.
|
||||||
|
for word <- words do
|
||||||
|
assert is_integer(word["count"])
|
||||||
|
assert word["size"] >= 14 and word["size"] <= 56
|
||||||
|
assert String.starts_with?(word["url"], "/tag/")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_tag_cloud_words(html) do
|
||||||
|
[_, raw] = Regex.run(~r/data-tag-cloud-words="([^"]*)"/, html)
|
||||||
|
|
||||||
|
raw
|
||||||
|
|> String.replace(""", "\"")
|
||||||
|
|> String.replace("&", "&")
|
||||||
|
|> String.replace("<", "<")
|
||||||
|
|> String.replace(">", ">")
|
||||||
|
|> String.replace("'", "'")
|
||||||
|
|> Jason.decode!()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tag cloud orientation aliases normalize like the old app", %{
|
||||||
|
project: project,
|
||||||
|
post: post
|
||||||
|
} do
|
||||||
|
ctx = RenderContext.build(project.id)
|
||||||
|
|
||||||
|
render = fn source -> PostRendering.cached_post_content(ctx, source, "en", post.id) end
|
||||||
|
|
||||||
|
assert render.("[[tag_cloud orientation=diagonal]]") =~ ~s(data-orientation="mixed-diagonal")
|
||||||
|
assert render.("[[tag_cloud orientation=diag]]") =~ ~s(data-orientation="mixed-diagonal")
|
||||||
|
|
||||||
|
assert render.("[[tag_cloud orientation=mixed-diagonal]]") =~
|
||||||
|
~s(data-orientation="mixed-diagonal")
|
||||||
|
|
||||||
|
assert render.("[[tag_cloud orientation=hv]]") =~ ~s(data-orientation="mixed-hv")
|
||||||
|
assert render.("[[tag_cloud orientation=mixed-hv]]") =~ ~s(data-orientation="mixed-hv")
|
||||||
|
assert render.("[[tag_cloud]]") =~ ~s(data-orientation="horizontal")
|
||||||
|
assert render.("[[tag_cloud orientation=bogus]]") =~ ~s(data-orientation="horizontal")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tag cloud clamps out-of-range width/height to defaults", %{project: project, post: post} do
|
||||||
|
ctx = RenderContext.build(project.id)
|
||||||
|
|
||||||
|
rendered =
|
||||||
|
PostRendering.cached_post_content(ctx, "[[tag_cloud width=99999 height=1]]", "en", post.id)
|
||||||
|
|
||||||
|
assert rendered =~ ~s(data-width="900")
|
||||||
|
assert rendered =~ ~s(data-height="420")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "gallery resolves linked media on single-post render", %{project: project, post: post} do
|
||||||
|
ctx = RenderContext.build(project.id)
|
||||||
|
|
||||||
|
rendered = PostRendering.cached_post_content(ctx, "[[gallery]]", "en", post.id)
|
||||||
|
|
||||||
|
assert rendered =~ "gallery-item"
|
||||||
|
assert rendered =~ ~s(src="/media/)
|
||||||
|
assert rendered =~ "Macro Image"
|
||||||
|
refute rendered =~ "gallery-empty"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "gallery resolves linked media on list-page render", %{project: project, post: post} do
|
||||||
|
posts = [
|
||||||
|
%{
|
||||||
|
id: post.id,
|
||||||
|
slug: post.slug,
|
||||||
|
title: post.title,
|
||||||
|
content: "[[gallery]]",
|
||||||
|
language: "en",
|
||||||
|
created_at: post.created_at,
|
||||||
|
updated_at: post.updated_at,
|
||||||
|
published_at: post.published_at,
|
||||||
|
tags: post.tags,
|
||||||
|
categories: post.categories,
|
||||||
|
href: "/2024/01/01/macro-post/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert {:ok, rendered} =
|
||||||
|
Rendering.render_list_page(project.id, %{
|
||||||
|
language: "en",
|
||||||
|
page_title: "Archive",
|
||||||
|
posts: posts,
|
||||||
|
archive_context: %{kind: "core"},
|
||||||
|
pagination: %{
|
||||||
|
current_page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
total_items: 1,
|
||||||
|
items_per_page: 10,
|
||||||
|
has_prev_page: false,
|
||||||
|
prev_page_href: nil,
|
||||||
|
has_next_page: false,
|
||||||
|
next_page_href: nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert rendered =~ "gallery-item"
|
||||||
|
assert rendered =~ ~s(src="/media/)
|
||||||
|
refute rendered =~ "gallery-empty"
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user