From 3a18b070d3c1f569ba53585848f760cee2bce85d Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 23 Apr 2026 22:03:16 +0200 Subject: [PATCH] feat: and even more on templates --- lib/bds/maintenance.ex | 229 ++++++++++++++++++ lib/bds/starter_templates.ex | 38 +-- .../default/templates/not-found.liquid | 2 +- .../default/templates/post-list.liquid | 2 +- .../default/templates/single-post.liquid | 2 +- test/bds/maintenance_test.exs | 183 ++++++++++++++ test/bds/projects_test.exs | 20 ++ 7 files changed, 455 insertions(+), 21 deletions(-) diff --git a/lib/bds/maintenance.ex b/lib/bds/maintenance.ex index 3715689..0892ef9 100644 --- a/lib/bds/maintenance.ex +++ b/lib/bds/maintenance.ex @@ -1,6 +1,17 @@ defmodule BDS.Maintenance do @moduledoc false + import Ecto.Query + + alias BDS.Frontmatter + alias BDS.Media.Media + alias BDS.Posts.Post + alias BDS.Projects + alias BDS.Repo + alias BDS.Scripts.Script + alias BDS.Sidecar + alias BDS.Templates.Template + def rebuild_from_filesystem(project_id, entity_type) do case normalize_entity_type(entity_type) do :post -> BDS.Posts.rebuild_posts_from_files(project_id) @@ -11,6 +22,20 @@ defmodule BDS.Maintenance do end end + def metadata_diff(project_id) when is_binary(project_id) do + project = Projects.get_project!(project_id) + + diff_reports = + post_diff_reports(project_id, project) ++ + media_diff_reports(project_id, project) ++ + script_diff_reports(project_id, project) ++ + template_diff_reports(project_id, project) + + orphan_reports = orphan_reports(project_id, project) + + {:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}} + end + defp normalize_entity_type(:post), do: :post defp normalize_entity_type(:media), do: :media defp normalize_entity_type(:script), do: :script @@ -20,4 +45,208 @@ defmodule BDS.Maintenance do defp normalize_entity_type("script"), do: :script defp normalize_entity_type("template"), do: :template defp normalize_entity_type(_entity_type), do: :unsupported + + defp post_diff_reports(project_id, project) do + Repo.all( + from post in Post, + where: post.project_id == ^project_id and not is_nil(post.file_path) and post.file_path != "" + ) + |> Enum.flat_map(fn post -> + case read_frontmatter_document(project, post.file_path) do + {:ok, %{fields: fields}} -> + differences = + [ + diff_field("title", post.title, Map.get(fields, "title")), + diff_field("excerpt", post.excerpt, Map.get(fields, "excerpt")), + diff_field("author", post.author, Map.get(fields, "author")), + diff_field("language", post.language, Map.get(fields, "language")), + diff_field("status", post.status, Map.get(fields, "status")), + diff_field("template_slug", post.template_slug, Map.get(fields, "template_slug")), + diff_field("tags", post.tags, Map.get(fields, "tags", [])), + diff_field("categories", post.categories, Map.get(fields, "categories", [])) + ] + |> Enum.reject(&is_nil/1) + + if differences == [] do + [] + else + [%{entity_type: "post", entity_id: post.id, differences: differences}] + end + + {:error, _reason} -> + [] + end + end) + end + + defp media_diff_reports(project_id, project) do + Repo.all( + from media in Media, + where: media.project_id == ^project_id and not is_nil(media.sidecar_path) and media.sidecar_path != "" + ) + |> Enum.flat_map(fn media -> + case read_sidecar_document(project, media.sidecar_path) do + {:ok, fields} -> + differences = + [ + diff_field("title", media.title, Map.get(fields, "title")), + diff_field("alt", media.alt, Map.get(fields, "alt")), + diff_field("caption", media.caption, Map.get(fields, "caption")), + diff_field("author", media.author, Map.get(fields, "author")), + diff_field("language", media.language, Map.get(fields, "language")), + diff_field("tags", media.tags, Map.get(fields, "tags", [])) + ] + |> Enum.reject(&is_nil/1) + + if differences == [] do + [] + else + [%{entity_type: "media", entity_id: media.id, differences: differences}] + end + + {:error, _reason} -> + [] + end + end) + end + + defp script_diff_reports(project_id, project) do + Repo.all( + from script in Script, + where: script.project_id == ^project_id and not is_nil(script.file_path) and script.file_path != "" + ) + |> Enum.flat_map(fn script -> + case read_frontmatter_document(project, script.file_path) do + {:ok, %{fields: fields}} -> + differences = + [ + diff_field("title", script.title, Map.get(fields, "title")), + diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")), + diff_field("enabled", script.enabled, Map.get(fields, "enabled")) + ] + |> Enum.reject(&is_nil/1) + + if differences == [] do + [] + else + [%{entity_type: "script", entity_id: script.id, differences: differences}] + end + + {:error, _reason} -> + [] + end + end) + end + + defp template_diff_reports(project_id, project) do + Repo.all( + from template in Template, + where: template.project_id == ^project_id and not is_nil(template.file_path) and template.file_path != "" + ) + |> Enum.flat_map(fn template -> + case read_frontmatter_document(project, template.file_path) do + {:ok, %{fields: fields}} -> + differences = + [ + diff_field("title", template.title, Map.get(fields, "title")), + diff_field("enabled", template.enabled, Map.get(fields, "enabled")) + ] + |> Enum.reject(&is_nil/1) + + if differences == [] do + [] + else + [%{entity_type: "template", entity_id: template.id, differences: differences}] + end + + {:error, _reason} -> + [] + end + end) + end + + defp orphan_reports(project_id, project) do + post_paths = MapSet.new(Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.file_path)) + media_paths = MapSet.new(Repo.all(from media in Media, where: media.project_id == ^project_id, select: media.sidecar_path)) + script_paths = MapSet.new(Repo.all(from script in Script, where: script.project_id == ^project_id, select: script.file_path)) + template_paths = MapSet.new(Repo.all(from template in Template, where: template.project_id == ^project_id, select: template.file_path)) + + post_orphans = + project + |> list_project_files("posts/**/*.md") + |> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project))) + |> Enum.reject(&MapSet.member?(post_paths, &1)) + + media_orphans = + project + |> list_project_files("media/**/*.meta") + |> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project))) + |> Enum.filter(&canonical_media_sidecar?/1) + |> Enum.reject(&MapSet.member?(media_paths, &1)) + + script_orphans = + project + |> list_project_files("scripts/**/*.lua") + |> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project))) + |> Enum.reject(&MapSet.member?(script_paths, &1)) + + template_orphans = + project + |> list_project_files("templates/*.liquid") + |> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project))) + |> Enum.reject(&MapSet.member?(template_paths, &1)) + + (post_orphans ++ media_orphans ++ script_orphans ++ template_orphans) + |> Enum.sort() + |> Enum.map(&%{file_path: &1}) + end + + defp diff_field(name, db_value, file_value) do + db_value = stringify_value(db_value) + file_value = stringify_value(file_value) + + if db_value == file_value do + nil + else + %{name: name, db_value: db_value, file_value: file_value} + end + end + + defp stringify_value(nil), do: "" + defp stringify_value(value) when is_atom(value), do: Atom.to_string(value) + defp stringify_value(value) when is_boolean(value), do: to_string(value) + defp stringify_value(value) when is_integer(value), do: Integer.to_string(value) + defp stringify_value(value) when is_binary(value), do: value + defp stringify_value(value) when is_list(value), do: Enum.map_join(value, ",", &stringify_value/1) + defp stringify_value(value), do: to_string(value) + + defp read_frontmatter_document(project, relative_path) do + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + case File.read(full_path) do + {:ok, contents} -> Frontmatter.parse_document(contents) + {:error, reason} -> {:error, reason} + end + end + + defp read_sidecar_document(project, relative_path) do + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + case File.read(full_path) do + {:ok, contents} -> Sidecar.parse_document(contents) + {:error, reason} -> {:error, reason} + end + end + + defp list_project_files(project, glob) do + project + |> Projects.project_data_dir() + |> Path.join(glob) + |> Path.wildcard() + |> Enum.sort() + end + + defp canonical_media_sidecar?(relative_path) do + not Regex.match?(~r/\.[a-z]{2}\.meta$/i, relative_path) + end end diff --git a/lib/bds/starter_templates.ex b/lib/bds/starter_templates.ex index 54814e5..ed4e89b 100644 --- a/lib/bds/starter_templates.ex +++ b/lib/bds/starter_templates.ex @@ -23,27 +23,29 @@ defmodule BDS.StarterTemplates do target_path = Path.join(target_root, relative_path) :ok = File.mkdir_p(Path.dirname(target_path)) - case Enum.find(@top_level_templates, &(&1.file_name == relative_path)) do - nil -> - File.cp!(source_path, target_path) + unless File.exists?(target_path) do + case Enum.find(@top_level_templates, &(&1.file_name == relative_path)) do + nil -> + File.cp!(source_path, target_path) - template -> - body = File.read!(source_path) + template -> + body = File.read!(source_path) - File.write!( - target_path, - Frontmatter.serialize_document( - [ - {:id, Ecto.UUID.generate()}, - {:slug, template.slug}, - {:title, template.title}, - {:kind, template.kind}, - {:enabled, true}, - {:version, 1} - ], - body + File.write!( + target_path, + Frontmatter.serialize_document( + [ + {:id, Ecto.UUID.generate()}, + {:slug, template.slug}, + {:title, template.title}, + {:kind, template.kind}, + {:enabled, true}, + {:version, 1} + ], + body + ) ) - ) + end end end) diff --git a/priv/data/projects/default/templates/not-found.liquid b/priv/data/projects/default/templates/not-found.liquid index b89dfc6..b16011f 100644 --- a/priv/data/projects/default/templates/not-found.liquid +++ b/priv/data/projects/default/templates/not-found.liquid @@ -1,5 +1,5 @@ --- -id: df4884e5-48e9-4e21-8013-468173fed7ab +id: 77f27148-8a19-4da2-8532-faa79683ba40 slug: not-found title: Not Found kind: not_found diff --git a/priv/data/projects/default/templates/post-list.liquid b/priv/data/projects/default/templates/post-list.liquid index cda105b..6a809b7 100644 --- a/priv/data/projects/default/templates/post-list.liquid +++ b/priv/data/projects/default/templates/post-list.liquid @@ -1,5 +1,5 @@ --- -id: 10dde9aa-ba80-44d2-be97-f51c10ff88f9 +id: b27d1547-548a-47ca-8bab-d81edef003ba slug: post-list title: Post List kind: list diff --git a/priv/data/projects/default/templates/single-post.liquid b/priv/data/projects/default/templates/single-post.liquid index 421d73a..be3c3d6 100644 --- a/priv/data/projects/default/templates/single-post.liquid +++ b/priv/data/projects/default/templates/single-post.liquid @@ -1,5 +1,5 @@ --- -id: a175840b-170a-41db-b102-c5410344c8e6 +id: d64dcf22-28dc-4406-bc2b-1ef72f92cc0a slug: single-post title: Single Post kind: post diff --git a/test/bds/maintenance_test.exs b/test/bds/maintenance_test.exs index f45d431..683f8ca 100644 --- a/test/bds/maintenance_test.exs +++ b/test/bds/maintenance_test.exs @@ -1,6 +1,8 @@ defmodule BDS.MaintenanceTest do use ExUnit.Case, async: false + import Ecto.Query + alias BDS.Repo setup do @@ -120,4 +122,185 @@ defmodule BDS.MaintenanceTest do test "rebuild_from_filesystem rejects unsupported entity types", %{project: project} do assert {:error, :unsupported_entity_type} = BDS.Maintenance.rebuild_from_filesystem(project.id, "unknown") end + + test "metadata_diff reports field differences and orphan files across managed entities", %{project: project, temp_dir: temp_dir} do + source_path = Path.join(temp_dir, "sample.txt") + File.write!(source_path, "hello media") + + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Original Post", + content: "Original body", + excerpt: "Original summary", + author: "Writer", + language: "en", + tags: ["alpha"], + categories: ["notes"] + }) + + assert {:ok, published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, media} = + BDS.Media.import_media(%{ + project_id: project.id, + source_path: source_path, + title: "Original media title", + alt: "Original alt", + caption: "Original caption", + author: "Photographer", + language: "en", + tags: ["alpha"] + }) + + assert {:ok, script} = + BDS.Scripts.create_script(%{ + project_id: project.id, + title: "Original Script", + kind: :utility, + entrypoint: "main", + content: "function main() return true end" + }) + + assert {:ok, published_script} = BDS.Scripts.publish_script(script.id) + + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Original Template", + kind: :list, + content: "
Original
" + }) + + assert {:ok, published_template} = BDS.Templates.publish_template(template.id) + + post_path = Path.join(temp_dir, published_post.file_path) + File.write!( + post_path, + [ + "---", + "id: #{published_post.id}", + "title: Edited Post", + "slug: #{published_post.slug}", + "excerpt: Edited summary", + "status: published", + "author: Editor", + "language: de", + "do_not_translate: false", + "template_slug: ", + "created_at: #{published_post.created_at}", + "updated_at: #{published_post.updated_at}", + "published_at: #{published_post.published_at}", + "tags:", + " - beta", + "categories:", + " - article", + "---", + "Changed body", + "" + ] + |> Enum.join("\n") + ) + + media_sidecar_path = Path.join(temp_dir, media.sidecar_path) + File.write!( + media_sidecar_path, + [ + "id: #{media.id}", + "original_name: #{media.original_name}", + "mime_type: #{media.mime_type}", + "size: #{media.size}", + "title: Edited media title", + "alt: Edited alt", + "caption: Edited caption", + "author: Editor", + "language: de", + "created_at: #{media.created_at}", + "updated_at: #{media.updated_at}", + "tags:", + " - beta", + "" + ] + |> Enum.join("\n") + ) + + script_path = Path.join(temp_dir, published_script.file_path) + File.write!( + script_path, + [ + "---", + "id: #{published_script.id}", + "slug: #{published_script.slug}", + "title: Edited Script", + "kind: utility", + "entrypoint: run", + "enabled: false", + "version: #{published_script.version}", + "created_at: #{published_script.created_at}", + "updated_at: #{published_script.updated_at}", + "---", + "function run() return false end", + "" + ] + |> Enum.join("\n") + ) + + template_path = Path.join(temp_dir, published_template.file_path) + File.write!( + template_path, + [ + "---", + "id: #{published_template.id}", + "slug: #{published_template.slug}", + "title: Edited Template", + "kind: list", + "enabled: false", + "version: #{published_template.version}", + "created_at: #{published_template.created_at}", + "updated_at: #{published_template.updated_at}", + "---", + "
Edited
", + "" + ] + |> Enum.join("\n") + ) + + File.write!(Path.join([temp_dir, "posts", "2026", "04", "orphan-post.md"]), "---\nid: orphan\ntitle: Orphan\nslug: orphan\nstatus: published\ncreated_at: 1\nupdated_at: 1\npublished_at: 1\ntags:\ncategories:\n---\nBody\n") + File.write!(Path.join([temp_dir, "media", "2026", "04", "orphan.txt"]), "orphan") + File.write!(Path.join([temp_dir, "media", "2026", "04", "orphan.txt.meta"]), "id: orphan-media\noriginal_name: orphan.txt\nmime_type: text/plain\nsize: 6\ncreated_at: 1\nupdated_at: 1\ntags:\n") + File.write!(Path.join([temp_dir, "scripts", "orphan.lua"]), "---\nid: orphan-script\nslug: orphan-script\ntitle: Orphan Script\nkind: utility\nentrypoint: main\nenabled: true\nversion: 1\ncreated_at: 1\nupdated_at: 1\n---\nfunction main() return true end\n") + File.write!(Path.join([temp_dir, "templates", "orphan-view.liquid"]), "---\nid: orphan-template\nslug: orphan-view\ntitle: Orphan View\nkind: list\nenabled: true\nversion: 1\ncreated_at: 1\nupdated_at: 1\n---\n
Orphan
\n") + + assert {:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}} = BDS.Maintenance.metadata_diff(project.id) + + assert Enum.any?(diff_reports, fn report -> + report.entity_type == "post" and report.entity_id == published_post.id and + Enum.any?(report.differences, &(&1.name == "title" and &1.db_value == "Original Post" and &1.file_value == "Edited Post")) and + Enum.any?(report.differences, &(&1.name == "excerpt" and &1.db_value == "Original summary" and &1.file_value == "Edited summary")) + end) + + assert Enum.any?(diff_reports, fn report -> + report.entity_type == "media" and report.entity_id == media.id and + Enum.any?(report.differences, &(&1.name == "title" and &1.file_value == "Edited media title")) and + Enum.any?(report.differences, &(&1.name == "language" and &1.file_value == "de")) + end) + + assert Enum.any?(diff_reports, fn report -> + report.entity_type == "script" and report.entity_id == published_script.id and + Enum.any?(report.differences, &(&1.name == "title" and &1.file_value == "Edited Script")) and + Enum.any?(report.differences, &(&1.name == "entrypoint" and &1.file_value == "run")) + end) + + assert Enum.any?(diff_reports, fn report -> + report.entity_type == "template" and report.entity_id == published_template.id and + Enum.any?(report.differences, &(&1.name == "title" and &1.file_value == "Edited Template")) and + Enum.any?(report.differences, &(&1.name == "enabled" and &1.file_value == "false")) + end) + + orphan_paths = Enum.map(orphan_reports, & &1.file_path) + assert "posts/2026/04/orphan-post.md" in orphan_paths + assert "media/2026/04/orphan.txt.meta" in orphan_paths + assert "scripts/orphan.lua" in orphan_paths + assert "templates/orphan-view.liquid" in orphan_paths + end end diff --git a/test/bds/projects_test.exs b/test/bds/projects_test.exs index 92f3455..43c5cc5 100644 --- a/test/bds/projects_test.exs +++ b/test/bds/projects_test.exs @@ -59,6 +59,26 @@ defmodule BDS.ProjectsTest do assert {"not-found", :not_found} in starter_slugs end + test "starter template installation is idempotent for existing top-level templates", %{temp_root: temp_root} do + temp_dir = Path.join(temp_root, "idempotent-starter") + File.mkdir_p!(temp_dir) + + assert {:ok, project} = BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir}) + + template_path = Path.join([temp_dir, "templates", "single-post.liquid"]) + original_contents = File.read!(template_path) + assert {:ok, %{fields: original_fields}} = BDS.Frontmatter.parse_document(original_contents) + assert is_binary(original_fields["id"]) + + assert :ok = BDS.StarterTemplates.install(project) + + reinstalled_contents = File.read!(template_path) + assert reinstalled_contents == original_contents + + assert {:ok, %{fields: reinstalled_fields}} = BDS.Frontmatter.parse_document(reinstalled_contents) + assert reinstalled_fields["id"] == original_fields["id"] + end + test "set_active_project clears the previous active project and activates the target", %{temp_root: temp_root} do first_dir = Path.join(temp_root, "active-first") second_dir = Path.join(temp_root, "active-second")