feat: base work for post translations

This commit is contained in:
2026-04-23 17:33:28 +02:00
parent 485c4b65b7
commit 67615a1afc
3 changed files with 457 additions and 0 deletions

View File

@@ -4,7 +4,9 @@ defmodule BDS.Posts do
import Ecto.Query import Ecto.Query
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Metadata
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.Translation
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Search alias BDS.Search
@@ -111,6 +113,7 @@ defmodule BDS.Posts do
|> Repo.update() |> Repo.update()
|> case do |> case do
{:ok, updated_post} -> {:ok, updated_post} ->
:ok = publish_post_translations(updated_post)
:ok = Search.sync_post(updated_post) :ok = Search.sync_post(updated_post)
{:ok, updated_post} {:ok, updated_post}
@@ -174,6 +177,120 @@ defmodule BDS.Posts do
def get_post!(post_id), do: Repo.get!(Post, post_id) def get_post!(post_id), do: Repo.get!(Post, post_id)
def get_post_translation!(translation_id), do: Repo.get!(Translation, translation_id)
def list_post_translations(post_id) do
{:ok,
Repo.all(
from translation in Translation,
where: translation.translation_for == ^post_id,
order_by: [asc: translation.language]
)}
end
def upsert_post_translation(post_id, language, attrs) do
case Repo.get(Post, post_id) do
nil ->
{:error, :not_found}
%Post{do_not_translate: true} = post ->
{:error,
post
|> Post.changeset(%{})
|> 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{}
updates = normalize_translation_updates(post, translation, normalized_language, attrs, now)
translation
|> Translation.changeset(updates)
|> Repo.insert_or_update()
|> case do
{:ok, saved_translation} ->
:ok = Search.sync_post(post.id)
{:ok, saved_translation}
error ->
error
end
end
end
def delete_post_translation(translation_id) do
case Repo.get(Translation, translation_id) do
nil ->
{:error, :not_found}
%Translation{} = translation ->
:ok = delete_translation_file(translation)
Repo.delete!(translation)
:ok = Search.sync_post(translation.translation_for)
{:ok, :deleted}
end
end
def validate_translations(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
posts =
Repo.all(
from post in Post,
where: post.project_id == ^project_id and post.status == :published,
order_by: [asc: post.created_at, asc: post.slug]
)
translation_languages =
Repo.all(
from translation in Translation,
join: post in Post,
on: post.id == translation.translation_for,
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)
required_languages =
metadata.blog_languages
|> Enum.map(&normalize_language/1)
|> Enum.reject(&(&1 == normalize_language(metadata.main_language)))
|> Enum.uniq()
|> Enum.sort()
missing =
posts
|> Enum.flat_map(fn post ->
available = Map.get(translation_languages, post.id, [])
cond do
post.do_not_translate -> []
true ->
required_languages
|> Enum.reject(&(&1 in available))
|> Enum.map(&%{post_id: post.id, language: &1})
end
end)
do_not_translate_posts =
posts
|> Enum.filter(& &1.do_not_translate)
|> Enum.map(& &1.id)
orphan_files = orphan_translation_files(project_id)
{:ok,
%{
missing: missing,
orphan_files: orphan_files,
do_not_translate_posts: do_not_translate_posts
}}
end
def rewrite_published_post(post_id) do def rewrite_published_post(post_id) do
post = Repo.get!(Post, post_id) post = Repo.get!(Post, post_id)
@@ -408,6 +525,156 @@ defmodule BDS.Posts do
end end
end end
defp normalize_translation_updates(post, %Translation{} = translation, language, attrs, now) do
updates =
%{}
|> maybe_put(:title, attr(attrs, :title))
|> maybe_put(:excerpt, attr(attrs, :excerpt))
|> maybe_put(:content, attr(attrs, :content))
reopened? = translation.status == :published and translation_content_change?(translation, updates)
%{
id: translation.id || Ecto.UUID.generate(),
project_id: post.project_id,
translation_for: post.id,
language: language,
title: Map.get(updates, :title, translation.title),
excerpt: Map.get(updates, :excerpt, translation.excerpt),
content: Map.get(updates, :content, translation.content),
status: if(reopened?, do: :draft, else: translation.status || :draft),
created_at: translation.created_at || now,
updated_at: now,
published_at: translation.published_at,
file_path: translation.file_path || "",
checksum: translation.checksum
}
end
defp translation_content_change?(translation, updates) do
Enum.any?([:title, :excerpt, :content], fn field ->
case Map.fetch(updates, field) do
{:ok, value} -> value != Map.get(translation, field)
:error -> false
end
end)
end
defp publish_post_translations(%Post{} = post) do
Repo.all(from translation in Translation, where: translation.translation_for == ^post.id)
|> Enum.each(fn translation ->
if translation.status == :draft do
publish_translation(post, translation)
end
end)
:ok
end
defp publish_translation(%Post{} = post, %Translation{} = translation) do
project = Projects.get_project!(post.project_id)
published_at = translation.published_at || System.system_time(:second)
relative_path = build_translation_relative_path(post, translation.language)
full_path = Path.join(Projects.project_data_dir(project), relative_path)
updated_at = System.system_time(:second)
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))
translation
|> Translation.changeset(%{
status: :published,
published_at: published_at,
file_path: relative_path,
content: nil,
updated_at: updated_at
})
|> Repo.update!()
:ok
end
defp build_translation_relative_path(post, language) do
datetime = DateTime.from_unix!(post.created_at)
year = Integer.to_string(datetime.year)
month = datetime.month |> Integer.to_string() |> String.pad_leading(2, "0")
Path.join(["posts", year, month, "#{post.slug}.#{language}.md"])
end
defp serialize_translation_file(translation, published_at) do
Frontmatter.serialize_document(
[
{:id, translation.id},
{:translation_for, translation.translation_for},
{:language, translation.language},
{:title, translation.title},
{:excerpt, translation.excerpt},
{:status, :published},
{:created_at, translation.created_at},
{:updated_at, translation.updated_at},
{:published_at, published_at}
],
translation.content || ""
)
end
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
{:ok, contents} ->
case String.split(contents, "\n---\n", parts: 2) do
[_frontmatter, body] -> String.trim_trailing(body, "\n")
_parts -> ""
end
{:error, _reason} ->
""
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{} = translation) do
project = Projects.get_project!(translation.project_id)
full_path = Path.join(Projects.project_data_dir(project), translation.file_path)
case File.rm(full_path) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, reason} -> {:error, reason}
end
end
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))
project
|> Projects.project_data_dir()
|> Path.join("posts")
|> list_matching_files("*.md")
|> Enum.map(&Path.relative_to(&1, Projects.project_data_dir(project)))
|> Enum.filter(&translation_file?/1)
|> Enum.reject(&MapSet.member?(translation_paths, &1))
|> Enum.sort()
end
defp translation_file?(relative_path) do
Regex.match?(~r/\.[a-z]{2}\.md$/i, relative_path)
end
defp normalize_language(nil), do: ""
defp normalize_language(language) do
language
|> to_string()
|> String.downcase()
|> String.split("-", parts: 2)
|> hd()
end
defp has_attr?(attrs, key) do defp has_attr?(attrs, key) do
Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key))
end end

