feat: PLAN step 2 done

This commit is contained in:
2026-04-25 22:06:34 +02:00
parent 2991edf4cf
commit 2b1aca4143
5 changed files with 477 additions and 3 deletions

View File

@@ -49,8 +49,8 @@ The remaining work needs to proceed from base contracts upward. Later phases sho
1. Lock compatibility contracts. Completed 2026-04-25.
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
2. Close engine-level behavior gaps.
Finish any remaining save/publish/delete side-effects, translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications so backend behavior is fully spec-complete independent of UI.
2. Close engine-level behavior gaps. Completed 2026-04-25.
Save/publish/delete side-effects, manual-translation source-post reopening, post-to-media sidecar cleanup, auto-translation task cascades, linked-media translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications are now implemented and covered at the backend layer independent of UI.
3. Finish the desktop shell primitives.
Complete route state, shell command coverage, panel integration, and menu wiring for every sidebar view and editor route so the shell exposes the entire product surface cleanly.

View File

@@ -101,6 +101,18 @@ defmodule BDS.Media do
end
end
def sync_media_sidecar(media_id) do
case Repo.get(Media, media_id) do
nil ->
{:error, :not_found}
media ->
project = Projects.get_project!(media.project_id)
:ok = write_sidecar(project, media)
:ok
end
end
def delete_media(media_id) do
case Repo.get(Media, media_id) do
nil ->

View File

