feat: PLAN step 2 done
This commit is contained in:
4
PLAN.md
4
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.
|
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.
|
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.
|
2. Close engine-level behavior gaps. Completed 2026-04-25.
|
||||||
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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
def delete_media(media_id) do
|
||||||
case Repo.get(Media, media_id) do
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
|
|||||||
184
lib/bds/posts.ex
184
lib/bds/posts.ex
@@ -5,6 +5,8 @@ defmodule BDS.Posts do
|
|||||||
|
|
||||||
alias BDS.Frontmatter
|
alias BDS.Frontmatter
|
||||||
alias BDS.Embeddings
|
alias BDS.Embeddings
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.Media
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.PostLinks
|
alias BDS.PostLinks
|
||||||
@@ -16,6 +18,7 @@ defmodule BDS.Posts do
|
|||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Search
|
alias BDS.Search
|
||||||
alias BDS.Slug
|
alias BDS.Slug
|
||||||
|
alias BDS.Tasks
|
||||||
|
|
||||||
def create_post(attrs) do
|
def create_post(attrs) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
@@ -54,6 +57,7 @@ defmodule BDS.Posts do
|
|||||||
{:ok, post} ->
|
{:ok, post} ->
|
||||||
:ok = Embeddings.sync_post(post)
|
:ok = Embeddings.sync_post(post)
|
||||||
:ok = Search.sync_post(post)
|
:ok = Search.sync_post(post)
|
||||||
|
:ok = maybe_schedule_auto_translations(post)
|
||||||
{:ok, post}
|
{:ok, post}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@@ -84,6 +88,7 @@ defmodule BDS.Posts do
|
|||||||
:ok = Embeddings.sync_post(updated_post)
|
:ok = Embeddings.sync_post(updated_post)
|
||||||
:ok = PostLinks.sync_post_links(updated_post)
|
:ok = PostLinks.sync_post_links(updated_post)
|
||||||
:ok = Search.sync_post(updated_post)
|
:ok = Search.sync_post(updated_post)
|
||||||
|
:ok = maybe_schedule_auto_translations(updated_post)
|
||||||
{:ok, updated_post}
|
{:ok, updated_post}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@@ -210,10 +215,12 @@ defmodule BDS.Posts do
|
|||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
%Post{} = post ->
|
%Post{} = post ->
|
||||||
|
linked_media_ids = linked_media_ids(post.id)
|
||||||
delete_post_file(post)
|
delete_post_file(post)
|
||||||
:ok = Embeddings.remove_post(post.id)
|
:ok = Embeddings.remove_post(post.id)
|
||||||
:ok = PostLinks.delete_post_links(post.id)
|
:ok = PostLinks.delete_post_links(post.id)
|
||||||
Repo.delete!(post)
|
Repo.delete!(post)
|
||||||
|
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
||||||
:ok = Search.delete_post(post.id)
|
:ok = Search.delete_post(post.id)
|
||||||
{:ok, :deleted}
|
{:ok, :deleted}
|
||||||
end
|
end
|
||||||
@@ -390,6 +397,7 @@ defmodule BDS.Posts do
|
|||||||
|> Repo.insert_or_update()
|
|> Repo.insert_or_update()
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, saved_translation} ->
|
{:ok, saved_translation} ->
|
||||||
|
{:ok, _post} = maybe_reopen_source_post_for_manual_translation(post, attrs)
|
||||||
:ok = Search.sync_post(post.id)
|
:ok = Search.sync_post(post.id)
|
||||||
{:ok, saved_translation}
|
{:ok, saved_translation}
|
||||||
|
|
||||||
@@ -932,6 +940,182 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
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
|
defp orphan_translation_files(project_id) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule BDS.MediaTest do
|
defmodule BDS.MediaTest do
|
||||||
use ExUnit.Case, async: false
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@@ -117,6 +119,49 @@ defmodule BDS.MediaTest do
|
|||||||
end)
|
end)
|
||||||
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", %{
|
test "rebuild_media_from_files recreates media rows from sidecars", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
|
|||||||
@@ -1,17 +1,62 @@
|
|||||||
defmodule BDS.PostTranslationsTest do
|
defmodule BDS.PostTranslationsTest do
|
||||||
use ExUnit.Case, async: false
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.Media
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
alias BDS.Posts
|
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
|
setup do
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
||||||
|
:ok = BDS.Tasks.clear_finished()
|
||||||
|
|
||||||
temp_dir =
|
temp_dir =
|
||||||
Path.join(System.tmp_dir!(), "bds-post-translations-#{System.unique_integer([:positive])}")
|
Path.join(System.tmp_dir!(), "bds-post-translations-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
File.mkdir_p!(temp_dir)
|
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})
|
{:ok, project} = BDS.Projects.create_project(%{name: "Translations", data_path: temp_dir})
|
||||||
%{project: project, temp_dir: 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.content == "Neuer Entwurf"
|
||||||
assert reopened_translation.updated_at >= published_translation.updated_at
|
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)
|
assert {:ok, :deleted} = Posts.delete_post_translation(reopened_translation.id)
|
||||||
refute File.exists?(translation_path)
|
refute File.exists?(translation_path)
|
||||||
assert {:ok, []} = Posts.list_post_translations(post.id)
|
assert {:ok, []} = Posts.list_post_translations(post.id)
|
||||||
end
|
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",
|
test "validate_translations reports missing languages, orphan translation files, and do-not-translate posts",
|
||||||
%{project: project, temp_dir: temp_dir} do
|
%{project: project, temp_dir: temp_dir} do
|
||||||
assert {:ok, _metadata} =
|
assert {:ok, _metadata} =
|
||||||
@@ -145,4 +298,84 @@ defmodule BDS.PostTranslationsTest do
|
|||||||
assert report.orphan_files == ["posts/2026/04/orphan.fr.md"]
|
assert report.orphan_files == ["posts/2026/04/orphan.fr.md"]
|
||||||
assert report.do_not_translate_posts == [ignored_post.id]
|
assert report.do_not_translate_posts == [ignored_post.id]
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user