View File

@@ -0,0 +1,48 @@
defmodule BDS.Posts.Translation do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string
@statuses [:draft, :published]
schema "post_translations" do
belongs_to :post, BDS.Posts.Post, foreign_key: :translation_for, references: :id, type: :string
field :project_id, :string
field :language, :string
field :title, :string
field :excerpt, :string
field :content, :string
field :status, Ecto.Enum, values: @statuses, default: :draft
field :created_at, :integer
field :updated_at, :integer
field :published_at, :integer
field :file_path, :string, default: ""
field :checksum, :string
end
def changeset(translation, attrs) do
translation
|> 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, :status, :created_at, :updated_at])
|> foreign_key_constraint(:translation_for)
|> unique_constraint(:language, name: :post_translations_translation_language_idx)
end
end

View File

@@ -0,0 +1,142 @@
defmodule BDS.PostTranslationsTest do
use ExUnit.Case, async: false
alias BDS.Metadata
alias BDS.Posts
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
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)
{:ok, project} = BDS.Projects.create_project(%{name: "Translations", data_path: temp_dir})
%{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
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Canonical Post",
excerpt: "English summary",
content: "Hello world",
language: "en"
})
assert {:ok, translation} =
Posts.upsert_post_translation(post.id, "de", %{
title: "Kanonischer Beitrag",
excerpt: "Deutsche Zusammenfassung",
content: "Hallo Welt"
})
assert translation.translation_for == post.id
assert translation.language == "de"
assert translation.status == :draft
assert translation.file_path == ""
assert translation.content == "Hallo Welt"
assert {:ok, [listed_translation]} = Posts.list_post_translations(post.id)
assert listed_translation.id == translation.id
assert {:ok, _published_post} = Posts.publish_post(post.id)
assert published_translation = Posts.get_post_translation!(translation.id)
assert published_translation.status == :published
assert is_integer(published_translation.published_at)
assert published_translation.content == nil
assert published_translation.file_path =~ ~r/^posts\/\d{4}\/\d{2}\/canonical-post\.de\.md$/
translation_path = Path.join(temp_dir, published_translation.file_path)
assert File.exists?(translation_path)
translation_contents = File.read!(translation_path)
assert translation_contents =~ "title: Kanonischer Beitrag\n"
assert translation_contents =~ "language: de\n"
assert translation_contents =~ "status: published\n"
assert translation_contents =~ "\n---\nHallo Welt\n"
assert {:ok, reopened_translation} =
Posts.upsert_post_translation(post.id, "de", %{
title: "Neu formuliert",
excerpt: "Aktualisiert",
content: "Neuer Entwurf"
})
assert reopened_translation.status == :draft
assert reopened_translation.title == "Neu formuliert"
assert reopened_translation.excerpt == "Aktualisiert"
assert reopened_translation.content == "Neuer Entwurf"
assert reopened_translation.updated_at >= published_translation.updated_at
assert {:ok, :deleted} = Posts.delete_post_translation(reopened_translation.id)
refute File.exists?(translation_path)
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
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en", "de", "fr"]
})
assert {:ok, translatable_post} =
Posts.create_post(%{
project_id: project.id,
title: "Needs French",
content: "Body",
language: "en"
})
assert {:ok, _translation} =
Posts.upsert_post_translation(translatable_post.id, "de", %{
title: "Braucht Französisch",
content: "Inhalt"
})
assert {:ok, _published} = Posts.publish_post(translatable_post.id)
assert {:ok, ignored_post} =
Posts.create_post(%{
project_id: project.id,
title: "Skip Me",
content: "Body",
language: "en"
})
assert {:ok, ignored_post} = Posts.update_post(ignored_post.id, %{do_not_translate: true})
assert {:ok, _published_ignored_post} = Posts.publish_post(ignored_post.id)
orphan_dir = Path.join([temp_dir, "posts", "2026", "04"])
File.mkdir_p!(orphan_dir)
orphan_path = Path.join(orphan_dir, "orphan.fr.md")
File.write!(
orphan_path,
[
"---",
"id: orphan-translation",
"translation_for: missing-post",
"language: fr",
"title: Orpheline",
"status: published",
"created_at: 1711843200",
"updated_at: 1711929600",
"published_at: 1712016000",
"---",
"Texte orphelin",
""
]
|> Enum.join("\n")
)
assert {:ok, report} = Posts.validate_translations(project.id)
assert report.missing == [%{post_id: translatable_post.id, language: "fr"}]
assert report.orphan_files == ["posts/2026/04/orphan.fr.md"]
assert report.do_not_translate_posts == [ignored_post.id]
end
end