From 2f09bf527da4dee44e191b1d2e0863890a549193 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 27 Apr 2026 22:36:53 +0200 Subject: [PATCH] feat: step 5 claimed done --- lib/bds/ai.ex | 42 ++ lib/bds/desktop/shell_commands.ex | 19 +- lib/bds/desktop/shell_live.ex | 21 + lib/bds/desktop/shell_live/chat_editor.ex | 115 ++++- .../chat_editor_html/chat_editor.html.heex | 94 +++- lib/bds/desktop/shell_live/misc_editor.ex | 199 +++++++- .../misc_editor_html/misc_editor.html.heex | 107 ++++- lib/bds/git.ex | 21 + lib/bds/posts.ex | 431 +++++++++++++++--- priv/i18n/locales/de.json | 31 ++ priv/i18n/locales/en.json | 31 ++ priv/i18n/locales/es.json | 31 ++ priv/i18n/locales/fr.json | 31 ++ priv/i18n/locales/it.json | 31 ++ priv/ui/app.css | 198 ++++++++ priv/ui/live.js | 133 ++++++ test/bds/desktop/shell_commands_test.exs | 63 ++- test/bds/desktop/shell_live_test.exs | 154 +++++++ test/bds/git_test.exs | 34 ++ test/bds/posts_test.exs | 69 +++ 20 files changed, 1740 insertions(+), 115 deletions(-) diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index be94f9d..b41c997 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -317,6 +317,48 @@ defmodule BDS.AI do |> Enum.map(&format_conversation/1) end + def available_chat_models(current_model \\ nil) do + endpoint_models = + [:online, :airplane] + |> Enum.flat_map(fn kind -> + case get_endpoint(kind) do + {:ok, %{model: model}} when is_binary(model) and model != "" -> [model] + _other -> [] + end + end) + + preference_models = + [:chat, :airplane_chat] + |> Enum.flat_map(fn key -> + case get_model_preference(key) do + {:ok, model} when is_binary(model) and model != "" -> [model] + _other -> [] + end + end) + + [current_model | endpoint_models ++ preference_models] + |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) + |> Enum.uniq() + |> Enum.map(&%{id: &1, name: &1}) + end + + def set_conversation_model(conversation_id, model_id) + when is_binary(conversation_id) and is_binary(model_id) do + case Repo.get(ChatConversation, conversation_id) do + nil -> + {:error, :not_found} + + %ChatConversation{} = conversation -> + conversation + |> ChatConversation.changeset(%{model: model_id, updated_at: Persistence.now_ms()}) + |> Repo.update() + |> case do + {:ok, updated_conversation} -> {:ok, format_conversation(updated_conversation)} + error -> error + end + end + end + def list_chat_messages(conversation_id) when is_binary(conversation_id) do Repo.all( from message in ChatMessage, diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex index 4347290..b616796 100644 --- a/lib/bds/desktop/shell_commands.ex +++ b/lib/bds/desktop/shell_commands.ex @@ -514,14 +514,10 @@ defmodule BDS.Desktop.ShellCommands do defp normalize_translation_validation(report) do %{ - summary: %{ - missing_count: length(report.missing), - orphan_count: length(report.orphan_files), - do_not_translate_count: length(report.do_not_translate_posts) - }, - missing: Enum.map(report.missing, &stringify_map/1), - orphan_files: report.orphan_files, - do_not_translate_posts: report.do_not_translate_posts + checked_database_row_count: report.checked_database_row_count, + checked_filesystem_file_count: report.checked_filesystem_file_count, + invalid_database_rows: Enum.map(report.invalid_database_rows, &stringify_map/1), + invalid_filesystem_files: Enum.map(report.invalid_filesystem_files, &stringify_map/1) } end @@ -532,11 +528,10 @@ defmodule BDS.Desktop.ShellCommands do project_id: project_id, route: "translation_validation", title: "Translation Validation", - subtitle: "Published posts checked against required blog languages", + subtitle: "Database rows and translation files checked for invalid state", editorMeta: [ - %{label: "Missing", value: Integer.to_string(length(report.missing))}, - %{label: "Orphan Files", value: Integer.to_string(length(report.orphan_files))}, - %{label: "Skipped", value: Integer.to_string(length(report.do_not_translate_posts))} + %{label: "Invalid DB", value: Integer.to_string(length(report.invalid_database_rows))}, + %{label: "Invalid Files", value: Integer.to_string(length(report.invalid_filesystem_files))} ], payload: normalize_translation_validation(report) } diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index c517c36..2e4f23e 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -97,7 +97,9 @@ defmodule BDS.Desktop.ShellLive do |> assign(:script_editor_drafts, %{}) |> assign(:template_editor_drafts, %{}) |> assign(:chat_editor_inputs, %{}) + |> assign(:chat_model_selectors_open, %{}) |> assign(:misc_editor_selected_pairs, %{}) + |> assign(:misc_editor_git_selected_files, %{}) |> assign(:metadata_diff_active_tabs, %{}) |> assign(:metadata_diff_field_filters, %{}) |> assign(:shell_overlay, nil) @@ -683,6 +685,14 @@ defmodule BDS.Desktop.ShellLive do {:noreply, ChatEditor.update_input(socket, message, &reload_shell/2)} end + def handle_event("toggle_chat_model_selector", _params, socket) do + {:noreply, ChatEditor.toggle_model_selector(socket, &reload_shell/2)} + end + + def handle_event("select_chat_model", %{"model" => model_id}, socket) do + {:noreply, ChatEditor.set_model(socket, model_id, &reload_shell/2, &append_output_entry/5)} + end + def handle_event("send_chat_editor_message", _params, socket) do {:noreply, ChatEditor.send_message(socket, &reload_shell/2, &append_output_entry/5)} end @@ -701,6 +711,17 @@ defmodule BDS.Desktop.ShellLive do end end + def handle_event("fix_translation_validation", _params, socket) do + case MiscEditor.fix_translation_validation(socket, &append_output_entry/5) do + {:rerun, next_socket} -> {:noreply, apply_shell_command(next_socket, "validate_translations")} + {:socket, next_socket} -> {:noreply, next_socket} + end + end + + def handle_event("select_git_diff_file", %{"path" => path}, socket) do + {:noreply, socket |> MiscEditor.select_git_diff_file(path) |> assign_misc_editor()} + end + def handle_event("toggle_duplicate_pair", %{"pair-id" => pair_id}, socket) do {:noreply, MiscEditor.toggle_duplicate(socket, pair_id, &reload_shell/2)} end diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 04f5a59..6d92b2b 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -13,6 +13,31 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do assign(socket, :chat_editor, build(socket.assigns)) end + def toggle_model_selector(socket, reload) do + %{id: conversation_id} = socket.assigns.current_tab + current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false) + + socket + |> assign(:chat_model_selectors_open, Map.put(socket.assigns.chat_model_selectors_open, conversation_id, not current)) + |> reload.(socket.assigns.workbench) + end + + def set_model(socket, model_id, reload, append_output) do + %{id: conversation_id} = socket.assigns.current_tab + + case AI.set_conversation_model(conversation_id, model_id) do + {:ok, _conversation} -> + socket + |> assign(:chat_model_selectors_open, Map.put(socket.assigns.chat_model_selectors_open, conversation_id, false)) + |> reload.(socket.assigns.workbench) + + {:error, reason} -> + socket + |> append_output.(translated("Chat"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + def update_input(socket, value, reload) do %{id: conversation_id} = socket.assigns.current_tab @@ -53,10 +78,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do %ChatConversation{} = conversation -> %{ id: conversation.id, - title: conversation.title || translated("New Chat"), + title: conversation.title || translated("chat.newChat"), model: conversation.model, + available_models: AI.available_chat_models(conversation.model), + model_selector_open?: Map.get(assigns.chat_model_selectors_open, conversation.id, false), input: Map.get(assigns.chat_editor_inputs, conversation.id, ""), - messages: AI.list_chat_messages(conversation.id), + messages: build_entries(AI.list_chat_messages(conversation.id)), offline?: Map.get(assigns, :offline_mode, true) } end @@ -64,5 +91,89 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do def build(_assigns), do: nil + def message_role_label(:user), do: translated("chat.role.you") + def message_role_label(_role), do: translated("chat.role.assistant") + + def tool_call_name(tool_call) when is_map(tool_call) do + Map.get(tool_call, "name") || Map.get(tool_call, :name) || "tool" + end + + def tool_surface_type(surface), do: Map.get(surface, :type, "json") + + defp build_entries(messages) do + {entries, current_entry} = + Enum.reduce(messages, {[], nil}, fn message, {entries, current_entry} -> + case message.role do + :tool -> + if current_entry && current_entry.role == :assistant do + {entries, append_tool_surface(current_entry, message)} + else + {entries, current_entry} + end + + :system -> + {entries, current_entry} + + _other -> + entries = finalize_entry(entries, current_entry) + {entries, start_entry(message)} + end + end) + + entries + |> finalize_entry(current_entry) + |> Enum.reverse() + end + + defp finalize_entry(entries, nil), do: entries + defp finalize_entry(entries, entry), do: [entry | entries] + + defp start_entry(message) do + %{ + id: message.id, + role: message.role, + content: message.content || "", + tool_markers: normalize_tool_calls(message.tool_calls), + tool_surfaces: [] + } + end + + defp append_tool_surface(entry, message) do + case normalize_tool_surface(message.content) do + nil -> entry + surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface])) + end + end + + defp normalize_tool_calls(tool_calls) when is_list(tool_calls) do + Enum.map(tool_calls, fn tool_call -> + %{ + name: tool_call_name(tool_call), + arguments: Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{} + } + end) + end + + defp normalize_tool_calls(_tool_calls), do: [] + + defp normalize_tool_surface(content) when is_binary(content) do + case Jason.decode(content) do + {:ok, %{"type" => type} = decoded} -> + %{ + type: type, + title: decoded["title"], + columns: List.wrap(decoded["columns"]), + rows: Enum.map(List.wrap(decoded["rows"]), &List.wrap/1), + fields: List.wrap(decoded["fields"]), + data: decoded + } + + _other -> + nil + end + end + + defp normalize_tool_surface(_content), do: nil + def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) end diff --git a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex index a87db3e..35674b9 100644 --- a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex +++ b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex @@ -1,37 +1,113 @@
<%= @chat_editor.title %>
+ +
+ + + <%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %> +
+ <%= for model <- @chat_editor.available_models do %> + + <% end %> +
+ <% end %> +
<%= if Enum.empty?(@chat_editor.messages) do %>
🤖
-

