feat: tag deletion and other elements

This commit is contained in:
2026-04-23 14:34:20 +02:00
parent 2062cd0df7
commit 2833c99c1c
7 changed files with 460 additions and 6 deletions

View File

@@ -1,6 +1,8 @@
defmodule BDS.Frontmatter do defmodule BDS.Frontmatter do
@moduledoc false @moduledoc false
@list_item_prefix " - "
def serialize_document(fields, body) when is_list(fields) do def serialize_document(fields, body) when is_list(fields) do
frontmatter = frontmatter =
fields fields
@@ -11,6 +13,22 @@ defmodule BDS.Frontmatter do
|> Enum.join("\n") |> Enum.join("\n")
end end
def parse_document(contents) when is_binary(contents) do
case String.split(contents, "\n---\n", parts: 2) do
[frontmatter_with_marker, body] ->
frontmatter = String.replace_prefix(frontmatter_with_marker, "---\n", "")
{:ok,
%{
fields: parse_frontmatter(frontmatter),
body: String.trim_trailing(body, "\n")
}}
_parts ->
{:error, :invalid_frontmatter}
end
end
defp serialize_field({_key, nil}), do: [] defp serialize_field({_key, nil}), do: []
defp serialize_field({_key, ""}), do: [] defp serialize_field({_key, ""}), do: []
defp serialize_field({_key, false}), do: [] defp serialize_field({_key, false}), do: []
@@ -26,4 +44,53 @@ defmodule BDS.Frontmatter do
defp serialize_field({key, value}) do defp serialize_field({key, value}) do
["#{key}: #{value}"] ["#{key}: #{value}"]
end end
defp parse_frontmatter(frontmatter) do
frontmatter
|> String.split("\n", trim: true)
|> parse_lines(%{})
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 end

View File

@@ -104,6 +104,19 @@ defmodule BDS.Scripts do
end end
end end
def rebuild_scripts_from_files(project_id) do
project = Projects.get_project!(project_id)
scripts =
project
|> Projects.project_data_dir()
|> Path.join("scripts")
|> list_matching_files("*.lua")
|> Enum.map(&upsert_script_from_file(project_id, project, &1))
{:ok, scripts}
end
defp default_entrypoint(:macro), do: "render" defp default_entrypoint(:macro), do: "render"
defp default_entrypoint(_kind), do: "main" defp default_entrypoint(_kind), do: "main"
@@ -175,6 +188,48 @@ defmodule BDS.Scripts do
end end
end end
defp upsert_script_from_file(project_id, project, path) do
contents = File.read!(path)
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
relative_path = Path.relative_to(path, Projects.project_data_dir(project))
now = System.system_time(:second)
attrs = %{
id: Map.get(fields, "id") || Ecto.UUID.generate(),
project_id: project_id,
slug: Map.fetch!(fields, "slug"),
title: Map.get(fields, "title") || "",
kind: parse_script_kind(Map.fetch!(fields, "kind")),
entrypoint: Map.get(fields, "entrypoint") || "main",
enabled: Map.get(fields, "enabled", true),
version: Map.get(fields, "version", 1),
file_path: relative_path,
status: :published,
content: nil,
created_at: Map.get(fields, "created_at", now),
updated_at: Map.get(fields, "updated_at", now)
}
script = Repo.get_by(Script, project_id: project_id, slug: attrs.slug) || %Script{}
script
|> Script.changeset(attrs)
|> Repo.insert_or_update!()
end
defp parse_script_kind(kind) when is_atom(kind), do: kind
defp parse_script_kind(kind), do: String.to_existing_atom(kind)
defp list_matching_files(dir, pattern) do
if File.dir?(dir) do
Path.join(dir, pattern)
|> Path.wildcard()
|> Enum.sort()
else
[]
end
end
defp maybe_put(map, _key, nil), do: map defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value) defp maybe_put(map, key, value), do: Map.put(map, key, value)

View File