@@ -5,6 +5,8 @@ defmodule BDS.Posts do
alias BDS.Frontmatter
alias BDS.Embeddings
alias BDS.AI
alias BDS.Media
alias BDS.Metadata
alias BDS.Persistence
alias BDS.PostLinks
@@ -16,6 +18,7 @@ defmodule BDS.Posts do
alias BDS.Repo
alias BDS.Search
alias BDS.Slug
alias BDS.Tasks
def create_post(attrs) do
now = Persistence.now_ms()
@@ -54,6 +57,7 @@ defmodule BDS.Posts do
{:ok, post} ->
:ok = Embeddings.sync_post(post)
:ok = Search.sync_post(post)
:ok = maybe_schedule_auto_translations(post)
{:ok, post}
error ->
@@ -84,6 +88,7 @@ defmodule BDS.Posts do
:ok = Embeddings.sync_post(updated_post)
:ok = PostLinks.sync_post_links(updated_post)
:ok = Search.sync_post(updated_post)
:ok = maybe_schedule_auto_translations(updated_post)
{:ok, updated_post}
error ->
@@ -210,10 +215,12 @@ defmodule BDS.Posts do
{:error, :not_found}
%Post{} = post ->
linked_media_ids = linked_media_ids(post.id)
delete_post_file(post)
:ok = Embeddings.remove_post(post.id)
:ok = PostLinks.delete_post_links(post.id)
Repo.delete!(post)
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
:ok = Search.delete_post(post.id)
{:ok, :deleted}
end
@@ -390,6 +397,7 @@ defmodule BDS.Posts do
|> Repo.insert_or_update()
|> case do
{:ok, saved_translation} ->
{:ok, _post} = maybe_reopen_source_post_for_manual_translation(post, attrs)
:ok = Search.sync_post(post.id)
{:ok, saved_translation}
@@ -932,6 +940,182 @@ defmodule BDS.Posts do
end
end
defp maybe_reopen_source_post_for_manual_translation(%Post{} = post, attrs) do
if attr(attrs, :auto_generated) == true or post.status != :published or post.file_path in [nil, ""] do
{:ok, post}
else
project = Projects.get_project!(post.project_id)
full_path = Path.join(Projects.project_data_dir(project), post.file_path)
restored_content = published_post_body(post, full_path)
post
|> Post.changeset(%{
status: :draft,
content: restored_content,
updated_at: Persistence.now_ms()
})
|> Repo.update()
end
end
defp maybe_schedule_auto_translations(%Post{do_not_translate: true}), do: :ok
defp maybe_schedule_auto_translations(%Post{} = post) do
with true <- auto_translation_configured?(),
{:ok, metadata} <- Metadata.get_project_metadata(post.project_id) do
post
|> missing_auto_translation_languages(metadata)
|> Enum.each(&queue_post_auto_translation(post, &1))
else
_other -> :ok
end
:ok
end
defp missing_auto_translation_languages(%Post{} = post, metadata) do
source_language = normalize_language(post.language || metadata.main_language)
configured_languages =
([metadata.main_language] ++ (metadata.blog_languages || []))
|> Enum.map(&normalize_language/1)
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
existing_languages =
Repo.all(
from translation in Translation,
where: translation.translation_for == ^post.id,
select: translation.language
)
configured_languages
|> Enum.reject(&(&1 == source_language or &1 in existing_languages))
end
defp queue_post_auto_translation(%Post{} = post, language) do
_ =
Tasks.submit_task(
"Auto-translate Post to #{language}",
fn report ->
report.(0.05, "Translating post to #{language}")
with {:ok, translation} <- AI.translate_post(post.id, language, auto_translation_ai_opts()),
{:ok, saved_translation} <-
upsert_post_translation(post.id, language, %{
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
auto_generated: true
}) do
report.(0.85, "Post translation saved")
:ok = queue_media_translation_cascade(post, language)
report.(1.0, "Post translation complete")
%{post_id: post.id, translation_id: saved_translation.id, language: language}
else
{:error, reason} -> {:error, reason}
other -> {:error, other}
end
end,
auto_translation_task_attrs(post)
)
:ok
end
defp queue_media_translation_cascade(%Post{} = post, language) do
linked_media_ids(post.id)
|> Enum.each(fn media_id ->
if media_translation_needed?(media_id, language) do
queue_media_translation(post, media_id, language)
end
end)
:ok
end
defp queue_media_translation(%Post{} = post, media_id, language) do
_ =
Tasks.submit_task(
"Auto-translate Media to #{language}",
fn report ->
report.(0.05, "Translating media to #{language}")
with {:ok, translation} <- AI.translate_media(media_id, language, auto_translation_ai_opts()),
{:ok, saved_translation} <-
Media.upsert_media_translation(media_id, language, %{
title: translation.title,
alt: translation.alt,
caption: translation.caption
}) do
report.(1.0, "Media translation complete")
%{media_id: media_id, translation_id: saved_translation.id, language: language}
else
{:error, reason} -> {:error, reason}
other -> {:error, other}
end
end,
auto_translation_task_attrs(post)
)
:ok
end
defp media_translation_needed?(media_id, language) do
case Repo.get(Media.Media, media_id) do
%Media.Media{language: source_language} when source_language not in [nil, ""] and source_language != language ->
not Repo.exists?(
from translation in Media.Translation,
where: translation.translation_for == ^media_id and translation.language == ^language
)
_other ->
false
end
end
defp auto_translation_task_attrs(%Post{} = post) do
%{
group_id: post.project_id,
group_name: "AI"
}
end
defp auto_translation_ai_opts do
Application.get_env(:bds, :posts, [])
|> Keyword.get(:auto_translation_ai_opts, [])
end
defp auto_translation_configured? do
mode = if AI.airplane_mode?(), do: :airplane, else: :online
case AI.get_endpoint(mode) do
{:ok, %{url: url, model: model} = endpoint}
when is_binary(url) and url != "" and is_binary(model) and model != "" ->
mode == :airplane or present?(Map.get(endpoint, :api_key))
_other ->
false
end
end
defp linked_media_ids(post_id) do
case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do
{:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end)
{:error, _reason} -> []
end
end
defp sync_deleted_post_media_sidecar(media_id) do
case Media.sync_media_sidecar(media_id) do
:ok -> :ok
{:error, :not_found} -> :ok
end
end
defp present?(value) when is_binary(value), do: String.trim(value) != ""
defp present?(value), do: not is_nil(value)
defp orphan_translation_files(project_id) do
project = Projects.get_project!(project_id)

