diff --git a/lib/bds/maintenance.ex b/lib/bds/maintenance.ex new file mode 100644 index 0000000..3715689 --- /dev/null +++ b/lib/bds/maintenance.ex @@ -0,0 +1,23 @@ +defmodule BDS.Maintenance do + @moduledoc false + + def rebuild_from_filesystem(project_id, entity_type) do + case normalize_entity_type(entity_type) do + :post -> BDS.Posts.rebuild_posts_from_files(project_id) + :media -> BDS.Media.rebuild_media_from_files(project_id) + :script -> BDS.Scripts.rebuild_scripts_from_files(project_id) + :template -> BDS.Templates.rebuild_templates_from_files(project_id) + :unsupported -> {:error, :unsupported_entity_type} + end + end + + defp normalize_entity_type(:post), do: :post + defp normalize_entity_type(:media), do: :media + defp normalize_entity_type(:script), do: :script + defp normalize_entity_type(:template), do: :template + defp normalize_entity_type("post"), do: :post + defp normalize_entity_type("media"), do: :media + defp normalize_entity_type("script"), do: :script + defp normalize_entity_type("template"), do: :template + defp normalize_entity_type(_entity_type), do: :unsupported +end diff --git a/lib/bds/media.ex b/lib/bds/media.ex new file mode 100644 index 0000000..6906a94 --- /dev/null +++ b/lib/bds/media.ex @@ -0,0 +1,247 @@ +defmodule BDS.Media do + @moduledoc false + + alias BDS.Media.Media + alias BDS.Projects + alias BDS.Repo + alias BDS.Sidecar + + def import_media(attrs) do + project = Projects.get_project!(attr(attrs, :project_id)) + source_path = attr(attrs, :source_path) + original_name = Path.basename(source_path) + now = System.system_time(:second) + file_name = Ecto.UUID.generate() <> Path.extname(original_name) + file_path = media_file_path(file_name, now) + sidecar_path = file_path <> ".meta" + destination = Path.join(Projects.project_data_dir(project), file_path) + stat = File.stat!(source_path) + + Repo.transaction(fn -> + media = + %Media{} + |> Media.changeset(%{ + id: Ecto.UUID.generate(), + project_id: project.id, + filename: file_name, + original_name: original_name, + mime_type: detect_mime(original_name), + size: stat.size, + width: attr(attrs, :width), + height: attr(attrs, :height), + title: attr(attrs, :title), + alt: attr(attrs, :alt), + caption: attr(attrs, :caption), + author: attr(attrs, :author), + language: attr(attrs, :language), + file_path: file_path, + sidecar_path: sidecar_path, + checksum: attr(attrs, :checksum), + tags: attr(attrs, :tags) || [], + created_at: now, + updated_at: now + }) + |> Repo.insert!() + + :ok = File.mkdir_p(Path.dirname(destination)) + :ok = File.cp(source_path, destination) + :ok = write_sidecar(project, media) + media + end) + |> case do + {:ok, media} -> {:ok, media} + {:error, reason} -> {:error, reason} + end + end + + def update_media(media_id, attrs) do + case Repo.get(Media, media_id) do + nil -> + {: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)) + + project = Projects.get_project!(media.project_id) + + Repo.transaction(fn -> + updated_media = + media + |> Media.changeset(updates) + |> Repo.update!() + + :ok = write_sidecar(project, updated_media) + updated_media + end) + |> case do + {:ok, updated_media} -> {:ok, updated_media} + {:error, reason} -> {:error, reason} + end + end + end + + def delete_media(media_id) do + case Repo.get(Media, media_id) do + nil -> + {:error, :not_found} + + media -> + delete_file_if_present(media.project_id, media.file_path) + delete_file_if_present(media.project_id, media.sidecar_path) + Repo.delete!(media) + {:ok, :deleted} + end + end + + def rebuild_media_from_files(project_id) do + project = Projects.get_project!(project_id) + + media_items = + project + |> Projects.project_data_dir() + |> Path.join("media") + |> list_matching_files("*.meta") + |> Enum.filter(&binary_exists_for_sidecar?/1) + |> Enum.map(&upsert_media_from_sidecar(project, &1)) + + {:ok, media_items} + end + + defp upsert_media_from_sidecar(project, sidecar_path) do + {:ok, fields} = sidecar_path |> File.read!() |> Sidecar.parse_document() + relative_sidecar_path = Path.relative_to(sidecar_path, Projects.project_data_dir(project)) + relative_file_path = String.trim_trailing(relative_sidecar_path, ".meta") + filename = Path.basename(relative_file_path) + now = System.system_time(:second) + + attrs = %{ + id: Map.get(fields, "id") || Ecto.UUID.generate(), + project_id: project.id, + filename: filename, + original_name: Map.get(fields, "original_name") || filename, + mime_type: Map.get(fields, "mime_type") || detect_mime(filename), + size: Map.get(fields, "size", 0), + width: blank_to_nil(Map.get(fields, "width")), + height: blank_to_nil(Map.get(fields, "height")), + title: Map.get(fields, "title"), + alt: Map.get(fields, "alt"), + caption: Map.get(fields, "caption"), + author: Map.get(fields, "author"), + language: Map.get(fields, "language"), + file_path: relative_file_path, + sidecar_path: relative_sidecar_path, + checksum: nil, + tags: Map.get(fields, "tags", []), + created_at: Map.get(fields, "created_at", now), + 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 + |> Media.changeset(attrs) + |> Repo.insert_or_update!() + end + + defp write_sidecar(project, media) do + path = Path.join(Projects.project_data_dir(project), media.sidecar_path) + :ok = File.mkdir_p(Path.dirname(path)) + + atomic_write( + path, + Sidecar.serialize_document([ + {:id, media.id}, + {:original_name, media.original_name}, + {:mime_type, media.mime_type}, + {:size, media.size}, + {:width, media.width}, + {:height, media.height}, + {:title, media.title}, + {:alt, media.alt}, + {:caption, media.caption}, + {:author, media.author}, + {:language, media.language}, + {:created_at, media.created_at}, + {:updated_at, media.updated_at}, + {:tags, media.tags || []} + ]) + ) + end + + defp media_file_path(file_name, timestamp) do + datetime = DateTime.from_unix!(timestamp) + year = Integer.to_string(datetime.year) + month = datetime.month |> Integer.to_string() |> String.pad_leading(2, "0") + Path.join(["media", year, month, file_name]) + end + + defp detect_mime(file_name) do + case String.downcase(Path.extname(file_name)) do + ".txt" -> "text/plain" + ".md" -> "text/markdown" + ".jpg" -> "image/jpeg" + ".jpeg" -> "image/jpeg" + ".png" -> "image/png" + ".gif" -> "image/gif" + ".webp" -> "image/webp" + _ -> "application/octet-stream" + end + end + + defp binary_exists_for_sidecar?(sidecar_path) do + sidecar_path + |> String.trim_trailing(".meta") + |> File.exists?() + end + + defp list_matching_files(dir, pattern) do + if File.dir?(dir) do + Path.join([dir, "**", pattern]) + |> Path.wildcard() + |> Enum.sort() + else + [] + end + end + + defp delete_file_if_present(project_id, relative_path) do + project = Projects.get_project!(project_id) + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + case File.rm(full_path) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp atomic_write(path, contents) do + temp_path = path <> ".tmp" + :ok = File.write(temp_path, contents) + File.rename(temp_path, path) + end + + defp blank_to_nil(nil), do: nil + defp blank_to_nil(""), do: nil + defp blank_to_nil(value), do: value + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp attr(attrs, key) do + cond do + Map.has_key?(attrs, key) -> Map.get(attrs, key) + Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key)) + true -> nil + end + end +end diff --git a/lib/bds/media/media.ex b/lib/bds/media/media.ex new file mode 100644 index 0000000..3ccc813 --- /dev/null +++ b/lib/bds/media/media.ex @@ -0,0 +1,64 @@ +defmodule BDS.Media.Media do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + alias BDS.Types.StringList + + @primary_key {:id, :string, autogenerate: false} + @foreign_key_type :string + + schema "media" do + belongs_to :project, BDS.Projects.Project, type: :string + + field :filename, :string + field :original_name, :string + field :mime_type, :string + field :size, :integer + field :width, :integer + field :height, :integer + field :title, :string + field :alt, :string + field :caption, :string + field :author, :string + field :language, :string + field :file_path, :string + field :sidecar_path, :string + field :checksum, :string + field :tags, StringList, default: [] + field :created_at, :integer + field :updated_at, :integer + end + + def changeset(media, attrs) do + media + |> cast( + attrs, + [ + :id, + :project_id, + :filename, + :original_name, + :mime_type, + :size, + :width, + :height, + :title, + :alt, + :caption, + :author, + :language, + :file_path, + :sidecar_path, + :checksum, + :tags, + :created_at, + :updated_at + ], + empty_values: [nil] + ) + |> 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/metadata.ex b/lib/bds/metadata.ex new file mode 100644 index 0000000..b89e839 --- /dev/null +++ b/lib/bds/metadata.ex @@ -0,0 +1,289 @@ +defmodule BDS.Metadata do + @moduledoc false + + alias BDS.Projects + alias BDS.Projects.Project + alias BDS.Repo + alias BDS.Settings.Setting + + @default_max_posts_per_page 50 + @default_categories ["article", "aside", "page", "picture"] + + def get_project_metadata(project_id) do + project = Projects.get_project!(project_id) + {:ok, load_state(project)} + end + + def update_project_metadata(project_id, attrs) do + project = Projects.get_project!(project_id) + state = load_state(project) + now = System.system_time(:second) + + 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.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}) + |> Repo.update!() + + persist_setting(project_id, "project", stringify_project_metadata(project_metadata), now) + write_project_metadata_files(updated_project, state, project_metadata) + load_state(updated_project) + end) + |> unwrap_transaction() + end + + def add_category(project_id, name) do + update_state(project_id, fn project, state, now -> + categories = + state.categories + |> Kernel.++([String.trim(name)]) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + |> Enum.sort() + + persist_setting(project.id, "categories", %{"categories" => categories}, now) + write_categories_json(project, categories) + %{state | categories: categories} + end) + end + + def remove_category(project_id, name) do + update_state(project_id, fn project, state, now -> + categories = Enum.reject(state.categories, &(&1 == name)) + category_settings = Map.delete(state.category_settings, name) + + persist_setting(project.id, "categories", %{"categories" => categories}, now) + persist_setting(project.id, "category_meta", %{"categories" => category_settings}, now) + write_categories_json(project, categories) + write_category_meta_json(project, category_settings) + %{state | categories: categories, category_settings: category_settings} + end) + end + + def update_category_settings(project_id, category, settings) do + update_state(project_id, fn project, state, now -> + normalized = normalize_category_settings(settings) + category_settings = Map.put(state.category_settings, category, normalized) + + persist_setting(project.id, "category_meta", %{"categories" => category_settings}, now) + write_category_meta_json(project, category_settings) + %{state | category_settings: category_settings} + end) + end + + def set_publishing_preferences(project_id, prefs) do + update_state(project_id, fn project, state, now -> + publishing_preferences = normalize_publishing_preferences(prefs) + persist_setting(project.id, "publishing", publishing_preferences, now) + write_publishing_json(project, publishing_preferences) + %{state | publishing_preferences: publishing_preferences} + end) + end + + def sync_project_metadata_from_filesystem(project_id) 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} + category_meta_from_files = read_json(project, "category-meta.json") || %{"categories" => %{}} + publishing_from_files = read_json(project, "publishing.json") || %{"ssh_mode" => "scp"} + + Repo.transaction(fn -> + updated_project = + project + |> Project.changeset(%{ + name: Map.get(project_metadata_from_files, "name", project.name), + description: Map.get(project_metadata_from_files, "description"), + updated_at: now + }) + |> Repo.update!() + + persist_setting(project_id, "project", project_metadata_from_files, now) + persist_setting(project_id, "categories", categories_from_files, now) + persist_setting(project_id, "category_meta", category_meta_from_files, now) + persist_setting(project_id, "publishing", publishing_from_files, now) + load_state(updated_project) + end) + |> unwrap_transaction() + end + + defp update_state(project_id, updater) do + project = Projects.get_project!(project_id) + state = load_state(project) + now = System.system_time(:second) + + Repo.transaction(fn -> + updater.(project, state, now) + end) + |> unwrap_transaction() + 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"] + + category_settings = + (load_setting(project.id, "category_meta") || %{"categories" => %{}})["categories"] + + publishing_preferences = load_setting(project.id, "publishing") || %{"ssh_mode" => "scp"} + + %{ + name: Map.get(project_metadata, "name", project.name), + description: Map.get(project_metadata, "description"), + 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), + 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), + blog_languages: Map.get(project_metadata, "blog_languages", []), + categories: categories, + category_settings: category_settings, + publishing_preferences: publishing_preferences + } + end + + defp default_project_metadata(project) do + %{ + name: project.name, + description: project.description, + public_url: nil, + main_language: nil, + default_author: nil, + max_posts_per_page: @default_max_posts_per_page, + blogmark_category: nil, + pico_theme: nil, + semantic_similarity_enabled: false, + blog_languages: [] + } + end + + defp normalize_project_metadata_attrs(attrs, project) do + %{ + name: attr(attrs, :name) || project.name, + description: attr(attrs, :description), + public_url: attr(attrs, :public_url), + main_language: attr(attrs, :main_language), + default_author: attr(attrs, :default_author), + max_posts_per_page: attr(attrs, :max_posts_per_page) || @default_max_posts_per_page, + blogmark_category: attr(attrs, :blogmark_category), + pico_theme: attr(attrs, :pico_theme), + semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false, + blog_languages: normalize_string_list(attr(attrs, :blog_languages) || []) + } + end + + defp normalize_category_settings(settings) do + %{ + "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")) + } + end + + defp normalize_publishing_preferences(prefs) do + %{ + "ssh_host" => attr(prefs, :ssh_host), + "ssh_user" => attr(prefs, :ssh_user), + "ssh_remote_path" => attr(prefs, :ssh_remote_path), + "ssh_mode" => attr(prefs, :ssh_mode) || "scp" + } + end + + defp stringify_project_metadata(project_metadata) do + %{ + "name" => project_metadata.name, + "description" => project_metadata.description, + "public_url" => project_metadata.public_url, + "main_language" => project_metadata.main_language, + "default_author" => project_metadata.default_author, + "max_posts_per_page" => project_metadata.max_posts_per_page, + "blogmark_category" => project_metadata.blogmark_category, + "pico_theme" => project_metadata.pico_theme, + "semantic_similarity_enabled" => project_metadata.semantic_similarity_enabled, + "blog_languages" => project_metadata.blog_languages + } + end + + defp write_project_metadata_files(project, state, project_metadata) do + write_project_json(project, stringify_project_metadata(project_metadata)) + write_categories_json(project, state.categories) + write_category_meta_json(project, state.category_settings) + 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_categories_json(project, categories) do + write_json(project, "categories.json", %{"categories" => Enum.sort(categories)}) + end + + defp write_category_meta_json(project, category_settings) do + write_json(project, "category-meta.json", %{"categories" => category_settings}) + end + + defp write_publishing_json(project, publishing_preferences) do + write_json(project, "publishing.json", publishing_preferences) + end + + defp write_json(project, file_name, payload) do + meta_dir = Path.join(Projects.project_data_dir(project), "meta") + :ok = File.mkdir_p(meta_dir) + path = Path.join(meta_dir, file_name) + temp_path = path <> ".tmp" + :ok = File.write(temp_path, Jason.encode!(payload)) + File.rename(temp_path, path) + end + + defp read_json(project, file_name) do + path = Path.join([Projects.project_data_dir(project), "meta", file_name]) + + case File.read(path) do + {:ok, contents} -> Jason.decode!(contents) + {:error, :enoent} -> nil + end + end + + defp load_setting(project_id, suffix) do + case Repo.get(Setting, setting_key(project_id, suffix)) do + nil -> nil + setting -> Jason.decode!(setting.value) + end + end + + defp persist_setting(project_id, suffix, payload, now) do + key = setting_key(project_id, suffix) + setting = Repo.get(Setting, key) || %Setting{} + + setting + |> Setting.changeset(%{key: key, value: Jason.encode!(payload), updated_at: now}) + |> Repo.insert_or_update!() + end + + defp setting_key(project_id, suffix), do: "project:#{project_id}:#{suffix}" + + defp normalize_string_list(values) do + values + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.uniq() + end + + defp unwrap_transaction({:ok, result}), do: {:ok, result} + defp unwrap_transaction({:error, reason}), do: {:error, reason} + + defp attr(attrs, key) do + cond do + Map.has_key?(attrs, key) -> Map.get(attrs, key) + Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key)) + true -> nil + end + end +end diff --git a/lib/bds/scripts.ex b/lib/bds/scripts.ex index e60c585..5b2f84e 100644 --- a/lib/bds/scripts.ex +++ b/lib/bds/scripts.ex @@ -218,7 +218,9 @@ defmodule BDS.Scripts do end defp parse_script_kind(kind) when is_atom(kind), do: kind - defp parse_script_kind(kind), do: String.to_existing_atom(kind) + defp parse_script_kind("macro"), do: :macro + defp parse_script_kind("utility"), do: :utility + defp parse_script_kind("transform"), do: :transform defp list_matching_files(dir, pattern) do if File.dir?(dir) do diff --git a/lib/bds/settings/setting.ex b/lib/bds/settings/setting.ex new file mode 100644 index 0000000..bbcf595 --- /dev/null +++ b/lib/bds/settings/setting.ex @@ -0,0 +1,19 @@ +defmodule BDS.Settings.Setting do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:key, :string, autogenerate: false} + + schema "settings" do + field :value, :string + field :updated_at, :integer + end + + def changeset(setting, attrs) do + setting + |> cast(attrs, [:key, :value, :updated_at], empty_values: [nil]) + |> validate_required([:key, :value, :updated_at]) + end +end diff --git a/lib/bds/sidecar.ex b/lib/bds/sidecar.ex new file mode 100644 index 0000000..6585f37 --- /dev/null +++ b/lib/bds/sidecar.ex @@ -0,0 +1,77 @@ +defmodule BDS.Sidecar do + @moduledoc false + + @list_item_prefix " - " + + def serialize_document(fields) when is_list(fields) do + fields + |> Enum.flat_map(&serialize_field/1) + |> Enum.join("\n") + |> Kernel.<>("\n") + end + + def parse_document(contents) when is_binary(contents) do + {:ok, + contents + |> String.split("\n", trim: true) + |> parse_lines(%{})} + end + + defp serialize_field({_key, nil}), do: [] + defp serialize_field({_key, ""}), do: [] + + defp serialize_field({key, values}) when is_list(values) do + ["#{key}:" | Enum.map(values, &" - #{&1}")] + end + + defp serialize_field({key, value}) when is_boolean(value) do + ["#{key}: #{if(value, do: "true", else: "false")}"] + end + + defp serialize_field({key, value}) do + ["#{key}: #{value}"] + end + + defp parse_lines([], acc), do: acc + + defp parse_lines([line | rest], acc) do + cond do + String.starts_with?(line, @list_item_prefix) -> + parse_lines(rest, acc) + + String.ends_with?(line, ":") -> + key = String.trim_trailing(line, ":") + {items, remaining} = take_list_items(rest, []) + parse_lines(remaining, Map.put(acc, key, Enum.reverse(items))) + + String.contains?(line, ": ") -> + [key, raw_value] = String.split(line, ": ", parts: 2) + parse_lines(rest, Map.put(acc, key, parse_scalar(raw_value))) + + true -> + parse_lines(rest, acc) + end + end + + defp take_list_items([line | rest], items) do + if String.starts_with?(line, @list_item_prefix) do + value = line |> String.replace_prefix(@list_item_prefix, "") |> parse_scalar() + take_list_items(rest, [value | items]) + else + {items, [line | rest]} + end + end + + defp take_list_items([], items), do: {items, []} + + defp parse_scalar("true"), do: true + defp parse_scalar("false"), do: false + + defp parse_scalar(value) do + if Regex.match?(~r/^-?\d+$/, value) do + String.to_integer(value) + else + value + end + end +end diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index 5855eda..c1cdd6d 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -324,7 +324,10 @@ defmodule BDS.Templates do end defp parse_template_kind(kind) when is_atom(kind), do: kind - defp parse_template_kind(kind), do: String.to_existing_atom(kind) + defp parse_template_kind("post"), do: :post + defp parse_template_kind("list"), do: :list + defp parse_template_kind("not_found"), do: :not_found + defp parse_template_kind("partial"), do: :partial defp list_matching_files(dir, pattern) do if File.dir?(dir) do diff --git a/test/bds/maintenance_test.exs b/test/bds/maintenance_test.exs new file mode 100644 index 0000000..ff829de --- /dev/null +++ b/test/bds/maintenance_test.exs @@ -0,0 +1,123 @@ +defmodule BDS.MaintenanceTest do + use ExUnit.Case, async: false + + alias BDS.Repo + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + 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) + + {:ok, project} = BDS.Projects.create_project(%{name: "Maintenance", data_path: temp_dir}) + %{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 + posts_dir = Path.join([temp_dir, "posts", "2026", "04"]) + File.mkdir_p!(posts_dir) + + File.write!( + Path.join(posts_dir, "dispatch-post.md"), + [ + "---", + "id: dispatch-post", + "title: Dispatch Post", + "slug: dispatch-post", + "status: published", + "created_at: 1711843200", + "updated_at: 1711929600", + "published_at: 1712016000", + "tags:", + "categories:", + "---", + "Body", + "" + ] + |> Enum.join("\n") + ) + + media_dir = Path.join([temp_dir, "media", "2026", "04"]) + File.mkdir_p!(media_dir) + File.write!(Path.join(media_dir, "asset.txt"), "hello media") + + File.write!( + Path.join(media_dir, "asset.txt.meta"), + [ + "id: dispatch-media", + "original_name: original.txt", + "mime_type: text/plain", + "size: 11", + "created_at: 1711843200", + "updated_at: 1711929600", + "tags:", + "" + ] + |> Enum.join("\n") + ) + + template_dir = Path.join(temp_dir, "templates") + File.mkdir_p!(template_dir) + File.write!( + Path.join(template_dir, "dispatch-view.liquid"), + [ + "---", + "id: dispatch-template", + "slug: dispatch-view", + "title: Dispatch View", + "kind: list", + "enabled: true", + "version: 1", + "created_at: 101", + "updated_at: 202", + "---", + "
Template
", + "" + ] + |> Enum.join("\n") + ) + + script_dir = Path.join(temp_dir, "scripts") + File.mkdir_p!(script_dir) + File.write!( + Path.join(script_dir, "dispatch.lua"), + [ + "---", + "id: dispatch-script", + "slug: dispatch", + "title: Dispatch Script", + "kind: utility", + "entrypoint: main", + "enabled: true", + "version: 1", + "created_at: 301", + "updated_at: 404", + "---", + "function main() return true end", + "" + ] + |> Enum.join("\n") + ) + + assert {:ok, posts} = BDS.Maintenance.rebuild_from_filesystem(project.id, "post") + assert length(posts) == 1 + + assert {:ok, media_items} = BDS.Maintenance.rebuild_from_filesystem(project.id, "media") + assert length(media_items) == 1 + + assert {:ok, scripts} = BDS.Maintenance.rebuild_from_filesystem(project.id, "script") + assert length(scripts) == 1 + + assert {:ok, templates} = BDS.Maintenance.rebuild_from_filesystem(project.id, "template") + assert length(templates) == 1 + + assert Repo.get(BDS.Posts.Post, "dispatch-post") != nil + assert Repo.get(BDS.Media.Media, "dispatch-media") != nil + assert Repo.get(BDS.Scripts.Script, "dispatch-script") != nil + assert Repo.get(BDS.Templates.Template, "dispatch-template") != nil + 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") + end +end diff --git a/test/bds/media_test.exs b/test/bds/media_test.exs new file mode 100644 index 0000000..0f59a0c --- /dev/null +++ b/test/bds/media_test.exs @@ -0,0 +1,142 @@ +defmodule BDS.MediaTest do + use ExUnit.Case, async: false + + alias BDS.Repo + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = Path.join(System.tmp_dir!(), "bds-media-#{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: "Media", data_path: temp_dir}) + %{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 + 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, + title: "Sample", + alt: "Alt text", + caption: "Caption", + author: "Writer", + language: "en", + tags: ["alpha"] + }) + + assert media.original_name == "sample.txt" + assert media.mime_type == "text/plain" + assert media.size == byte_size("hello media") + assert media.tags == ["alpha"] + assert media.file_path =~ ~r/^media\/\d{4}\/\d{2}\/.+\.txt$/ + assert media.sidecar_path == media.file_path <> ".meta" + + assert File.read!(Path.join(temp_dir, media.file_path)) == "hello media" + + sidecar = File.read!(Path.join(temp_dir, media.sidecar_path)) + assert sidecar =~ "id: #{media.id}\n" + assert sidecar =~ "original_name: sample.txt\n" + assert sidecar =~ "mime_type: text/plain\n" + assert sidecar =~ "title: Sample\n" + assert sidecar =~ "alt: Alt text\n" + assert sidecar =~ "caption: Caption\n" + assert sidecar =~ "author: Writer\n" + assert sidecar =~ "language: en\n" + assert sidecar =~ "tags:\n - alpha\n" + end + + test "update_media rewrites the sidecar metadata", %{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, updated} = + BDS.Media.update_media(media.id, %{ + title: "Updated", + alt: "Updated alt", + tags: ["beta"], + language: "de" + }) + + assert updated.title == "Updated" + assert updated.alt == "Updated alt" + assert updated.tags == ["beta"] + assert updated.language == "de" + + sidecar = File.read!(Path.join(temp_dir, updated.sidecar_path)) + assert sidecar =~ "title: Updated\n" + assert sidecar =~ "alt: Updated alt\n" + assert sidecar =~ "language: de\n" + assert sidecar =~ "tags:\n - beta\n" + end + + 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, :deleted} = BDS.Media.delete_media(media.id) + assert Repo.get(BDS.Media.Media, media.id) == nil + refute File.exists?(Path.join(temp_dir, media.file_path)) + refute File.exists?(Path.join(temp_dir, media.sidecar_path)) + end + + 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) + + binary_path = Path.join(media_dir, "asset.txt") + sidecar_path = binary_path <> ".meta" + + File.write!(binary_path, "hello media") + + File.write!( + sidecar_path, + [ + "id: media-from-file", + "original_name: original.txt", + "mime_type: text/plain", + "size: 11", + "width: 0", + "height: 0", + "title: Recovered", + "alt: Recovered alt", + "caption: Recovered caption", + "author: Writer", + "language: en", + "created_at: 1711843200", + "updated_at: 1711929600", + "tags:", + " - alpha", + "" + ] + |> Enum.join("\n") + ) + + assert {:ok, media_items} = BDS.Media.rebuild_media_from_files(project.id) + assert length(media_items) == 1 + + [media] = media_items + assert media.id == "media-from-file" + assert media.project_id == project.id + assert media.filename == "asset.txt" + assert media.original_name == "original.txt" + assert media.mime_type == "text/plain" + assert media.size == 11 + assert media.title == "Recovered" + assert media.alt == "Recovered alt" + assert media.caption == "Recovered caption" + assert media.author == "Writer" + assert media.language == "en" + assert media.tags == ["alpha"] + assert media.file_path == "media/2026/04/asset.txt" + assert media.sidecar_path == "media/2026/04/asset.txt.meta" + end +end diff --git a/test/bds/metadata_test.exs b/test/bds/metadata_test.exs new file mode 100644 index 0000000..36b8b1b --- /dev/null +++ b/test/bds/metadata_test.exs @@ -0,0 +1,115 @@ +defmodule BDS.MetadataTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = Path.join(System.tmp_dir!(), "bds-metadata-#{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: "Metadata", data_path: temp_dir}) + %{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 + assert {:ok, metadata} = + BDS.Metadata.update_project_metadata(project.id, %{ + name: "Renamed Blog", + description: "Description", + public_url: "https://example.com", + main_language: "en", + default_author: "Writer", + max_posts_per_page: 25, + blogmark_category: "links", + pico_theme: "blue", + semantic_similarity_enabled: true, + blog_languages: ["de", "fr"] + }) + + assert metadata.name == "Renamed Blog" + assert metadata.max_posts_per_page == 25 + assert metadata.blog_languages == ["de", "fr"] + + project_json_path = Path.join([temp_dir, "meta", "project.json"]) + + assert %{ + "name" => "Renamed Blog", + "description" => "Description", + "public_url" => "https://example.com", + "main_language" => "en", + "default_author" => "Writer", + "max_posts_per_page" => 25, + "blogmark_category" => "links", + "pico_theme" => "blue", + "semantic_similarity_enabled" => true, + "blog_languages" => ["de", "fr"] + } = Jason.decode!(File.read!(project_json_path)) + + assert {:ok, loaded} = BDS.Metadata.get_project_metadata(project.id) + assert loaded.name == "Renamed Blog" + assert loaded.public_url == "https://example.com" + 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 + assert {:ok, _metadata} = BDS.Metadata.add_category(project.id, "news") + + assert {:ok, _metadata} = + BDS.Metadata.update_category_settings(project.id, "news", %{ + render_in_lists: false, + show_title: true, + post_template_slug: "article", + list_template_slug: "listing" + }) + + assert {:ok, _metadata} = + BDS.Metadata.set_publishing_preferences(project.id, %{ + ssh_host: "example.com", + ssh_user: "deploy", + ssh_remote_path: "/srv/site", + ssh_mode: "rsync" + }) + + categories_path = Path.join([temp_dir, "meta", "categories.json"]) + category_meta_path = Path.join([temp_dir, "meta", "category-meta.json"]) + publishing_path = Path.join([temp_dir, "meta", "publishing.json"]) + + assert %{"categories" => ["article", "aside", "news", "page", "picture"]} = + Jason.decode!(File.read!(categories_path)) + + assert %{ + "categories" => %{ + "news" => %{ + "render_in_lists" => false, + "show_title" => true, + "post_template_slug" => "article", + "list_template_slug" => "listing" + } + } + } = Jason.decode!(File.read!(category_meta_path)) + + assert %{ + "ssh_host" => "example.com", + "ssh_user" => "deploy", + "ssh_remote_path" => "/srv/site", + "ssh_mode" => "rsync" + } = Jason.decode!(File.read!(publishing_path)) + + assert {:ok, synced} = BDS.Metadata.sync_project_metadata_from_filesystem(project.id) + assert synced.categories == ["article", "aside", "news", "page", "picture"] + + assert synced.category_settings["news"] == %{ + "render_in_lists" => false, + "show_title" => true, + "post_template_slug" => "article", + "list_template_slug" => "listing" + } + + assert synced.publishing_preferences == %{ + "ssh_host" => "example.com", + "ssh_user" => "deploy", + "ssh_remote_path" => "/srv/site", + "ssh_mode" => "rsync" + } + end +end