@@ -73,6 +73,30 @@ defmodule BDS.Tags do
end end
end end
def delete_tag(tag_id) do
case Repo.get(Tag, tag_id) do
nil ->
{:error, :not_found}
tag ->
Repo.transaction(fn ->
affected_posts = posts_with_tag(tag.project_id, tag.name)
Enum.each(affected_posts, fn post ->
updated_tags = Enum.reject(post.tags || [], &(&1 == tag.name))
update_post_tags(post, updated_tags)
end)
Repo.delete!(tag)
write_tags_json(tag.project_id)
end)
|> case do
{:ok, _} -> {:ok, :deleted}
{:error, reason} -> {:error, reason}
end
end
end
def rename_tag(tag_id, new_name) do def rename_tag(tag_id, new_name) do
case Repo.get(Tag, tag_id) do case Repo.get(Tag, tag_id) do
nil -> nil ->

View File

@@ -4,9 +4,11 @@ defmodule BDS.Templates do
import Ecto.Query import Ecto.Query
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Posts
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Slug alias BDS.Slug
alias BDS.Tags
alias BDS.Templates.Template alias BDS.Templates.Template
def create_template(attrs) do def create_template(attrs) do
@@ -71,24 +73,58 @@ defmodule BDS.Templates do
end 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) now = System.system_time(:second)
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 = %{} updates = %{}
|> maybe_put(:title, attr(attrs, :title)) |> maybe_put(:title, attr(attrs, :title))
|> maybe_put(:kind, attr(attrs, :kind)) |> maybe_put(:kind, attr(attrs, :kind))
|> maybe_put(:enabled, attr(attrs, :enabled)) |> maybe_put(:enabled, attr(attrs, :enabled))
|> maybe_put(:content, attr(attrs, :content)) |> maybe_put(:content, attr(attrs, :content))
|> Map.put(:file_path, next_file_path)
|> Map.put(:slug, next_slug) |> Map.put(:slug, next_slug)
|> Map.put(:version, template.version + 1) |> Map.put(:version, template.version + 1)
|> Map.put(:updated_at, now) |> Map.put(:updated_at, now)
|> maybe_put(:status, if(template.status == :published and content_changed?, do: :draft, else: nil)) |> Map.put(:status, next_status)
template Repo.transaction(fn ->
|> Template.changeset(updates) updated_template =
|> Repo.update() template
|> Template.changeset(updates)
|> Repo.update!()
if slug_changed? do
cascade_template_slug_change(template, updated_template, now)
end
if template.file_path not in [nil, ""] and next_file_path != template.file_path do
rewrite_template_file(template, updated_template)
end
updated_template
end)
|> case do
{:ok, updated_template} -> {:ok, updated_template}
{:error, reason} -> {:error, reason}
end
end end
end end
def rebuild_templates_from_files(project_id) do
project = Projects.get_project!(project_id)
templates =
project
|> Projects.project_data_dir()
|> Path.join("templates")
|> list_matching_files("*.liquid")
|> Enum.map(&upsert_template_from_file(project_id, project, &1))
{:ok, templates}
end
def delete_template(template_id, opts \\ []) do def delete_template(template_id, opts \\ []) do
case Repo.get(Template, template_id) do case Repo.get(Template, template_id) do
nil -> nil ->
@@ -154,6 +190,9 @@ defmodule BDS.Templates do
Path.join(Projects.project_data_dir(project), relative_path) Path.join(Projects.project_data_dir(project), relative_path)
end end
defp next_template_file_path(%Template{file_path: ""}, _next_slug), do: ""
defp next_template_file_path(%Template{}, next_slug), do: template_file_path(next_slug)
defp serialize_template_file(template, content) do defp serialize_template_file(template, content) do
Frontmatter.serialize_document( Frontmatter.serialize_document(
[ [
@@ -195,14 +234,108 @@ defmodule BDS.Templates do
|> Repo.update_all(set: [post_template_slug: nil, updated_at: now]) |> Repo.update_all(set: [post_template_slug: nil, updated_at: now])
Enum.each(affected_posts, fn post -> Enum.each(affected_posts, fn post ->
BDS.Posts.rewrite_published_post(post.id) Posts.rewrite_published_post(post.id)
end) end)
BDS.Tags.sync_tags_json(template.project_id) Tags.sync_tags_json(template.project_id)
:ok :ok
end end
defp cascade_template_slug_change(original_template, updated_template, updated_at) 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
)
)
from(post in BDS.Posts.Post,
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
)
|> Repo.update_all(set: [post_template_slug: updated_template.slug, updated_at: updated_at])
Enum.each(affected_posts, fn post ->
Posts.rewrite_published_post(post.id)
end)
Tags.sync_tags_json(original_template.project_id)
end
defp rewrite_template_file(original_template, updated_template) do
body = published_template_body(original_template)
new_full_path = full_file_path(updated_template.project_id, updated_template.file_path)
:ok = File.mkdir_p(Path.dirname(new_full_path))
:ok = File.write(new_full_path, serialize_template_file(updated_template, body))
if original_template.file_path != updated_template.file_path do
_ = delete_file_if_present(original_template.project_id, original_template.file_path)
end
:ok
end
defp published_template_body(%Template{content: content}) when is_binary(content), do: content
defp published_template_body(template) do
case File.read(full_file_path(template.project_id, template.file_path)) do
{:ok, contents} ->
case Frontmatter.parse_document(contents) do
{:ok, %{body: body}} -> body
{:error, _reason} -> ""
end
{:error, _reason} ->
""
end
end
defp upsert_template_from_file(project_id, project, path) do
contents = File.read!(path)
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
relative_path = Path.relative_to(path, Projects.project_data_dir(project))
now = System.system_time(:second)
attrs = %{
id: Map.get(fields, "id") || Ecto.UUID.generate(),
project_id: project_id,
slug: Map.fetch!(fields, "slug"),
title: Map.get(fields, "title") || "",
kind: parse_template_kind(Map.fetch!(fields, "kind")),
enabled: Map.get(fields, "enabled", true),
version: Map.get(fields, "version", 1),
file_path: relative_path,
status: :published,
content: nil,
created_at: Map.get(fields, "created_at", now),
updated_at: Map.get(fields, "updated_at", now)
}
template = Repo.get_by(Template, project_id: project_id, slug: attrs.slug) || %Template{}
template
|> Template.changeset(attrs)
|> Repo.insert_or_update!()
end
defp parse_template_kind(kind) when is_atom(kind), do: kind
defp parse_template_kind(kind), do: String.to_existing_atom(kind)
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, file_path) when file_path in [nil, ""], do: :ok defp delete_file_if_present(_project_id, file_path) when file_path in [nil, ""], do: :ok
defp delete_file_if_present(project_id, file_path) do defp delete_file_if_present(project_id, file_path) do

View File

@@ -115,4 +115,48 @@ defmodule BDS.ScriptsTest do
assert Repo.get(Script, published.id) == nil assert Repo.get(Script, published.id) == nil
refute File.exists?(Path.join(temp_dir, published.file_path)) refute File.exists?(Path.join(temp_dir, published.file_path))
end end
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)
file_path = Path.join(script_dir, "recovered.lua")
File.write!(
file_path,
[
"---",
"id: script-from-file",
"slug: recovered",
"title: Recovered Script",
"kind: utility",
"entrypoint: main",
"enabled: true",
"version: 4",
"created_at: 301",
"updated_at: 404",
"---",
"function main() return 'restored' end",
""
]
|> Enum.join("\n")
)
assert {:ok, scripts} = BDS.Scripts.rebuild_scripts_from_files(project.id)
assert length(scripts) == 1
[script] = Repo.all(Script)
assert script.id == "script-from-file"
assert script.slug == "recovered"
assert script.title == "Recovered Script"
assert script.kind == :utility
assert script.entrypoint == "main"
assert script.enabled == true
assert script.version == 4
assert script.status == :published
assert script.file_path == "scripts/recovered.lua"
assert script.content == nil
assert script.created_at == 301
assert script.updated_at == 404
end
end end