View File

@@ -1,6 +1,8 @@
defmodule BDS.MediaTest do
use ExUnit.Case, async: false
import Ecto.Query
alias BDS.Repo
setup do
@@ -117,6 +119,49 @@ defmodule BDS.MediaTest do
end)
end
test "deleting a post rewrites linked media sidecars to remove that post id", %{
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, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Linked Post",
content: "Body"
})
now = BDS.Persistence.now_ms()
Repo.insert_all("post_media", [
%{
id: Ecto.UUID.generate(),
project_id: project.id,
post_id: post.id,
media_id: media.id,
sort_order: 0,
created_at: now
}
])
assert {:ok, _updated_media} = BDS.Media.update_media(media.id, %{})
sidecar_before = File.read!(Path.join(temp_dir, media.sidecar_path))
assert sidecar_before =~ "linkedPostIds: [\"#{post.id}\"]\n"
assert {:ok, :deleted} = BDS.Posts.delete_post(post.id)
refute Repo.exists?(from row in "post_media", where: field(row, :media_id) == ^media.id)
sidecar_after = File.read!(Path.join(temp_dir, media.sidecar_path))
assert sidecar_after =~ "linkedPostIds: []\n"
end
test "rebuild_media_from_files recreates media rows from sidecars", %{
project: project,
temp_dir: temp_dir

View File

@@ -1,17 +1,62 @@
defmodule BDS.PostTranslationsTest do
use ExUnit.Case, async: false
import Ecto.Query
alias BDS.AI
alias BDS.Media
alias BDS.Metadata
alias BDS.Posts
alias BDS.Repo
defmodule FakeRuntime do
def generate(endpoint, request, opts) do
test_pid = Keyword.fetch!(opts, :test_pid)
send(test_pid, {:runtime_request, endpoint, request})
case request.operation do
:translate_post ->
{:ok,
%{
json: %{
"title" => "Hallo Welt",
"excerpt" => "Kurze Zusammenfassung",
"content" => "# Hallo Welt\n\nUbersetzter Inhalt"
},
usage: %{input_tokens: 22, output_tokens: 14, cache_read_tokens: 0, cache_write_tokens: 0}
}}
:translate_media ->
{:ok,
%{
json: %{
"title" => "Medientitel",
"alt" => "Medien Alt",
"caption" => "Medien Beschriftung"
},
usage: %{input_tokens: 12, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0}
}}
end
end
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
:ok = BDS.Tasks.clear_finished()
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)
original_posts_config = Application.get_env(:bds, :posts, [])
on_exit(fn ->
File.rm_rf(temp_dir)
Application.put_env(:bds, :posts, original_posts_config)
_ = BDS.Tasks.clear_finished()
end)
{:ok, project} = BDS.Projects.create_project(%{name: "Translations", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
@@ -75,11 +120,119 @@ defmodule BDS.PostTranslationsTest do
assert reopened_translation.content == "Neuer Entwurf"
assert reopened_translation.updated_at >= published_translation.updated_at
reopened_source = Posts.get_post!(post.id)
assert reopened_source.status == :draft
assert reopened_source.content == "Hello world"
assert reopened_source.file_path ==
String.replace_suffix(published_translation.file_path, ".de.md", ".md")
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 "create_post enqueues and completes auto-translation tasks for missing project languages",
%{project: project} do
configure_auto_translation_test_runtime()
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en", "de", "fr"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Hello World",
excerpt: "Short summary",
content: "# Hello\n\nSource body",
language: "en"
})
translations = wait_for_post_translations(post.id, ["de", "fr"])
assert Enum.map(translations, & &1.language) == ["de", "fr"]
assert Enum.all?(translations, &(&1.title == "Hallo Welt"))
assert Enum.all?(translations, &(&1.excerpt == "Kurze Zusammenfassung"))
assert Enum.all?(translations, &(&1.content == "# Hallo Welt\n\nUbersetzter Inhalt"))
tasks = wait_for_ai_tasks(2)
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to de" and &1.status == :completed))
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to fr" and &1.status == :completed))
assert Enum.all?(tasks, &(&1.group_name == "AI"))
end
test "update_post auto-translates missing languages and cascades linked media translations",
%{project: project, temp_dir: temp_dir} do
configure_auto_translation_test_runtime()
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en", "de"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Needs Translation",
excerpt: "Draft excerpt",
content: "Source body",
language: "en",
do_not_translate: true
})
source_path = Path.join(temp_dir, "linked-media.txt")
File.write!(source_path, "linked media")
assert {:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "Source Media",
alt: "Source Alt",
caption: "Source Caption",
language: "en"
})
Repo.insert_all("post_media", [
%{
id: Ecto.UUID.generate(),
project_id: project.id,
post_id: post.id,
media_id: media.id,
sort_order: 0,
created_at: BDS.Persistence.now_ms()
}
])
assert {:ok, _updated_post} =
Posts.update_post(post.id, %{
do_not_translate: false,
content: "Updated source body"
})
[translation] = wait_for_post_translations(post.id, ["de"])
assert translation.language == "de"
media_translation = wait_for_media_translation(media.id, "de")
assert media_translation.title == "Medientitel"
assert media_translation.alt == "Medien Alt"
assert media_translation.caption == "Medien Beschriftung"
tasks = wait_for_ai_tasks(2)
assert Enum.any?(tasks, &(&1.name == "Auto-translate Post to de" and &1.status == :completed))
assert Enum.any?(tasks, fn task ->
task.name == "Auto-translate Media to de" and task.status == :completed
end)
assert File.exists?(Path.join(temp_dir, media.file_path <> ".de.meta"))
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} =
@@ -145,4 +298,84 @@ defmodule BDS.PostTranslationsTest do
assert report.orphan_files == ["posts/2026/04/orphan.fr.md"]
assert report.do_not_translate_posts == [ignored_post.id]
end
defp configure_auto_translation_test_runtime do
assert {:ok, _endpoint} =
AI.put_endpoint(
:online,
%{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
}
)
assert :ok = AI.set_airplane_mode(false)
assert :ok = AI.put_model_preference(:title, "gpt-4.1-mini")
Application.put_env(:bds, :posts,
auto_translation_ai_opts: [
runtime: FakeRuntime,
test_pid: self()
]
)
end
defp wait_for_post_translations(post_id, languages, attempts \\ 100)
defp wait_for_post_translations(_post_id, _languages, 0) do
flunk("post translations did not reach expected state")
end
defp wait_for_post_translations(post_id, languages, attempts) do
{:ok, translations} = Posts.list_post_translations(post_id)
expected = Enum.sort(languages)
if Enum.sort(Enum.map(translations, & &1.language)) == expected do
translations
else
Process.sleep(20)
wait_for_post_translations(post_id, languages, attempts - 1)
end
end
defp wait_for_media_translation(media_id, language, attempts \\ 100)
defp wait_for_media_translation(_media_id, _language, 0) do
flunk("media translation did not reach expected state")
end
defp wait_for_media_translation(media_id, language, attempts) do
translation =
Repo.one(
from translation in BDS.Media.Translation,
where: translation.translation_for == ^media_id and translation.language == ^language
)
if translation do
translation
else
Process.sleep(20)
wait_for_media_translation(media_id, language, attempts - 1)
end
end
defp wait_for_ai_tasks(count, attempts \\ 100)
defp wait_for_ai_tasks(_count, 0) do
flunk("AI tasks did not reach expected state")
end
defp wait_for_ai_tasks(count, attempts) do
tasks =
BDS.Tasks.list_tasks()
|> Enum.filter(&(&1.group_name == "AI"))
if length(tasks) >= count and Enum.all?(tasks, &(&1.status == :completed)) do
tasks
else
Process.sleep(20)
wait_for_ai_tasks(count, attempts - 1)
end
end
end