From 6f57728a6d659b1fd90350f14afb1144c1b9a623 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 23 Apr 2026 12:51:59 +0200 Subject: [PATCH] feat:added more persistence code --- lib/bds/posts.ex | 76 ++++++++++++++++++++++++++++++- lib/bds/projects.ex | 38 +++++++++++++++- lib/bds/scripts.ex | 69 ++++++++++++++++++++++++++++ lib/bds/scripts/script.ex | 35 +++++++++++++++ lib/bds/tags.ex | 85 +++++++++++++++++++++++++++++++++++ lib/bds/tags/tag.ex | 28 ++++++++++++ lib/bds/templates.ex | 64 ++++++++++++++++++++++++++ lib/bds/templates/template.ex | 34 ++++++++++++++ test/bds/posts_test.exs | 47 +++++++++++++++++++ test/bds/projects_test.exs | 17 +++++++ test/bds/scripts_test.exs | 37 +++++++++++++++ test/bds/tags_test.exs | 42 +++++++++++++++++ test/bds/templates_test.exs | 31 +++++++++++++ 13 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 lib/bds/scripts.ex create mode 100644 lib/bds/scripts/script.ex create mode 100644 lib/bds/tags.ex create mode 100644 lib/bds/tags/tag.ex create mode 100644 lib/bds/templates.ex create mode 100644 lib/bds/templates/template.ex create mode 100644 test/bds/scripts_test.exs create mode 100644 test/bds/tags_test.exs create mode 100644 test/bds/templates_test.exs diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index 8286183..ba0e57a 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -4,6 +4,7 @@ defmodule BDS.Posts do import Ecto.Query alias BDS.Posts.Post + alias BDS.Projects alias BDS.Repo alias BDS.Slug @@ -65,6 +66,32 @@ defmodule BDS.Posts do end end + def publish_post(post_id) do + case Repo.get(Post, post_id) do + nil -> + {:error, :not_found} + + %Post{} = post -> + project = Projects.get_project!(post.project_id) + published_at = post.published_at || System.system_time(:second) + relative_path = build_post_relative_path(post.slug, post.created_at) + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + :ok = File.mkdir_p(Path.dirname(full_path)) + :ok = File.write(full_path, serialize_post_file(post, published_at)) + + post + |> Post.changeset(%{ + status: :published, + published_at: published_at, + file_path: relative_path, + content: nil, + updated_at: System.system_time(:second) + }) + |> Repo.update() + end + end + defp normalize_updates(attrs, _post) do %{} |> maybe_put(:title, normalize_optional_title(attr(attrs, :title), attrs)) @@ -162,11 +189,58 @@ defmodule BDS.Posts do defp default_slug_source(""), do: "untitled" defp default_slug_source(title), do: title + defp build_post_relative_path(slug, created_at) do + datetime = DateTime.from_unix!(created_at) + year = Integer.to_string(datetime.year) + month = datetime.month |> Integer.to_string() |> String.pad_leading(2, "0") + Path.join(["posts", year, month, "#{slug}.md"]) + end + + defp serialize_post_file(post, published_at) do + frontmatter_lines = [ + "id: #{post.id}", + "title: #{post.title}", + "slug: #{post.slug}", + maybe_line("excerpt", post.excerpt), + "status: published", + maybe_line("author", post.author), + maybe_line("language", post.language), + maybe_boolean_line("do_not_translate", post.do_not_translate), + maybe_line("template_slug", post.template_slug), + "created_at: #{post.created_at}", + "updated_at: #{post.updated_at}", + "published_at: #{published_at}", + list_lines("tags", post.tags), + list_lines("categories", post.categories) + ] + |> List.flatten() + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + + ["---", frontmatter_lines, "---", post.content || "", ""] + |> Enum.join("\n") + end + + defp list_lines(label, items) do + ["#{label}:" | Enum.map(items || [], &" - #{&1}")] + end + + defp maybe_line(_label, nil), do: nil + defp maybe_line(_label, ""), do: nil + defp maybe_line(label, value), do: "#{label}: #{value}" + + defp maybe_boolean_line(_label, false), do: nil + defp maybe_boolean_line(label, true), do: "#{label}: true" + defp has_attr?(attrs, key) do Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) end defp attr(attrs, key) do - Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + 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/projects.ex b/lib/bds/projects.ex index 9d3efac..eab765a 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -8,12 +8,44 @@ defmodule BDS.Projects do alias BDS.Repo alias BDS.Slug + @default_project_id "default" + @default_project_name "My Blog" + def list_projects do Repo.all(from project in Project, order_by: [asc: project.created_at]) end + def get_project(id), do: Repo.get(Project, id) def get_project!(id), do: Repo.get!(Project, id) + def ensure_default_project do + case Repo.get(Project, @default_project_id) do + %Project{} = project -> + {:ok, project} + + nil -> + now = System.system_time(:second) + is_active = not Repo.exists?(from project in Project, where: project.is_active == true) + + %Project{} + |> Project.changeset(%{ + id: @default_project_id, + name: @default_project_name, + slug: unique_slug(Slug.slugify(@default_project_name)), + description: nil, + data_path: nil, + created_at: now, + updated_at: now, + is_active: is_active + }) + |> Repo.insert() + end + end + + def project_data_dir(%Project{} = project) do + project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__) + end + def create_project(attrs) do now = System.system_time(:second) name = attr(attrs, :name) || "" @@ -79,6 +111,10 @@ defmodule BDS.Projects do end defp attr(attrs, key) do - Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + 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 new file mode 100644 index 0000000..f234109 --- /dev/null +++ b/lib/bds/scripts.ex @@ -0,0 +1,69 @@ +defmodule BDS.Scripts do + @moduledoc false + + import Ecto.Query + + alias BDS.Repo + alias BDS.Scripts.Script + alias BDS.Slug + + def create_script(attrs) do + now = System.system_time(:second) + project_id = attr(attrs, :project_id) + title = attr(attrs, :title) || "" + kind = attr(attrs, :kind) + + %Script{} + |> Script.changeset(%{ + id: Ecto.UUID.generate(), + project_id: project_id, + slug: unique_slug(project_id, Slug.slugify(title), "script"), + title: title, + kind: kind, + entrypoint: attr(attrs, :entrypoint) || default_entrypoint(kind), + enabled: true, + version: 1, + file_path: "", + status: :draft, + content: attr(attrs, :content), + created_at: now, + updated_at: now + }) + |> Repo.insert() + end + + defp default_entrypoint(:macro), do: "render" + defp default_entrypoint(_kind), do: "main" + + defp unique_slug(project_id, base_slug, fallback) do + normalized = if base_slug in [nil, ""], do: fallback, else: base_slug + + if slug_available?(project_id, normalized) do + normalized + else + find_unique_slug(project_id, normalized, 2) + end + end + + defp find_unique_slug(project_id, base_slug, suffix) do + candidate = "#{base_slug}-#{suffix}" + + if slug_available?(project_id, candidate) do + candidate + else + find_unique_slug(project_id, base_slug, suffix + 1) + end + end + + defp slug_available?(project_id, slug) do + not Repo.exists?(from script in Script, where: script.project_id == ^project_id and script.slug == ^slug) + end + + 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/script.ex b/lib/bds/scripts/script.ex new file mode 100644 index 0000000..078442f --- /dev/null +++ b/lib/bds/scripts/script.ex @@ -0,0 +1,35 @@ +defmodule BDS.Scripts.Script do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :string, autogenerate: false} + @foreign_key_type :string + + schema "scripts" do + field :slug, :string + field :title, :string + field :kind, Ecto.Enum, values: [:macro, :utility, :transform] + field :entrypoint, :string + field :enabled, :boolean, default: true + field :version, :integer, default: 1 + field :file_path, :string, default: "" + field :status, Ecto.Enum, values: [:draft, :published], default: :draft + field :content, :string + field :created_at, :integer + field :updated_at, :integer + + belongs_to :project, BDS.Projects.Project, type: :string + end + + 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], + empty_values: [nil] + ) + |> 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 +end diff --git a/lib/bds/tags.ex b/lib/bds/tags.ex new file mode 100644 index 0000000..0f2f6b0 --- /dev/null +++ b/lib/bds/tags.ex @@ -0,0 +1,85 @@ +defmodule BDS.Tags do + @moduledoc false + + import Ecto.Query + + alias BDS.Projects + alias BDS.Repo + alias BDS.Tags.Tag + + def create_tag(attrs) do + project_id = attr(attrs, :project_id) + name = attr(attrs, :name) |> to_string() |> String.trim() + + with :ok <- validate_unique_name(project_id, name) do + now = System.system_time(:second) + + %Tag{} + |> Tag.changeset(%{ + id: Ecto.UUID.generate(), + project_id: project_id, + name: name, + color: attr(attrs, :color), + post_template_slug: attr(attrs, :post_template_slug), + created_at: now, + updated_at: now + }) + |> Repo.insert() + |> case do + {:ok, tag} -> + write_tags_json(project_id) + {:ok, tag} + + error -> + error + end + end + end + + def list_tags(project_id) do + Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name]) + end + + defp write_tags_json(project_id) do + project = Projects.get_project!(project_id) + path = Path.join([Projects.project_data_dir(project), "meta", "tags.json"]) + :ok = File.mkdir_p(Path.dirname(path)) + + payload = %{ + "tags" => + project_id + |> list_tags() + |> Enum.sort_by(&String.downcase(&1.name)) + |> Enum.map(fn tag -> + %{"name" => tag.name} + |> maybe_put("color", tag.color) + |> maybe_put("post_template_slug", tag.post_template_slug) + end) + } + + File.write!(path, Jason.encode!(payload)) + 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 + {:error, + %Tag{} + |> 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 + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, _key, ""), 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/tags/tag.ex b/lib/bds/tags/tag.ex new file mode 100644 index 0000000..a4b46bf --- /dev/null +++ b/lib/bds/tags/tag.ex @@ -0,0 +1,28 @@ +defmodule BDS.Tags.Tag do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :string, autogenerate: false} + @foreign_key_type :string + + schema "tags" do + field :name, :string + field :color, :string + field :post_template_slug, :string + field :created_at, :integer + field :updated_at, :integer + + belongs_to :project, BDS.Projects.Project, type: :string + end + + def changeset(tag, attrs) do + tag + |> 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]) + |> assoc_constraint(:project) + end +end diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex new file mode 100644 index 0000000..c9b1f77 --- /dev/null +++ b/lib/bds/templates.ex @@ -0,0 +1,64 @@ +defmodule BDS.Templates do + @moduledoc false + + import Ecto.Query + + alias BDS.Repo + alias BDS.Slug + alias BDS.Templates.Template + + def create_template(attrs) do + now = System.system_time(:second) + project_id = attr(attrs, :project_id) + title = attr(attrs, :title) || "" + + %Template{} + |> Template.changeset(%{ + id: Ecto.UUID.generate(), + project_id: project_id, + slug: unique_slug(project_id, Slug.slugify(title), "template"), + title: title, + kind: attr(attrs, :kind), + enabled: true, + version: 1, + file_path: "", + status: :draft, + content: attr(attrs, :content), + created_at: now, + updated_at: now + }) + |> Repo.insert() + end + + defp unique_slug(project_id, base_slug, fallback) do + normalized = if base_slug in [nil, ""], do: fallback, else: base_slug + + if slug_available?(project_id, normalized) do + normalized + else + find_unique_slug(project_id, normalized, 2) + end + end + + defp find_unique_slug(project_id, base_slug, suffix) do + candidate = "#{base_slug}-#{suffix}" + + if slug_available?(project_id, candidate) do + candidate + else + find_unique_slug(project_id, base_slug, suffix + 1) + end + end + + defp slug_available?(project_id, slug) do + not Repo.exists?(from template in Template, where: template.project_id == ^project_id and template.slug == ^slug) + end + + 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/templates/template.ex b/lib/bds/templates/template.ex new file mode 100644 index 0000000..c220b6b --- /dev/null +++ b/lib/bds/templates/template.ex @@ -0,0 +1,34 @@ +defmodule BDS.Templates.Template do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :string, autogenerate: false} + @foreign_key_type :string + + schema "templates" do + field :slug, :string + field :title, :string + field :kind, Ecto.Enum, values: [:post, :list, :not_found, :partial] + field :enabled, :boolean, default: true + field :version, :integer, default: 1 + field :file_path, :string, default: "" + field :status, Ecto.Enum, values: [:draft, :published], default: :draft + field :content, :string + field :created_at, :integer + field :updated_at, :integer + + belongs_to :project, BDS.Projects.Project, type: :string + end + + def changeset(template, attrs) do + template + |> 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]) + |> assoc_constraint(:project) + |> unique_constraint(:slug, name: :templates_project_slug_idx) + end +end diff --git a/test/bds/posts_test.exs b/test/bds/posts_test.exs index 44a9391..6e8d792 100644 --- a/test/bds/posts_test.exs +++ b/test/bds/posts_test.exs @@ -87,6 +87,53 @@ defmodule BDS.PostsTest do assert reopened.updated_at >= published.updated_at 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])}") + 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, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Published Post", + excerpt: "Summary", + content: "Hello from markdown", + tags: ["alpha"], + categories: ["notes"], + author: "Writer", + language: "en", + template_slug: "article" + }) + + assert {:ok, post} = BDS.Posts.update_post(post.id, %{do_not_translate: true}) + assert {:ok, published} = BDS.Posts.publish_post(post.id) + + assert published.status == :published + assert published.content == nil + assert published.file_path =~ ~r/^posts\/\d{4}\/\d{2}\/published-post\.md$/ + assert is_integer(published.published_at) + + full_path = Path.join(temp_dir, published.file_path) + assert File.exists?(full_path) + + file_contents = File.read!(full_path) + + assert file_contents =~ "---\nid: #{published.id}\n" + assert file_contents =~ "title: Published Post\n" + assert file_contents =~ "slug: published-post\n" + assert file_contents =~ "status: published\n" + assert file_contents =~ "excerpt: Summary\n" + assert file_contents =~ "author: Writer\n" + assert file_contents =~ "language: en\n" + assert file_contents =~ "do_not_translate: true\n" + assert file_contents =~ "template_slug: article\n" + assert file_contents =~ "tags:\n - alpha\n" + assert file_contents =~ "categories:\n - notes\n" + assert file_contents =~ "\n---\nHello from markdown\n" + end + defp errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> diff --git a/test/bds/projects_test.exs b/test/bds/projects_test.exs index 48b26d9..dc11336 100644 --- a/test/bds/projects_test.exs +++ b/test/bds/projects_test.exs @@ -1,6 +1,9 @@ defmodule BDS.ProjectsTest do use ExUnit.Case, async: false + alias BDS.Projects.Project + alias BDS.Repo + setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) end @@ -37,4 +40,18 @@ defmodule BDS.ProjectsTest do assert refetched_second.is_active == true assert Enum.count(BDS.Projects.list_projects(), & &1.is_active) == 1 end + + test "ensure_default_project creates the default project once and keeps it active" do + Repo.delete_all(Project) + + assert {:ok, default_project} = BDS.Projects.ensure_default_project() + assert default_project.id == "default" + assert default_project.name == "My Blog" + assert default_project.slug == "my-blog" + assert default_project.is_active == true + + assert {:ok, same_project} = BDS.Projects.ensure_default_project() + assert same_project.id == default_project.id + assert Repo.aggregate(Project, :count, :id) == 1 + end end diff --git a/test/bds/scripts_test.exs b/test/bds/scripts_test.exs new file mode 100644 index 0000000..a228e50 --- /dev/null +++ b/test/bds/scripts_test.exs @@ -0,0 +1,37 @@ +defmodule BDS.ScriptsTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + {:ok, project} = BDS.Projects.create_project(%{name: "Scripts"}) + %{project: project} + end + + test "create_script creates draft scripts with default entrypoints by kind", %{project: project} do + assert {:ok, utility} = + BDS.Scripts.create_script(%{ + project_id: project.id, + title: "Importer", + kind: :utility, + content: "function main() end" + }) + + assert utility.slug == "importer" + assert utility.entrypoint == "main" + assert utility.status == :draft + assert utility.enabled == true + assert utility.version == 1 + assert utility.file_path == "" + + assert {:ok, macro_script} = + BDS.Scripts.create_script(%{ + project_id: project.id, + title: "Render Card", + kind: :macro, + content: "function render() end" + }) + + assert macro_script.entrypoint == "render" + assert macro_script.slug == "render-card" + end +end diff --git a/test/bds/tags_test.exs b/test/bds/tags_test.exs new file mode 100644 index 0000000..142d9bc --- /dev/null +++ b/test/bds/tags_test.exs @@ -0,0 +1,42 @@ +defmodule BDS.TagsTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = Path.join(System.tmp_dir!(), "bds-tags-#{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: "Tags", data_path: temp_dir}) + %{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"}) + assert {:ok, alpha} = BDS.Tags.create_tag(%{project_id: project.id, name: "Alpha"}) + + assert zebra.name == "Zebra" + assert zebra.color == "#000000" + assert alpha.color == nil + + tags_path = Path.join([temp_dir, "meta", "tags.json"]) + assert File.exists?(tags_path) + + assert %{"tags" => [%{"name" => "Alpha"}, %{"color" => "#000000", "name" => "Zebra"}]} = + Jason.decode!(File.read!(tags_path)) + end + + test "create_tag rejects case-insensitive duplicates per project", %{project: project} do + assert {:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "Elixir"}) + assert {:error, changeset} = BDS.Tags.create_tag(%{project_id: project.id, name: "elixir"}) + assert "has already been taken" in errors_on(changeset).name + end + + defp errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/bds/templates_test.exs b/test/bds/templates_test.exs new file mode 100644 index 0000000..0b826e3 --- /dev/null +++ b/test/bds/templates_test.exs @@ -0,0 +1,31 @@ +defmodule BDS.TemplatesTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + {:ok, project} = BDS.Projects.create_project(%{name: "Templates"}) + %{project: project} + end + + test "create_template creates a draft template with slug deduplication", %{project: project} do + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Article View", + kind: :post, + content: "
{{ content }}
" + }) + + assert template.slug == "article-view" + assert template.status == :draft + assert template.enabled == true + assert template.version == 1 + assert template.file_path == "" + assert template.content == "
{{ content }}
" + + assert {:ok, duplicate} = + BDS.Templates.create_template(%{project_id: project.id, title: "Article View", kind: :post, content: "x"}) + + assert duplicate.slug == "article-view-2" + end +end