<%= translated("New Chat") %>

-

<%= translated("Ask the assistant about the active project.") %>

+

<%= translated("chat.welcomeTitle") %>

+

<%= translated("chat.welcomeDescription") %>

    -
  • <%= translated("Search posts and media") %>
  • -
  • <%= translated("Inspect metadata") %>
  • -
  • <%= translated("Open related tabs") %>
  • -
  • <%= translated("Review generated output") %>
  • -
  • <%= translated("Navigate settings") %>
  • +
  • <%= translated("chat.welcomeTipSearch") %>
  • +
  • <%= translated("chat.welcomeTipChart") %>
  • +
  • <%= translated("chat.welcomeTipTable") %>
  • +
  • <%= translated("chat.welcomeTipMetadata") %>
  • +
  • <%= translated("chat.welcomeTipTabs") %>
<% else %> <%= for message <- @chat_editor.messages do %>
-
<%= String.capitalize(to_string(message.role || "assistant")) %>
+
<%= message_role_label(message.role) %>
+ + <%= if message.tool_markers != [] do %> +
+ <%= for tool_call <- message.tool_markers do %> +
+ <%= tool_call_name(tool_call) %> +
+ <% end %> +
+ <% end %> +
<%= message.content || "" %>
+ + <%= for surface <- message.tool_surfaces do %> +
+ <%= if surface.title do %> +

<%= surface.title %>