View File

@@ -115,6 +115,36 @@ defmodule BDS.TagsTest do
assert %{"tags" => [%{"name" => "Gamma"}]} = Jason.decode!(File.read!(tags_path)) assert %{"tags" => [%{"name" => "Gamma"}]} = Jason.decode!(File.read!(tags_path))
end 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
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"})
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Delete Me",
content: "Body",
tags: ["Alpha", "Beta"]
})
assert {:ok, published_post} = BDS.Posts.publish_post(post.id)
assert {:ok, :deleted} = BDS.Tags.delete_tag(doomed.id)
reloaded_post = Repo.get!(Post, published_post.id)
assert reloaded_post.tags == ["Beta"]
assert Repo.get(BDS.Tags.Tag, doomed.id) == nil
post_path = Path.join(temp_dir, reloaded_post.file_path)
contents = File.read!(post_path)
refute contents =~ " - Alpha\n"
assert contents =~ "tags:\n - Beta\n"
assert contents =~ "\n---\nBody\n"
tags_path = Path.join([temp_dir, "meta", "tags.json"])
assert %{"tags" => [%{"name" => "Beta"}]} = Jason.decode!(File.read!(tags_path))
end
defp errors_on(changeset) do defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key -> Regex.replace(~r"%{(\w+)}", message, fn _, key ->

View File

@@ -142,4 +142,105 @@ defmodule BDS.TemplatesTest do
tags_path = Path.join([temp_dir, "meta", "tags.json"]) tags_path = Path.join([temp_dir, "meta", "tags.json"])
assert %{"tags" => [%{"name" => "Feature"}]} = Jason.decode!(File.read!(tags_path)) assert %{"tags" => [%{"name" => "Feature"}]} = Jason.decode!(File.read!(tags_path))
end end
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,
title: "Article View",
kind: :post,
content: "<article>{{ content }}</article>"
})
assert {:ok, published} = BDS.Templates.publish_template(template.id)
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Uses Template",
content: "Body",
template_slug: published.slug
})
assert {:ok, published_post} = BDS.Posts.publish_post(post.id)
assert {:ok, _tag} =
BDS.Tags.create_tag(%{
project_id: project.id,
name: "Feature",
post_template_slug: published.slug
})
old_template_path = Path.join(temp_dir, published.file_path)
assert File.exists?(old_template_path)
assert {:ok, updated} = BDS.Templates.update_template(published.id, %{slug: "feature-view"})
assert updated.slug == "feature-view"
assert updated.file_path == "templates/feature-view.liquid"
reloaded_post = Repo.get!(Post, published_post.id)
assert reloaded_post.template_slug == "feature-view"
reloaded_tag = Repo.get_by!(Tag, project_id: project.id, name: "Feature")
assert reloaded_tag.post_template_slug == "feature-view"
refute File.exists?(old_template_path)
new_template_path = Path.join(temp_dir, updated.file_path)
assert File.exists?(new_template_path)
template_contents = File.read!(new_template_path)
assert template_contents =~ "slug: feature-view\n"
assert template_contents =~ "\n---\n<article>{{ content }}</article>\n"
post_contents = File.read!(Path.join(temp_dir, reloaded_post.file_path))
assert post_contents =~ "template_slug: feature-view\n"
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
template_dir = Path.join(temp_dir, "templates")
File.mkdir_p!(template_dir)
file_path = Path.join(template_dir, "recovered-view.liquid")
File.write!(
file_path,
[
"---",
"id: template-from-file",
"slug: recovered-view",
"title: Recovered View",
"kind: list",
"enabled: true",
"version: 3",
"created_at: 101",
"updated_at: 202",
"---",
"<section>Recovered</section>",
""
]
|> Enum.join("\n")
)
assert {:ok, templates} = BDS.Templates.rebuild_templates_from_files(project.id)
assert length(templates) == 1
[template] = Repo.all(BDS.Templates.Template)
assert template.id == "template-from-file"
assert template.slug == "recovered-view"
assert template.title == "Recovered View"
assert template.kind == :list
assert template.enabled == true
assert template.version == 3
assert template.status == :published
assert template.file_path == "templates/recovered-view.liquid"
assert template.content == nil
assert template.created_at == 101
assert template.updated_at == 202
end
end end