diff --git a/config/config.exs b/config/config.exs index c6551be..8efaf7b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,8 +9,7 @@ config :bds, BDS.Repo, stacktrace: true, show_sensitive_data_on_connection_error: true -config :bds, BDS.Application, - desktop_adapter: :pending_selection +config :bds, BDS.Application, desktop_adapter: :pending_selection config :bds, :scripting, runtime: BDS.Scripting.Lua, diff --git a/config/dev.exs b/config/dev.exs index b2203c8..9933bc0 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,4 +1,3 @@ import Config -config :bds, BDS.Repo, - pool_size: 5 +config :bds, BDS.Repo, pool_size: 5 diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index b286f40..5e41a1d 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -14,7 +14,8 @@ defmodule BDS.Generation do @core_sections [:core, :single, :category, :tag, :date] - def plan_generation(project_id, sections \\ [:core]) when is_binary(project_id) and is_list(sections) do + def plan_generation(project_id, sections \\ [:core]) + when is_binary(project_id) and is_list(sections) do project = Projects.get_project!(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, generated_files} = list_generated_files(project_id) @@ -27,14 +28,15 @@ defmodule BDS.Generation do language: metadata.main_language, blog_languages: normalize_blog_languages(metadata.main_language, metadata.blog_languages), max_posts_per_page: metadata.max_posts_per_page, - categories: metadata.categories, + categories: metadata.categories, pico_theme: metadata.pico_theme, sections: normalize_sections(sections), generated_files: generated_files }} end - def generate_site(project_id, sections \\ [:core]) when is_binary(project_id) and is_list(sections) do + def generate_site(project_id, sections \\ [:core]) + when is_binary(project_id) and is_list(sections) do with {:ok, plan} <- plan_generation(project_id, sections) do outputs = build_outputs(plan) @@ -106,7 +108,8 @@ defmodule BDS.Generation do )} end - def delete_generated_file(project_id, relative_path) when is_binary(project_id) and is_binary(relative_path) do + def delete_generated_file(project_id, relative_path) + when is_binary(project_id) and is_binary(relative_path) do project = Projects.get_project!(project_id) case File.rm(output_path(project, relative_path)) do @@ -117,7 +120,9 @@ defmodule BDS.Generation do Repo.delete_all( from generated_file in GeneratedFileHash, - where: generated_file.project_id == ^project_id and generated_file.relative_path == ^relative_path + where: + generated_file.project_id == ^project_id and + generated_file.relative_path == ^relative_path ) :ok @@ -146,8 +151,10 @@ defmodule BDS.Generation do build_archive_outputs(plan, published_posts) urls = - core_outputs ++ single_outputs ++ archive_outputs - |> Enum.map(fn {relative_path, _content} -> url_for_output(plan.base_url, relative_path) end) + (core_outputs ++ single_outputs ++ archive_outputs) + |> Enum.map(fn {relative_path, _content} -> + url_for_output(plan.base_url, relative_path) + end) sitemap = if :core in plan.sections do @@ -199,9 +206,42 @@ defmodule BDS.Generation do Enum.with_index(paginated_posts, 1) |> Enum.flat_map(fn {page_posts, page_number} -> Enum.map(languages, fn language -> + pagination = %{ + current_page: page_number, + total_pages: length(paginated_posts), + total_items: length(posts), + items_per_page: max(plan.max_posts_per_page, 1), + has_prev_page: page_number > 1, + prev_page_href: + if(page_number > 1, + do: + archive_href( + route_language(plan.language, language), + ["category", category_slug], + page_number - 1 + ), + else: "" + ), + has_next_page: page_number < length(paginated_posts), + next_page_href: + if(page_number < length(paginated_posts), + do: + archive_href( + route_language(plan.language, language), + ["category", category_slug], + page_number + 1 + ), + else: "" + ) + } + { - archive_path(route_language(plan.language, language), ["category", category_slug], page_number), - render_archive_page(plan, category, page_posts, language, "category") + archive_path( + route_language(plan.language, language), + ["category", category_slug], + page_number + ), + render_archive_page(plan, category, page_posts, language, "category", pagination) } end) end) @@ -216,11 +256,12 @@ defmodule BDS.Generation do Enum.flat_map(tag_posts, fn {tag, posts} -> tag_slug = Slug.slugify(tag) + pagination = pagination_for_posts(posts) Enum.map(languages, fn language -> { archive_path(route_language(plan.language, language), ["tag", tag_slug], 1), - render_archive_page(plan, tag, posts, language, "tag") + render_archive_page(plan, tag, posts, language, "tag", pagination) } end) end) @@ -232,20 +273,24 @@ defmodule BDS.Generation do year_outputs = Enum.flat_map(years, fn {year, posts} -> + pagination = pagination_for_posts(posts) + Enum.map(languages, fn language -> { archive_path(route_language(plan.language, language), [year], 1), - render_date_archive_page(plan, year, posts, language) + render_date_archive_page(plan, year, posts, language, pagination) } end) end) month_outputs = Enum.flat_map(months, fn {{year, month}, posts} -> + pagination = pagination_for_posts(posts) + Enum.map(languages, fn language -> { archive_path(route_language(plan.language, language), [year, month], 1), - render_date_archive_page(plan, "#{year}-#{month}", posts, language) + render_date_archive_page(plan, "#{year}-#{month}", posts, language, pagination) } end) end) @@ -259,7 +304,16 @@ defmodule BDS.Generation do main_posts = build_list_posts(plan.base_url, published_posts, nil) [ - {"index.html", render_list_output(plan, language, plan.project_name, main_posts, %{kind: "core"}, fn -> render_home(plan, language) end)}, + {"index.html", + render_list_output( + plan, + language, + plan.project_name, + main_posts, + %{kind: "core"}, + pagination_for_posts(main_posts), + fn -> render_home(plan, language) end + )}, {"404.html", render_not_found_output(plan, language)}, {"feed.xml", render_feed(plan, language, published_posts)}, {"atom.xml", render_atom(plan, language, published_posts)}, @@ -270,10 +324,22 @@ defmodule BDS.Generation do localized_posts = build_list_posts(plan.base_url, published_posts, localized_prefix) [ - {Path.join(localized_language, "index.html"), render_list_output(plan, localized_language, plan.project_name, localized_posts, %{kind: "core"}, fn -> render_home(plan, localized_language) end)}, - {Path.join(localized_language, "404.html"), render_not_found_output(plan, localized_language)}, - {Path.join(localized_language, "feed.xml"), render_feed(plan, localized_language, published_posts)}, - {Path.join(localized_language, "atom.xml"), render_atom(plan, localized_language, published_posts)} + {Path.join(localized_language, "index.html"), + render_list_output( + plan, + localized_language, + plan.project_name, + localized_posts, + %{kind: "core"}, + pagination_for_posts(localized_posts), + fn -> render_home(plan, localized_language) end + )}, + {Path.join(localized_language, "404.html"), + render_not_found_output(plan, localized_language)}, + {Path.join(localized_language, "feed.xml"), + render_feed(plan, localized_language, published_posts)}, + {Path.join(localized_language, "atom.xml"), + render_atom(plan, localized_language, published_posts)} ] end) end @@ -284,9 +350,21 @@ defmodule BDS.Generation do body = load_body(project_id, post.file_path, post.content) {post_output_path(post), - render_post_output(project_id, post.template_slug, %{id: post.id, title: post.title, content: body, slug: post.slug, language: post.language, excerpt: post.excerpt}, fn -> - render_post_page(post.title, body, post.slug, post.language) - end)} + render_post_output( + project_id, + post.template_slug, + %{ + id: post.id, + title: post.title, + content: body, + slug: post.slug, + language: post.language, + excerpt: post.excerpt + }, + fn -> + render_post_page(post.title, body, post.slug, post.language) + end + )} end) translation_outputs = @@ -300,9 +378,21 @@ defmodule BDS.Generation do [ {post_output_path(post, translation.language), - render_post_output(project_id, post.template_slug, %{id: translation.id, title: translation.title, content: body, slug: post.slug, language: translation.language, excerpt: translation.excerpt}, fn -> - render_post_page(translation.title, body, post.slug, translation.language) - end)} + render_post_output( + project_id, + post.template_slug, + %{ + id: translation.id, + title: translation.title, + content: body, + slug: post.slug, + language: translation.language, + excerpt: translation.excerpt + }, + fn -> + render_post_page(translation.title, body, post.slug, translation.language) + end + )} ] end end) @@ -434,7 +524,7 @@ defmodule BDS.Generation do |> IO.iodata_to_binary() end - defp render_archive_page(plan, title, posts, language, kind) do + defp render_archive_page(plan, title, posts, language, kind, pagination) do fallback = fn -> items = posts @@ -460,14 +550,23 @@ defmodule BDS.Generation do language, title, Enum.map(posts, fn post -> - %{title: post.title, href: "#", excerpt: post.excerpt, content: nil} + %{ + id: post.id, + slug: post.slug, + title: post.title, + href: "#", + excerpt: post.excerpt, + content: nil, + language: post.language + } end), %{kind: kind, name: title}, + pagination, fallback ) end - defp render_date_archive_page(plan, label, posts, language) do + defp render_date_archive_page(plan, label, posts, language, pagination) do fallback = fn -> items = posts @@ -491,21 +590,37 @@ defmodule BDS.Generation do language, label, Enum.map(posts, fn post -> - %{title: post.title, href: "#", excerpt: post.excerpt, content: nil} + %{ + id: post.id, + slug: post.slug, + title: post.title, + href: "#", + excerpt: post.excerpt, + content: nil, + language: post.language + } end), %{kind: "date", name: label}, + pagination, fallback ) end - defp load_body(_project_id, _file_path, inline_content) when is_binary(inline_content), do: inline_content + defp load_body(_project_id, _file_path, inline_content) when is_binary(inline_content), + do: inline_content defp load_body(project_id, file_path, _inline_content) do case file_path do - nil -> "" - "" -> "" + nil -> + "" + + "" -> + "" + value -> - project_path = Path.expand(value, Projects.project_data_dir(Projects.get_project!(project_id))) + project_path = + Path.expand(value, Projects.project_data_dir(Projects.get_project!(project_id))) + case File.read(project_path) do {:ok, contents} -> parse_frontmatter_body(contents) {:error, _reason} -> "" @@ -529,7 +644,9 @@ defmodule BDS.Generation do defp month_key(created_at) do datetime = DateTime.from_unix!(created_at) - {Integer.to_string(datetime.year), Integer.to_string(datetime.month) |> String.pad_leading(2, "0")} + + {Integer.to_string(datetime.year), + Integer.to_string(datetime.month) |> String.pad_leading(2, "0")} end defp build_list_posts(base_url, posts, language_prefix) do @@ -552,25 +669,46 @@ defmodule BDS.Generation do end end - defp render_list_output(%{project_id: project_id, language: main_language}, language, page_title, posts, archive_context, fallback) + defp render_list_output( + %{project_id: project_id, language: main_language}, + language, + page_title, + posts, + archive_context, + pagination, + fallback + ) when is_binary(project_id) do case Rendering.render_list_page(project_id, %{ language: language, language_prefix: language_prefix(language, main_language), page_title: page_title, posts: posts, - archive_context: archive_context + archive_context: archive_context, + pagination: pagination }) do {:ok, rendered} -> rendered {:error, _reason} -> fallback.() end end - defp render_list_output(_plan, _language, _page_title, _posts, _archive_context, fallback), do: fallback.() + defp render_list_output( + _plan, + _language, + _page_title, + _posts, + _archive_context, + _pagination, + fallback + ), + do: fallback.() defp render_not_found_output(%{project_id: project_id, language: main_language}, language) when is_binary(project_id) do - case Rendering.render_not_found_page(project_id, %{language: language, language_prefix: language_prefix(language, main_language)}) do + case Rendering.render_not_found_page(project_id, %{ + language: language, + language_prefix: language_prefix(language, main_language) + }) do {:ok, rendered} -> rendered {:error, _reason} -> render_not_found_page(language) end @@ -582,6 +720,25 @@ defmodule BDS.Generation do defp language_prefix(nil, _main_language), do: "" defp language_prefix(language, _main_language), do: "/#{language}" + defp pagination_for_posts(posts) do + %{ + current_page: 1, + total_pages: 1, + total_items: length(posts), + items_per_page: length(posts), + has_prev_page: false, + prev_page_href: "", + has_next_page: false, + next_page_href: "" + } + end + + defp archive_href(language, segments, page_number) do + archive_path(language, segments, page_number) + |> String.trim_trailing("index.html") + |> then(&("/" <> String.trim_leading(&1, "/"))) + end + defp url_for_output(nil, relative_path), do: "/" <> String.trim_leading(relative_path, "/") defp url_for_output(base_url, relative_path) do diff --git a/lib/bds/maintenance.ex b/lib/bds/maintenance.ex index 009d0f5..1d881cd 100644 --- a/lib/bds/maintenance.ex +++ b/lib/bds/maintenance.ex @@ -53,7 +53,8 @@ defmodule BDS.Maintenance do 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 != "" + 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 @@ -66,6 +67,9 @@ defmodule BDS.Maintenance do 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("created_at", post.created_at, Map.get(fields, "created_at")), + diff_field("updated_at", post.updated_at, Map.get(fields, "updated_at")), + diff_field("published_at", post.published_at, Map.get(fields, "published_at")), diff_field("tags", post.tags, Map.get(fields, "tags", [])), diff_field("categories", post.categories, Map.get(fields, "categories", [])) ] @@ -86,7 +90,9 @@ defmodule BDS.Maintenance do 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 != "" + 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 @@ -98,6 +104,8 @@ defmodule BDS.Maintenance do 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("created_at", media.created_at, Map.get(fields, "created_at")), + diff_field("updated_at", media.updated_at, Map.get(fields, "updated_at")), diff_field("tags", media.tags, Map.get(fields, "tags", [])) ] |> Enum.reject(&is_nil/1) @@ -117,7 +125,9 @@ defmodule BDS.Maintenance do defp post_translation_diff_reports(project_id, project) do Repo.all( from translation in PostTranslation, - where: translation.project_id == ^project_id and not is_nil(translation.file_path) and translation.file_path != "" + where: + translation.project_id == ^project_id and not is_nil(translation.file_path) and + translation.file_path != "" ) |> Enum.flat_map(fn translation -> case read_frontmatter_document(project, translation.file_path) do @@ -128,14 +138,31 @@ defmodule BDS.Maintenance do diff_field("excerpt", translation.excerpt, Map.get(fields, "excerpt")), diff_field("language", translation.language, Map.get(fields, "language")), diff_field("status", translation.status, Map.get(fields, "status")), - diff_field("translation_for", translation.translation_for, Map.get(fields, "translation_for")) + diff_field( + "translation_for", + translation.translation_for, + Map.get(fields, "translation_for") + ), + diff_field("created_at", translation.created_at, Map.get(fields, "created_at")), + diff_field("updated_at", translation.updated_at, Map.get(fields, "updated_at")), + diff_field( + "published_at", + translation.published_at, + Map.get(fields, "published_at") + ) ] |> Enum.reject(&is_nil/1) if differences == [] do [] else - [%{entity_type: "post_translation", entity_id: translation.id, differences: differences}] + [ + %{ + entity_type: "post_translation", + entity_id: translation.id, + differences: differences + } + ] end {:error, _reason} -> @@ -157,14 +184,24 @@ defmodule BDS.Maintenance do diff_field("alt", translation.alt, Map.get(fields, "alt")), diff_field("caption", translation.caption, Map.get(fields, "caption")), diff_field("language", translation.language, Map.get(fields, "language")), - diff_field("translation_for", translation.translation_for, Map.get(fields, "translation_for")) + diff_field( + "translation_for", + translation.translation_for, + Map.get(fields, "translation_for") + ) ] |> Enum.reject(&is_nil/1) if differences == [] do [] else - [%{entity_type: "media_translation", entity_id: translation.id, differences: differences}] + [ + %{ + entity_type: "media_translation", + entity_id: translation.id, + differences: differences + } + ] end _ -> @@ -176,7 +213,9 @@ defmodule BDS.Maintenance do 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 != "" + 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 @@ -185,7 +224,9 @@ defmodule BDS.Maintenance do [ 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")) + diff_field("enabled", script.enabled, Map.get(fields, "enabled")), + diff_field("created_at", script.created_at, Map.get(fields, "created_at")), + diff_field("updated_at", script.updated_at, Map.get(fields, "updated_at")) ] |> Enum.reject(&is_nil/1) @@ -204,7 +245,9 @@ defmodule BDS.Maintenance do 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 != "" + 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 @@ -212,7 +255,9 @@ defmodule BDS.Maintenance do differences = [ diff_field("title", template.title, Map.get(fields, "title")), - diff_field("enabled", template.enabled, Map.get(fields, "enabled")) + diff_field("enabled", template.enabled, Map.get(fields, "enabled")), + diff_field("created_at", template.created_at, Map.get(fields, "created_at")), + diff_field("updated_at", template.updated_at, Map.get(fields, "updated_at")) ] |> Enum.reject(&is_nil/1) @@ -229,12 +274,44 @@ defmodule BDS.Maintenance do 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)) - post_translation_paths = MapSet.new(Repo.all(from translation in PostTranslation, where: translation.project_id == ^project_id, select: translation.file_path)) + 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 + ) + ) + + post_translation_paths = + MapSet.new( + Repo.all( + from translation in PostTranslation, + where: translation.project_id == ^project_id, + select: translation.file_path + ) + ) + media_translation_paths = MapSet.new(media_translation_sidecar_paths(project_id)) - 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)) + + 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 @@ -276,7 +353,9 @@ defmodule BDS.Maintenance do |> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project))) |> Enum.reject(&MapSet.member?(template_paths, &1)) - (post_orphans ++ post_translation_orphans ++ media_orphans ++ media_translation_orphans ++ script_orphans ++ template_orphans) + (post_orphans ++ + post_translation_orphans ++ + media_orphans ++ media_translation_orphans ++ script_orphans ++ template_orphans) |> Enum.sort() |> Enum.map(&%{file_path: &1}) end @@ -297,7 +376,10 @@ defmodule BDS.Maintenance do 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) 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 @@ -345,7 +427,11 @@ defmodule BDS.Maintenance do end defp media_translation_sidecar_path(project_id, translation) do - case Repo.one(from media in Media, where: media.project_id == ^project_id and media.id == ^translation.translation_for, select: media.file_path) do + case Repo.one( + from media in Media, + where: media.project_id == ^project_id and media.id == ^translation.translation_for, + select: media.file_path + ) do nil -> nil file_path -> "#{file_path}.#{translation.language}.meta" end diff --git a/lib/bds/media.ex b/lib/bds/media.ex index afc761e..8798bb5 100644 --- a/lib/bds/media.ex +++ b/lib/bds/media.ex @@ -68,16 +68,17 @@ defmodule BDS.Media do {:error, :not_found} media -> - updates = %{} - |> maybe_put(:title, attr(attrs, :title)) - |> maybe_put(:alt, attr(attrs, :alt)) - |> maybe_put(:caption, attr(attrs, :caption)) - |> maybe_put(:author, attr(attrs, :author)) - |> maybe_put(:language, attr(attrs, :language)) - |> maybe_put(:tags, attr(attrs, :tags)) - |> maybe_put(:width, attr(attrs, :width)) - |> maybe_put(:height, attr(attrs, :height)) - |> Map.put(:updated_at, System.system_time(:second)) + updates = + %{} + |> maybe_put(:title, attr(attrs, :title)) + |> maybe_put(:alt, attr(attrs, :alt)) + |> maybe_put(:caption, attr(attrs, :caption)) + |> maybe_put(:author, attr(attrs, :author)) + |> maybe_put(:language, attr(attrs, :language)) + |> maybe_put(:tags, attr(attrs, :tags)) + |> maybe_put(:width, attr(attrs, :width)) + |> maybe_put(:height, attr(attrs, :height)) + |> Map.put(:updated_at, System.system_time(:second)) project = Projects.get_project!(media.project_id) @@ -104,14 +105,21 @@ defmodule BDS.Media do {:error, :not_found} media -> - translations = Repo.all(from translation in Translation, where: translation.translation_for == ^media.id) + translations = + Repo.all( + from translation in Translation, where: translation.translation_for == ^media.id + ) delete_file_if_present(media.project_id, media.file_path) delete_file_if_present(media.project_id, media.sidecar_path) delete_thumbnail_files(media.project_id, media) Enum.each(translations, fn translation -> - delete_file_if_present(media.project_id, translation_sidecar_path(media, translation.language)) + delete_file_if_present( + media.project_id, + translation_sidecar_path(media, translation.language) + ) + Repo.delete!(translation) end) @@ -243,7 +251,9 @@ defmodule BDS.Media do updated_at: Map.get(fields, "updated_at", now) } - media = Repo.get(Media, attrs.id) || Repo.get_by(Media, project_id: project.id, file_path: relative_file_path) || %Media{} + media = + Repo.get(Media, attrs.id) || + Repo.get_by(Media, project_id: project.id, file_path: relative_file_path) || %Media{} media |> Media.changeset(attrs) @@ -278,7 +288,12 @@ defmodule BDS.Media do end defp write_translation_sidecar(project, media, translation) do - path = Path.join(Projects.project_data_dir(project), translation_sidecar_path(media, translation.language)) + path = + Path.join( + Projects.project_data_dir(project), + translation_sidecar_path(media, translation.language) + ) + :ok = File.mkdir_p(Path.dirname(path)) atomic_write( diff --git a/lib/bds/media/media.ex b/lib/bds/media/media.ex index 3ccc813..80ba16d 100644 --- a/lib/bds/media/media.ex +++ b/lib/bds/media/media.ex @@ -58,7 +58,18 @@ defmodule BDS.Media.Media do ], empty_values: [nil] ) - |> validate_required([:id, :project_id, :filename, :original_name, :mime_type, :size, :file_path, :sidecar_path, :created_at, :updated_at]) + |> validate_required([ + :id, + :project_id, + :filename, + :original_name, + :mime_type, + :size, + :file_path, + :sidecar_path, + :created_at, + :updated_at + ]) |> assoc_constraint(:project) end end diff --git a/lib/bds/media/translation.ex b/lib/bds/media/translation.ex index 96c5b01..8b20e49 100644 --- a/lib/bds/media/translation.ex +++ b/lib/bds/media/translation.ex @@ -8,7 +8,11 @@ defmodule BDS.Media.Translation do @foreign_key_type :string schema "media_translations" do - belongs_to :media, BDS.Media.Media, foreign_key: :translation_for, references: :id, type: :string + belongs_to :media, BDS.Media.Media, + foreign_key: :translation_for, + references: :id, + type: :string + field :project_id, :string field :language, :string field :title, :string @@ -20,10 +24,29 @@ defmodule BDS.Media.Translation do def changeset(translation, attrs) do translation - |> cast(attrs, [:id, :project_id, :translation_for, :language, :title, :alt, :caption, :created_at, :updated_at], + |> cast( + attrs, + [ + :id, + :project_id, + :translation_for, + :language, + :title, + :alt, + :caption, + :created_at, + :updated_at + ], empty_values: [nil] ) - |> validate_required([:id, :project_id, :translation_for, :language, :created_at, :updated_at]) + |> validate_required([ + :id, + :project_id, + :translation_for, + :language, + :created_at, + :updated_at + ]) |> foreign_key_constraint(:translation_for) |> unique_constraint(:language, name: :media_translations_translation_language_idx) end diff --git a/lib/bds/menu.ex b/lib/bds/menu.ex index f94e84a..12bf4ff 100644 --- a/lib/bds/menu.ex +++ b/lib/bds/menu.ex @@ -6,7 +6,11 @@ defmodule BDS.Menu do alias BDS.Projects Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl")) - Record.defrecord(:xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")) + + Record.defrecord( + :xmlAttribute, + Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl") + ) @valid_kinds [:page, :submenu, :category_archive, :home] @@ -187,7 +191,7 @@ defmodule BDS.Menu do |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") - |> String.replace(~s(") , """) + |> String.replace(~s("), """) end defp attr(attrs, key) do diff --git a/lib/bds/metadata.ex b/lib/bds/metadata.ex index b89e839..5348564 100644 --- a/lib/bds/metadata.ex +++ b/lib/bds/metadata.ex @@ -21,13 +21,28 @@ defmodule BDS.Metadata do project_metadata = state - |> Map.take([:name, :description, :public_url, :main_language, :default_author, :max_posts_per_page, :blogmark_category, :pico_theme, :semantic_similarity_enabled, :blog_languages]) + |> Map.take([ + :name, + :description, + :public_url, + :main_language, + :default_author, + :max_posts_per_page, + :blogmark_category, + :pico_theme, + :semantic_similarity_enabled, + :blog_languages + ]) |> Map.merge(normalize_project_metadata_attrs(attrs, project)) Repo.transaction(fn -> updated_project = project - |> Project.changeset(%{name: project_metadata.name, description: project_metadata.description, updated_at: now}) + |> Project.changeset(%{ + name: project_metadata.name, + description: project_metadata.description, + updated_at: now + }) |> Repo.update!() persist_setting(project_id, "project", stringify_project_metadata(project_metadata), now) @@ -89,8 +104,13 @@ defmodule BDS.Metadata do project = Projects.get_project!(project_id) now = System.system_time(:second) - project_metadata_from_files = read_json(project, "project.json") || stringify_project_metadata(default_project_metadata(project)) - categories_from_files = read_json(project, "categories.json") || %{"categories" => @default_categories} + project_metadata_from_files = + read_json(project, "project.json") || + stringify_project_metadata(default_project_metadata(project)) + + categories_from_files = + read_json(project, "categories.json") || %{"categories" => @default_categories} + category_meta_from_files = read_json(project, "category-meta.json") || %{"categories" => %{}} publishing_from_files = read_json(project, "publishing.json") || %{"ssh_mode" => "scp"} @@ -125,8 +145,14 @@ defmodule BDS.Metadata do end defp load_state(project) do - project_metadata = load_setting(project.id, "project") || stringify_project_metadata(default_project_metadata(project)) - categories = (load_setting(project.id, "categories") || %{"categories" => @default_categories})["categories"] + project_metadata = + load_setting(project.id, "project") || + stringify_project_metadata(default_project_metadata(project)) + + categories = + (load_setting(project.id, "categories") || %{"categories" => @default_categories})[ + "categories" + ] category_settings = (load_setting(project.id, "category_meta") || %{"categories" => %{}})["categories"] @@ -139,10 +165,12 @@ defmodule BDS.Metadata do public_url: Map.get(project_metadata, "public_url"), main_language: Map.get(project_metadata, "main_language"), 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), + max_posts_per_page: + Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page), blogmark_category: Map.get(project_metadata, "blogmark_category"), pico_theme: Map.get(project_metadata, "pico_theme"), - semantic_similarity_enabled: Map.get(project_metadata, "semantic_similarity_enabled", false), + semantic_similarity_enabled: + Map.get(project_metadata, "semantic_similarity_enabled", false), blog_languages: Map.get(project_metadata, "blog_languages", []), categories: categories, category_settings: category_settings, @@ -182,10 +210,13 @@ defmodule BDS.Metadata do defp normalize_category_settings(settings) do %{ - "render_in_lists" => Map.get(settings, :render_in_lists, Map.get(settings, "render_in_lists", true)), + "render_in_lists" => + Map.get(settings, :render_in_lists, Map.get(settings, "render_in_lists", true)), "show_title" => Map.get(settings, :show_title, Map.get(settings, "show_title", true)), - "post_template_slug" => Map.get(settings, :post_template_slug, Map.get(settings, "post_template_slug")), - "list_template_slug" => Map.get(settings, :list_template_slug, Map.get(settings, "list_template_slug")) + "post_template_slug" => + Map.get(settings, :post_template_slug, Map.get(settings, "post_template_slug")), + "list_template_slug" => + Map.get(settings, :list_template_slug, Map.get(settings, "list_template_slug")) } end @@ -220,7 +251,8 @@ defmodule BDS.Metadata do write_publishing_json(project, state.publishing_preferences) end - defp write_project_json(project, project_json), do: write_json(project, "project.json", project_json) + defp write_project_json(project, project_json), + do: write_json(project, "project.json", project_json) defp write_categories_json(project, categories) do write_json(project, "categories.json", %{"categories" => Enum.sort(categories)}) diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index 22ea01d..1eb3d93 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -37,7 +37,7 @@ defmodule BDS.Posts do categories: attr(attrs, :categories) || [], template_slug: attr(attrs, :template_slug), language: attr(attrs, :language), - do_not_translate: false, + do_not_translate: attr(attrs, :do_not_translate) || false, published_title: nil, published_content: nil, published_tags: nil, @@ -63,6 +63,7 @@ defmodule BDS.Posts do post -> with :ok <- validate_slug_change(post, attrs) do now = System.system_time(:second) + updates = attrs |> normalize_updates(post) @@ -100,7 +101,12 @@ defmodule BDS.Posts do body = publishable_post_body(post, full_path, project) :ok = File.mkdir_p(Path.dirname(full_path)) - :ok = File.write(full_path, serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at)) + + :ok = + File.write( + full_path, + serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at) + ) post |> Post.changeset(%{ @@ -197,16 +203,21 @@ defmodule BDS.Posts do {:error, post |> Post.changeset(%{}) - |> Ecto.Changeset.add_error(:do_not_translate, "cannot add translations when do_not_translate is true")} + |> Ecto.Changeset.add_error( + :do_not_translate, + "cannot add translations when do_not_translate is true" + )} %Post{} = post -> now = System.system_time(:second) normalized_language = normalize_language(language) translation = - Repo.get_by(Translation, translation_for: post.id, language: normalized_language) || %Translation{} + Repo.get_by(Translation, translation_for: post.id, language: normalized_language) || + %Translation{} - updates = normalize_translation_updates(post, translation, normalized_language, attrs, now) + updates = + normalize_translation_updates(post, translation, normalized_language, attrs, now) translation |> Translation.changeset(updates) @@ -253,7 +264,9 @@ defmodule BDS.Posts do where: post.project_id == ^project_id, select: {translation.translation_for, translation.language} ) - |> Enum.group_by(fn {post_id, _language} -> post_id end, fn {_post_id, language} -> language end) + |> Enum.group_by(fn {post_id, _language} -> post_id end, fn {_post_id, language} -> + language + end) required_languages = metadata.blog_languages @@ -268,7 +281,9 @@ defmodule BDS.Posts do available = Map.get(translation_languages, post.id, []) cond do - post.do_not_translate -> [] + post.do_not_translate -> + [] + true -> required_languages |> Enum.reject(&(&1 in available)) @@ -299,7 +314,15 @@ defmodule BDS.Posts do full_path = Path.join(Projects.project_data_dir(project), post.file_path) body = published_post_body(post, full_path) :ok = File.mkdir_p(Path.dirname(full_path)) - :ok = File.write(full_path, serialize_post_file(%{post | content: body}, post.published_at || System.system_time(:second))) + + :ok = + File.write( + full_path, + serialize_post_file( + %{post | content: body}, + post.published_at || System.system_time(:second) + ) + ) end :ok @@ -328,7 +351,8 @@ defmodule BDS.Posts do |> maybe_put(:published_excerpt, attr(attrs, :published_excerpt)) end - defp validate_slug_change(%Post{published_at: published_at} = post, attrs) when not is_nil(published_at) do + defp validate_slug_change(%Post{published_at: published_at} = post, attrs) + when not is_nil(published_at) do case attr(attrs, :slug) do nil -> :ok @@ -357,12 +381,25 @@ defmodule BDS.Posts do defp maybe_reopen_published_post(updates, _post), do: updates defp published_content_change?(updates, post) do - Enum.any?([:title, :excerpt, :content, :author, :language, :template_slug, :tags, :categories, :do_not_translate], fn field -> - case Map.fetch(updates, field) do - {:ok, value} -> value != Map.get(post, field) - :error -> false + Enum.any?( + [ + :title, + :excerpt, + :content, + :author, + :language, + :template_slug, + :tags, + :categories, + :do_not_translate + ], + fn field -> + case Map.fetch(updates, field) do + {:ok, value} -> value != Map.get(post, field) + :error -> false + end end - end) + ) end defp unique_slug(project_id, base_slug) do @@ -386,7 +423,9 @@ defmodule BDS.Posts do end defp slug_available?(project_id, slug) do - not Repo.exists?(from post in Post, where: post.project_id == ^project_id and post.slug == ^slug) + not Repo.exists?( + from post in Post, where: post.project_id == ^project_id and post.slug == ^slug + ) end defp maybe_put(map, _key, nil), do: map @@ -409,7 +448,8 @@ defmodule BDS.Posts do Path.join(["posts", year, month, "#{slug}.md"]) end - defp publishable_post_body(%Post{content: content}, _full_path, _project) when is_binary(content), do: content + defp publishable_post_body(%Post{content: content}, _full_path, _project) + when is_binary(content), do: content defp publishable_post_body(%Post{file_path: file_path} = post, full_path, project) do source_path = @@ -444,7 +484,8 @@ defmodule BDS.Posts do ) end - defp published_post_body(%Post{content: content}, _full_path) when is_binary(content), do: content + defp published_post_body(%Post{content: content}, _full_path) when is_binary(content), + do: content defp published_post_body(_post, full_path) do case File.read(full_path) do @@ -512,7 +553,8 @@ defmodule BDS.Posts do end end - defp delete_post_file(%Post{project_id: _project_id, file_path: file_path}) when file_path in [nil, ""], do: :ok + defp delete_post_file(%Post{project_id: _project_id, file_path: file_path}) + when file_path in [nil, ""], do: :ok defp delete_post_file(%Post{} = post) do project = Projects.get_project!(post.project_id) @@ -532,7 +574,8 @@ defmodule BDS.Posts do |> maybe_put(:excerpt, attr(attrs, :excerpt)) |> maybe_put(:content, attr(attrs, :content)) - reopened? = translation.status == :published and translation_content_change?(translation, updates) + reopened? = + translation.status == :published and translation_content_change?(translation, updates) %{ id: translation.id || Ecto.UUID.generate(), @@ -580,7 +623,15 @@ defmodule BDS.Posts do body = publishable_translation_body(translation, full_path) :ok = File.mkdir_p(Path.dirname(full_path)) - :ok = File.write(full_path, serialize_translation_file(%{translation | updated_at: updated_at, content: body}, published_at)) + + :ok = + File.write( + full_path, + serialize_translation_file( + %{translation | updated_at: updated_at, content: body}, + published_at + ) + ) translation |> Translation.changeset(%{ @@ -619,7 +670,8 @@ defmodule BDS.Posts do ) end - defp publishable_translation_body(%Translation{content: content}, _full_path) when is_binary(content), do: content + defp publishable_translation_body(%Translation{content: content}, _full_path) + when is_binary(content), do: content defp publishable_translation_body(_translation, full_path) do case File.read(full_path) do @@ -634,7 +686,8 @@ defmodule BDS.Posts do end end - defp delete_translation_file(%Translation{project_id: _project_id, file_path: file_path}) when file_path in [nil, ""], do: :ok + defp delete_translation_file(%Translation{project_id: _project_id, file_path: file_path}) + when file_path in [nil, ""], do: :ok defp delete_translation_file(%Translation{} = translation) do project = Projects.get_project!(translation.project_id) @@ -649,7 +702,15 @@ defmodule BDS.Posts do defp orphan_translation_files(project_id) do project = Projects.get_project!(project_id) - translation_paths = MapSet.new(Repo.all(from translation in Translation, where: translation.project_id == ^project_id, select: translation.file_path)) + + translation_paths = + MapSet.new( + Repo.all( + from translation in Translation, + where: translation.project_id == ^project_id, + select: translation.file_path + ) + ) project |> Projects.project_data_dir() diff --git a/lib/bds/posts/post.ex b/lib/bds/posts/post.ex index 9b0f2f7..4c3c44f 100644 --- a/lib/bds/posts/post.ex +++ b/lib/bds/posts/post.ex @@ -67,7 +67,15 @@ defmodule BDS.Posts.Post do ], empty_values: [nil] ) - |> validate_required([:id, :project_id, :slug, :status, :created_at, :updated_at, :do_not_translate]) + |> validate_required([ + :id, + :project_id, + :slug, + :status, + :created_at, + :updated_at, + :do_not_translate + ]) |> assoc_constraint(:project) |> unique_constraint(:slug, name: :posts_project_slug_idx) end diff --git a/lib/bds/posts/translation.ex b/lib/bds/posts/translation.ex index d9e23dd..443b1fe 100644 --- a/lib/bds/posts/translation.ex +++ b/lib/bds/posts/translation.ex @@ -9,7 +9,10 @@ defmodule BDS.Posts.Translation do @statuses [:draft, :published] schema "post_translations" do - belongs_to :post, BDS.Posts.Post, foreign_key: :translation_for, references: :id, type: :string + belongs_to :post, BDS.Posts.Post, + foreign_key: :translation_for, + references: :id, + type: :string field :project_id, :string field :language, :string @@ -26,22 +29,35 @@ defmodule BDS.Posts.Translation do def changeset(translation, attrs) do translation - |> cast(attrs, [ + |> cast( + attrs, + [ + :id, + :project_id, + :translation_for, + :language, + :title, + :excerpt, + :content, + :status, + :created_at, + :updated_at, + :published_at, + :file_path, + :checksum + ], + empty_values: [nil] + ) + |> validate_required([ :id, :project_id, :translation_for, :language, :title, - :excerpt, - :content, :status, :created_at, - :updated_at, - :published_at, - :file_path, - :checksum - ], empty_values: [nil]) - |> validate_required([:id, :project_id, :translation_for, :language, :title, :status, :created_at, :updated_at]) + :updated_at + ]) |> foreign_key_constraint(:translation_for) |> unique_constraint(:language, name: :post_translations_translation_language_idx) end diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index 6bdc462..80a7adb 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -16,7 +16,11 @@ defmodule BDS.Preview do def start_preview(project_id) when is_binary(project_id) do project = Projects.get_project!(project_id) - GenServer.call(__MODULE__, {:start_preview, project_id, Projects.project_data_dir(project), self()}) + + GenServer.call( + __MODULE__, + {:start_preview, project_id, Projects.project_data_dir(project), self()} + ) end def stop_preview(project_id) when is_binary(project_id) do @@ -58,7 +62,15 @@ defmodule BDS.Preview do state = stop_current_server(state) maybe_allow_repo(owner_pid) - {:ok, listener} = :gen_tcp.listen(@port, [:binary, packet: :raw, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + {:ok, listener} = + :gen_tcp.listen(@port, [ + :binary, + packet: :raw, + active: false, + reuseaddr: true, + ip: {127, 0, 0, 1} + ]) + acceptor_pid = spawn_link(fn -> accept_loop(listener, project_id) end) server = %{ @@ -145,7 +157,9 @@ defmodule BDS.Preview do end case full_path do - {:error, :not_found} -> {:error, :not_found} + {:error, :not_found} -> + {:error, :not_found} + resolved_path -> case read_response(resolved_path) do {:error, :not_found} -> render_not_found_response(server.project_id) @@ -258,7 +272,11 @@ defmodule BDS.Preview do path = uri.path || "/" query_params = URI.decode_query(uri.query || "") - case GenServer.call(__MODULE__, {:http_request, project_id, method, path, query_params}, 5_000) do + case GenServer.call( + __MODULE__, + {:http_request, project_id, method, path, query_params}, + 5_000 + ) do {:ok, response} -> http_ok_response(response) {:error, :not_found} -> http_error_response(404) {:error, :not_running} -> http_error_response(503) diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index 6e56dc8..878ebcb 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -101,7 +101,10 @@ defmodule BDS.Projects do |> Multi.update_all(:clear_previous, from(p in Project, where: p.is_active == true), set: [is_active: false, updated_at: now] ) - |> Multi.update(:activate, Project.changeset(project, %{is_active: true, updated_at: now})) + |> Multi.update( + :activate, + Project.changeset(project, %{is_active: true, updated_at: now}) + ) |> Repo.transaction() |> case do {:ok, %{activate: active_project}} -> {:ok, active_project} diff --git a/lib/bds/projects/project.ex b/lib/bds/projects/project.ex index e547162..f16ddad 100644 --- a/lib/bds/projects/project.ex +++ b/lib/bds/projects/project.ex @@ -21,7 +21,9 @@ defmodule BDS.Projects.Project do def changeset(project, attrs) do project - |> cast(attrs, [:id, :name, :slug, :description, :data_path, :created_at, :updated_at, :is_active], + |> cast( + attrs, + [:id, :name, :slug, :description, :data_path, :created_at, :updated_at, :is_active], empty_values: [nil] ) |> validate_required([:id, :name, :slug, :created_at, :updated_at, :is_active]) diff --git a/lib/bds/publishing.ex b/lib/bds/publishing.ex index d1b2350..8c81e49 100644 --- a/lib/bds/publishing.ex +++ b/lib/bds/publishing.ex @@ -10,7 +10,8 @@ defmodule BDS.Publishing do GenServer.start_link(__MODULE__, %{}, name: __MODULE__) end - def upload_site(project_id, credentials, opts \\ []) when is_binary(project_id) and is_map(credentials) and is_list(opts) do + def upload_site(project_id, credentials, opts \\ []) + when is_binary(project_id) and is_map(credentials) and is_list(opts) do project = Projects.get_project!(project_id) normalized_credentials = normalize_credentials(credentials) targets = build_upload_targets(Projects.project_data_dir(project), normalized_credentials) @@ -23,7 +24,7 @@ defmodule BDS.Publishing do @impl true def init(_state) do - {:ok, %{jobs: %{}}} + {:ok, %{jobs: %{}, scp_uploads: %{}}} end @impl true @@ -41,15 +42,32 @@ defmodule BDS.Publishing do {:reply, :ok, next_state} end + def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do + should_upload? = + case state.scp_uploads[upload_key] do + nil -> true + recorded_mtime -> local_mtime > recorded_mtime + end + + {:reply, should_upload?, state} + end + + def handle_call({:mark_uploaded_scp_file, upload_key, local_mtime}, _from, state) do + {:reply, :ok, put_in(state, [:scp_uploads, upload_key], local_mtime)} + end + def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic])) - uploader = build_uploader(opts) + uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id)) job = %{ id: job_id, project_id: project_id, status: :pending, task_id: nil, + ssh_host: credentials.ssh_host, + ssh_user: credentials.ssh_user, + ssh_remote_path: credentials.ssh_remote_path, ssh_mode: credentials.ssh_mode, targets: Enum.map(targets, & &1.kind), error: nil, @@ -58,12 +76,16 @@ defmodule BDS.Publishing do } {:ok, task} = - Tasks.submit_task("publish #{project_id}", fn report -> - run_upload(job_id, credentials, targets, uploader, report) - end, %{ - group_id: project_id, - group_name: "Publishing" - }) + Tasks.submit_task( + "publish #{project_id}", + fn report -> + run_upload(job_id, credentials, targets, uploader, report) + end, + %{ + group_id: project_id, + group_name: "Publishing" + } + ) next_job = %{job | task_id: task.id} {:reply, {:ok, next_job}, put_in(state, [:jobs, job_id], next_job)} @@ -104,9 +126,10 @@ defmodule BDS.Publishing do nil -> runner = Keyword.get(opts, :command_runner, &System.cmd/3) ssh_auth_sock = Keyword.get(opts, :ssh_auth_sock, System.get_env("SSH_AUTH_SOCK")) + project_id = Keyword.fetch!(opts, :project_id) fn target, files, credentials -> - run_command_upload(target, files, credentials, runner, ssh_auth_sock) + run_command_upload(project_id, target, files, credentials, runner, ssh_auth_sock) end uploader -> @@ -114,22 +137,60 @@ defmodule BDS.Publishing do end end - defp run_command_upload(target, _files, %{ssh_mode: :rsync} = credentials, runner, ssh_auth_sock) do + defp run_command_upload( + _project_id, + target, + _files, + %{ssh_mode: :rsync} = credentials, + runner, + ssh_auth_sock + ) do args = ["--update", "--compress", "--verbose"] ++ rsync_excludes(target) ++ - ["-e", "ssh", ensure_trailing_slash(target.local_dir), remote_dir_spec(credentials, target.remote_dir)] + [ + "-e", + "ssh", + ensure_trailing_slash(target.local_dir), + remote_dir_spec(credentials, target.remote_dir) + ] run_command(runner, "rsync", args, ssh_auth_sock) end - defp run_command_upload(target, files, credentials, runner, ssh_auth_sock) do + defp run_command_upload(project_id, target, files, credentials, runner, ssh_auth_sock) do Enum.reduce_while(files, :ok, fn relative_path, :ok -> local_path = Path.join(target.local_dir, relative_path) - remote_path = remote_file_spec(credentials, target.remote_dir, relative_path) - case run_command(runner, "scp", ["-q", local_path, remote_path], ssh_auth_sock) do - :ok -> {:cont, :ok} + with {:ok, local_mtime} <- file_mtime(local_path), + true <- + should_upload_scp_file?( + project_id, + credentials, + target.kind, + relative_path, + local_mtime + ) do + remote_path = remote_file_spec(credentials, target.remote_dir, relative_path) + + case run_command(runner, "scp", ["-q", local_path, remote_path], ssh_auth_sock) do + :ok -> + :ok = + mark_uploaded_scp_file( + project_id, + credentials, + target.kind, + relative_path, + local_mtime + ) + + {:cont, :ok} + + {:error, reason} -> + {:halt, {:error, reason}} + end + else + false -> {:cont, :ok} {:error, reason} -> {:halt, {:error, reason}} end end) @@ -147,10 +208,49 @@ defmodule BDS.Publishing do end defp command_opts(nil), do: [stderr_to_stdout: true] - defp command_opts(ssh_auth_sock), do: [stderr_to_stdout: true, env: [{"SSH_AUTH_SOCK", ssh_auth_sock}]] - defp normalize_command_error(_command, output, _status) when is_binary(output) and output != "", do: output - defp normalize_command_error(command, _output, status), do: "#{command} exited with status #{status}" + defp command_opts(ssh_auth_sock), + do: [stderr_to_stdout: true, env: [{"SSH_AUTH_SOCK", ssh_auth_sock}]] + + defp normalize_command_error(_command, output, _status) when is_binary(output) and output != "", + do: output + + defp normalize_command_error(command, _output, status), + do: "#{command} exited with status #{status}" + + defp file_mtime(path) do + case File.stat(path, time: :posix) do + {:ok, stat} -> {:ok, stat.mtime} + {:error, reason} -> {:error, reason} + end + end + + defp should_upload_scp_file?(project_id, credentials, target_kind, relative_path, local_mtime) do + GenServer.call( + __MODULE__, + {:should_upload_scp_file, + scp_upload_key(project_id, credentials, target_kind, relative_path), local_mtime} + ) + end + + defp mark_uploaded_scp_file(project_id, credentials, target_kind, relative_path, local_mtime) do + GenServer.call( + __MODULE__, + {:mark_uploaded_scp_file, + scp_upload_key(project_id, credentials, target_kind, relative_path), local_mtime} + ) + end + + defp scp_upload_key(project_id, credentials, target_kind, relative_path) do + { + project_id, + credentials.ssh_host, + credentials.ssh_user, + credentials.ssh_remote_path, + target_kind, + relative_path + } + end defp rsync_excludes(%{kind: :media}), do: ["--exclude=*.meta"] defp rsync_excludes(_target), do: [] @@ -172,8 +272,16 @@ defmodule BDS.Publishing do [ %{kind: :html, local_dir: Path.join(base_dir, "html"), remote_dir: remote_root}, - %{kind: :thumbnails, local_dir: Path.join(base_dir, "thumbnails"), remote_dir: Path.join(remote_root, "thumbnails")}, - %{kind: :media, local_dir: Path.join(base_dir, "media"), remote_dir: Path.join(remote_root, "media")} + %{ + kind: :thumbnails, + local_dir: Path.join(base_dir, "thumbnails"), + remote_dir: Path.join(remote_root, "thumbnails") + }, + %{ + kind: :media, + local_dir: Path.join(base_dir, "media"), + remote_dir: Path.join(remote_root, "media") + } ] end @@ -184,7 +292,9 @@ defmodule BDS.Publishing do |> Path.wildcard(match_dot: true) |> Enum.filter(&File.regular?/1) |> Enum.map(&Path.relative_to(&1, target.local_dir)) - |> Enum.reject(fn relative_path -> target.kind == :media and String.ends_with?(relative_path, ".meta") end) + |> Enum.reject(fn relative_path -> + target.kind == :media and String.ends_with?(relative_path, ".meta") + end) |> Enum.sort() else [] diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index 3e45541..c5bf218 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -17,23 +17,28 @@ defmodule BDS.Rendering do alias BDS.Posts.Translation alias BDS.Templates.Template - def render_post_page(project_id, template_slug, assigns) when is_binary(project_id) and is_map(assigns) do + def render_post_page(project_id, template_slug, assigns) + when is_binary(project_id) and is_map(assigns) do with {:ok, template_source} <- load_template_source(project_id, :post, template_slug), - {:ok, rendered} <- render_template(project_id, template_source, post_assigns(project_id, assigns)) do + {:ok, rendered} <- + render_template(project_id, template_source, post_assigns(project_id, assigns)) do {:ok, rendered} end end def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do with {:ok, template_source} <- load_template_source(project_id, :list, nil), - {:ok, rendered} <- render_template(project_id, template_source, list_assigns(project_id, assigns)) do + {:ok, rendered} <- + render_template(project_id, template_source, list_assigns(project_id, assigns)) do {:ok, rendered} end end - def render_not_found_page(project_id, assigns \\ %{}) when is_binary(project_id) and is_map(assigns) do + def render_not_found_page(project_id, assigns \\ %{}) + when is_binary(project_id) and is_map(assigns) do with {:ok, template_source} <- load_template_source(project_id, :not_found, nil), - {:ok, rendered} <- render_template(project_id, template_source, not_found_assigns(project_id, assigns)) do + {:ok, rendered} <- + render_template(project_id, template_source, not_found_assigns(project_id, assigns)) do {:ok, rendered} end end @@ -49,7 +54,8 @@ defmodule BDS.Rendering do Repo.one( from template in Template, where: - template.project_id == ^project_id and template.kind == ^kind and template.status == :published and + template.project_id == ^project_id and template.kind == ^kind and + template.status == :published and template.enabled == true and template.slug == ^slug, limit: 1 ) || select_template(project_id, kind, nil) @@ -59,14 +65,16 @@ defmodule BDS.Rendering do Repo.one( from template in Template, where: - template.project_id == ^project_id and template.kind == ^kind and template.status == :published and + template.project_id == ^project_id and template.kind == ^kind and + template.status == :published and template.enabled == true, order_by: [asc: template.created_at, asc: template.slug], limit: 1 ) end - defp published_template_body(%Template{content: content}) when is_binary(content), do: {:ok, content} + defp published_template_body(%Template{content: content}) when is_binary(content), + do: {:ok, content} defp published_template_body(%Template{} = template) do project = Projects.get_project!(template.project_id) @@ -105,17 +113,32 @@ defmodule BDS.Rendering do defp post_assigns(project_id, assigns) do metadata = project_metadata(project_id) - language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + + language = + Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + main_language = metadata.main_language || language post_record = load_post_record(assigns) post_categories = Map.get(post_record || %{}, :categories, []) || [] post_tags = Map.get(post_record || %{}, :tags, []) || [] + canonical_post_paths = canonical_post_path_by_slug(project_id, main_language) + canonical_media_paths = canonical_media_path_by_source_path(project_id) %{ language: language, - language_prefix: Map.get(assigns, :language_prefix, Map.get(assigns, "language_prefix", language_prefix(language, main_language))), - page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", Map.get(assigns, :title, Map.get(assigns, "title")))), - pico_stylesheet_href: nil, + language_prefix: + Map.get( + assigns, + :language_prefix, + Map.get(assigns, "language_prefix", language_prefix(language, main_language)) + ), + page_title: + Map.get( + assigns, + :page_title, + Map.get(assigns, "page_title", Map.get(assigns, :title, Map.get(assigns, "title"))) + ), + pico_stylesheet_href: default_pico_stylesheet_href(), html_theme_attribute: html_theme_attribute(metadata.pico_theme), blog_languages: blog_languages(metadata, language), alternate_links: [], @@ -126,34 +149,40 @@ defmodule BDS.Rendering do post_tags: post_tags, tag_color_by_name: tag_color_by_name(project_id), backlinks: [], - canonical_post_path_by_slug: canonical_post_path_by_slug(project_id, main_language), - canonical_media_path_by_source_path: canonical_media_path_by_source_path(project_id), - post_data_json_by_id: post_data_json(assigns), - post: %{ - id: Map.get(assigns, :id, Map.get(assigns, "id")), - slug: Map.get(assigns, :slug, Map.get(assigns, "slug")), - title: Map.get(assigns, :title, Map.get(assigns, "title")), - content: Map.get(assigns, :content, Map.get(assigns, "content")), - excerpt: Map.get(assigns, :excerpt, Map.get(assigns, "excerpt")), - language: Map.get(assigns, :language, Map.get(assigns, "language")), - show_title: true - } + canonical_post_path_by_slug: canonical_post_paths, + canonical_media_path_by_source_path: canonical_media_paths, + post_data_json_by_id: post_data_json(assigns, post_record), + post: build_post_context(assigns, post_record) } end defp list_assigns(project_id, assigns) do metadata = project_metadata(project_id) - language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + + language = + Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + main_language = metadata.main_language || language posts = normalize_list_posts(Map.get(assigns, :posts, Map.get(assigns, "posts", []))) archive_context = Map.get(assigns, :archive_context, Map.get(assigns, "archive_context", %{})) + pagination = + normalize_pagination(Map.get(assigns, :pagination, Map.get(assigns, "pagination")), posts) + + canonical_post_paths = canonical_post_path_by_slug(project_id, main_language) + canonical_media_paths = canonical_media_path_by_source_path(project_id) + %{ language: language, - language_prefix: Map.get(assigns, :language_prefix, Map.get(assigns, "language_prefix", language_prefix(language, main_language))), + language_prefix: + Map.get( + assigns, + :language_prefix, + Map.get(assigns, "language_prefix", language_prefix(language, main_language)) + ), page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title")), posts: posts, - pico_stylesheet_href: nil, + pico_stylesheet_href: default_pico_stylesheet_href(), html_theme_attribute: html_theme_attribute(metadata.pico_theme), blog_languages: blog_languages(metadata, language), alternate_links: [], @@ -165,15 +194,20 @@ defmodule BDS.Rendering do min_date: nil, max_date: nil, is_list_page: true, - is_first_page: true, - is_last_page: true, - has_prev_page: false, - has_next_page: false, - prev_page_href: "", - next_page_href: "", - canonical_post_path_by_slug: canonical_post_path_by_slug(project_id, main_language), - canonical_media_path_by_source_path: canonical_media_path_by_source_path(project_id), - post_data_json_by_id: Enum.into(posts, %{}, fn post -> {post.id, Jason.encode!(Map.take(post, [:id, :slug, :title, :content]))} end), + is_first_page: pagination.current_page <= 1, + is_last_page: pagination.current_page >= pagination.total_pages, + has_prev_page: pagination.has_prev_page, + has_next_page: pagination.has_next_page, + prev_page_href: pagination.prev_page_href, + next_page_href: pagination.next_page_href, + current_page: pagination.current_page, + total_pages: pagination.total_pages, + total_items: pagination.total_items, + items_per_page: pagination.items_per_page, + canonical_post_path_by_slug: canonical_post_paths, + canonical_media_path_by_source_path: canonical_media_paths, + post_data_json_by_id: + Enum.into(posts, %{}, fn post -> {post.id, post_data_json_value(post)} end), day_blocks: [ %{ date_label: "", @@ -187,20 +221,46 @@ defmodule BDS.Rendering do defp not_found_assigns(project_id, assigns) do metadata = project_metadata(project_id) - language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + + language = + Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + main_language = metadata.main_language || language %{ - page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", "404 Not Found")), + page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", "404")), language: language, - language_prefix: Map.get(assigns, :language_prefix, Map.get(assigns, "language_prefix", language_prefix(language, main_language))), - pico_stylesheet_href: nil, + language_prefix: + Map.get( + assigns, + :language_prefix, + Map.get(assigns, "language_prefix", language_prefix(language, main_language)) + ), + pico_stylesheet_href: default_pico_stylesheet_href(), html_theme_attribute: html_theme_attribute(metadata.pico_theme), blog_languages: blog_languages(metadata, language), menu_items: menu_items(project_id), alternate_links: [], - not_found_message: Map.get(assigns, :not_found_message, Map.get(assigns, "not_found_message")), - not_found_back_label: Map.get(assigns, :not_found_back_label, Map.get(assigns, "not_found_back_label")) + not_found_message: + Map.get( + assigns, + :not_found_message, + Map.get( + assigns, + "not_found_message", + I18n.translate(language, "render.notFound.message") + ) + ), + not_found_back_label: + Map.get( + assigns, + :not_found_back_label, + Map.get( + assigns, + "not_found_back_label", + I18n.translate(language, "render.notFound.back") + ) + ) } end @@ -232,8 +292,13 @@ defmodule BDS.Rendering do end defp menu_item_href(%{kind: :home}), do: "/" - defp menu_item_href(%{kind: :page, slug: slug}) when is_binary(slug) and slug != "", do: "/#{slug}/" - defp menu_item_href(%{kind: :category_archive, slug: slug}) when is_binary(slug) and slug != "", do: "/category/#{URI.encode(slug)}/" + + defp menu_item_href(%{kind: :page, slug: slug}) when is_binary(slug) and slug != "", + do: "/#{slug}/" + + defp menu_item_href(%{kind: :category_archive, slug: slug}) when is_binary(slug) and slug != "", + do: "/category/#{URI.encode(slug)}/" + defp menu_item_href(%{kind: :submenu}), do: "#" defp menu_item_href(_item), do: "#" @@ -243,11 +308,13 @@ defmodule BDS.Rendering do |> Enum.uniq() |> Enum.map(fn language -> normalized = I18n.normalize_language(language) + href_prefix = language_prefix(normalized, metadata.main_language || current_language) %{ code: normalized, flag: I18n.flag(normalized), - href_prefix: language_prefix(normalized, metadata.main_language || current_language), + href: href_for_language(href_prefix), + href_prefix: href_prefix, is_current: normalized == I18n.normalize_language(current_language) } end) @@ -265,26 +332,39 @@ defmodule BDS.Rendering do end end - defp post_data_json(assigns) do + defp post_data_json(assigns, post_record) do id = Map.get(assigns, :id, Map.get(assigns, "id")) if is_binary(id) do %{ - id => - Jason.encode!(%{ - id: id, - slug: Map.get(assigns, :slug, Map.get(assigns, "slug")), - title: Map.get(assigns, :title, Map.get(assigns, "title")), - content: Map.get(assigns, :content, Map.get(assigns, "content")) - }) + id => post_data_json_value(build_post_context(assigns, post_record)) } else %{} end end + defp post_data_json_value(post_context) do + Jason.encode!(%{ + id: Map.get(post_context, :id), + title: Map.get(post_context, :title), + slug: Map.get(post_context, :slug), + excerpt: Map.get(post_context, :excerpt), + author: Map.get(post_context, :author), + language: Map.get(post_context, :language), + published_at: Map.get(post_context, :published_at), + created_at: Map.get(post_context, :created_at), + updated_at: Map.get(post_context, :updated_at), + tags: Map.get(post_context, :tags, []), + categories: Map.get(post_context, :categories, []) + }) + end + defp canonical_post_path_by_slug(project_id, main_language) do - posts = Repo.all(from post in Post, where: post.project_id == ^project_id and post.status == :published) + posts = + Repo.all( + from post in Post, where: post.project_id == ^project_id and post.status == :published + ) translations = Repo.all( @@ -311,6 +391,7 @@ defmodule BDS.Rendering do Repo.all(from media in MediaAsset, where: media.project_id == ^project_id) |> Enum.reduce(%{}, fn media, acc -> datetime = DateTime.from_unix!(media.created_at) + source_key = Path.join([ "media", @@ -324,7 +405,8 @@ defmodule BDS.Rendering do end) end - defp post_path(post, language_prefix) when is_binary(language_prefix) and language_prefix != "" do + defp post_path(post, language_prefix) + when is_binary(language_prefix) and language_prefix != "" do Path.join([String.trim_leading(language_prefix, "/"), post_path(post, nil)]) end @@ -338,7 +420,7 @@ defmodule BDS.Rendering do post.slug, "index.html" ]) - |> then(&"/" <> String.trim_trailing(&1, "index.html")) + |> then(&("/" <> String.trim_trailing(&1, "index.html"))) end defp post_path(post, language, main_language) do @@ -348,16 +430,119 @@ defmodule BDS.Rendering do defp normalize_list_posts(posts) do Enum.map(posts, fn post -> + post_record = load_post_record(post) + %{ id: Map.get(post, :id, Map.get(post, "id")), slug: Map.get(post, :slug, Map.get(post, "slug")), title: Map.get(post, :title, Map.get(post, "title")), - content: Map.get(post, :content, Map.get(post, "content", Map.get(post, :excerpt, Map.get(post, "excerpt", "")))), - show_title: true + content: + Map.get( + post, + :content, + Map.get(post, "content", Map.get(post, :excerpt, Map.get(post, "excerpt", ""))) + ), + excerpt: + Map.get(post, :excerpt, Map.get(post, "excerpt", Map.get(post_record || %{}, :excerpt))), + author: Map.get(post_record || %{}, :author), + language: + Map.get( + post, + :language, + Map.get(post, "language", Map.get(post_record || %{}, :language)) + ), + published_at: Map.get(post_record || %{}, :published_at), + created_at: Map.get(post_record || %{}, :created_at), + updated_at: Map.get(post_record || %{}, :updated_at), + tags: Map.get(post_record || %{}, :tags, []) || [], + categories: Map.get(post_record || %{}, :categories, []) || [], + template_slug: Map.get(post_record || %{}, :template_slug), + do_not_translate: Map.get(post_record || %{}, :do_not_translate, false), + href: Map.get(post, :href, Map.get(post, "href")), + show_title: true, + linked_media: [], + outgoing_links: [], + incoming_links: [] } end) end + defp build_post_context(assigns, post_record) do + %{ + id: Map.get(assigns, :id, Map.get(assigns, "id")), + slug: Map.get(assigns, :slug, Map.get(assigns, "slug")), + title: Map.get(assigns, :title, Map.get(assigns, "title")), + content: Map.get(assigns, :content, Map.get(assigns, "content")), + excerpt: + Map.get( + assigns, + :excerpt, + Map.get(assigns, "excerpt", Map.get(post_record || %{}, :excerpt)) + ), + author: Map.get(post_record || %{}, :author), + language: + Map.get( + assigns, + :language, + Map.get(assigns, "language", Map.get(post_record || %{}, :language)) + ), + show_title: true, + published_at: Map.get(post_record || %{}, :published_at), + created_at: Map.get(post_record || %{}, :created_at), + updated_at: Map.get(post_record || %{}, :updated_at), + tags: Map.get(post_record || %{}, :tags, []) || [], + categories: Map.get(post_record || %{}, :categories, []) || [], + template_slug: + Map.get( + post_record || %{}, + :template_slug, + Map.get(assigns, :template_slug, Map.get(assigns, "template_slug")) + ), + do_not_translate: Map.get(post_record || %{}, :do_not_translate, false), + linked_media: [], + outgoing_links: [], + incoming_links: [] + } + end + + defp normalize_pagination(nil, posts) do + total_items = length(posts) + + %{ + current_page: 1, + total_pages: 1, + total_items: total_items, + items_per_page: total_items, + has_prev_page: false, + prev_page_href: "", + has_next_page: false, + next_page_href: "" + } + end + + defp normalize_pagination(%{} = pagination, posts) do + total_items = + Map.get(pagination, :total_items, Map.get(pagination, "total_items", length(posts))) + + items_per_page = + Map.get(pagination, :items_per_page, Map.get(pagination, "items_per_page", total_items)) + + %{ + current_page: Map.get(pagination, :current_page, Map.get(pagination, "current_page", 1)), + total_pages: Map.get(pagination, :total_pages, Map.get(pagination, "total_pages", 1)), + total_items: total_items, + items_per_page: items_per_page, + has_prev_page: + Map.get(pagination, :has_prev_page, Map.get(pagination, "has_prev_page", false)), + prev_page_href: + Map.get(pagination, :prev_page_href, Map.get(pagination, "prev_page_href", "")), + has_next_page: + Map.get(pagination, :has_next_page, Map.get(pagination, "has_next_page", false)), + next_page_href: + Map.get(pagination, :next_page_href, Map.get(pagination, "next_page_href", "")) + } + end + defp normalize_archive_context(nil), do: nil defp normalize_archive_context(%{} = archive_context), do: archive_context @@ -365,10 +550,19 @@ defmodule BDS.Rendering do defp html_theme_attribute(""), do: nil defp html_theme_attribute(theme), do: ~s(data-theme="#{theme}") - defp calendar_initial_year(%{created_at: created_at}) when is_integer(created_at), do: DateTime.from_unix!(created_at).year + defp default_pico_stylesheet_href, do: "/assets/pico.min.css" + + defp href_for_language(""), do: "/" + defp href_for_language(prefix), do: prefix <> "/" + + defp calendar_initial_year(%{created_at: created_at}) when is_integer(created_at), + do: DateTime.from_unix!(created_at).year + defp calendar_initial_year(_post), do: nil - defp calendar_initial_month(%{created_at: created_at}) when is_integer(created_at), do: DateTime.from_unix!(created_at).month + defp calendar_initial_month(%{created_at: created_at}) when is_integer(created_at), + do: DateTime.from_unix!(created_at).month + defp calendar_initial_month(_post), do: nil defp calendar_initial_year_from_posts([post | _rest]), do: calendar_initial_year(post) diff --git a/lib/bds/rendering/filters.ex b/lib/bds/rendering/filters.ex index 3a4efb0..31533b8 100644 --- a/lib/bds/rendering/filters.ex +++ b/lib/bds/rendering/filters.ex @@ -15,7 +15,16 @@ defmodule BDS.Rendering.Filters do end end - def markdown(value, _post_id, _post_data_json_by_id, canonical_post_paths, canonical_media_paths, language, _language_prefix, context) do + def markdown( + value, + _post_id, + _post_data_json_by_id, + canonical_post_paths, + canonical_media_paths, + language, + _language_prefix, + context + ) do value |> to_string() |> replace_built_in_macros(language, context) @@ -24,21 +33,37 @@ defmodule BDS.Rendering.Filters do end defp replace_built_in_macros(content, language, context) do - Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match, macro_name, raw_params -> + Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match, + macro_name, + raw_params -> params = parse_macro_params(raw_params) case String.downcase(macro_name) do "youtube" -> - render_macro_template("macros/youtube", %{ - "id" => Map.get(params, "id", ""), - "title" => default_macro_title(Map.get(params, "title"), language, "render.video.youtubeTitle") - }, context) + render_macro_template( + "macros/youtube", + %{ + "id" => Map.get(params, "id", ""), + "title" => + default_macro_title( + Map.get(params, "title"), + language, + "render.video.youtubeTitle" + ) + }, + context + ) "vimeo" -> - render_macro_template("macros/vimeo", %{ - "id" => Map.get(params, "id", ""), - "title" => default_macro_title(Map.get(params, "title"), language, "render.video.vimeoTitle") - }, context) + render_macro_template( + "macros/vimeo", + %{ + "id" => Map.get(params, "id", ""), + "title" => + default_macro_title(Map.get(params, "title"), language, "render.video.vimeoTitle") + }, + context + ) _other -> full_match @@ -46,8 +71,12 @@ defmodule BDS.Rendering.Filters do end) end - defp default_macro_title(nil, language, translation_key), do: I18n.translate(language, translation_key) - defp default_macro_title("", language, translation_key), do: I18n.translate(language, translation_key) + defp default_macro_title(nil, language, translation_key), + do: I18n.translate(language, translation_key) + + defp default_macro_title("", language, translation_key), + do: I18n.translate(language, translation_key) + defp default_macro_title(title, _language, _translation_key), do: title defp parse_macro_params(nil), do: %{} @@ -63,8 +92,12 @@ defmodule BDS.Rendering.Filters do defp render_macro_template(template_path, assigns, context) do case Map.get(assigns, "id") do - "" -> "" - nil -> "" + "" -> + "" + + nil -> + "" + _id -> template_source = Liquex.FileSystem.read_template_file(context.file_system, template_path) template_ast = Liquex.parse!(template_source) @@ -78,7 +111,10 @@ defmodule BDS.Rendering.Filters do defp rewrite_rendered_html_urls(html, canonical_post_paths, canonical_media_paths) do html - |> rewrite_attribute("href", &normalize_post_href(&1, canonical_post_paths, canonical_media_paths)) + |> rewrite_attribute( + "href", + &normalize_post_href(&1, canonical_post_paths, canonical_media_paths) + ) |> rewrite_attribute("src", &normalize_media_src(&1, canonical_media_paths)) end @@ -91,8 +127,12 @@ defmodule BDS.Rendering.Filters do defp normalize_post_href(raw_href, canonical_post_paths, canonical_media_paths) do cond do - raw_href == "" -> raw_href - external_or_special_url?(raw_href) -> raw_href + raw_href == "" -> + raw_href + + external_or_special_url?(raw_href) -> + raw_href + true -> {path_part, suffix} = split_path_suffix(raw_href) @@ -103,7 +143,8 @@ defmodule BDS.Rendering.Filters do canonical -> canonical <> suffix end - _other -> raw_href + _other -> + raw_href end end end @@ -136,8 +177,12 @@ defmodule BDS.Rendering.Filters do defp normalize_media_src(raw_src, canonical_media_paths) do cond do - raw_src == "" -> raw_src - external_or_special_url?(raw_src) -> raw_src + raw_src == "" -> + raw_src + + external_or_special_url?(raw_src) -> + raw_src + true -> {path_part, suffix} = split_path_suffix(raw_src) diff --git a/lib/bds/scripting.ex b/lib/bds/scripting.ex index ead8daa..5adf45b 100644 --- a/lib/bds/scripting.ex +++ b/lib/bds/scripting.ex @@ -64,7 +64,9 @@ defmodule BDS.Scripting do opts: batch_job_defaults(opts)} case DynamicSupervisor.start_child(BDS.Scripting.JobSupervisor, child_spec) do - {:ok, _pid} -> {:ok, BDS.Scripting.JobStore.fetch_job!(job_id)} + {:ok, _pid} -> + {:ok, BDS.Scripting.JobStore.fetch_job!(job_id)} + {:error, reason} -> :ok = BDS.Scripting.JobStore.update_job(job_id, %{ @@ -91,7 +93,8 @@ defmodule BDS.Scripting do _job -> {:error, :not_running} end - pid -> BDS.Scripting.JobRunner.cancel(pid) + pid -> + BDS.Scripting.JobRunner.cancel(pid) end end diff --git a/lib/bds/scripting/job_store.ex b/lib/bds/scripting/job_store.ex index 4f9f021..c83ea39 100644 --- a/lib/bds/scripting/job_store.ex +++ b/lib/bds/scripting/job_store.ex @@ -50,10 +50,11 @@ defmodule BDS.Scripting.JobStore do end def handle_call({:update_job, job_id, attrs}, _from, state) do - next_state = update_in(state, [:jobs, job_id], fn - nil -> nil - job -> Map.merge(job, attrs) - end) + next_state = + update_in(state, [:jobs, job_id], fn + nil -> nil + job -> Map.merge(job, attrs) + end) {:reply, :ok, next_state} end diff --git a/lib/bds/scripting/lua.ex b/lib/bds/scripting/lua.ex index 71bc06d..5509276 100644 --- a/lib/bds/scripting/lua.ex +++ b/lib/bds/scripting/lua.ex @@ -66,7 +66,8 @@ defmodule BDS.Scripting.Lua do end end - defp install_progress_callback(_state, callback), do: {:error, {:invalid_progress_callback, callback}} + defp install_progress_callback(_state, callback), + do: {:error, {:invalid_progress_callback, callback}} defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state} @@ -81,7 +82,8 @@ defmodule BDS.Scripting.Lua do end) end - defp install_capabilities(_state, capabilities), do: {:error, {:invalid_capabilities, capabilities}} + defp install_capabilities(_state, capabilities), + do: {:error, {:invalid_capabilities, capabilities}} defp normalize_progress_payload(payload) when is_list(payload) do if Enum.all?(payload, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do diff --git a/lib/bds/scripts.ex b/lib/bds/scripts.ex index 5b2f84e..85e1a9b 100644 --- a/lib/bds/scripts.ex +++ b/lib/bds/scripts.ex @@ -50,11 +50,19 @@ defmodule BDS.Scripts do :ok = File.write( full_path, - serialize_script_file(%{script | status: :published, file_path: file_path, updated_at: updated_at}, content) + serialize_script_file( + %{script | status: :published, file_path: file_path, updated_at: updated_at}, + content + ) ) script - |> Script.changeset(%{status: :published, file_path: file_path, content: nil, updated_at: updated_at}) + |> Script.changeset(%{ + status: :published, + file_path: file_path, + content: nil, + updated_at: updated_at + }) |> Repo.update() end end @@ -75,16 +83,20 @@ defmodule BDS.Scripts do content_changed? = has_attr?(attrs, :content) and attr(attrs, :content) != script.content now = System.system_time(:second) - updates = %{} - |> maybe_put(:title, attr(attrs, :title)) - |> maybe_put(:kind, attr(attrs, :kind)) - |> maybe_put(:entrypoint, attr(attrs, :entrypoint)) - |> maybe_put(:enabled, attr(attrs, :enabled)) - |> maybe_put(:content, attr(attrs, :content)) - |> Map.put(:slug, next_slug) - |> Map.put(:version, script.version + 1) - |> Map.put(:updated_at, now) - |> maybe_put(:status, if(script.status == :published and content_changed?, do: :draft, else: nil)) + updates = + %{} + |> maybe_put(:title, attr(attrs, :title)) + |> maybe_put(:kind, attr(attrs, :kind)) + |> maybe_put(:entrypoint, attr(attrs, :entrypoint)) + |> maybe_put(:enabled, attr(attrs, :enabled)) + |> maybe_put(:content, attr(attrs, :content)) + |> Map.put(:slug, next_slug) + |> Map.put(:version, script.version + 1) + |> Map.put(:updated_at, now) + |> maybe_put( + :status, + if(script.status == :published and content_changed?, do: :draft, else: nil) + ) script |> Script.changeset(updates) @@ -141,7 +153,8 @@ defmodule BDS.Scripts do end defp slug_available?(project_id, slug, exclude_id) do - query = from script in Script, where: script.project_id == ^project_id and script.slug == ^slug + query = + from script in Script, where: script.project_id == ^project_id and script.slug == ^slug scoped_query = case exclude_id do diff --git a/lib/bds/scripts/script.ex b/lib/bds/scripts/script.ex index 078442f..af76d53 100644 --- a/lib/bds/scripts/script.ex +++ b/lib/bds/scripts/script.ex @@ -25,10 +25,38 @@ defmodule BDS.Scripts.Script do def changeset(script, attrs) do script - |> cast(attrs, [:id, :project_id, :slug, :title, :kind, :entrypoint, :enabled, :version, :file_path, :status, :content, :created_at, :updated_at], + |> cast( + attrs, + [ + :id, + :project_id, + :slug, + :title, + :kind, + :entrypoint, + :enabled, + :version, + :file_path, + :status, + :content, + :created_at, + :updated_at + ], empty_values: [nil] ) - |> validate_required([:id, :project_id, :slug, :title, :kind, :entrypoint, :enabled, :version, :status, :created_at, :updated_at]) + |> validate_required([ + :id, + :project_id, + :slug, + :title, + :kind, + :entrypoint, + :enabled, + :version, + :status, + :created_at, + :updated_at + ]) |> assoc_constraint(:project) |> unique_constraint(:slug, name: :scripts_project_slug_idx) end diff --git a/lib/bds/search.ex b/lib/bds/search.ex index ebb3353..6e50573 100644 --- a/lib/bds/search.ex +++ b/lib/bds/search.ex @@ -96,8 +96,15 @@ defmodule BDS.Search do end def reindex_project(project_id) do - Repo.query!("DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", [project_id]) - Repo.query!("DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)", [project_id]) + Repo.query!( + "DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", + [project_id] + ) + + Repo.query!( + "DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)", + [project_id] + ) Repo.all(from post in Post, where: post.project_id == ^project_id) |> Enum.each(&sync_post/1) @@ -241,7 +248,11 @@ defmodule BDS.Search do matches_month?(post, filters.month) and matches_from?(post, filters.from) and matches_to?(post, filters.to) and - matches_missing_translation?(post, filters.missing_translation_language, translation_languages) + matches_missing_translation?( + post, + filters.missing_translation_language, + translation_languages + ) end) end @@ -270,7 +281,13 @@ defmodule BDS.Search do defp matches_to?(post, to_unix), do: post.created_at <= to_unix defp matches_missing_translation?(_post, nil, _translation_languages), do: true - defp matches_missing_translation?(%Post{do_not_translate: true}, _language, _translation_languages), do: false + + defp matches_missing_translation?( + %Post{do_not_translate: true}, + _language, + _translation_languages + ), + do: false defp matches_missing_translation?(post, language, translation_languages) do language not in Map.get(translation_languages, post.id, []) @@ -286,7 +303,9 @@ defmodule BDS.Search do "SELECT translation_for, language FROM post_translations WHERE translation_for IN (#{placeholders})", post_ids ).rows - |> Enum.group_by(fn [post_id, _language] -> post_id end, fn [_post_id, language] -> language end) + |> Enum.group_by(fn [post_id, _language] -> post_id end, fn [_post_id, language] -> + language + end) end defp paginate(items, filters) do @@ -300,16 +319,27 @@ defmodule BDS.Search do post_language = normalize_language(post.language) title = - [stem(post.title, post_language) | Enum.map(translations, &stem(Map.get(&1, "title"), Map.get(&1, "language")))] + [ + stem(post.title, post_language) + | Enum.map(translations, &stem(Map.get(&1, "title"), Map.get(&1, "language"))) + ] |> join_text() excerpt = - [stem(post.excerpt, post_language) | Enum.map(translations, &stem(Map.get(&1, "excerpt"), Map.get(&1, "language")))] + [ + stem(post.excerpt, post_language) + | Enum.map(translations, &stem(Map.get(&1, "excerpt"), Map.get(&1, "language"))) + ] |> join_text() content = - [stem(post_content(post), post_language) | - Enum.map(translations, &stem(translation_content(post.project_id, &1), Map.get(&1, "language")))] + [ + stem(post_content(post), post_language) + | Enum.map( + translations, + &stem(translation_content(post.project_id, &1), Map.get(&1, "language")) + ) + ] |> join_text() tags = stem(Enum.join(post.tags || [], " "), post_language) @@ -320,15 +350,25 @@ defmodule BDS.Search do defp media_index_fields(media) do translations = - Repo.all(from translation in MediaTranslation, where: translation.translation_for == ^media.id) + Repo.all( + from translation in MediaTranslation, where: translation.translation_for == ^media.id + ) media_language = normalize_language(media.language) - title = [stem(media.title, media_language) | Enum.map(translations, &stem(&1.title, &1.language))] |> join_text() - alt = [stem(media.alt, media_language) | Enum.map(translations, &stem(&1.alt, &1.language))] |> join_text() + title = + [stem(media.title, media_language) | Enum.map(translations, &stem(&1.title, &1.language))] + |> join_text() + + alt = + [stem(media.alt, media_language) | Enum.map(translations, &stem(&1.alt, &1.language))] + |> join_text() caption = - [stem(media.caption, media_language) | Enum.map(translations, &stem(&1.caption, &1.language))] + [ + stem(media.caption, media_language) + | Enum.map(translations, &stem(&1.caption, &1.language)) + ] |> join_text() original_name = stem(media.original_name || "", media_language) @@ -356,7 +396,8 @@ defmodule BDS.Search do defp post_content(%Post{content: content}) when is_binary(content), do: content - defp post_content(%Post{project_id: project_id, file_path: file_path}) when is_binary(file_path) and file_path != "" do + defp post_content(%Post{project_id: project_id, file_path: file_path}) + when is_binary(file_path) and file_path != "" do project_id |> Projects.get_project!() |> Projects.project_data_dir() @@ -366,9 +407,11 @@ defmodule BDS.Search do defp post_content(_post), do: "" - defp translation_content(_project_id, %{"content" => content}) when is_binary(content), do: content + defp translation_content(_project_id, %{"content" => content}) when is_binary(content), + do: content - defp translation_content(project_id, %{"status" => "published", "file_path" => file_path}) when is_binary(file_path) and file_path != "" do + defp translation_content(project_id, %{"status" => "published", "file_path" => file_path}) + when is_binary(file_path) and file_path != "" do project_id |> Projects.get_project!() |> Projects.project_data_dir() @@ -403,7 +446,7 @@ defmodule BDS.Search do |> Enum.map_join(" OR ", fn tokens -> tokens |> Enum.map_join(" AND ", "ed_term/1) - |> then(&"(" <> &1 <> ")") + |> then(&("(" <> &1 <> ")")) end) end @@ -495,7 +538,10 @@ defmodule BDS.Search do end defp normalize_non_negative_integer(nil, default), do: default - defp normalize_non_negative_integer(value, _default) when is_integer(value) and value >= 0, do: value + + defp normalize_non_negative_integer(value, _default) when is_integer(value) and value >= 0, + do: value + defp normalize_non_negative_integer(value, default), do: normalize_integer(value) || default defp normalize_timestamp(nil, _position), do: nil @@ -508,7 +554,8 @@ defmodule BDS.Search do {:ok, datetime} = DateTime.new(date, time, "Etc/UTC") DateTime.to_unix(datetime) - {:error, _reason} -> nil + {:error, _reason} -> + nil end end diff --git a/lib/bds/tags.ex b/lib/bds/tags.ex index d1b6ad3..1208b6b 100644 --- a/lib/bds/tags.ex +++ b/lib/bds/tags.ex @@ -185,7 +185,10 @@ defmodule BDS.Tags do target_tag -> source_tags = - Repo.all(from tag in Tag, where: tag.id in ^source_tag_ids and tag.project_id == ^target_tag.project_id) + Repo.all( + from tag in Tag, + where: tag.id in ^source_tag_ids and tag.project_id == ^target_tag.project_id + ) Repo.transaction(fn -> source_names = Enum.map(source_tags, & &1.name) @@ -227,10 +230,21 @@ defmodule BDS.Tags do end defp validate_unique_name(project_id, name) do - if Repo.exists?(from tag in Tag, where: tag.project_id == ^project_id and fragment("lower(?)", tag.name) == ^String.downcase(name)) do + if Repo.exists?( + from tag in Tag, + where: + tag.project_id == ^project_id and + fragment("lower(?)", tag.name) == ^String.downcase(name) + ) do {:error, %Tag{} - |> Tag.changeset(%{project_id: project_id, name: name, id: Ecto.UUID.generate(), created_at: 0, updated_at: 0}) + |> Tag.changeset(%{ + project_id: project_id, + name: name, + id: Ecto.UUID.generate(), + created_at: 0, + updated_at: 0 + }) |> Ecto.Changeset.add_error(:name, "has already been taken")} else :ok @@ -238,10 +252,21 @@ defmodule BDS.Tags do end defp validate_rename_target(project_id, tag_id, name) do - if Repo.exists?(from tag in Tag, where: tag.project_id == ^project_id and tag.id != ^tag_id and fragment("lower(?)", tag.name) == ^String.downcase(name)) do + if Repo.exists?( + from tag in Tag, + where: + tag.project_id == ^project_id and tag.id != ^tag_id and + fragment("lower(?)", tag.name) == ^String.downcase(name) + ) do {:error, %Tag{} - |> Tag.changeset(%{project_id: project_id, name: name, id: tag_id, created_at: 0, updated_at: 0}) + |> Tag.changeset(%{ + project_id: project_id, + name: name, + id: tag_id, + created_at: 0, + updated_at: 0 + }) |> Ecto.Changeset.add_error(:name, "has already been taken")} else :ok diff --git a/lib/bds/tags/tag.ex b/lib/bds/tags/tag.ex index a4b46bf..d382e51 100644 --- a/lib/bds/tags/tag.ex +++ b/lib/bds/tags/tag.ex @@ -19,7 +19,9 @@ defmodule BDS.Tags.Tag do def changeset(tag, attrs) do tag - |> cast(attrs, [:id, :project_id, :name, :color, :post_template_slug, :created_at, :updated_at], + |> cast( + attrs, + [:id, :project_id, :name, :color, :post_template_slug, :created_at, :updated_at], empty_values: [nil] ) |> validate_required([:id, :project_id, :name, :created_at, :updated_at]) diff --git a/lib/bds/tasks.ex b/lib/bds/tasks.ex index 5814f15..529107a 100644 --- a/lib/bds/tasks.ex +++ b/lib/bds/tasks.ex @@ -10,7 +10,8 @@ defmodule BDS.Tasks do GenServer.start_link(__MODULE__, %{}, name: __MODULE__) end - def submit_task(name, work, attrs \\ %{}) when is_binary(name) and is_function(work, 1) and is_map(attrs) do + def submit_task(name, work, attrs \\ %{}) + when is_binary(name) and is_function(work, 1) and is_map(attrs) do GenServer.call(__MODULE__, {:submit_task, name, work, attrs}) end @@ -57,7 +58,8 @@ defmodule BDS.Tasks do if map_size(next_state.running) < max_concurrent() do {:reply, {:ok, public_task(task)}, start_task(next_state, task.id, work)} else - {:reply, {:ok, public_task(task)}, %{next_state | queue: next_state.queue ++ [{task.id, work}]}} + {:reply, {:ok, public_task(task)}, + %{next_state | queue: next_state.queue ++ [{task.id, work}]}} end end @@ -83,7 +85,9 @@ defmodule BDS.Tasks do next_state = state |> update_task(task_id, %{status: :cancelled, finished_at: DateTime.utc_now()}) - |> Map.update!(:queue, fn queue -> Enum.reject(queue, fn {queued_id, _work} -> queued_id == task_id end) end) + |> Map.update!(:queue, fn queue -> + Enum.reject(queue, fn {queued_id, _work} -> queued_id == task_id end) + end) |> start_queued_tasks() {:reply, :ok, next_state} @@ -109,7 +113,11 @@ defmodule BDS.Tasks do def handle_call({:complete_task, task_id}, _from, state) do next_state = state - |> update_task(task_id, %{status: :completed, progress: 1.0, finished_at: DateTime.utc_now()}) + |> update_task(task_id, %{ + status: :completed, + progress: 1.0, + finished_at: DateTime.utc_now() + }) |> start_queued_tasks() {:reply, :ok, next_state} @@ -118,7 +126,11 @@ defmodule BDS.Tasks do def handle_call({:fail_task, task_id, error_message}, _from, state) do next_state = state - |> update_task(task_id, %{status: :failed, message: error_message, finished_at: DateTime.utc_now()}) + |> update_task(task_id, %{ + status: :failed, + message: error_message, + finished_at: DateTime.utc_now() + }) |> start_queued_tasks() {:reply, :ok, next_state} @@ -146,8 +158,16 @@ defmodule BDS.Tasks do _status -> attrs = case normalize_result(result) do - {:ok, value} -> %{status: :completed, result: value, progress: 1.0, finished_at: DateTime.utc_now()} - {:error, reason} -> %{status: :failed, error: reason, finished_at: DateTime.utc_now()} + {:ok, value} -> + %{ + status: :completed, + result: value, + progress: 1.0, + finished_at: DateTime.utc_now() + } + + {:error, reason} -> + %{status: :failed, error: reason, finished_at: DateTime.utc_now()} end update_task(state, task_id, attrs) @@ -176,7 +196,11 @@ defmodule BDS.Tasks do state true -> - update_task(state, task_id, %{status: :failed, error: reason, finished_at: DateTime.utc_now()}) + update_task(state, task_id, %{ + status: :failed, + error: reason, + finished_at: DateTime.utc_now() + }) end |> remove_running(task_id, ref) |> start_queued_tasks() @@ -186,7 +210,9 @@ defmodule BDS.Tasks do end defp start_task(state, task_id, work) do - reporter = fn value, message -> send(__MODULE__, {:task_progress, task_id, value, message}) end + reporter = fn value, message -> + send(__MODULE__, {:task_progress, task_id, value, message}) + end task = Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> @@ -209,6 +235,7 @@ defmodule BDS.Tasks do true -> [{task_id, work} | remaining] = state.queue + state |> Map.put(:queue, remaining) |> start_task(task_id, work) @@ -231,8 +258,13 @@ defmodule BDS.Tasks do now_ms = System.monotonic_time(:millisecond) last_reported_at = Map.get(task, :last_reported_at) - if is_nil(last_reported_at) or now_ms - last_reported_at >= progress_throttle_ms() or value == 1.0 do - update_task(state, task_id, %{progress: value, message: message, last_reported_at: now_ms}) + if is_nil(last_reported_at) or now_ms - last_reported_at >= progress_throttle_ms() or + value == 1.0 do + update_task(state, task_id, %{ + progress: value, + message: message, + last_reported_at: now_ms + }) else state end diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index c1cdd6d..84cf093 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -50,11 +50,19 @@ defmodule BDS.Templates do :ok = File.write( full_path, - serialize_template_file(%{template | status: :published, file_path: file_path, updated_at: updated_at}, content) + serialize_template_file( + %{template | status: :published, file_path: file_path, updated_at: updated_at}, + content + ) ) template - |> Template.changeset(%{status: :published, file_path: file_path, content: nil, updated_at: updated_at}) + |> Template.changeset(%{ + status: :published, + file_path: file_path, + content: nil, + updated_at: updated_at + }) |> Repo.update() end end @@ -67,27 +75,41 @@ defmodule BDS.Templates do template -> next_slug = if has_attr?(attrs, :slug) do - unique_slug(template.project_id, Slug.slugify(attr(attrs, :slug)), "template", template.id) + unique_slug( + template.project_id, + Slug.slugify(attr(attrs, :slug)), + "template", + template.id + ) else template.slug end - content_changed? = has_attr?(attrs, :content) and attr(attrs, :content) != template.content + content_changed? = + has_attr?(attrs, :content) and attr(attrs, :content) != template.content + slug_changed? = next_slug != template.slug now = System.system_time(:second) - next_status = if(template.status == :published and content_changed?, do: :draft, else: template.status) + + next_status = + if(template.status == :published and content_changed?, + do: :draft, + else: template.status + ) + next_file_path = next_template_file_path(template, next_slug) - updates = %{} - |> maybe_put(:title, attr(attrs, :title)) - |> maybe_put(:kind, attr(attrs, :kind)) - |> maybe_put(:enabled, attr(attrs, :enabled)) - |> maybe_put(:content, attr(attrs, :content)) - |> Map.put(:file_path, next_file_path) - |> Map.put(:slug, next_slug) - |> Map.put(:version, template.version + 1) - |> Map.put(:updated_at, now) - |> Map.put(:status, next_status) + updates = + %{} + |> maybe_put(:title, attr(attrs, :title)) + |> maybe_put(:kind, attr(attrs, :kind)) + |> maybe_put(:enabled, attr(attrs, :enabled)) + |> maybe_put(:content, attr(attrs, :content)) + |> Map.put(:file_path, next_file_path) + |> Map.put(:slug, next_slug) + |> Map.put(:version, template.version + 1) + |> Map.put(:updated_at, now) + |> Map.put(:status, next_status) Repo.transaction(fn -> updated_template = @@ -172,7 +194,9 @@ defmodule BDS.Templates do end defp slug_available?(project_id, slug, exclude_id) do - query = from template in Template, where: template.project_id == ^project_id and template.slug == ^slug + query = + from template in Template, + where: template.project_id == ^project_id and template.slug == ^slug scoped_query = case exclude_id do @@ -210,11 +234,23 @@ defmodule BDS.Templates do end defp count_referencing_posts(template) do - Repo.aggregate(from(post in BDS.Posts.Post, where: post.project_id == ^template.project_id and post.template_slug == ^template.slug), :count, :id) + Repo.aggregate( + from(post in BDS.Posts.Post, + where: post.project_id == ^template.project_id and post.template_slug == ^template.slug + ), + :count, + :id + ) end defp count_referencing_tags(template) do - Repo.aggregate(from(tag in BDS.Tags.Tag, where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug), :count, :id) + Repo.aggregate( + from(tag in BDS.Tags.Tag, + where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug + ), + :count, + :id + ) end defp clear_template_references(template) do @@ -227,10 +263,14 @@ defmodule BDS.Templates do ) ) - from(post in BDS.Posts.Post, where: post.project_id == ^template.project_id and post.template_slug == ^template.slug) + from(post in BDS.Posts.Post, + where: post.project_id == ^template.project_id and post.template_slug == ^template.slug + ) |> Repo.update_all(set: [template_slug: nil, updated_at: now]) - from(tag in BDS.Tags.Tag, where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug) + from(tag in BDS.Tags.Tag, + where: tag.project_id == ^template.project_id and tag.post_template_slug == ^template.slug + ) |> Repo.update_all(set: [post_template_slug: nil, updated_at: now]) Enum.each(affected_posts, fn post -> @@ -246,17 +286,23 @@ defmodule BDS.Templates do affected_posts = Repo.all( from(post in BDS.Posts.Post, - where: post.project_id == ^original_template.project_id and post.template_slug == ^original_template.slug + where: + post.project_id == ^original_template.project_id and + post.template_slug == ^original_template.slug ) ) from(post in BDS.Posts.Post, - where: post.project_id == ^original_template.project_id and post.template_slug == ^original_template.slug + where: + post.project_id == ^original_template.project_id and + post.template_slug == ^original_template.slug ) |> Repo.update_all(set: [template_slug: updated_template.slug, updated_at: updated_at]) from(tag in BDS.Tags.Tag, - where: tag.project_id == ^original_template.project_id and tag.post_template_slug == ^original_template.slug + where: + tag.project_id == ^original_template.project_id and + tag.post_template_slug == ^original_template.slug ) |> Repo.update_all(set: [post_template_slug: updated_template.slug, updated_at: updated_at]) diff --git a/lib/bds/templates/template.ex b/lib/bds/templates/template.ex index c220b6b..17709b1 100644 --- a/lib/bds/templates/template.ex +++ b/lib/bds/templates/template.ex @@ -24,10 +24,36 @@ defmodule BDS.Templates.Template do def changeset(template, attrs) do template - |> cast(attrs, [:id, :project_id, :slug, :title, :kind, :enabled, :version, :file_path, :status, :content, :created_at, :updated_at], + |> cast( + attrs, + [ + :id, + :project_id, + :slug, + :title, + :kind, + :enabled, + :version, + :file_path, + :status, + :content, + :created_at, + :updated_at + ], empty_values: [nil] ) - |> validate_required([:id, :project_id, :slug, :title, :kind, :enabled, :version, :status, :created_at, :updated_at]) + |> validate_required([ + :id, + :project_id, + :slug, + :title, + :kind, + :enabled, + :version, + :status, + :created_at, + :updated_at + ]) |> assoc_constraint(:project) |> unique_constraint(:slug, name: :templates_project_slug_idx) end diff --git a/lib/bds/types/string_list.ex b/lib/bds/types/string_list.ex index 0aae140..150a60e 100644 --- a/lib/bds/types/string_list.ex +++ b/lib/bds/types/string_list.ex @@ -27,7 +27,8 @@ defmodule BDS.Types.StringList do :error end - _ -> :error + _ -> + :error end end diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index 8891ac7..511ba25 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -10,7 +10,10 @@ defmodule BDS.GenerationTest do setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) - temp_dir = Path.join(System.tmp_dir!(), "bds-generation-#{System.unique_integer([:positive])}") + + temp_dir = + Path.join(System.tmp_dir!(), "bds-generation-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -18,8 +21,13 @@ defmodule BDS.GenerationTest do %{project: project, temp_dir: temp_dir} end - test "write_generated_file writes under html output and skips unchanged content by hash", %{project: project, temp_dir: temp_dir} do - assert {:ok, first_write} = BDS.Generation.write_generated_file(project.id, "index.html", "hello") + test "write_generated_file writes under html output and skips unchanged content by hash", %{ + project: project, + temp_dir: temp_dir + } do + assert {:ok, first_write} = + BDS.Generation.write_generated_file(project.id, "index.html", "hello") + assert first_write.written? == true output_path = Path.join([temp_dir, "html", "index.html"]) @@ -29,18 +37,30 @@ defmodule BDS.GenerationTest do assert tracked_file.relative_path == "index.html" assert tracked_file.content_hash == first_write.content_hash - assert {:ok, second_write} = BDS.Generation.write_generated_file(project.id, "index.html", "hello") + assert {:ok, second_write} = + BDS.Generation.write_generated_file(project.id, "index.html", "hello") + assert second_write.written? == false assert second_write.content_hash == first_write.content_hash - assert {:ok, third_write} = BDS.Generation.write_generated_file(project.id, "index.html", "updated") + assert {:ok, third_write} = + BDS.Generation.write_generated_file(project.id, "index.html", "updated") + assert third_write.written? == true assert third_write.content_hash != first_write.content_hash assert File.read!(output_path) == "updated" end - test "delete_generated_file removes tracked output and forgets its hash", %{project: project, temp_dir: temp_dir} do - assert {:ok, _write} = BDS.Generation.write_generated_file(project.id, "tag/elixir/index.html", "tag") + test "delete_generated_file removes tracked output and forgets its hash", %{ + project: project, + temp_dir: temp_dir + } do + assert {:ok, _write} = + BDS.Generation.write_generated_file( + project.id, + "tag/elixir/index.html", + "tag" + ) output_path = Path.join([temp_dir, "html", "tag", "elixir", "index.html"]) assert File.exists?(output_path) @@ -52,7 +72,8 @@ defmodule BDS.GenerationTest do assert files == [] end - test "plan_generation derives generation settings from project metadata and core generation writes tracked files", %{project: project, temp_dir: temp_dir} do + test "plan_generation derives generation settings from project metadata and core generation writes tracked files", + %{project: project, temp_dir: temp_dir} do assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{ public_url: "https://example.com/blog", @@ -88,7 +109,8 @@ defmodule BDS.GenerationTest do "de/atom.xml" ] - assert Enum.sort(Enum.map(result.generated_files, & &1.relative_path)) == Enum.sort(expected_paths) + assert Enum.sort(Enum.map(result.generated_files, & &1.relative_path)) == + Enum.sort(expected_paths) for relative_path <- expected_paths do assert File.exists?(Path.join([temp_dir, "html", relative_path])) @@ -97,7 +119,10 @@ defmodule BDS.GenerationTest do assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/" end - test "generation renders published list and post templates for core and single pages", %{project: project, temp_dir: temp_dir} do + test "generation renders published list and post templates for core and single pages", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{ public_url: "https://example.com/blog", @@ -110,7 +135,8 @@ defmodule BDS.GenerationTest do project_id: project.id, title: "List View", kind: :list, - content: "

{{ page_title }}

{% for post in posts %}{{ post.title }}{% endfor %}
" + content: + "

{{ page_title }}

{% for post in posts %}{{ post.title }}{% endfor %}
" }) assert {:ok, _published_list} = BDS.Templates.publish_template(list_template.id) @@ -120,7 +146,8 @@ defmodule BDS.GenerationTest do project_id: project.id, title: "Post View", kind: :post, - content: "

{{ post.title }}

{{ post.content }}
" + content: + "

{{ post.title }}

{{ post.content }}
" }) assert {:ok, published_post_template} = BDS.Templates.publish_template(post_template.id) @@ -153,7 +180,10 @@ defmodule BDS.GenerationTest do assert post_html =~ "Rendered body" end - test "generation renders copied starter templates with partials, i18n, and markdown", %{project: project, temp_dir: temp_dir} do + test "generation renders copied starter templates with partials, i18n, and markdown", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, _menu} = BDS.Menu.update_menu(project.id, [ %{kind: :page, label: "Notes", slug: "notes"} @@ -199,7 +229,8 @@ defmodule BDS.GenerationTest do assert post_html =~ "Language" end - test "generation expands starter-template markdown macros, rewrites canonical post links, media links, and emits not-found page", %{project: project, temp_dir: temp_dir} do + test "generation expands starter-template markdown macros, rewrites canonical post links, media links, and emits not-found page", + %{project: project, temp_dir: temp_dir} do assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{ public_url: "https://example.com/blog", @@ -228,7 +259,10 @@ defmodule BDS.GenerationTest do assert {:ok, published_linked_post} = Posts.publish_post(linked_post.id) media_source_reference = "/" <> Path.join(Path.dirname(media.file_path), media.original_name) - canonical_post_href = "/" <> String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html") + + canonical_post_href = + "/" <> + String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html") assert {:ok, post} = Posts.create_post(%{ @@ -252,7 +286,9 @@ defmodule BDS.GenerationTest do assert "404.html" in Enum.map(result.generated_files, & &1.relative_path) - post_html = File.read!(Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)])) + post_html = + File.read!(Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)])) + assert post_html =~ ~s(src="https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0") assert post_html =~ ~s(href="#{canonical_post_href}") assert post_html =~ ~s(src="/#{media.file_path}") @@ -262,7 +298,10 @@ defmodule BDS.GenerationTest do assert not_found_html =~ "Back to preview home" end - test "single generation writes canonical post pages and language-prefixed translation pages", %{project: project, temp_dir: temp_dir} do + test "single generation writes canonical post pages and language-prefixed translation pages", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{ public_url: "https://example.com/blog", @@ -291,14 +330,18 @@ defmodule BDS.GenerationTest do post_path = BDS.Generation.post_output_path(published_post) translation_path = BDS.Generation.post_output_path(published_post, "de") - assert Enum.map(result.generated_files, & &1.relative_path) |> Enum.sort() == Enum.sort([post_path, translation_path]) + assert Enum.map(result.generated_files, & &1.relative_path) |> Enum.sort() == + Enum.sort([post_path, translation_path]) assert File.read!(Path.join([temp_dir, "html", post_path])) =~ "Hello generated world" assert File.read!(Path.join([temp_dir, "html", translation_path])) =~ "Hallo generierte Welt" assert File.read!(Path.join([temp_dir, "html", post_path])) =~ ~s(data-pagefind-body) end - test "archive generation writes paginated category, tag, and date pages", %{project: project, temp_dir: temp_dir} do + test "archive generation writes paginated category, tag, and date pages", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{ public_url: "https://example.com/blog", @@ -319,7 +362,11 @@ defmodule BDS.GenerationTest do }) created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z]) + index - Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id), set: [created_at: created_at, updated_at: created_at]) + + Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id), + set: [created_at: created_at, updated_at: created_at] + ) + assert {:ok, _published} = Posts.publish_post(post.id) end @@ -341,8 +388,13 @@ defmodule BDS.GenerationTest do assert expected_paths -- Enum.map(result.generated_files, & &1.relative_path) == [] - assert File.read!(Path.join([temp_dir, "html", "category", "notes", "index.html"])) =~ "Archive 1" - assert File.read!(Path.join([temp_dir, "html", "category", "notes", "page", "2", "index.html"])) =~ "Archive 3" + assert File.read!(Path.join([temp_dir, "html", "category", "notes", "index.html"])) =~ + "Archive 1" + + assert File.read!( + Path.join([temp_dir, "html", "category", "notes", "page", "2", "index.html"]) + ) =~ "Archive 3" + assert File.read!(Path.join([temp_dir, "html", "tag", "elixir", "index.html"])) =~ "Elixir" assert File.read!(Path.join([temp_dir, "html", "2026", "04", "index.html"])) =~ "2026-04" end diff --git a/test/bds/maintenance_test.exs b/test/bds/maintenance_test.exs index f454acb..9a1c014 100644 --- a/test/bds/maintenance_test.exs +++ b/test/bds/maintenance_test.exs @@ -7,7 +7,10 @@ defmodule BDS.MaintenanceTest do setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) - temp_dir = Path.join(System.tmp_dir!(), "bds-maintenance-#{System.unique_integer([:positive])}") + + temp_dir = + Path.join(System.tmp_dir!(), "bds-maintenance-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -15,7 +18,10 @@ defmodule BDS.MaintenanceTest do %{project: project, temp_dir: temp_dir} end - test "rebuild_from_filesystem dispatches to posts, media, scripts, and templates rebuilders", %{project: project, temp_dir: temp_dir} do + test "rebuild_from_filesystem dispatches to posts, media, scripts, and templates rebuilders", %{ + project: project, + temp_dir: temp_dir + } do posts_dir = Path.join([temp_dir, "posts", "2026", "04"]) File.mkdir_p!(posts_dir) @@ -60,6 +66,7 @@ defmodule BDS.MaintenanceTest do template_dir = Path.join(temp_dir, "templates") File.mkdir_p!(template_dir) + File.write!( Path.join(template_dir, "dispatch-view.liquid"), [ @@ -81,6 +88,7 @@ defmodule BDS.MaintenanceTest do script_dir = Path.join(temp_dir, "scripts") File.mkdir_p!(script_dir) + File.write!( Path.join(script_dir, "dispatch.lua"), [ @@ -120,10 +128,14 @@ defmodule BDS.MaintenanceTest do end test "rebuild_from_filesystem rejects unsupported entity types", %{project: project} do - assert {:error, :unsupported_entity_type} = BDS.Maintenance.rebuild_from_filesystem(project.id, "unknown") + 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 + 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") @@ -192,6 +204,7 @@ defmodule BDS.MaintenanceTest do assert {:ok, published_template} = BDS.Templates.publish_template(template.id) post_path = Path.join(temp_dir, published_post.file_path) + File.write!( post_path, [ @@ -205,9 +218,9 @@ defmodule BDS.MaintenanceTest do "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}", + "created_at: #{published_post.created_at + 10}", + "updated_at: #{published_post.updated_at + 20}", + "published_at: #{published_post.published_at + 30}", "tags:", " - beta", "categories:", @@ -220,6 +233,7 @@ defmodule BDS.MaintenanceTest do ) post_translation_path = Path.join(temp_dir, published_post_translation.file_path) + File.write!( post_translation_path, [ @@ -241,6 +255,7 @@ defmodule BDS.MaintenanceTest do ) media_sidecar_path = Path.join(temp_dir, media.sidecar_path) + File.write!( media_sidecar_path, [ @@ -262,7 +277,9 @@ defmodule BDS.MaintenanceTest do |> Enum.join("\n") ) - media_translation_sidecar_path = Path.join(temp_dir, "#{media.file_path}.#{media_translation.language}.meta") + media_translation_sidecar_path = + Path.join(temp_dir, "#{media.file_path}.#{media_translation.language}.meta") + File.write!( media_translation_sidecar_path, [ @@ -277,6 +294,7 @@ defmodule BDS.MaintenanceTest do ) script_path = Path.join(temp_dir, published_script.file_path) + File.write!( script_path, [ @@ -298,6 +316,7 @@ defmodule BDS.MaintenanceTest do ) template_path = Path.join(temp_dir, published_template.file_path) + File.write!( template_path, [ @@ -317,50 +336,128 @@ defmodule BDS.MaintenanceTest do |> 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, "posts", "2026", "04", "orphan-post.es.md"]), "---\nid: orphan-post-translation\ntranslation_for: orphan\nlanguage: es\ntitle: Huerfano\nstatus: published\ncreated_at: 1\nupdated_at: 1\npublished_at: 1\n---\nCuerpo\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, "media", "2026", "04", "orphan.txt.es.meta"]), "translation_for: orphan-media\nlanguage: es\ntitle: Huerfano\nalt: Texto\ncaption: Leyenda\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") + 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" + ) - assert {:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}} = BDS.Maintenance.metadata_diff(project.id) + File.write!( + Path.join([temp_dir, "posts", "2026", "04", "orphan-post.es.md"]), + "---\nid: orphan-post-translation\ntranslation_for: orphan\nlanguage: es\ntitle: Huerfano\nstatus: published\ncreated_at: 1\nupdated_at: 1\npublished_at: 1\n---\nCuerpo\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, "media", "2026", "04", "orphan.txt.es.meta"]), + "translation_for: orphan-media\nlanguage: es\ntitle: Huerfano\nalt: Texto\ncaption: Leyenda\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")) + 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") + ) and + Enum.any?( + report.differences, + &(&1.name == "created_at" and + &1.file_value == Integer.to_string(published_post.created_at + 10)) + ) and + Enum.any?( + report.differences, + &(&1.name == "updated_at" and + &1.file_value == Integer.to_string(published_post.updated_at + 20)) + ) and + Enum.any?( + report.differences, + &(&1.name == "published_at" and + &1.file_value == Integer.to_string(published_post.published_at + 30)) + ) 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 == "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")) + 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 == "title" and &1.file_value == "Edited Template") + ) and Enum.any?(report.differences, &(&1.name == "enabled" and &1.file_value == "false")) end) assert Enum.any?(diff_reports, fn report -> - report.entity_type == "post_translation" and report.entity_id == published_post_translation.id and - Enum.any?(report.differences, &(&1.name == "title" and &1.db_value == "Ursprunglicher Beitrag" and &1.file_value == "Bearbeiteter Beitrag")) and - Enum.any?(report.differences, &(&1.name == "excerpt" and &1.db_value == "Zusammenfassung" and &1.file_value == "Bearbeitete Zusammenfassung")) + report.entity_type == "post_translation" and + report.entity_id == published_post_translation.id and + Enum.any?( + report.differences, + &(&1.name == "title" and &1.db_value == "Ursprunglicher Beitrag" and + &1.file_value == "Bearbeiteter Beitrag") + ) and + Enum.any?( + report.differences, + &(&1.name == "excerpt" and &1.db_value == "Zusammenfassung" and + &1.file_value == "Bearbeitete Zusammenfassung") + ) end) assert Enum.any?(diff_reports, fn report -> - report.entity_type == "media_translation" and report.entity_id == media_translation.id and - Enum.any?(report.differences, &(&1.name == "title" and &1.db_value == "Ubersetzter Medientitel" and &1.file_value == "Bearbeiteter Medientitel")) and - Enum.any?(report.differences, &(&1.name == "alt" and &1.db_value == "Ubersetzter Alt-Text" and &1.file_value == "Bearbeiteter Alt-Text")) + report.entity_type == "media_translation" and + report.entity_id == media_translation.id and + Enum.any?( + report.differences, + &(&1.name == "title" and &1.db_value == "Ubersetzter Medientitel" and + &1.file_value == "Bearbeiteter Medientitel") + ) and + Enum.any?( + report.differences, + &(&1.name == "alt" and &1.db_value == "Ubersetzter Alt-Text" and + &1.file_value == "Bearbeiteter Alt-Text") + ) end) orphan_paths = Enum.map(orphan_reports, & &1.file_path) diff --git a/test/bds/media_test.exs b/test/bds/media_test.exs index 18c7654..ddb3daa 100644 --- a/test/bds/media_test.exs +++ b/test/bds/media_test.exs @@ -13,7 +13,10 @@ defmodule BDS.MediaTest do %{project: project, temp_dir: temp_dir} end - test "import_media copies the binary, creates a sidecar, and persists the row", %{project: project, temp_dir: temp_dir} do + test "import_media copies the binary, creates a sidecar, and persists the row", %{ + project: project, + temp_dir: temp_dir + } do source_path = Path.join(temp_dir, "sample.txt") File.write!(source_path, "hello media") @@ -54,7 +57,8 @@ defmodule BDS.MediaTest do source_path = Path.join(temp_dir, "sample.txt") File.write!(source_path, "hello media") - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, updated} = BDS.Media.update_media(media.id, %{ @@ -76,11 +80,15 @@ defmodule BDS.MediaTest do assert sidecar =~ "tags:\n - beta\n" end - test "delete_media removes the binary, sidecar, and database row", %{project: project, temp_dir: temp_dir} do + test "delete_media removes the binary, sidecar, and database row", %{ + project: project, + temp_dir: temp_dir + } do source_path = Path.join(temp_dir, "sample.txt") File.write!(source_path, "hello media") - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, _translation} = BDS.Media.upsert_media_translation(media.id, "de", %{ @@ -103,7 +111,10 @@ defmodule BDS.MediaTest do end) end - test "rebuild_media_from_files recreates media rows from sidecars", %{project: project, temp_dir: temp_dir} do + test "rebuild_media_from_files recreates media rows from sidecars", %{ + project: project, + temp_dir: temp_dir + } do media_dir = Path.join([temp_dir, "media", "2026", "04"]) File.mkdir_p!(media_dir) @@ -181,16 +192,27 @@ defmodule BDS.MediaTest do end) end - test "import_media generates the four thumbnail files in bucketed thumbnail paths", %{project: project, temp_dir: temp_dir} do + test "import_media generates the four thumbnail files in bucketed thumbnail paths", %{ + project: project, + temp_dir: temp_dir + } do source_path = Path.join(temp_dir, "sample.jpg") File.write!(source_path, tiny_jpeg_binary()) - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) thumbnail_paths = BDS.Media.thumbnail_paths(media) - assert thumbnail_paths.small == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-small.webp" - assert thumbnail_paths.medium == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-medium.webp" - assert thumbnail_paths.large == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-large.webp" + + assert thumbnail_paths.small == + "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-small.webp" + + assert thumbnail_paths.medium == + "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-medium.webp" + + assert thumbnail_paths.large == + "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-large.webp" + assert thumbnail_paths.ai == "thumbnails/#{String.slice(media.id, 0, 2)}/#{media.id}-ai.jpg" Enum.each(Map.values(thumbnail_paths), fn path -> @@ -198,11 +220,15 @@ defmodule BDS.MediaTest do end) end - test "import_media extracts image dimensions and writes real encoded thumbnails", %{project: project, temp_dir: temp_dir} do + test "import_media extracts image dimensions and writes real encoded thumbnails", %{ + project: project, + temp_dir: temp_dir + } do source_path = Path.join(temp_dir, "sample.jpg") File.write!(source_path, tiny_jpeg_binary()) - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert media.mime_type == "image/jpeg" assert media.width == 3 @@ -230,11 +256,13 @@ defmodule BDS.MediaTest do assert Path.extname(thumbnail_paths.ai) == ".jpg" end - test "import_media keeps raw header dimensions but autorotates thumbnails from EXIF orientation", %{project: project, temp_dir: temp_dir} do + test "import_media keeps raw header dimensions but autorotates thumbnails from EXIF orientation", + %{project: project, temp_dir: temp_dir} do source_path = Path.join(temp_dir, "rotated.jpg") write_oriented_jpeg!(source_path, 6) - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert media.width == 2 assert media.height == 3 @@ -248,11 +276,15 @@ defmodule BDS.MediaTest do assert_images_match!(actual_thumbnail, expected_thumbnail) end - test "regenerate_thumbnails recreates thumbnail files for an existing image media item", %{project: project, temp_dir: temp_dir} do + test "regenerate_thumbnails recreates thumbnail files for an existing image media item", %{ + project: project, + temp_dir: temp_dir + } do source_path = Path.join(temp_dir, "sample.jpg") File.write!(source_path, tiny_jpeg_binary()) - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) thumbnail_paths = BDS.Media.thumbnail_paths(media) File.rm!(Path.join(temp_dir, thumbnail_paths.small)) @@ -266,12 +298,17 @@ defmodule BDS.MediaTest do end) end - test "import_media generates thumbnails for png and webp sources", %{project: project, temp_dir: temp_dir} do - Enum.each([{ ".png", "image/png"}, {".webp", "image/webp"}], fn {extension, mime_type} -> + test "import_media generates thumbnails for png and webp sources", %{ + project: project, + temp_dir: temp_dir + } do + Enum.each([{".png", "image/png"}, {".webp", "image/webp"}], fn {extension, mime_type} -> source_path = Path.join(temp_dir, "sample#{extension}") File.write!(source_path, sample_image_binary(extension)) - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert media.mime_type == mime_type assert media.width == 2 assert media.height == 3 @@ -282,29 +319,39 @@ defmodule BDS.MediaTest do end) end - test "import_media detects supported TIFF, BMP, HEIC, and HEIF extensions", %{project: project, temp_dir: temp_dir} do - Enum.each([ - {"asset.tif", "image/tiff"}, - {"asset.tiff", "image/tiff"}, - {"asset.bmp", "image/bmp"}, - {"asset.heic", "image/heic"}, - {"asset.heif", "image/heif"} - ], fn {file_name, mime_type} -> - source_path = Path.join(temp_dir, file_name) - File.write!(source_path, "placeholder") + test "import_media detects supported TIFF, BMP, HEIC, and HEIF extensions", %{ + project: project, + temp_dir: temp_dir + } do + Enum.each( + [ + {"asset.tif", "image/tiff"}, + {"asset.tiff", "image/tiff"}, + {"asset.bmp", "image/bmp"}, + {"asset.heic", "image/heic"}, + {"asset.heif", "image/heif"} + ], + fn {file_name, mime_type} -> + source_path = Path.join(temp_dir, file_name) + File.write!(source_path, "placeholder") - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) - assert media.mime_type == mime_type - assert media.width == nil - assert media.height == nil - end) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + + assert media.mime_type == mime_type + assert media.width == nil + assert media.height == nil + end + ) end - test "upsert_media_translation persists the row and writes a translated sidecar next to the binary", %{project: project, temp_dir: temp_dir} do + test "upsert_media_translation persists the row and writes a translated sidecar next to the binary", + %{project: project, temp_dir: temp_dir} do source_path = Path.join(temp_dir, "sample.txt") File.write!(source_path, "hello media") - assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) + assert {:ok, media} = + BDS.Media.import_media(%{project_id: project.id, source_path: source_path}) assert {:ok, translation} = BDS.Media.upsert_media_translation(media.id, "de", %{ diff --git a/test/bds/menu_test.exs b/test/bds/menu_test.exs index cdf0072..fbe19f7 100644 --- a/test/bds/menu_test.exs +++ b/test/bds/menu_test.exs @@ -11,7 +11,8 @@ defmodule BDS.MenuTest do %{project: project, temp_dir: temp_dir} end - test "update_menu normalizes Home first, writes meta/menu.opml, and load returns nested items", %{project: project, temp_dir: temp_dir} do + test "update_menu normalizes Home first, writes meta/menu.opml, and load returns nested items", + %{project: project, temp_dir: temp_dir} do assert {:ok, menu} = BDS.Menu.update_menu(project.id, [ %{kind: :page, label: "About", slug: "about"}, @@ -52,7 +53,10 @@ defmodule BDS.MenuTest do assert loaded == menu end - test "sync_menu_from_filesystem loads canonical OPML and preserves a prepended Home entry", %{project: project, temp_dir: temp_dir} do + test "sync_menu_from_filesystem loads canonical OPML and preserves a prepended Home entry", %{ + project: project, + temp_dir: temp_dir + } do meta_dir = Path.join(temp_dir, "meta") File.mkdir_p!(meta_dir) diff --git a/test/bds/metadata_test.exs b/test/bds/metadata_test.exs index 36b8b1b..c86d406 100644 --- a/test/bds/metadata_test.exs +++ b/test/bds/metadata_test.exs @@ -11,7 +11,10 @@ defmodule BDS.MetadataTest do %{project: project, temp_dir: temp_dir} end - test "update_project_metadata writes meta/project.json and load returns the saved values", %{project: project, temp_dir: temp_dir} do + test "update_project_metadata writes meta/project.json and load returns the saved values", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, metadata} = BDS.Metadata.update_project_metadata(project.id, %{ name: "Renamed Blog", @@ -51,7 +54,8 @@ defmodule BDS.MetadataTest do assert loaded.blog_languages == ["de", "fr"] end - test "category and publishing updates write their meta files and sync_project_metadata_from_filesystem loads them", %{project: project, temp_dir: temp_dir} do + test "category and publishing updates write their meta files and sync_project_metadata_from_filesystem loads them", + %{project: project, temp_dir: temp_dir} do assert {:ok, _metadata} = BDS.Metadata.add_category(project.id, "news") assert {:ok, _metadata} = diff --git a/test/bds/post_translations_test.exs b/test/bds/post_translations_test.exs index 103eb55..10e9756 100644 --- a/test/bds/post_translations_test.exs +++ b/test/bds/post_translations_test.exs @@ -6,7 +6,10 @@ defmodule BDS.PostTranslationsTest do setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) - temp_dir = Path.join(System.tmp_dir!(), "bds-post-translations-#{System.unique_integer([:positive])}") + + temp_dir = + Path.join(System.tmp_dir!(), "bds-post-translations-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -14,7 +17,8 @@ defmodule BDS.PostTranslationsTest do %{project: project, temp_dir: temp_dir} end - test "upserted post translations publish with the canonical post, reopen on edit, and delete their file", %{project: project, temp_dir: temp_dir} do + test "upserted post translations publish with the canonical post, reopen on edit, and delete their file", + %{project: project, temp_dir: temp_dir} do assert {:ok, post} = Posts.create_post(%{ project_id: project.id, @@ -75,7 +79,8 @@ defmodule BDS.PostTranslationsTest do assert {:ok, []} = Posts.list_post_translations(post.id) end - test "validate_translations reports missing languages, orphan translation files, and do-not-translate posts", %{project: project, temp_dir: temp_dir} do + test "validate_translations reports missing languages, orphan translation files, and do-not-translate posts", + %{project: project, temp_dir: temp_dir} do assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{ main_language: "en", diff --git a/test/bds/posts_test.exs b/test/bds/posts_test.exs index 96a8cc5..3ce4947 100644 --- a/test/bds/posts_test.exs +++ b/test/bds/posts_test.exs @@ -13,7 +13,9 @@ defmodule BDS.PostsTest do %{project: project, temp_dir: temp_dir} end - test "create_post slugifies titles, stores list fields, and defaults draft fields", %{project: project} do + test "create_post slugifies titles, stores list fields, and defaults draft fields", %{ + project: project + } do assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, @@ -48,7 +50,10 @@ defmodule BDS.PostsTest do assert duplicate_slug_post.categories == [] end - test "create_post falls back to untitled and keeps slug uniqueness scoped to a project", %{project: project, temp_dir: temp_dir} do + test "create_post falls back to untitled and keeps slug uniqueness scoped to a project", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, first} = BDS.Posts.create_post(%{project_id: project.id, title: nil}) assert first.title == "" assert first.slug == "untitled" @@ -59,12 +64,15 @@ defmodule BDS.PostsTest do other_temp_dir = Path.join(temp_dir, "elsewhere") File.mkdir_p!(other_temp_dir) - assert {:ok, other_project} = BDS.Projects.create_project(%{name: "Elsewhere", data_path: other_temp_dir}) + assert {:ok, other_project} = + BDS.Projects.create_project(%{name: "Elsewhere", data_path: other_temp_dir}) + assert {:ok, other_post} = BDS.Posts.create_post(%{project_id: other_project.id, title: nil}) assert other_post.slug == "untitled" end - test "update_post rejects slug changes after first publish and reopens published posts when content changes", %{project: project} do + test "update_post rejects slug changes after first publish and reopens published posts when content changes", + %{project: project} do assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, @@ -97,11 +105,14 @@ defmodule BDS.PostsTest do end test "publish_post writes frontmatter to the project data directory and clears draft content" do - temp_dir = Path.join(System.tmp_dir!(), "bds-post-publish-#{System.unique_integer([:positive])}") + temp_dir = + Path.join(System.tmp_dir!(), "bds-post-publish-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) - assert {:ok, project} = BDS.Projects.create_project(%{name: "Filesystem", data_path: temp_dir}) + assert {:ok, project} = + BDS.Projects.create_project(%{name: "Filesystem", data_path: temp_dir}) assert {:ok, post} = BDS.Posts.create_post(%{ @@ -144,7 +155,9 @@ defmodule BDS.PostsTest do end test "delete_post removes the database row and published markdown file when present" do - temp_dir = Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}") + temp_dir = + Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -178,11 +191,14 @@ defmodule BDS.PostsTest do assert {:ok, archived_draft} = BDS.Posts.archive_post(draft_post.id) assert archived_draft.status == :archived - temp_dir = Path.join(System.tmp_dir!(), "bds-post-archive-#{System.unique_integer([:positive])}") + temp_dir = + Path.join(System.tmp_dir!(), "bds-post-archive-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) - assert {:ok, publish_project} = BDS.Projects.create_project(%{name: "Archive Published", data_path: temp_dir}) + assert {:ok, publish_project} = + BDS.Projects.create_project(%{name: "Archive Published", data_path: temp_dir}) assert {:ok, published_post} = BDS.Posts.create_post(%{ @@ -200,7 +216,9 @@ defmodule BDS.PostsTest do end test "publish_post republishes archived posts without losing the existing body or original published_at" do - temp_dir = Path.join(System.tmp_dir!(), "bds-post-republish-#{System.unique_integer([:positive])}") + temp_dir = + Path.join(System.tmp_dir!(), "bds-post-republish-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -227,7 +245,9 @@ defmodule BDS.PostsTest do end test "rebuild_posts_from_files recreates published posts from disk" do - temp_dir = Path.join(System.tmp_dir!(), "bds-post-rebuild-#{System.unique_integer([:positive])}") + temp_dir = + Path.join(System.tmp_dir!(), "bds-post-rebuild-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -279,9 +299,9 @@ defmodule BDS.PostsTest do assert post.language == "en" assert post.do_not_translate == true assert post.template_slug == "article" - assert post.created_at == 1711843200 - assert post.updated_at == 1711929600 - assert post.published_at == 1712016000 + assert post.created_at == 1_711_843_200 + assert post.updated_at == 1_711_929_600 + assert post.published_at == 1_712_016_000 assert post.tags == ["alpha"] assert post.categories == ["notes"] assert post.file_path == "posts/2026/04/recovered-post.md" diff --git a/test/bds/preview_test.exs b/test/bds/preview_test.exs index 89208f7..40a5edf 100644 --- a/test/bds/preview_test.exs +++ b/test/bds/preview_test.exs @@ -16,7 +16,8 @@ defmodule BDS.PreviewTest do %{project: project, temp_dir: temp_dir} end - test "start_preview binds localhost and request resolves generated routes, assets, media, and draft previews", %{project: project, temp_dir: temp_dir} do + test "start_preview binds localhost and request resolves generated routes, assets, media, and draft previews", + %{project: project, temp_dir: temp_dir} do assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{ public_url: "https://example.com/blog", @@ -24,10 +25,29 @@ defmodule BDS.PreviewTest do blog_languages: ["en", "de"] }) - assert {:ok, _} = Generation.write_generated_file(project.id, "index.html", "home") - assert {:ok, _} = Generation.write_generated_file(project.id, "de/index.html", "startseite") - assert {:ok, _} = Generation.write_generated_file(project.id, "tag/elixir/index.html", "tag archive") - assert {:ok, _} = Generation.write_generated_file(project.id, "pagefind/pagefind-ui.js", "console.log('pagefind')") + assert {:ok, _} = + Generation.write_generated_file(project.id, "index.html", "home") + + assert {:ok, _} = + Generation.write_generated_file( + project.id, + "de/index.html", + "startseite" + ) + + assert {:ok, _} = + Generation.write_generated_file( + project.id, + "tag/elixir/index.html", + "tag archive" + ) + + assert {:ok, _} = + Generation.write_generated_file( + project.id, + "pagefind/pagefind-ui.js", + "console.log('pagefind')" + ) media_dir = Path.join([temp_dir, "media", "2026", "04"]) File.mkdir_p!(media_dir) @@ -46,11 +66,20 @@ defmodule BDS.PreviewTest do assert server.port == 4123 assert server.is_running == true - assert {:ok, %{body: "home", content_type: "text/html"}} = BDS.Preview.request(project.id, "/") - assert {:ok, %{body: "startseite", content_type: "text/html"}} = BDS.Preview.request(project.id, "/de/") - assert {:ok, %{body: "tag archive", content_type: "text/html"}} = BDS.Preview.request(project.id, "/tag/elixir") - assert {:ok, %{body: "console.log('pagefind')", content_type: "application/javascript"}} = BDS.Preview.request(project.id, "/pagefind/pagefind-ui.js") - assert {:ok, %{body: "media body", content_type: "text/plain"}} = BDS.Preview.request(project.id, "/media/2026/04/image.txt") + assert {:ok, %{body: "home", content_type: "text/html"}} = + BDS.Preview.request(project.id, "/") + + assert {:ok, %{body: "startseite", content_type: "text/html"}} = + BDS.Preview.request(project.id, "/de/") + + assert {:ok, %{body: "tag archive", content_type: "text/html"}} = + BDS.Preview.request(project.id, "/tag/elixir") + + assert {:ok, %{body: "console.log('pagefind')", content_type: "application/javascript"}} = + BDS.Preview.request(project.id, "/pagefind/pagefind-ui.js") + + assert {:ok, %{body: "media body", content_type: "text/plain"}} = + BDS.Preview.request(project.id, "/media/2026/04/image.txt") assert {:ok, %{body: draft_html, content_type: "text/html"}} = BDS.Preview.preview_draft(project.id, "/draft/draft-post", post.id) @@ -67,7 +96,8 @@ defmodule BDS.PreviewTest do project_id: project.id, title: "Preview Post", kind: :post, - content: "

{{ post.title }}

{{ post.content }}
" + content: + "

{{ post.title }}

{{ post.content }}
" }) assert {:ok, published_template} = BDS.Templates.publish_template(template.id) @@ -93,7 +123,9 @@ defmodule BDS.PreviewTest do assert :ok = BDS.Preview.stop_preview(project.id) end - test "draft preview renders through copied starter templates with markdown and i18n", %{project: project} do + test "draft preview renders through copied starter templates with markdown and i18n", %{ + project: project + } do assert {:ok, _menu} = BDS.Menu.update_menu(project.id, [ %{kind: :page, label: "Notes", slug: "notes"} @@ -126,7 +158,8 @@ defmodule BDS.PreviewTest do assert :ok = BDS.Preview.stop_preview(project.id) end - test "preview renders not-found template for missing routes and rewrites markdown macros and canonical URLs", %{project: project, temp_dir: temp_dir} do + test "preview renders not-found template for missing routes and rewrites markdown macros and canonical URLs", + %{project: project, temp_dir: temp_dir} do :inets.start() assert {:ok, _metadata} = @@ -157,7 +190,10 @@ defmodule BDS.PreviewTest do assert {:ok, published_linked_post} = Posts.publish_post(linked_post.id) media_source_reference = "/" <> Path.join(Path.dirname(media.file_path), media.original_name) - canonical_post_href = "/" <> String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html") + + canonical_post_href = + "/" <> + String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html") assert {:ok, post} = Posts.create_post(%{ @@ -190,14 +226,21 @@ defmodule BDS.PreviewTest do assert missing_body =~ ~s(data-template="not-found") assert {:ok, {{_version, 404, _reason}, _headers, body}} = - :httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/missing-page"), []}, [], body_format: :binary) + :httpc.request( + :get, + {to_charlist("http://#{server.host}:#{server.port}/missing-page"), []}, + [], + body_format: :binary + ) assert body =~ ~s(data-template="not-found") assert :ok = BDS.Preview.stop_preview(project.id) end - test "start_preview serves generated and draft routes over real HTTP on localhost", %{project: project} do + test "start_preview serves generated and draft routes over real HTTP on localhost", %{ + project: project + } do :inets.start() assert {:ok, _metadata} = @@ -207,7 +250,8 @@ defmodule BDS.PreviewTest do blog_languages: ["en"] }) - assert {:ok, _} = Generation.write_generated_file(project.id, "index.html", "http home") + assert {:ok, _} = + Generation.write_generated_file(project.id, "index.html", "http home") assert {:ok, post} = Posts.create_post(%{ @@ -220,13 +264,26 @@ defmodule BDS.PreviewTest do assert {:ok, server} = BDS.Preview.start_preview(project.id) assert {:ok, {{_version, 200, _reason}, headers, body}} = - :httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/"), []}, [], body_format: :binary) + :httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/"), []}, [], + body_format: :binary + ) assert body == "http home" - assert Enum.any?(headers, fn {name, value} -> String.downcase(to_string(name)) == "content-type" and to_string(value) =~ "text/html" end) + + assert Enum.any?(headers, fn {name, value} -> + String.downcase(to_string(name)) == "content-type" and + to_string(value) =~ "text/html" + end) assert {:ok, {{_version, 200, _reason}, _headers, draft_body}} = - :httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/draft/http-draft?post_id=#{post.id}"), []}, [], body_format: :binary) + :httpc.request( + :get, + {to_charlist( + "http://#{server.host}:#{server.port}/draft/http-draft?post_id=#{post.id}" + ), []}, + [], + body_format: :binary + ) assert draft_body =~ "Draft over HTTP" diff --git a/test/bds/projects_test.exs b/test/bds/projects_test.exs index 43c5cc5..c7db178 100644 --- a/test/bds/projects_test.exs +++ b/test/bds/projects_test.exs @@ -18,13 +18,16 @@ defmodule BDS.ProjectsTest do %{temp_root: temp_root} end - test "create_project slugifies names, keeps new projects inactive, and deduplicates slugs", %{temp_root: temp_root} do + test "create_project slugifies names, keeps new projects inactive, and deduplicates slugs", %{ + temp_root: temp_root + } do first_dir = Path.join(temp_root, "first") second_dir = Path.join(temp_root, "second") File.mkdir_p!(first_dir) File.mkdir_p!(second_dir) - assert {:ok, first} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: first_dir}) + assert {:ok, first} = + BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: first_dir}) assert first.name == "Föö Bär Blog" assert first.slug == "foo-bar-blog" @@ -33,16 +36,21 @@ defmodule BDS.ProjectsTest do assert is_integer(first.created_at) assert is_integer(first.updated_at) - assert {:ok, second} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: second_dir}) + assert {:ok, second} = + BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: second_dir}) + assert second.slug == "foo-bar-blog-2" assert second.is_active == false end - test "create_project installs starter templates into the project data directory", %{temp_root: temp_root} do + test "create_project installs starter templates into the project data directory", %{ + temp_root: temp_root + } do temp_dir = Path.join(temp_root, "starter") File.mkdir_p!(temp_dir) - assert {:ok, project} = BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir}) + assert {:ok, project} = + BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir}) assert File.exists?(Path.join([temp_dir, "templates", "single-post.liquid"])) assert File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"])) @@ -52,18 +60,25 @@ defmodule BDS.ProjectsTest do assert File.exists?(Path.join([temp_dir, "templates", "macros", "gallery.liquid"])) starter_slugs = - Repo.all(from template in Template, where: template.project_id == ^project.id, select: {template.slug, template.kind}) + Repo.all( + from template in Template, + where: template.project_id == ^project.id, + select: {template.slug, template.kind} + ) assert {"single-post", :post} in starter_slugs assert {"post-list", :list} in starter_slugs 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 + 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}) + 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) @@ -75,11 +90,15 @@ defmodule BDS.ProjectsTest do reinstalled_contents = File.read!(template_path) assert reinstalled_contents == original_contents - assert {:ok, %{fields: reinstalled_fields}} = BDS.Frontmatter.parse_document(reinstalled_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 + 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") File.mkdir_p!(first_dir) diff --git a/test/bds/publishing_test.exs b/test/bds/publishing_test.exs index 79aa989..2382844 100644 --- a/test/bds/publishing_test.exs +++ b/test/bds/publishing_test.exs @@ -3,7 +3,10 @@ defmodule BDS.PublishingTest do setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) - temp_dir = Path.join(System.tmp_dir!(), "bds-publishing-#{System.unique_integer([:positive])}") + + temp_dir = + Path.join(System.tmp_dir!(), "bds-publishing-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -11,7 +14,10 @@ defmodule BDS.PublishingTest do %{project: project, temp_dir: temp_dir} end - test "upload_site creates a publish job, uploads all targets, and excludes media sidecars", %{project: project, temp_dir: temp_dir} do + test "upload_site creates a publish job, uploads all targets, and excludes media sidecars", %{ + project: project, + temp_dir: temp_dir + } do test_pid = self() File.mkdir_p!(Path.join([temp_dir, "html"])) @@ -25,7 +31,11 @@ defmodule BDS.PublishingTest do File.write!(Path.join([temp_dir, "media", "asset.jpg.meta"]), "meta") uploader = fn target, files, credentials -> - send(test_pid, {:uploaded, target.kind, target.remote_dir, Enum.sort(files), credentials.ssh_mode}) + send( + test_pid, + {:uploaded, target.kind, target.remote_dir, Enum.sort(files), credentials.ssh_mode} + ) + :ok end @@ -46,7 +56,10 @@ defmodule BDS.PublishingTest do assert_receive {:uploaded, :media, "/srv/blog/media", ["asset.jpg"], :rsync} end - test "upload_site runs rsync commands with SSH agent env and media exclude filters", %{project: project, temp_dir: temp_dir} do + test "upload_site runs rsync commands with SSH agent env and media exclude filters", %{ + project: project, + temp_dir: temp_dir + } do test_pid = self() File.mkdir_p!(Path.join([temp_dir, "html"])) @@ -78,17 +91,47 @@ defmodule BDS.PublishingTest do assert wait_for_publish_job(job.id, &(&1.status == :completed)).status == :completed assert_receive {:command_run, "rsync", html_args, html_opts} - assert html_args == ["--update", "--compress", "--verbose", "-e", "ssh", Path.join([temp_dir, "html"]) <> "/", "deploy@example.com:/srv/blog/"] + + assert html_args == [ + "--update", + "--compress", + "--verbose", + "-e", + "ssh", + Path.join([temp_dir, "html"]) <> "/", + "deploy@example.com:/srv/blog/" + ] + assert html_opts[:env] == [{"SSH_AUTH_SOCK", "/tmp/test-agent.sock"}] assert_receive {:command_run, "rsync", thumb_args, _thumb_opts} - assert thumb_args == ["--update", "--compress", "--verbose", "-e", "ssh", Path.join([temp_dir, "thumbnails"]) <> "/", "deploy@example.com:/srv/blog/thumbnails/"] + + assert thumb_args == [ + "--update", + "--compress", + "--verbose", + "-e", + "ssh", + Path.join([temp_dir, "thumbnails"]) <> "/", + "deploy@example.com:/srv/blog/thumbnails/" + ] assert_receive {:command_run, "rsync", media_args, _media_opts} - assert media_args == ["--update", "--compress", "--verbose", "--exclude=*.meta", "-e", "ssh", Path.join([temp_dir, "media"]) <> "/", "deploy@example.com:/srv/blog/media/"] + + assert media_args == [ + "--update", + "--compress", + "--verbose", + "--exclude=*.meta", + "-e", + "ssh", + Path.join([temp_dir, "media"]) <> "/", + "deploy@example.com:/srv/blog/media/" + ] end - test "upload_site runs scp commands for each eligible file and fails when a command exits non-zero", %{project: project, temp_dir: temp_dir} do + test "upload_site runs scp commands for each eligible file and fails when a command exits non-zero", + %{project: project, temp_dir: temp_dir} do test_pid = self() html_index = Path.join([temp_dir, "html", "index.html"]) html_entry = Path.join([temp_dir, "html", "posts", "entry.html"]) @@ -129,14 +172,26 @@ defmodule BDS.PublishingTest do failed_job = wait_for_publish_job(job.id, &(&1.status == :failed)) assert failed_job.error =~ "thumbnail failure" - assert_receive {:command_run, "scp", ["-q", ^html_index, "deploy@example.com:/srv/blog/index.html"], opts_a} + assert_receive {:command_run, "scp", + ["-q", ^html_index, "deploy@example.com:/srv/blog/index.html"], opts_a} + assert opts_a[:env] == [{"SSH_AUTH_SOCK", "/tmp/test-agent.sock"}] - assert_receive {:command_run, "scp", ["-q", ^html_entry, "deploy@example.com:/srv/blog/posts/entry.html"], _opts_b} - assert_receive {:command_run, "scp", ["-q", ^thumb_path, "deploy@example.com:/srv/blog/thumbnails/thumb.jpg"], _opts_c} - refute_receive {:command_run, "scp", ["-q", _, "deploy@example.com:/srv/blog/media/asset.jpg"], _opts_d} + + assert_receive {:command_run, "scp", + ["-q", ^html_entry, "deploy@example.com:/srv/blog/posts/entry.html"], _opts_b} + + assert_receive {:command_run, "scp", + ["-q", ^thumb_path, "deploy@example.com:/srv/blog/thumbnails/thumb.jpg"], + _opts_c} + + refute_receive {:command_run, "scp", + ["-q", _, "deploy@example.com:/srv/blog/media/asset.jpg"], _opts_d} end - test "upload_site marks the publish job failed when a target upload fails", %{project: project, temp_dir: temp_dir} do + test "upload_site marks the publish job failed when a target upload fails", %{ + project: project, + temp_dir: temp_dir + } do File.mkdir_p!(Path.join([temp_dir, "html"])) File.write!(Path.join([temp_dir, "html", "index.html"]), "") File.mkdir_p!(Path.join([temp_dir, "thumbnails"])) @@ -161,6 +216,75 @@ defmodule BDS.PublishingTest do assert failed_job.error == "thumbnail failure" end + test "upload_site skips unchanged files for scp and only re-uploads files with newer mtimes", %{ + project: project, + temp_dir: temp_dir + } do + test_pid = self() + html_index = Path.join([temp_dir, "html", "index.html"]) + media_asset = Path.join([temp_dir, "media", "asset.jpg"]) + + File.mkdir_p!(Path.dirname(html_index)) + File.write!(html_index, "") + File.mkdir_p!(Path.join([temp_dir, "thumbnails"])) + File.write!(Path.join([temp_dir, "thumbnails", "thumb.jpg"]), "thumb") + File.mkdir_p!(Path.dirname(media_asset)) + File.write!(media_asset, "asset") + + runner = fn command, args, opts -> + send(test_pid, {:command_run, command, args, opts}) + {"", 0} + end + + credentials = %{ + ssh_host: "example.com", + ssh_user: "deploy", + ssh_remote_path: "/srv/blog", + ssh_mode: :scp + } + + assert {:ok, first_job} = + BDS.Publishing.upload_site(project.id, credentials, + command_runner: runner, + ssh_auth_sock: "/tmp/test-agent.sock" + ) + + assert wait_for_publish_job(first_job.id, &(&1.status == :completed)).status == :completed + first_uploads = collect_command_runs() + assert length(first_uploads) == 3 + + assert {:ok, second_job} = + BDS.Publishing.upload_site(project.id, credentials, + command_runner: runner, + ssh_auth_sock: "/tmp/test-agent.sock" + ) + + assert wait_for_publish_job(second_job.id, &(&1.status == :completed)).status == :completed + assert collect_command_runs() == [] + + :ok = File.touch(html_index, {{2099, 1, 1}, {0, 0, 0}}) + + assert {:ok, third_job} = + BDS.Publishing.upload_site(project.id, credentials, + command_runner: runner, + ssh_auth_sock: "/tmp/test-agent.sock" + ) + + assert wait_for_publish_job(third_job.id, &(&1.status == :completed)).status == :completed + + assert [html_upload] = collect_command_runs() + assert elem(html_upload, 0) == "scp" + assert elem(html_upload, 1) == ["-q", html_index, "deploy@example.com:/srv/blog/index.html"] + end + + defp collect_command_runs(acc \\ []) do + receive do + {:command_run, command, args, _opts} -> collect_command_runs([{command, args} | acc]) + after + 50 -> Enum.reverse(acc) + end + end + defp wait_for_publish_job(job_id, predicate, attempts \\ 100) defp wait_for_publish_job(job_id, predicate, attempts) when attempts > 0 do diff --git a/test/bds/rendering_test.exs b/test/bds/rendering_test.exs new file mode 100644 index 0000000..59f4560 --- /dev/null +++ b/test/bds/rendering_test.exs @@ -0,0 +1,152 @@ +defmodule BDS.RenderingTest do + use ExUnit.Case, async: false + + import Ecto.Query + + alias BDS.Rendering + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = Path.join(System.tmp_dir!(), "bds-rendering-#{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: "Rendering", data_path: temp_dir}) + %{project: project, temp_dir: temp_dir} + end + + test "render_post_page exposes the spec post context and blog language links", %{ + project: project + } do + assert {:ok, _metadata} = + BDS.Metadata.update_project_metadata(project.id, %{ + main_language: "en", + blog_languages: ["en", "de"] + }) + + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Render Post Context", + kind: :post, + content: + "{{ pico_stylesheet_href }}|{% for lang in blog_languages %}[{{ lang.code }}={{ lang.href }}:{{ lang.href_prefix }}]{% endfor %}|{{ post.author }}|{{ post.published_at }}|{{ post.created_at }}|{{ post.updated_at }}|{{ post.tags.size }}|{{ post.categories.size }}|{{ post.template_slug }}|{{ post.do_not_translate }}" + }) + + assert {:ok, published_template} = BDS.Templates.publish_template(template.id) + + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Render Me", + content: "Body", + author: "Writer", + tags: ["alpha", "beta"], + categories: ["notes"], + language: "en", + template_slug: published_template.slug, + do_not_translate: true + }) + + assert {:ok, published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, rendered} = + Rendering.render_post_page(project.id, published_template.slug, %{ + id: published_post.id, + title: published_post.title, + content: published_post.content || "", + slug: published_post.slug, + language: "de", + excerpt: published_post.excerpt, + template_slug: published_post.template_slug + }) + + assert rendered =~ "/assets/pico.min.css" + assert rendered =~ "[en=/:]" + assert rendered =~ "[de=/de/:/de]" + assert rendered =~ "|Writer|" + + assert rendered =~ + "|#{published_post.published_at}|#{published_post.created_at}|#{published_post.updated_at}|" + + assert rendered =~ "|2|1|#{published_template.slug}|true" + end + + test "render_list_page exposes pagination and render_not_found_page localizes default copy", %{ + project: project + } do + assert {:ok, _metadata} = + BDS.Metadata.update_project_metadata(project.id, %{ + main_language: "en", + blog_languages: ["en", "de"] + }) + + assert {:ok, list_template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Render List Context", + kind: :list, + content: + "{{ current_page }}|{{ total_pages }}|{{ total_items }}|{{ items_per_page }}|{{ has_prev_page }}|{{ prev_page_href }}|{{ has_next_page }}|{{ next_page_href }}" + }) + + assert {:ok, not_found_template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Render Not Found Context", + kind: :not_found, + content: "{{ not_found_message }}|{{ not_found_back_label }}" + }) + + assert {:ok, published_list_template} = BDS.Templates.publish_template(list_template.id) + + assert {:ok, _published_not_found_template} = + BDS.Templates.publish_template(not_found_template.id) + + BDS.Repo.update_all( + from(template in BDS.Templates.Template, + where: + template.project_id == ^project.id and template.kind == :list and + template.id != ^published_list_template.id + ), + set: [enabled: false] + ) + + BDS.Repo.update_all( + from(template in BDS.Templates.Template, + where: + template.project_id == ^project.id and template.kind == :not_found and + template.slug != ^not_found_template.slug + ), + set: [enabled: false] + ) + + assert {:ok, rendered_list} = + Rendering.render_list_page(project.id, %{ + language: "en", + page_title: "Archive", + posts: [], + archive_context: %{kind: "tag", name: "elixir"}, + pagination: %{ + current_page: 2, + total_pages: 5, + total_items: 12, + items_per_page: 3, + has_prev_page: true, + prev_page_href: "/page/1/", + has_next_page: true, + next_page_href: "/page/3/" + } + }) + + assert rendered_list == "2|5|12|3|true|/page/1/|true|/page/3/" + + assert {:ok, rendered_not_found} = + Rendering.render_not_found_page(project.id, %{language: "de"}) + + assert rendered_not_found == + "Die angeforderte Vorschauseite konnte nicht gefunden werden.|Zurück zur Vorschau-Startseite" + + assert published_list_template.kind == :list + end +end diff --git a/test/bds/repo/schema_migration_test.exs b/test/bds/repo/schema_migration_test.exs index a31431d..e84f538 100644 --- a/test/bds/repo/schema_migration_test.exs +++ b/test/bds/repo/schema_migration_test.exs @@ -180,7 +180,13 @@ defmodule BDS.Repo.SchemaMigrationTest do "ai_model_modalities" => ["provider", "model_id", "direction", "modality"], "ai_catalog_meta" => ["key", "value"], "embedding_keys" => ["label", "post_id", "project_id", "content_hash", "vector"], - "dismissed_duplicate_pairs" => ["id", "project_id", "post_id_a", "post_id_b", "dismissed_at"], + "dismissed_duplicate_pairs" => [ + "id", + "project_id", + "post_id_a", + "post_id_b", + "dismissed_at" + ], "import_definitions" => [ "id", "project_id", @@ -229,8 +235,16 @@ defmodule BDS.Repo.SchemaMigrationTest do assert unique_index_columns("tags", "tags_project_name_idx") == ["project_id", "name"] assert unique_index_columns("scripts", "scripts_project_slug_idx") == ["project_id", "slug"] - assert unique_index_columns("templates", "templates_project_slug_idx") == ["project_id", "slug"] - assert unique_index_columns("post_media", "post_media_post_media_idx") == ["post_id", "media_id"] + + assert unique_index_columns("templates", "templates_project_slug_idx") == [ + "project_id", + "slug" + ] + + assert unique_index_columns("post_media", "post_media_post_media_idx") == [ + "post_id", + "media_id" + ] assert unique_index_columns( "generated_file_hashes", @@ -345,7 +359,9 @@ defmodule BDS.Repo.SchemaMigrationTest do defp unique_index_columns(table, index_name) do indexes = query_rows("PRAGMA index_list(#{table})") - assert Enum.any?(indexes, fn [_seq, name, unique | _rest] -> name == index_name and unique == 1 end), + assert Enum.any?(indexes, fn [_seq, name, unique | _rest] -> + name == index_name and unique == 1 + end), "expected unique index #{index_name} on #{table}" query_rows("PRAGMA index_info(#{index_name})") diff --git a/test/bds/scripting/job_test.exs b/test/bds/scripting/job_test.exs index 5c47513..183b6f7 100644 --- a/test/bds/scripting/job_test.exs +++ b/test/bds/scripting/job_test.exs @@ -58,7 +58,13 @@ defmodule BDS.Scripting.JobTest do assert {:ok, job} = BDS.Scripting.start_job("irrelevant", "main") assert job.status in [:queued, :running] - running_job = wait_for_job(job.id, &(&1.status == :running and &1.progress == %{"phase" => "started", "current" => 1, "total" => 2})) + running_job = + wait_for_job( + job.id, + &(&1.status == :running and + &1.progress == %{"phase" => "started", "current" => 1, "total" => 2}) + ) + assert running_job.started_at != nil completed_job = wait_for_job(job.id, &(&1.status == :completed)) diff --git a/test/bds/scripts_test.exs b/test/bds/scripts_test.exs index 8d1ea50..6554db0 100644 --- a/test/bds/scripts_test.exs +++ b/test/bds/scripts_test.exs @@ -42,7 +42,10 @@ defmodule BDS.ScriptsTest do assert macro_script.slug == "render-card" end - test "publish_script writes a lua file with frontmatter and clears draft content", %{project: project, temp_dir: temp_dir} do + test "publish_script writes a lua file with frontmatter and clears draft content", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, script} = BDS.Scripts.create_script(%{ project_id: project.id, @@ -73,7 +76,9 @@ defmodule BDS.ScriptsTest do assert contents =~ "\n---\nfunction main() return 'ok' end\n" end - test "update_script bumps version and reopens a published script when content changes", %{project: project} do + test "update_script bumps version and reopens a published script when content changes", %{ + project: project + } do assert {:ok, script} = BDS.Scripts.create_script(%{ project_id: project.id, @@ -99,7 +104,10 @@ defmodule BDS.ScriptsTest do assert updated.updated_at >= published.updated_at end - test "delete_script removes the published file and database row", %{project: project, temp_dir: temp_dir} do + test "delete_script removes the published file and database row", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, script} = BDS.Scripts.create_script(%{ project_id: project.id, @@ -116,7 +124,10 @@ defmodule BDS.ScriptsTest do refute File.exists?(Path.join(temp_dir, published.file_path)) end - test "rebuild_scripts_from_files recreates published scripts from disk", %{project: project, temp_dir: temp_dir} do + test "rebuild_scripts_from_files recreates published scripts from disk", %{ + project: project, + temp_dir: temp_dir + } do script_dir = Path.join(temp_dir, "scripts") File.mkdir_p!(script_dir) diff --git a/test/bds/search_test.exs b/test/bds/search_test.exs index 6ef75d5..6b34e0a 100644 --- a/test/bds/search_test.exs +++ b/test/bds/search_test.exs @@ -13,7 +13,8 @@ defmodule BDS.SearchTest do %{project: project, temp_dir: temp_dir} end - test "search_posts indexes writes, supports filters and pagination, and removes deleted posts", %{project: project} do + test "search_posts indexes writes, supports filters and pagination, and removes deleted posts", + %{project: project} do assert {:ok, draft_post} = BDS.Posts.create_post(%{ project_id: project.id, @@ -54,14 +55,25 @@ defmodule BDS.SearchTest do assert results.limit == 50 assert Enum.map(results.posts, & &1.id) == [draft_post.id] - assert {:ok, tag_results} = BDS.Search.search_posts(project.id, "galaxy", %{tags: ["space"], categories: ["astronomy"]}) - assert tag_results.total == 2 - assert Enum.sort(Enum.map(tag_results.posts, & &1.id)) == Enum.sort([draft_post.id, published_post.id]) + assert {:ok, tag_results} = + BDS.Search.search_posts(project.id, "galaxy", %{ + tags: ["space"], + categories: ["astronomy"] + }) + + assert tag_results.total == 2 + + assert Enum.sort(Enum.map(tag_results.posts, & &1.id)) == + Enum.sort([draft_post.id, published_post.id]) + + assert {:ok, language_results} = + BDS.Search.search_posts(project.id, "galaxy", %{language: "de"}) - assert {:ok, language_results} = BDS.Search.search_posts(project.id, "galaxy", %{language: "de"}) assert Enum.map(language_results.posts, & &1.id) == [published_post.id] - assert {:ok, paged_results} = BDS.Search.search_posts(project.id, "galaxy", %{limit: 1, offset: 1}) + assert {:ok, paged_results} = + BDS.Search.search_posts(project.id, "galaxy", %{limit: 1, offset: 1}) + assert paged_results.total == 3 assert paged_results.offset == 1 assert paged_results.limit == 1 @@ -120,12 +132,17 @@ defmodule BDS.SearchTest do assert Enum.map(results.posts, & &1.id) == [post.id] assert {:ok, missing_translation_results} = - BDS.Search.search_posts(project.id, "Canonical", %{missing_translation_language: "de"}) + BDS.Search.search_posts(project.id, "Canonical", %{ + missing_translation_language: "de" + }) assert Enum.map(missing_translation_results.posts, & &1.id) == [post.id] end - test "search_media indexes metadata, includes translation text, and removes deleted media", %{project: project, temp_dir: temp_dir} do + test "search_media indexes metadata, includes translation text, and removes deleted media", %{ + project: project, + temp_dir: temp_dir + } do source_path = Path.join(temp_dir, "hero.txt") File.write!(source_path, "hero") @@ -164,7 +181,10 @@ defmodule BDS.SearchTest do assert deleted_results.total == 0 end - test "rebuild operations repopulate the search index from filesystem truth", %{project: project, temp_dir: temp_dir} do + test "rebuild operations repopulate the search index from filesystem truth", %{ + project: project, + temp_dir: temp_dir + } do posts_dir = Path.join([temp_dir, "posts", "2026", "04"]) File.mkdir_p!(posts_dir) @@ -225,7 +245,9 @@ defmodule BDS.SearchTest do assert Enum.map(media_results.media, & &1.id) == ["search-media-from-file"] end - test "search_posts applies language-aware stemming to indexed and query text", %{project: project} do + test "search_posts applies language-aware stemming to indexed and query text", %{ + project: project + } do assert {:ok, german_post} = BDS.Posts.create_post(%{ project_id: project.id, diff --git a/test/bds/tags_test.exs b/test/bds/tags_test.exs index 0e9e9cf..8a47890 100644 --- a/test/bds/tags_test.exs +++ b/test/bds/tags_test.exs @@ -14,8 +14,13 @@ defmodule BDS.TagsTest do %{project: project, temp_dir: temp_dir} end - test "create_tag persists the row and rewrites meta/tags.json sorted by name", %{project: project, temp_dir: temp_dir} do - assert {:ok, zebra} = BDS.Tags.create_tag(%{project_id: project.id, name: "Zebra", color: "#000000"}) + test "create_tag persists the row and rewrites meta/tags.json sorted by name", %{ + project: project, + temp_dir: temp_dir + } do + assert {:ok, zebra} = + BDS.Tags.create_tag(%{project_id: project.id, name: "Zebra", color: "#000000"}) + assert {:ok, alpha} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert zebra.name == "Zebra" @@ -35,7 +40,10 @@ defmodule BDS.TagsTest do assert "has already been taken" in errors_on(changeset).name end - test "update_tag rewrites the tag row and meta/tags.json", %{project: project, temp_dir: temp_dir} do + test "update_tag rewrites the tag row and meta/tags.json", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, updated} = @@ -51,11 +59,16 @@ defmodule BDS.TagsTest do tags_path = Path.join([temp_dir, "meta", "tags.json"]) - assert %{"tags" => [%{"name" => "Alpha", "color" => "#112233", "post_template_slug" => "article"}]} = + assert %{ + "tags" => [ + %{"name" => "Alpha", "color" => "#112233", "post_template_slug" => "article"} + ] + } = Jason.decode!(File.read!(tags_path)) end - test "rename_tag updates post tag arrays, rewrites published post files, and refreshes tags.json", %{project: project, temp_dir: temp_dir} do + test "rename_tag updates post tag arrays, rewrites published post files, and refreshes tags.json", + %{project: project, temp_dir: temp_dir} do assert {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, post} = @@ -83,7 +96,8 @@ defmodule BDS.TagsTest do assert %{"tags" => [%{"name" => "Beta"}]} = Jason.decode!(File.read!(tags_path)) end - test "merge_tags moves source tags onto the target, deduplicates post tags, deletes sources, and refreshes tags.json", %{project: project, temp_dir: temp_dir} do + test "merge_tags moves source tags onto the target, deduplicates post tags, deletes sources, and refreshes tags.json", + %{project: project, temp_dir: temp_dir} do assert {:ok, source_a} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, source_b} = BDS.Tags.create_tag(%{project_id: project.id, name: "Beta"}) assert {:ok, target} = BDS.Tags.create_tag(%{project_id: project.id, name: "Gamma"}) @@ -115,7 +129,8 @@ defmodule BDS.TagsTest do assert %{"tags" => [%{"name" => "Gamma"}]} = Jason.decode!(File.read!(tags_path)) end - test "delete_tag removes the tag from posts, rewrites published files, deletes the row, and refreshes tags.json", %{project: project, temp_dir: temp_dir} do + test "delete_tag removes the tag from posts, rewrites published files, deletes the row, and refreshes tags.json", + %{project: project, temp_dir: temp_dir} do assert {:ok, doomed} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) assert {:ok, _other} = BDS.Tags.create_tag(%{project_id: project.id, name: "Beta"}) @@ -145,7 +160,8 @@ defmodule BDS.TagsTest do assert %{"tags" => [%{"name" => "Beta"}]} = Jason.decode!(File.read!(tags_path)) end - test "sync_tags_from_posts creates missing tags from post tag arrays and refreshes tags.json", %{project: project, temp_dir: temp_dir} do + test "sync_tags_from_posts creates missing tags from post tag arrays and refreshes tags.json", + %{project: project, temp_dir: temp_dir} do assert {:ok, existing} = BDS.Tags.create_tag(%{ project_id: project.id, @@ -183,7 +199,11 @@ defmodule BDS.TagsTest do assert %{ "tags" => [ %{"name" => "Another"}, - %{"name" => "Existing", "color" => "#112233", "post_template_slug" => "feature-view"}, + %{ + "name" => "Existing", + "color" => "#112233", + "post_template_slug" => "feature-view" + }, %{"name" => "Missing"} ] } = Jason.decode!(File.read!(tags_path)) diff --git a/test/bds/tasks_test.exs b/test/bds/tasks_test.exs index 9007278..649bc9f 100644 --- a/test/bds/tasks_test.exs +++ b/test/bds/tasks_test.exs @@ -94,7 +94,11 @@ defmodule BDS.TasksTest do end test "external tasks are registered as running and can report progress and complete" do - assert {:ok, task} = BDS.Tasks.register_external_task("preview build", %{group_id: "generation", group_name: "Generation"}) + assert {:ok, task} = + BDS.Tasks.register_external_task("preview build", %{ + group_id: "generation", + group_name: "Generation" + }) assert task.status == :running assert task.group_id == "generation" @@ -106,7 +110,9 @@ defmodule BDS.TasksTest do assert progressed.status == :running assert :ok = BDS.Tasks.complete_task(task.id) - assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status == :completed + + assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status == + :completed end defp receive_started do diff --git a/test/bds/templates_test.exs b/test/bds/templates_test.exs index 089029e..7b93464 100644 --- a/test/bds/templates_test.exs +++ b/test/bds/templates_test.exs @@ -33,12 +33,20 @@ defmodule BDS.TemplatesTest do assert template.content == "
{{ content }}
" assert {:ok, duplicate} = - BDS.Templates.create_template(%{project_id: project.id, title: "Article View", kind: :post, content: "x"}) + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Article View", + kind: :post, + content: "x" + }) assert duplicate.slug == "article-view-2" end - test "publish_template writes a liquid file with frontmatter and clears draft content", %{project: project, temp_dir: temp_dir} do + test "publish_template writes a liquid file with frontmatter and clears draft content", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, template} = BDS.Templates.create_template(%{ project_id: project.id, @@ -68,7 +76,9 @@ defmodule BDS.TemplatesTest do assert contents =~ "\n---\n
{{ page_title }}
\n" end - test "update_template bumps version and reopens a published template when content changes", %{project: project} do + test "update_template bumps version and reopens a published template when content changes", %{ + project: project + } do assert {:ok, template} = BDS.Templates.create_template(%{ project_id: project.id, @@ -94,7 +104,8 @@ defmodule BDS.TemplatesTest do assert updated.updated_at >= published.updated_at end - test "delete_template refuses referenced templates unless forced, then clears references and deletes the file", %{project: project, temp_dir: temp_dir} do + test "delete_template refuses referenced templates unless forced, then clears references and deletes the file", + %{project: project, temp_dir: temp_dir} do assert {:ok, template} = BDS.Templates.create_template(%{ project_id: project.id, @@ -122,7 +133,8 @@ defmodule BDS.TemplatesTest do post_template_slug: published.slug }) - assert {:error, {:has_references, %{posts: 1, tags: 1}}} = BDS.Templates.delete_template(published.id) + assert {:error, {:has_references, %{posts: 1, tags: 1}}} = + BDS.Templates.delete_template(published.id) assert {:ok, :deleted} = BDS.Templates.delete_template(published.id, force: true) @@ -143,7 +155,8 @@ defmodule BDS.TemplatesTest do assert %{"tags" => [%{"name" => "Feature"}]} = Jason.decode!(File.read!(tags_path)) end - test "update_template cascades slug changes to posts and tags and renames the published file", %{project: project, temp_dir: temp_dir} do + test "update_template cascades slug changes to posts and tags and renames the published file", + %{project: project, temp_dir: temp_dir} do assert {:ok, template} = BDS.Templates.create_template(%{ project_id: project.id, @@ -198,11 +211,15 @@ defmodule BDS.TemplatesTest do assert post_contents =~ "\n---\nBody\n" tags_path = Path.join([temp_dir, "meta", "tags.json"]) + assert %{"tags" => [%{"name" => "Feature", "post_template_slug" => "feature-view"}]} = Jason.decode!(File.read!(tags_path)) end - test "rebuild_templates_from_files recreates published templates from disk", %{project: project, temp_dir: temp_dir} do + test "rebuild_templates_from_files recreates published templates from disk", %{ + project: project, + temp_dir: temp_dir + } do template_dir = Path.join(temp_dir, "templates") File.mkdir_p!(template_dir)