+ <% end %> + + <%= case tool_surface_type(surface) do %> + <% "table" -> %> +
+ + + + <%= for column <- surface.columns do %> + + <% end %> + + + + <%= for row <- surface.rows do %> + + <%= for value <- row do %> + + <% end %> + + <% end %> + +
<%= column %>
<%= value %>
+
+ + <% _other -> %> +
<%= Jason.encode!(surface.data, pretty: true) %>
+ <% end %> +
+ <% end %> <% end %> <% end %>
- +
diff --git a/lib/bds/desktop/shell_live/misc_editor.ex b/lib/bds/desktop/shell_live/misc_editor.ex index d9ae3cf..26d86fb 100644 --- a/lib/bds/desktop/shell_live/misc_editor.ex +++ b/lib/bds/desktop/shell_live/misc_editor.ex @@ -3,8 +3,11 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do use Phoenix.Component - alias BDS.{Embeddings, Generation, Git} + import Ecto.Query + + alias BDS.{Embeddings, Generation, Git, Posts, Repo} alias BDS.Desktop.ShellData + alias BDS.Settings.Setting embed_templates "misc_editor_html/*" @@ -114,6 +117,37 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + def fix_translation_validation(socket, append_output) do + report = + socket.assigns + |> meta() + |> Map.get(:payload, %{}) + |> normalize_translation_validation_report() + + {:ok, result} = Posts.fix_invalid_translations(report) + + {:rerun, + socket + |> append_output.( + translated("Translation Validation"), + translated("translationValidation.toast.fixSuccess", %{ + dbRows: result.deleted_database_rows, + files: result.deleted_files, + flushed: result.flushed_translations + }) + )} + rescue + error -> {:socket, append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")} + end + + def select_git_diff_file(socket, file_path) do + assign( + socket, + :misc_editor_git_selected_files, + Map.put(socket.assigns.misc_editor_git_selected_files, socket.assigns.current_tab.id, file_path) + ) + end + def metadata_diff_repair_request(socket, field, direction) do meta = meta(socket.assigns) payload = Map.get(meta, :payload, %{}) @@ -245,14 +279,23 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end defp build_translation_validation(meta, payload) do + report = normalize_translation_validation_report(payload) + %{ kind: :translation_validation, title: Map.get(meta, :title, translated("Translation Validation")), subtitle: Map.get(meta, :subtitle, ""), - summary: Map.get(payload, :summary, %{}), - missing: Map.get(payload, :missing, []), - orphan_files: Map.get(payload, :orphan_files, []), - do_not_translate_posts: Map.get(payload, :do_not_translate_posts, []) + summary: %{}, + summary_text: + translated("translationValidation.summary", %{ + dbRows: report.checked_database_row_count, + files: report.checked_filesystem_file_count, + invalidDb: length(report.invalid_database_rows), + invalidFiles: length(report.invalid_filesystem_files) + }), + invalid_database_rows: report.invalid_database_rows, + invalid_filesystem_files: report.invalid_filesystem_files, + can_fix?: report.invalid_database_rows != [] or report.invalid_filesystem_files != [] } end @@ -270,29 +313,93 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end defp build_git_diff(assigns, meta) do - diff_text = - case Git.diff(assigns.projects.active_project_id) do - {:ok, %{staged_diff: staged, unstaged_diff: unstaged}} -> - [ - "# Staged Changes\n\n", - if(String.trim(staged) == "", do: translated("No staged changes"), else: staged), - "\n\n# Working Tree\n\n", - if(String.trim(unstaged) == "", do: translated("No unstaged changes"), else: unstaged) - ] - |> IO.iodata_to_binary() + project_id = assigns.projects.active_project_id + selected_files = Map.get(assigns, :misc_editor_git_selected_files, %{}) - {:error, reason} -> inspect(reason) + {files, diff, error_message} = + case Git.status(project_id) do + {:ok, %{files: files}} -> + file_paths = files |> Enum.map(&Map.get(&1, :path)) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.sort() + selected_file_path = select_git_diff_path(assigns.current_tab.id, file_paths, selected_files) + + diff = + case selected_file_path do + nil -> + empty_git_diff(project_id) + + file_path -> + case Git.get_diff_content(project_id, file_path) do + {:ok, diff} -> diff + {:error, reason} -> Map.merge(empty_git_diff(project_id), %{file_path: file_path, error: inspect(reason)}) + end + end + + {file_paths, diff, nil} + + {:error, reason} -> + {[], empty_git_diff(project_id), inspect(reason)} end + preferences = git_diff_preferences() + %{ kind: :git_diff, title: Map.get(meta, :title, translated("Git Diff")), subtitle: Map.get(meta, :subtitle, ""), - diff_text: diff_text, - summary: %{} + summary: %{}, + files: files, + selected_file_path: diff.file_path, + active_diff: Map.put(diff, :language, git_diff_language(diff.file_path)), + preferences: preferences, + empty_message: error_message || translated("No unstaged changes") } end + def translation_issue_label(issue) do + case issue_value(issue, :issue) do + "same-language-as-canonical" -> translated("translationValidation.issue.sameLanguage") + "do-not-translate-has-translations" -> translated("translationValidation.issue.doNotTranslate") + "content-in-database" -> translated("translationValidation.issue.contentInDatabase") + _other -> translated("translationValidation.issue.missingSource") + end + end + + def translation_issue_languages(issue) do + canonical_language = issue_value(issue, :canonical_language) + translation_language = issue_value(issue, :translation_language) + + if canonical_language in [nil, ""] do + translation_language + else + translated("translationValidation.languagesWithCanonical", %{ + canonical: canonical_language, + translation: translation_language + }) + end + end + + def translation_issue_value(issue, key), do: issue_value(issue, key) + + def git_diff_language(nil), do: "plaintext" + + def git_diff_language(file_path) do + case file_path |> Path.extname() |> String.downcase() do + ".md" -> "markdown" + ".markdown" -> "markdown" + ".mdx" -> "markdown" + ".ts" -> "typescript" + ".tsx" -> "typescript" + ".js" -> "javascript" + ".jsx" -> "javascript" + ".json" -> "json" + ".css" -> "css" + ".html" -> "html" + ".yml" -> "yaml" + ".yaml" -> "yaml" + _other -> "plaintext" + end + end + defp meta(assigns) do Map.get(assigns.tab_meta, {assigns.current_tab.type, assigns.current_tab.id}, %{}) end @@ -496,4 +603,60 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do defp diff_name(diff) do Map.get(diff, :field) || Map.get(diff, "field") || Map.get(diff, :name) || Map.get(diff, "name") || "value" end + + defp normalize_translation_validation_report(payload) when is_map(payload) do + %{ + checked_database_row_count: issue_value(payload, :checked_database_row_count) || 0, + checked_filesystem_file_count: issue_value(payload, :checked_filesystem_file_count) || 0, + invalid_database_rows: + payload + |> issue_value(:invalid_database_rows) + |> List.wrap(), + invalid_filesystem_files: + payload + |> issue_value(:invalid_filesystem_files) + |> List.wrap() + } + end + + defp normalize_translation_validation_report(_payload) do + %{ + checked_database_row_count: 0, + checked_filesystem_file_count: 0, + invalid_database_rows: [], + invalid_filesystem_files: [] + } + end + + defp issue_value(issue, key) when is_map(issue) do + Map.get(issue, key) || Map.get(issue, Atom.to_string(key)) + end + + defp issue_value(_issue, _key), do: nil + + defp select_git_diff_path(tab_id, files, selected_files) do + selected = Map.get(selected_files, tab_id) + + if selected in files do + selected + else + List.first(files) + end + end + + defp empty_git_diff(file_path) do + %{file_path: file_path, original: "", modified: "", error: nil} + end + + defp git_diff_preferences do + %{ + view_style: get_global_setting("ui.git_diff_view_style") || "inline", + word_wrap: get_global_setting("ui.git_diff_word_wrap") == "true", + hide_unchanged_regions: get_global_setting("ui.git_diff_hide_unchanged_regions") == "true" + } + end + + defp get_global_setting(key) do + Repo.one(from setting in Setting, where: setting.key == ^key, select: setting.value) + end end diff --git a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex index eb2ad06..87b8a1e 100644 --- a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex +++ b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex @@ -181,10 +181,79 @@
<% :translation_validation -> %> -
-

<%= translated("Missing") %>

    <%= for issue <- @misc_editor.missing do %>
  • <%= inspect(issue) %>
  • <% end %>
-

<%= translated("Orphan Files") %>

    <%= for file <- @misc_editor.orphan_files do %>
  • <%= file %>
  • <% end %>
-

<%= translated("Do Not Translate") %>

    <%= for post <- @misc_editor.do_not_translate_posts do %>
  • <%= inspect(post) %>
  • <% end %>
+
+
+

<%= @misc_editor.summary_text %>

+
+ +
+

<%= translated("translationValidation.databaseTitle") %>

+ + <%= if @misc_editor.invalid_database_rows == [] do %> +

<%= translated("translationValidation.noneDatabase") %>

+ <% else %> +
+ <%= for issue <- @misc_editor.invalid_database_rows do %> +
+

<%= translation_issue_label(issue) %>

+
+
<%= translated("translationValidation.field.translationFor") %>
+
<%= translation_issue_value(issue, :translation_for) %>
+ <%= if translation_issue_value(issue, :translation_id) do %> +
<%= translated("translationValidation.field.translationId") %>
+
<%= translation_issue_value(issue, :translation_id) %>
+ <% end %> + <%= if translation_issue_value(issue, :title) do %> +
<%= translated("translationValidation.field.title") %>
+
<%= translation_issue_value(issue, :title) %>
+ <% end %> +
<%= translated("translationValidation.field.languages") %>
+
<%= translation_issue_languages(issue) %>
+ <%= if translation_issue_value(issue, :file_path) do %> +
<%= translated("translationValidation.field.filePath") %>
+
<%= translation_issue_value(issue, :file_path) %>
+ <% end %> +
+
+ <% end %> +
+ <% end %> +
+ +
+

<%= translated("translationValidation.filesystemTitle") %>

+ + <%= if @misc_editor.invalid_filesystem_files == [] do %> +

<%= translated("translationValidation.noneFilesystem") %>

+ <% else %> +
+ <%= for issue <- @misc_editor.invalid_filesystem_files do %> +
+

<%= translation_issue_label(issue) %>

+
+
<%= translated("translationValidation.field.translationFor") %>
+
<%= translation_issue_value(issue, :translation_for) %>
+ <%= if translation_issue_value(issue, :title) do %> +
<%= translated("translationValidation.field.title") %>
+
<%= translation_issue_value(issue, :title) %>
+ <% end %> +
<%= translated("translationValidation.field.languages") %>
+
<%= translation_issue_languages(issue) %>
+ <%= if translation_issue_value(issue, :file_path) do %> +
<%= translated("translationValidation.field.filePath") %>
+
<%= translation_issue_value(issue, :file_path) %>
+ <% end %> +
+
+ <% end %> +
+ <% end %> +
+ +
+ + +
<% :find_duplicates -> %> @@ -202,7 +271,35 @@
<% :git_diff -> %> -
<%= @misc_editor.diff_text %>
+
+ <%= if @misc_editor.files == [] do %> +

<%= @misc_editor.empty_message %>

+ <% else %> +
+ + +
+ +
+ + +
+
+ <% end %> +
<% end %> \ No newline at end of file diff --git a/lib/bds/git.ex b/lib/bds/git.ex index 25d5dfe..fa9e997 100644 --- a/lib/bds/git.ex +++ b/lib/bds/git.ex @@ -87,6 +87,27 @@ defmodule BDS.Git do end end + def get_diff_content(project_id, file_path, opts \\ []) + when is_binary(project_id) and is_binary(file_path) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id) do + runner = Keyword.get(opts, :runner, &system_runner/3) + + original = + case runner.("git", ["show", "HEAD:#{file_path}"], command_opts(project_dir)) do + {output, 0} -> output + {_output, _status} -> "" + end + + modified = + case File.read(Path.join(project_dir, file_path)) do + {:ok, contents} -> contents + {:error, _reason} -> "" + end + + {:ok, %{file_path: file_path, original: original, modified: modified}} + end + end + def history(project_id, branch, opts \\ []) when is_binary(project_id) and is_binary(branch) and is_list(opts) do with {:ok, project_dir} <- project_dir(project_id), diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index 74ba454..c37cca5 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -580,72 +580,119 @@ defmodule BDS.Posts do end def validate_translations(project_id, opts \\ []) do + project = Projects.get_project!(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id) on_progress = progress_callback(opts) - posts = + source_posts = Repo.all( from post in Post, - where: post.project_id == ^project_id and post.status == :published, + where: post.project_id == ^project_id, order_by: [asc: post.created_at, asc: post.slug] ) - total_posts = length(posts) - :ok = report_rebuild_started(on_progress, total_posts, "published posts") + source_post_map = Map.new(source_posts, &{&1.id, &1}) - translation_languages = + translation_rows = 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} + where: translation.project_id == ^project_id, + order_by: [asc: translation.translation_for, asc: translation.language, asc: translation.id] ) - |> 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() + project_data_dir = Projects.project_data_dir(project) - missing = - posts + markdown_files = + project_data_dir + |> Path.join("posts") + |> list_markdown_files_recursive() + + total_items = length(translation_rows) + length(markdown_files) + :ok = report_rebuild_started(on_progress, total_items, "translations") + + invalid_database_rows = + translation_rows |> Enum.with_index(1) - |> Enum.flat_map(fn {post, index} -> - available = Map.get(translation_languages, post.id, []) + |> Enum.flat_map(fn {translation, index} -> + :ok = report_rebuild_progress(on_progress, index, total_items, "translations") - :ok = report_rebuild_progress(on_progress, index, total_posts, "published posts") + case invalid_database_translation_issue(translation, source_post_map, metadata) do + nil -> [] + issue -> [issue] + end + end) + |> Enum.sort_by(&translation_validation_issue_sort_key/1) - cond do - post.do_not_translate -> - [] + {checked_filesystem_file_count, invalid_filesystem_files} = + markdown_files + |> Enum.with_index(length(translation_rows) + 1) + |> Enum.reduce({0, []}, fn {file_path, index}, {count, issues} -> + :ok = report_rebuild_progress(on_progress, index, total_items, "translations") - true -> - required_languages - |> Enum.reject(&(&1 in available)) - |> Enum.map(&%{post_id: post.id, language: &1}) + case invalid_filesystem_translation_issue(file_path, source_post_map, metadata) do + {:ok, nil} -> + {count + 1, issues} + + {:ok, issue} -> + {count + 1, [issue | issues]} + + :skip -> + {count, issues} end end) - do_not_translate_posts = - posts - |> Enum.filter(& &1.do_not_translate) - |> Enum.map(& &1.id) - - orphan_files = orphan_translation_files(project_id) + missing = legacy_missing_translation_entries(source_posts, translation_rows, metadata) + orphan_files = legacy_orphan_translation_files(invalid_filesystem_files, project_data_dir) + do_not_translate_posts = legacy_do_not_translate_posts(source_posts) {:ok, %{ + checked_database_row_count: length(translation_rows), + checked_filesystem_file_count: checked_filesystem_file_count, + invalid_database_rows: invalid_database_rows, + invalid_filesystem_files: Enum.reverse(invalid_filesystem_files) |> Enum.sort_by(&translation_validation_issue_sort_key/1), missing: missing, orphan_files: orphan_files, do_not_translate_posts: do_not_translate_posts }} end + def fix_invalid_translations(report) when is_map(report) do + normalized_report = normalize_translation_validation_report(report) + + {deleted_database_rows, flushed_translations, synced_post_ids} = + Enum.reduce(normalized_report.invalid_database_rows, {0, 0, MapSet.new()}, fn issue, {deleted, flushed, synced_ids} -> + case fix_invalid_database_translation(issue) do + {:deleted, post_id} -> + {deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)} + + {:flushed, post_id} -> + {deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)} + + :noop -> + {deleted, flushed, synced_ids} + end + end) + + deleted_files = + Enum.reduce(normalized_report.invalid_filesystem_files, 0, fn issue, count -> + if delete_translation_validation_file(issue.file_path) do + count + 1 + else + count + end + end) + + Enum.each(synced_post_ids, &Search.sync_post/1) + + {:ok, + %{ + deleted_database_rows: deleted_database_rows, + deleted_files: deleted_files, + flushed_translations: flushed_translations + }} + end + def rewrite_published_post(post_id) do post = Repo.get!(Post, post_id) @@ -997,6 +1044,12 @@ defmodule BDS.Posts do end defp normalize_translation_updates(post, %Translation{} = translation, language, attrs, now) do + requested_status = + case attr(attrs, :status) do + nil -> nil + status -> parse_translation_status(status) + end + updates = %{} |> maybe_put(:title, attr(attrs, :title)) @@ -1006,6 +1059,8 @@ defmodule BDS.Posts do reopened? = translation.status == :published and translation_content_change?(translation, updates) + status = if(reopened?, do: :draft, else: requested_status || translation.status || :draft) + %{ id: translation.id || Ecto.UUID.generate(), project_id: post.project_id, @@ -1014,10 +1069,10 @@ defmodule BDS.Posts do 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), + status: status, created_at: translation.created_at || now, updated_at: now, - published_at: translation.published_at, + published_at: translation.published_at || if(status == :published, do: now, else: nil), file_path: translation.file_path || "", checksum: translation.checksum } @@ -1303,32 +1358,290 @@ defmodule BDS.Posts do 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) - - 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)) + defp list_markdown_files_recursive(dir) do + ["*.md", "*.markdown", "*.mdx"] + |> Enum.flat_map(&list_matching_files(dir, &1)) + |> Enum.uniq() |> Enum.sort() end - defp translation_file?(relative_path) do - Regex.match?(~r/\.[a-z]{2}\.md$/i, relative_path) + defp invalid_database_translation_issue(%Translation{} = translation, source_post_map, metadata) do + source_post = Map.get(source_post_map, translation.translation_for) + normalized_language = normalize_language(translation.language) + + cond do + is_nil(source_post) -> + translation_validation_issue(%{ + issue: "missing-source-post", + translation_id: translation.id, + translation_for: translation.translation_for, + translation_language: normalized_language, + title: translation.title, + file_path: blank_to_nil(translation.file_path) + }) + + canonical_translation_language?(source_post, normalized_language, metadata) -> + translation_validation_issue(%{ + issue: "same-language-as-canonical", + translation_id: translation.id, + translation_for: translation.translation_for, + canonical_language: canonical_translation_language(source_post, metadata), + translation_language: normalized_language, + title: translation.title, + file_path: blank_to_nil(translation.file_path) + }) + + source_post.do_not_translate -> + translation_validation_issue(%{ + issue: "do-not-translate-has-translations", + translation_id: translation.id, + translation_for: translation.translation_for, + translation_language: normalized_language, + title: translation.title, + file_path: blank_to_nil(translation.file_path) + }) + + translation.status == :published and present?(translation.content) -> + translation_validation_issue(%{ + issue: "content-in-database", + translation_id: translation.id, + translation_for: translation.translation_for, + translation_language: normalized_language, + title: translation.title, + file_path: blank_to_nil(translation.file_path) + }) + + true -> + nil + end end + defp invalid_filesystem_translation_issue(file_path, source_post_map, metadata) do + with {:ok, contents} <- File.read(file_path), + {:ok, %{fields: fields}} <- Frontmatter.parse_document(contents), + true <- translation_rebuild_file?(%{fields: fields}) do + translation_for = DocumentFields.get(fields, "translationFor") + source_post = Map.get(source_post_map, translation_for) + normalized_language = normalize_language(DocumentFields.get(fields, "language")) + title = DocumentFields.get(fields, "title") + + issue = + cond do + is_nil(source_post) -> + translation_validation_issue(%{ + issue: "missing-source-post", + translation_for: translation_for, + translation_language: normalized_language, + title: title, + file_path: file_path + }) + + canonical_translation_language?(source_post, normalized_language, metadata) -> + translation_validation_issue(%{ + issue: "same-language-as-canonical", + translation_for: translation_for, + canonical_language: canonical_translation_language(source_post, metadata), + translation_language: normalized_language, + title: title, + file_path: file_path + }) + + source_post.do_not_translate -> + translation_validation_issue(%{ + issue: "do-not-translate-has-translations", + translation_for: translation_for, + translation_language: normalized_language, + title: title, + file_path: file_path + }) + + true -> + nil + end + + {:ok, issue} + else + false -> :skip + _other -> :skip + end + end + + defp normalize_translation_validation_report(report) do + %{ + checked_database_row_count: map_value(report, :checked_database_row_count, 0), + checked_filesystem_file_count: map_value(report, :checked_filesystem_file_count, 0), + invalid_database_rows: + report + |> map_value(:invalid_database_rows, []) + |> Enum.map(&normalize_translation_validation_issue/1), + invalid_filesystem_files: + report + |> map_value(:invalid_filesystem_files, []) + |> Enum.map(&normalize_translation_validation_issue/1) + } + end + + defp legacy_missing_translation_entries(source_posts, translation_rows, metadata) do + configured_languages = + ([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, [])) + |> Enum.map(&normalize_language/1) + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.uniq() + + existing_languages_by_post = + Enum.reduce(translation_rows, %{}, fn translation, acc -> + Map.update( + acc, + translation.translation_for, + MapSet.new([normalize_language(translation.language)]), + &MapSet.put(&1, normalize_language(translation.language)) + ) + end) + + source_posts + |> Enum.filter(&(&1.status == :published and not &1.do_not_translate)) + |> Enum.flat_map(fn post -> + canonical_language = canonical_translation_language(post, metadata) + existing_languages = Map.get(existing_languages_by_post, post.id, MapSet.new()) + + configured_languages + |> Enum.reject(&(&1 == canonical_language or MapSet.member?(existing_languages, &1))) + |> Enum.map(&%{post_id: post.id, language: &1}) + end) + |> Enum.sort_by(&{&1.post_id, &1.language}) + end + + defp legacy_orphan_translation_files(invalid_filesystem_files, project_data_dir) do + invalid_filesystem_files + |> Enum.filter(&(Map.get(&1, :issue) == "missing-source-post")) + |> Enum.map(fn issue -> + issue + |> Map.get(:file_path) + |> relative_project_data_path(project_data_dir) + end) + |> Enum.reject(&is_nil/1) + |> Enum.sort() + end + + defp legacy_do_not_translate_posts(source_posts) do + source_posts + |> Enum.filter(&(&1.status == :published and &1.do_not_translate)) + |> Enum.map(& &1.id) + |> Enum.sort() + end + + defp normalize_translation_validation_issue(issue) when is_map(issue) do + %{ + issue: map_value(issue, :issue), + translation_id: blank_to_nil(map_value(issue, :translation_id)), + translation_for: map_value(issue, :translation_for), + canonical_language: blank_to_nil(map_value(issue, :canonical_language)), + translation_language: map_value(issue, :translation_language), + title: blank_to_nil(map_value(issue, :title)), + file_path: blank_to_nil(map_value(issue, :file_path)) + } + end + + defp fix_invalid_database_translation(%{issue: "content-in-database", translation_id: translation_id}) + when is_binary(translation_id) do + case Repo.get(Translation, translation_id) do + %Translation{} = translation -> + case Repo.get(Post, translation.translation_for) do + %Post{} = post -> + :ok = publish_translation(post, translation) + {:flushed, translation.translation_for} + + nil -> + :noop + end + + nil -> + :noop + end + end + + defp fix_invalid_database_translation(%{translation_id: translation_id, translation_for: translation_for}) + when is_binary(translation_id) do + case Repo.get(Translation, translation_id) do + %Translation{} = translation -> + Repo.delete!(translation) + {:deleted, translation_for} + + nil -> + :noop + end + end + + defp fix_invalid_database_translation(_issue), do: :noop + + defp delete_translation_validation_file(file_path) when file_path in [nil, ""], do: false + + defp delete_translation_validation_file(file_path) do + case File.rm(file_path) do + :ok -> true + {:error, :enoent} -> false + {:error, _reason} -> false + end + end + + defp translation_validation_issue(attrs) do + %{ + issue: Map.get(attrs, :issue), + translation_id: Map.get(attrs, :translation_id), + translation_for: Map.get(attrs, :translation_for), + canonical_language: Map.get(attrs, :canonical_language), + translation_language: Map.get(attrs, :translation_language), + title: Map.get(attrs, :title), + file_path: Map.get(attrs, :file_path) + } + end + + defp translation_validation_issue_sort_key(issue) do + [Map.get(issue, :translation_for), Map.get(issue, :translation_id), Map.get(issue, :file_path)] + |> Enum.map(&to_string(&1 || "")) + |> Enum.join(":") + end + + defp canonical_translation_language(source_post, metadata) do + language = normalize_language(source_post.language) + + if language == "" do + normalize_language(Map.get(metadata, :main_language)) + else + language + end + end + + defp canonical_translation_language?(source_post, language, metadata) do + canonical_language = canonical_translation_language(source_post, metadata) + canonical_language != "" and canonical_language == normalize_language(language) + end + + defp map_value(map, key, default \\ nil) when is_map(map) do + Map.get(map, key, Map.get(map, Atom.to_string(key), default)) + end + + defp blank_to_nil(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp blank_to_nil(value), do: value + + defp relative_project_data_path(nil, _project_data_dir), do: nil + + defp relative_project_data_path(file_path, project_data_dir) do + case Path.relative_to(file_path, project_data_dir) do + relative_path when relative_path == file_path -> file_path + relative_path -> relative_path + end + end + + defp maybe_put_synced_post(set, post_id) when is_binary(post_id) and post_id != "", do: MapSet.put(set, post_id) + defp maybe_put_synced_post(set, _post_id), do: set + defp normalize_language(nil), do: "" defp normalize_language(language) do diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index a431202..f500af9 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -45,6 +45,37 @@ "render.video.vimeoTitle": "Vimeo-Video", "render.video.youtubeTitle": "YouTube-Video", "sidebar.chat.yesterday": "Gestern", + "translationValidation.title": "Übersetzungen validieren", + "translationValidation.summary": "Geprüfte DB-Zeilen: %{dbRows} · Geprüfte Dateien: %{files} · Ungültige DB-Zeilen: %{invalidDb} · Ungültige Dateien: %{invalidFiles}", + "translationValidation.databaseTitle": "Ungültige Übersetzungszeilen in der Datenbank", + "translationValidation.filesystemTitle": "Ungültige Übersetzungsdateien auf dem Datenträger", + "translationValidation.noneDatabase": "Keine ungültigen Übersetzungszeilen gefunden.", + "translationValidation.noneFilesystem": "Keine ungültigen Übersetzungsdateien gefunden.", + "translationValidation.issue.sameLanguage": "Übersetzungssprache entspricht der kanonischen Beitragssprache", + "translationValidation.issue.missingSource": "Übersetzung verweist auf einen fehlenden Quellbeitrag", + "translationValidation.issue.doNotTranslate": "Beitrag ist als nicht-übersetzen markiert, hat aber Übersetzungen", + "translationValidation.issue.contentInDatabase": "Veröffentlichte Übersetzung hat Inhalt in der DB statt im Dateisystem", + "translationValidation.field.translationFor": "Quellbeitrag", + "translationValidation.field.translationId": "Übersetzungszeile", + "translationValidation.field.title": "Titel", + "translationValidation.field.languages": "Sprachen", + "translationValidation.field.filePath": "Datei", + "translationValidation.languagesWithCanonical": "%{canonical} = %{translation}", + "translationValidation.revalidate": "Erneut validieren", + "translationValidation.fix": "Probleme beheben", + "translationValidation.toast.fixSuccess": "%{dbRows} DB-Zeilen und %{files} Dateien gelöscht, %{flushed} Übersetzungen auf Datenträger geschrieben", + "chat.newChat": "Neuer Chat", + "chat.welcomeTitle": "Willkommen beim KI-Assistenten", + "chat.welcomeDescription": "Ich kann dir mit interaktiven Visualisierungen bei deinem Blog helfen. Frag mich zum Beispiel nach:", + "chat.welcomeTipSearch": "Beiträgen zu einem bestimmten Thema", + "chat.welcomeTipChart": "einem Diagramm der pro Monat veröffentlichten Beiträge", + "chat.welcomeTipTable": "einem Tabellenvergleich meiner letzten Beiträge", + "chat.welcomeTipMetadata": "Metadaten für Beiträge oder Medien", + "chat.welcomeTipTabs": "Beitragsstatistiken pro Jahr in Tabs mit Diagrammen", + "chat.role.you": "Du", + "chat.role.assistant": "Assistent", + "chat.inputPlaceholder": "Nachricht eingeben...", + "gitDiff.changedFiles": "Geänderte Dateien", "sidebar.tags": "Schlagwörter", "sidebar.categories": "Kategorien", "sidebar.clearTags": "Tags löschen", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index 1f0c3e9..a1f8d8d 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -45,6 +45,37 @@ "render.video.vimeoTitle": "Vimeo video", "render.video.youtubeTitle": "YouTube video", "sidebar.chat.yesterday": "Yesterday", + "translationValidation.title": "Validate Translations", + "translationValidation.summary": "Checked DB rows: %{dbRows} · Checked files: %{files} · Invalid DB rows: %{invalidDb} · Invalid files: %{invalidFiles}", + "translationValidation.databaseTitle": "Invalid database translation rows", + "translationValidation.filesystemTitle": "Invalid translation files on disk", + "translationValidation.noneDatabase": "No invalid translation rows found.", + "translationValidation.noneFilesystem": "No invalid translation files found.", + "translationValidation.issue.sameLanguage": "Translation language matches canonical post language", + "translationValidation.issue.missingSource": "Translation points to a missing source post", + "translationValidation.issue.doNotTranslate": "Post is marked as do-not-translate but has translations", + "translationValidation.issue.contentInDatabase": "Published translation has content stuck in DB instead of filesystem", + "translationValidation.field.translationFor": "Source post", + "translationValidation.field.translationId": "Translation row", + "translationValidation.field.title": "Title", + "translationValidation.field.languages": "Languages", + "translationValidation.field.filePath": "File", + "translationValidation.languagesWithCanonical": "%{canonical} = %{translation}", + "translationValidation.revalidate": "Revalidate", + "translationValidation.fix": "Fix Issues", + "translationValidation.toast.fixSuccess": "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk", + "chat.newChat": "New Chat", + "chat.welcomeTitle": "Welcome to the AI Assistant", + "chat.welcomeDescription": "I can help you manage your blog with rich visualizations. Try asking me to:", + "chat.welcomeTipSearch": "Search for posts about a specific topic", + "chat.welcomeTipChart": "Show a chart of posts published per month", + "chat.welcomeTipTable": "Compare my recent posts in a table", + "chat.welcomeTipMetadata": "Update metadata for posts or media", + "chat.welcomeTipTabs": "Show post statistics by year in tabs with charts", + "chat.role.you": "You", + "chat.role.assistant": "Assistant", + "chat.inputPlaceholder": "Type a message...", + "gitDiff.changedFiles": "Changed files", "sidebar.tags": "Tags", "sidebar.categories": "Categories", "sidebar.clearTags": "Clear tags", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index 65585fb..db08386 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -45,6 +45,37 @@ "render.video.vimeoTitle": "Vídeo de Vimeo", "render.video.youtubeTitle": "Vídeo de YouTube", "sidebar.chat.yesterday": "Ayer", + "translationValidation.title": "Validar traducciones", + "translationValidation.summary": "Filas de BD revisadas: %{dbRows} · Archivos revisados: %{files} · Filas de BD inválidas: %{invalidDb} · Archivos inválidos: %{invalidFiles}", + "translationValidation.databaseTitle": "Filas de traducción inválidas en la base de datos", + "translationValidation.filesystemTitle": "Archivos de traducción inválidos en disco", + "translationValidation.noneDatabase": "No se encontraron filas de traducción inválidas.", + "translationValidation.noneFilesystem": "No se encontraron archivos de traducción inválidos.", + "translationValidation.issue.sameLanguage": "El idioma de la traducción coincide con el idioma canónico de la entrada", + "translationValidation.issue.missingSource": "La traducción apunta a una entrada de origen inexistente", + "translationValidation.issue.doNotTranslate": "La entrada está marcada como no-traducir pero tiene traducciones", + "translationValidation.issue.contentInDatabase": "Traducción publicada con contenido en la BD en lugar del sistema de archivos", + "translationValidation.field.translationFor": "Entrada de origen", + "translationValidation.field.translationId": "Fila de traducción", + "translationValidation.field.title": "Título", + "translationValidation.field.languages": "Idiomas", + "translationValidation.field.filePath": "Archivo", + "translationValidation.languagesWithCanonical": "%{canonical} = %{translation}", + "translationValidation.revalidate": "Revalidar", + "translationValidation.fix": "Corregir problemas", + "translationValidation.toast.fixSuccess": "%{dbRows} filas de BD y %{files} archivos eliminados, %{flushed} traducciones escritas a disco", + "chat.newChat": "Nuevo chat", + "chat.welcomeTitle": "Bienvenido al asistente de IA", + "chat.welcomeDescription": "Puedo ayudarte a gestionar tu blog con visualizaciones interactivas. Prueba a pedirme que:", + "chat.welcomeTipSearch": "Busque entradas sobre un tema específico", + "chat.welcomeTipChart": "Muestre un gráfico de entradas publicadas por mes", + "chat.welcomeTipTable": "Compare mis entradas recientes en una tabla", + "chat.welcomeTipMetadata": "Actualice metadatos de entradas o medios", + "chat.welcomeTipTabs": "Muestre estadísticas por año en pestañas con gráficos", + "chat.role.you": "Tú", + "chat.role.assistant": "Asistente", + "chat.inputPlaceholder": "Escribe un mensaje...", + "gitDiff.changedFiles": "Archivos modificados", "sidebar.tags": "Etiquetas", "sidebar.categories": "Categorías", "sidebar.clearTags": "Limpiar etiquetas", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index 3ecf161..77964a2 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -45,6 +45,37 @@ "render.video.vimeoTitle": "Vidéo Vimeo", "render.video.youtubeTitle": "Vidéo YouTube", "sidebar.chat.yesterday": "Hier", + "translationValidation.title": "Valider les traductions", + "translationValidation.summary": "Lignes BD vérifiées : %{dbRows} · Fichiers vérifiés : %{files} · Lignes BD invalides : %{invalidDb} · Fichiers invalides : %{invalidFiles}", + "translationValidation.databaseTitle": "Lignes de traduction invalides dans la base de données", + "translationValidation.filesystemTitle": "Fichiers de traduction invalides sur le disque", + "translationValidation.noneDatabase": "Aucune ligne de traduction invalide trouvée.", + "translationValidation.noneFilesystem": "Aucun fichier de traduction invalide trouvé.", + "translationValidation.issue.sameLanguage": "La langue de traduction correspond à la langue canonique de l’article", + "translationValidation.issue.missingSource": "La traduction pointe vers un article source manquant", + "translationValidation.issue.doNotTranslate": "L'article est marqué ne-pas-traduire mais a des traductions", + "translationValidation.issue.contentInDatabase": "Traduction publiée avec contenu encore en base au lieu du système de fichiers", + "translationValidation.field.translationFor": "Article source", + "translationValidation.field.translationId": "Ligne de traduction", + "translationValidation.field.title": "Titre", + "translationValidation.field.languages": "Langues", + "translationValidation.field.filePath": "Fichier", + "translationValidation.languagesWithCanonical": "%{canonical} = %{translation}", + "translationValidation.revalidate": "Revalider", + "translationValidation.fix": "Corriger les problèmes", + "translationValidation.toast.fixSuccess": "%{dbRows} lignes DB et %{files} fichiers supprimés, %{flushed} traductions écrites sur disque", + "chat.newChat": "Nouveau chat", + "chat.welcomeTitle": "Bienvenue dans l’assistant IA", + "chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :", + "chat.welcomeTipSearch": "Rechercher des articles sur un sujet précis", + "chat.welcomeTipChart": "Afficher un graphique des articles publiés par mois", + "chat.welcomeTipTable": "Comparer mes derniers articles dans un tableau", + "chat.welcomeTipMetadata": "Mettre à jour les métadonnées des articles ou médias", + "chat.welcomeTipTabs": "Afficher les statistiques par année dans des onglets avec graphiques", + "chat.role.you": "Vous", + "chat.role.assistant": "Assistant IA", + "chat.inputPlaceholder": "Saisissez un message...", + "gitDiff.changedFiles": "Fichiers modifiés", "sidebar.tags": "Étiquettes", "sidebar.categories": "Catégories", "sidebar.clearTags": "Effacer les étiquettes", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index 6535851..775e1ca 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -45,6 +45,37 @@ "render.video.vimeoTitle": "Video Vimeo", "render.video.youtubeTitle": "Video YouTube", "sidebar.chat.yesterday": "Ieri", + "translationValidation.title": "Valida traduzioni", + "translationValidation.summary": "Righe DB controllate: %{dbRows} · File controllati: %{files} · Righe DB non valide: %{invalidDb} · File non validi: %{invalidFiles}", + "translationValidation.databaseTitle": "Righe di traduzione non valide nel database", + "translationValidation.filesystemTitle": "File di traduzione non validi sul disco", + "translationValidation.noneDatabase": "Nessuna riga di traduzione non valida trovata.", + "translationValidation.noneFilesystem": "Nessun file di traduzione non valido trovato.", + "translationValidation.issue.sameLanguage": "La lingua della traduzione coincide con la lingua canonica del post", + "translationValidation.issue.missingSource": "La traduzione punta a un post sorgente mancante", + "translationValidation.issue.doNotTranslate": "Il post è contrassegnato come non-tradurre ma ha traduzioni", + "translationValidation.issue.contentInDatabase": "Traduzione pubblicata con contenuto nel DB invece del filesystem", + "translationValidation.field.translationFor": "Post sorgente", + "translationValidation.field.translationId": "Riga traduzione", + "translationValidation.field.title": "Titolo", + "translationValidation.field.languages": "Lingue", + "translationValidation.field.filePath": "File", + "translationValidation.languagesWithCanonical": "%{canonical} = %{translation}", + "translationValidation.revalidate": "Rivalidare", + "translationValidation.fix": "Correggi problemi", + "translationValidation.toast.fixSuccess": "%{dbRows} righe DB e %{files} file eliminati, %{flushed} traduzioni scritte su disco", + "chat.newChat": "Nuova chat", + "chat.welcomeTitle": "Benvenuto nell’assistente IA", + "chat.welcomeDescription": "Posso aiutarti a gestire il tuo blog con visualizzazioni interattive. Prova a chiedermi di:", + "chat.welcomeTipSearch": "Cercare post su un argomento specifico", + "chat.welcomeTipChart": "Mostrare un grafico dei post pubblicati per mese", + "chat.welcomeTipTable": "Confrontare i miei post recenti in una tabella", + "chat.welcomeTipMetadata": "Aggiornare i metadati di post o media", + "chat.welcomeTipTabs": "Mostrare statistiche per anno in schede con grafici", + "chat.role.you": "Tu", + "chat.role.assistant": "Assistente", + "chat.inputPlaceholder": "Scrivi un messaggio...", + "gitDiff.changedFiles": "File modificati", "sidebar.tags": "Tag", "sidebar.categories": "Categorie", "sidebar.clearTags": "Cancella tag", diff --git a/priv/ui/app.css b/priv/ui/app.css index 6927238..154ebbf 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -3272,6 +3272,57 @@ button svg * { font-weight: 700; } +.chat-panel-header { + position: relative; +} + +.chat-panel-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.chat-model-selector-button, +.chat-model-selector-option { + border: 1px solid var(--line, #3c3c3c); + border-radius: 10px; + background: var(--panel-2, #252526); + color: inherit; +} + +.chat-model-selector-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; +} + +.chat-model-selector-menu { + position: absolute; + top: calc(100% - 4px); + right: 20px; + min-width: 220px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--line, #3c3c3c); + border-radius: 12px; + background: var(--panel-1, #1e1e1e); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28); + z-index: 2; +} + +.chat-model-selector-option { + width: 100%; + padding: 8px 10px; + text-align: left; +} + +.chat-model-selector-option.active { + border-color: var(--accent-color); +} + .chat-messages { padding: 20px; overflow: auto; @@ -3300,6 +3351,56 @@ button svg * { background: rgba(0, 122, 204, 0.15); } +.chat-tool-markers { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.chat-tool-marker { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--line, #3c3c3c); + background: rgba(255, 255, 255, 0.03); + font-size: 12px; +} + +.chat-tool-surface { + max-width: min(820px, 100%); + margin-left: auto; + margin-right: auto; + border: 1px solid var(--line, #3c3c3c); + border-radius: 14px; + background: var(--panel-2, #252526); + padding: 16px; +} + +.chat-tool-surface h3 { + margin: 0 0 12px; +} + +.chat-tool-surface-table { + width: 100%; + border-collapse: collapse; +} + +.chat-tool-surface-table th, +.chat-tool-surface-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--line, #3c3c3c); + text-align: left; +} + +.chat-tool-surface-json { + margin: 0; + white-space: pre-wrap; + font: 12px/1.5 "SFMono-Regular", Menlo, Monaco, Consolas, monospace; +} + .chat-input-container { padding: 16px 20px 20px; border-top: 1px solid var(--line, #3c3c3c); @@ -3436,6 +3537,103 @@ button svg * { font: 12px/1.5 "SFMono-Regular", Menlo, Monaco, Consolas, monospace; } +.translation-validation-view, +.git-diff-view { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; +} + +.translation-validation-summary, +.translation-validation-section, +.git-diff-toolbar { + border: 1px solid var(--line, #3c3c3c); + border-radius: 12px; + background: var(--panel-2, #252526); + padding: 16px; +} + +.translation-validation-summary p, +.git-diff-empty { + margin: 0; + color: var(--vscode-descriptionForeground); +} + +.translation-validation-list { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.translation-validation-card { + border: 1px solid var(--line, #3c3c3c); + border-radius: 12px; + background: var(--panel-1, #1e1e1e); + padding: 16px; +} + +.translation-validation-card-title { + margin: 0 0 12px; + font-weight: 600; +} + +.translation-validation-card-meta { + margin: 0; + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 8px 12px; +} + +.translation-validation-card-meta dt { + color: var(--vscode-descriptionForeground); +} + +.translation-validation-card-meta dd { + margin: 0; + word-break: break-word; +} + +.translation-validation-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.translation-validation-empty { + margin: 12px 0 0; + color: var(--vscode-descriptionForeground); +} + +.git-diff-toolbar { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.git-diff-toolbar label { + font-weight: 600; +} + +.git-diff-toolbar select { + min-width: min(420px, 100%); +} + +.git-diff-editor { + flex: 1; + min-height: 420px; + border: 1px solid var(--line, #3c3c3c); + border-radius: 12px; + overflow: hidden; + background: var(--panel-1, #1e1e1e); +} + +.monaco-diff-editor-instance { + height: 100%; + min-height: 420px; +} + .linkish { padding: 0; border: none; diff --git a/priv/ui/live.js b/priv/ui/live.js index ff955e4..1c8edb1 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -238,6 +238,11 @@ document.addEventListener("DOMContentLoaded", () => { document.head.appendChild(script); }); + const diffModelPath = (filePath, side) => { + const normalized = String(filePath || "working-tree").replace(/^\/+/, ""); + return `inmemory://model/git-diff/${side}/${normalized}`; + }; + const registerLiquidLanguage = (monaco) => { if (liquidLanguageRegistered) { return; @@ -826,6 +831,134 @@ document.addEventListener("DOMContentLoaded", () => { this.changeSubscription?.dispose(); this.editor?.dispose(); } + }, + + MonacoDiffEditor: { + mounted() { + this.host = this.el.querySelector(".monaco-diff-editor-instance"); + this.originalInput = this.el.querySelector(".monaco-diff-original"); + this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); + this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; + this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; + this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; + this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; + this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; + + this.readValues = () => ({ + original: this.originalInput?.value || "", + modified: this.modifiedInput?.value || "" + }); + + this.applyDataset = () => { + this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; + this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; + this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; + this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; + this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; + }; + + this.setModels = (monaco) => { + const values = this.readValues(); + + this.originalModel?.dispose(); + this.modifiedModel?.dispose(); + + this.originalModel = monaco.editor.createModel( + values.original, + this.language, + monaco.Uri.parse(diffModelPath(this.filePath, "original")) + ); + + this.modifiedModel = monaco.editor.createModel( + values.modified, + this.language, + monaco.Uri.parse(diffModelPath(this.filePath, "modified")) + ); + + this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel }); + this.lastFilePath = this.filePath; + }; + + loadMonaco() + .then((monaco) => { + if (!this.host) { + return; + } + + ensureMonacoTheme(monaco); + + this.editor = monaco.editor.createDiffEditor(this.host, { + theme: "bds-theme", + automaticLayout: true, + readOnly: true, + renderSideBySide: this.viewStyle === "side-by-side", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbers: "on", + diffCodeLens: false, + originalEditable: false, + wordWrap: this.wordWrap, + hideUnchangedRegions: { enabled: this.hideUnchanged }, + ignoreTrimWhitespace: false + }); + + this.setModels(monaco); + }) + .catch((error) => { + console.error("Failed to load Monaco diff editor", error); + }); + }, + + updated() { + this.host = this.el.querySelector(".monaco-diff-editor-instance"); + this.originalInput = this.el.querySelector(".monaco-diff-original"); + this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); + this.applyDataset(); + + if (!this.editor) { + return; + } + + loadMonaco().then((monaco) => { + ensureMonacoTheme(monaco); + monaco.editor.setTheme("bds-theme"); + + this.editor.updateOptions({ + renderSideBySide: this.viewStyle === "side-by-side", + wordWrap: this.wordWrap, + hideUnchangedRegions: { enabled: this.hideUnchanged } + }); + + if (this.lastFilePath !== this.filePath) { + this.setModels(monaco); + return; + } + + const values = this.readValues(); + + if (this.originalModel && this.originalModel.getLanguageId() !== this.language) { + monaco.editor.setModelLanguage(this.originalModel, this.language); + } + + if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) { + monaco.editor.setModelLanguage(this.modifiedModel, this.language); + } + + if (this.originalModel && this.originalModel.getValue() !== values.original) { + this.originalModel.setValue(values.original); + } + + if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) { + this.modifiedModel.setValue(values.modified); + } + }); + }, + + destroyed() { + this.originalModel?.dispose(); + this.modifiedModel?.dispose(); + this.editor?.dispose(); + } } }; diff --git a/test/bds/desktop/shell_commands_test.exs b/test/bds/desktop/shell_commands_test.exs index cd50ad2..e6c923b 100644 --- a/test/bds/desktop/shell_commands_test.exs +++ b/test/bds/desktop/shell_commands_test.exs @@ -51,13 +51,7 @@ defmodule BDS.Desktop.ShellCommandsTest do assert result.project_id == project.id end - test "validate_translations returns an editor payload with current translation gaps", %{project: project} do - assert {:ok, _metadata} = - BDS.Metadata.update_project_metadata(project.id, %{ - main_language: "en", - blog_languages: ["en", "de"] - }) - + test "validate_translations returns an editor payload with current translation gaps", %{project: project, temp_dir: temp_dir} do assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, @@ -66,7 +60,37 @@ defmodule BDS.Desktop.ShellCommandsTest do language: "en" }) - assert {:ok, _published_post} = BDS.Posts.publish_post(post.id) + assert {:ok, published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, translation} = + BDS.Posts.upsert_post_translation(post.id, "de", %{ + title: "Hallo", + content: "Welt", + status: :published + }) + + translation_id = translation.id + + invalid_file_path = + Path.join([ + temp_dir, + Path.dirname(published_post.file_path), + "#{published_post.slug}.en.md" + ]) + + File.write!( + invalid_file_path, + [ + "---", + "translationFor: #{post.id}", + "language: en", + "title: Wrong Language", + "---", + "Invalid translation", + "" + ] + |> Enum.join("\n") + ) assert {:ok, result} = ShellCommands.execute("validate_translations") @@ -79,9 +103,28 @@ defmodule BDS.Desktop.ShellCommandsTest do assert completed.group_name == "Validation" assert completed.result.kind == "open_editor" assert completed.result.route == "translation_validation" - assert completed.result.payload.summary.missing_count == 1 + assert completed.result.payload.checked_database_row_count == 1 + assert completed.result.payload.checked_filesystem_file_count == 1 + post_id = post.id - assert [%{"language" => "de", "post_id" => ^post_id}] = completed.result.payload.missing + + assert [ + %{ + "issue" => "content-in-database", + "translation_for" => ^post_id, + "translation_id" => ^translation_id, + "translation_language" => "de" + } + ] = completed.result.payload.invalid_database_rows + + assert [ + %{ + "issue" => "same-language-as-canonical", + "translation_for" => ^post_id, + "translation_language" => "en", + "file_path" => ^invalid_file_path + } + ] = completed.result.payload.invalid_filesystem_files end test "validate_site queues a tracked validation task and returns the report as an editor payload" do diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 883ded8..949ae18 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -1736,6 +1736,160 @@ defmodule BDS.Desktop.ShellLiveTest do refute chat_html =~ "Desktop workbench content routed through the Elixir shell." end + test "chat editor renders legacy model controls, tool markers, and structured tool surfaces" do + assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat", model: "gpt-4.1"}) + + now = Persistence.now_ms() + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :user, + content: "Show me a table", + created_at: now + }) + ) + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :assistant, + content: "Here is the current summary.", + tool_calls: + Jason.encode!([ + %{ + "id" => "call-table", + "name" => "render_table", + "arguments" => %{"title" => "Blog Stats", "columns" => ["Metric", "Value"]} + } + ]), + created_at: now + 1 + }) + ) + + Repo.insert!( + BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{ + conversation_id: conversation.id, + role: :tool, + tool_call_id: "call-table", + content: + Jason.encode!(%{ + "type" => "table", + "title" => "Blog Stats", + "columns" => ["Metric", "Value"], + "rows" => [["Posts", "1"], ["Media", "0"]] + }), + created_at: now + 2 + }) + ) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + assert html =~ ~s(data-testid="chat-model-selector-button") + assert html =~ "gpt-4.1" + assert html =~ ~s(data-testid="chat-tool-marker") + assert html =~ "render_table" + assert html =~ ~s(data-testid="chat-tool-surface") + assert html =~ "Blog Stats" + assert html =~ "Metric" + assert html =~ "Posts" + end + + test "translation validation route renders dedicated cards and fix controls", %{project: project, temp_dir: temp_dir} do + assert {:ok, _metadata} = + BDS.Metadata.update_project_metadata(project.id, %{ + main_language: "en", + blog_languages: ["en", "de"] + }) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Hello", + content: "World", + language: "en" + }) + + assert {:ok, published_post} = Posts.publish_post(post.id) + + assert {:ok, _translation} = + Posts.upsert_post_translation(post.id, "de", %{ + title: "Hallo", + content: "Welt", + status: :published + }) + + invalid_file_path = + Path.join([ + temp_dir, + Path.dirname(published_post.file_path), + "#{published_post.slug}.en.md" + ]) + + File.write!( + invalid_file_path, + [ + "---", + "translationFor: #{post.id}", + "language: en", + "title: Wrong Language", + "---", + "Invalid translation", + "" + ] + |> Enum.join("\n") + ) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + assert {:ok, queued} = BDS.Desktop.ShellCommands.execute("validate_translations") + completed_task!(queued.task_id) + send(view.pid, :refresh_task_status) + + html = render(view) + + assert html =~ ~s(class="translation-validation-view") + assert html =~ ~s(data-testid="translation-validation-revalidate") + assert html =~ ~s(data-testid="translation-validation-fix") + assert html =~ ~s(data-testid="translation-validation-card") + assert html =~ invalid_file_path + end + + test "git diff route renders a structured Monaco diff surface for working tree changes", %{temp_dir: temp_dir} do + posts_dir = Path.join(temp_dir, "posts") + File.mkdir_p!(posts_dir) + + file_path = Path.join(posts_dir, "first.md") + File.write!(file_path, "Old content\n") + + init_git_repo!(temp_dir, "initial") + File.write!(file_path, "New content\n") + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "git_diff", + "id" => "git-working-tree", + "title" => "Working tree", + "subtitle" => "Working tree and history" + }) + + assert html =~ ~s(class="git-diff-view") + assert html =~ ~s(data-testid="git-diff-file-select") + assert html =~ "posts/first.md" + assert html =~ ~s(phx-hook="MonacoDiffEditor") + refute html =~ ~s(
)
+  end
+
   test "settings sidebar categories render the full old-app section model and target the requested section" do
     {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
 
diff --git a/test/bds/git_test.exs b/test/bds/git_test.exs
index e2e1cb7..b9552b2 100644
--- a/test/bds/git_test.exs
+++ b/test/bds/git_test.exs
@@ -72,6 +72,26 @@ defmodule BDS.GitTest do
     assert repo.current_branch == "main"
   end
 
+  test "get_diff_content returns HEAD and working tree content for a changed file", %{
+    project: project,
+    project_dir: project_dir
+  } do
+    posts_dir = Path.join(project_dir, "posts")
+    File.mkdir_p!(posts_dir)
+
+    relative_path = "posts/first.md"
+    full_path = Path.join(project_dir, relative_path)
+
+    File.write!(full_path, "Old content\n")
+    init_git_repo!(project_dir, "initial")
+    File.write!(full_path, "New content\n")
+
+    assert {:ok, diff} = Git.get_diff_content(project.id, relative_path)
+    assert diff.file_path == relative_path
+    assert diff.original == "Old content\n"
+    assert diff.modified == "New content\n"
+  end
+
   test "remote_state reports upstream ahead and behind counts", %{project: project} do
     runner = fake_runner(fn
       "git", ["rev-parse", "--abbrev-ref", "HEAD"], _opts -> {"main\n", 0}
@@ -146,4 +166,18 @@ defmodule BDS.GitTest do
   defp fake_runner(handler) do
     fn command, args, opts -> handler.(command, args, opts) end
   end
+
+  defp init_git_repo!(project_dir, message) do
+    run_git!(project_dir, ["init", "-b", "master"])
+    run_git!(project_dir, ["config", "user.name", "bDS Tests"])
+    run_git!(project_dir, ["config", "user.email", "tests@example.com"])
+    run_git!(project_dir, ["add", "-A"])
+    run_git!(project_dir, ["commit", "-m", message])
+  end
+
+  defp run_git!(dir, args) do
+    {output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
+
+    assert status == 0, output
+  end
 end
diff --git a/test/bds/posts_test.exs b/test/bds/posts_test.exs
index 2e868b4..c3dcfee 100644
--- a/test/bds/posts_test.exs
+++ b/test/bds/posts_test.exs
@@ -705,6 +705,75 @@ defmodule BDS.PostsTest do
     assert {:ok, %{indexed: 3, total: 3}} = BDS.Embeddings.get_indexing_progress(project.id)
   end
 
+  test "validate_translations and fix_invalid_translations follow the legacy invalid-translation workflow",
+       %{project: project} do
+    assert {:ok, post} =
+             BDS.Posts.create_post(%{
+               project_id: project.id,
+               title: "Source Post",
+               content: "Canonical body",
+               language: "en"
+             })
+
+    assert {:ok, published_post} = BDS.Posts.publish_post(post.id)
+
+    assert {:ok, translation} =
+             BDS.Posts.upsert_post_translation(post.id, "de", %{
+               title: "Translated Post",
+               content: "Translated body",
+               status: :published
+             })
+
+    invalid_file_path =
+      Path.join([
+        BDS.Projects.project_data_dir(project),
+        Path.dirname(published_post.file_path),
+        "#{published_post.slug}.en.md"
+      ])
+
+    File.write!(
+      invalid_file_path,
+      [
+        "---",
+        "translationFor: #{post.id}",
+        "language: en",
+        "title: Invalid Same Language",
+        "---",
+        "Wrong translation",
+        ""
+      ]
+      |> Enum.join("\n")
+    )
+
+    assert {:ok, report} = BDS.Posts.validate_translations(project.id)
+
+    assert report.checked_database_row_count == 1
+    assert report.checked_filesystem_file_count == 1
+
+    assert [db_issue] = report.invalid_database_rows
+    assert db_issue.issue == "content-in-database"
+    assert db_issue.translation_id == translation.id
+    assert db_issue.translation_for == post.id
+    assert db_issue.translation_language == "de"
+
+    assert [file_issue] = report.invalid_filesystem_files
+    assert file_issue.issue == "same-language-as-canonical"
+    assert file_issue.translation_for == post.id
+    assert file_issue.translation_language == "en"
+    assert file_issue.file_path == invalid_file_path
+
+    assert {:ok, result} = BDS.Posts.fix_invalid_translations(report)
+    assert result.deleted_database_rows == 0
+    assert result.deleted_files == 1
+    assert result.flushed_translations == 1
+
+    saved_translation = BDS.Repo.get!(BDS.Posts.Translation, translation.id)
+    assert saved_translation.content == nil
+    assert is_binary(saved_translation.file_path)
+    assert File.exists?(Path.join(BDS.Projects.project_data_dir(project), saved_translation.file_path))
+    refute File.exists?(invalid_file_path)
+  end
+
   def handle_repo_query(_event, _measurements, metadata, owner_pid) do
     send(owner_pid, {:repo_query, metadata.query || ""})
   end