diff --git a/PLAN.md b/PLAN.md index 6a4c818..155ef2b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. diff --git a/lib/bds/media.ex b/lib/bds/media.ex index 5fa02db..6fa4e28 100644 --- a/lib/bds/media.ex +++ b/lib/bds/media.ex @@ -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 -> diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index 23e305d..9a4382c 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -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) diff --git a/test/bds/media_test.exs b/test/bds/media_test.exs index 4016f48..7af4c6c 100644 --- a/test/bds/media_test.exs +++ b/test/bds/media_test.exs @@ -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 diff --git a/test/bds/post_translations_test.exs b/test/bds/post_translations_test.exs index 4ef4ff9..97cc7c5 100644 --- a/test/bds/post_translations_test.exs +++ b/test/bds/post_translations_test.exs @@ -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