feat: step 5 claimed done
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,37 +1,113 @@
|
||||
<div class="chat-panel" data-testid="chat-editor">
|
||||
<div class="chat-panel-header">
|
||||
<div class="chat-panel-title"><%= @chat_editor.title %></div>
|
||||
|
||||
<div class="chat-panel-header-actions">
|
||||
<button
|
||||
class="chat-model-selector-button"
|
||||
type="button"
|
||||
phx-click="toggle_chat_model_selector"
|
||||
data-testid="chat-model-selector-button"
|
||||
>
|
||||
<span><%= @chat_editor.model || translated("chat.newChat") %></span>
|
||||
<span class="chat-model-selector-caret">▾</span>
|
||||
</button>
|
||||
|
||||
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
||||
<div class="chat-model-selector-menu">
|
||||
<%= for model <- @chat_editor.available_models do %>
|
||||
<button
|
||||
class={[
|
||||
"chat-model-selector-option",
|
||||
if(model.id == @chat_editor.model, do: "active")
|
||||
]}
|
||||
type="button"
|
||||
phx-click="select_chat_model"
|
||||
phx-value-model={model.id}
|
||||
>
|
||||
<%= model.name %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages">
|
||||
<%= if Enum.empty?(@chat_editor.messages) do %>
|
||||
<div class="chat-welcome">
|
||||
<div class="chat-welcome-icon">🤖</div>
|
||||
<h2><%= translated("New Chat") %></h2>
|
||||
<p><%= translated("Ask the assistant about the active project.") %></p>
|
||||
<h2><%= translated("chat.welcomeTitle") %></h2>
|
||||
<p><%= translated("chat.welcomeDescription") %></p>
|
||||
<ul>
|
||||
<li><%= translated("Search posts and media") %></li>
|
||||
<li><%= translated("Inspect metadata") %></li>
|
||||
<li><%= translated("Open related tabs") %></li>
|
||||
<li><%= translated("Review generated output") %></li>
|
||||
<li><%= translated("Navigate settings") %></li>
|
||||
<li><%= translated("chat.welcomeTipSearch") %></li>
|
||||
<li><%= translated("chat.welcomeTipChart") %></li>
|
||||
<li><%= translated("chat.welcomeTipTable") %></li>
|
||||
<li><%= translated("chat.welcomeTipMetadata") %></li>
|
||||
<li><%= translated("chat.welcomeTipTabs") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= for message <- @chat_editor.messages do %>
|
||||
<div class={["chat-message", to_string(message.role || "assistant")]}>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-message-header"><span class="chat-message-role"><%= String.capitalize(to_string(message.role || "assistant")) %></span></div>
|
||||
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
|
||||
|
||||
<%= if message.tool_markers != [] do %>
|
||||
<div class="chat-tool-markers">
|
||||
<%= for tool_call <- message.tool_markers do %>
|
||||
<div class="chat-tool-marker" data-testid="chat-tool-marker">
|
||||
<span class="chat-tool-marker-name"><%= tool_call_name(tool_call) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="chat-message-text"><%= message.content || "" %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= for surface <- message.tool_surfaces do %>
|
||||
<article class="chat-tool-surface" data-testid="chat-tool-surface">
|
||||
<%= if surface.title do %>
|
||||
<h3><%= surface.title %></h3>
|
||||
<% end %>
|
||||
|
||||
<%= case tool_surface_type(surface) do %>
|
||||
<% "table" -> %>
|
||||
<div class="chat-tool-surface-table-wrap">
|
||||
<table class="chat-tool-surface-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<%= for column <- surface.columns do %>
|
||||
<th><%= column %></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for row <- surface.rows do %>
|
||||
<tr>
|
||||
<%= for value <- row do %>
|
||||
<td><%= value %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<% _other -> %>
|
||||
<pre class="chat-tool-surface-json"><%= Jason.encode!(surface.data, pretty: true) %></pre>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<form class="chat-input-wrapper" phx-change="change_chat_editor_input">
|
||||
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("Send a message")}><%= @chat_editor.input %></textarea>
|
||||
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")}><%= @chat_editor.input %></textarea>
|
||||
<button class="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={String.trim(@chat_editor.input || "") == "" or @chat_editor.offline?}>↑</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -181,10 +181,79 @@
|
||||
</div>
|
||||
|
||||
<% :translation_validation -> %>
|
||||
<div class="misc-columns">
|
||||
<section class="misc-card"><h3><%= translated("Missing") %></h3><ul><%= for issue <- @misc_editor.missing do %><li><%= inspect(issue) %></li><% end %></ul></section>
|
||||
<section class="misc-card"><h3><%= translated("Orphan Files") %></h3><ul><%= for file <- @misc_editor.orphan_files do %><li><%= file %></li><% end %></ul></section>
|
||||
<section class="misc-card"><h3><%= translated("Do Not Translate") %></h3><ul><%= for post <- @misc_editor.do_not_translate_posts do %><li><%= inspect(post) %></li><% end %></ul></section>
|
||||
<div class="translation-validation-view">
|
||||
<section class="translation-validation-summary">
|
||||
<p><%= @misc_editor.summary_text %></p>
|
||||
</section>
|
||||
|
||||
<section class="translation-validation-section">
|
||||
<h3><%= translated("translationValidation.databaseTitle") %></h3>
|
||||
|
||||
<%= if @misc_editor.invalid_database_rows == [] do %>
|
||||
<p class="translation-validation-empty"><%= translated("translationValidation.noneDatabase") %></p>
|
||||
<% else %>
|
||||
<div class="translation-validation-list">
|
||||
<%= for issue <- @misc_editor.invalid_database_rows do %>
|
||||
<article class="translation-validation-card translation-validation-card-db" data-testid="translation-validation-card">
|
||||
<p class="translation-validation-card-title"><%= translation_issue_label(issue) %></p>
|
||||
<dl class="translation-validation-card-meta">
|
||||
<dt><%= translated("translationValidation.field.translationFor") %></dt>
|
||||
<dd><%= translation_issue_value(issue, :translation_for) %></dd>
|
||||
<%= if translation_issue_value(issue, :translation_id) do %>
|
||||
<dt><%= translated("translationValidation.field.translationId") %></dt>
|
||||
<dd><%= translation_issue_value(issue, :translation_id) %></dd>
|
||||
<% end %>
|
||||
<%= if translation_issue_value(issue, :title) do %>
|
||||
<dt><%= translated("translationValidation.field.title") %></dt>
|
||||
<dd><%= translation_issue_value(issue, :title) %></dd>
|
||||
<% end %>
|
||||
<dt><%= translated("translationValidation.field.languages") %></dt>
|
||||
<dd><%= translation_issue_languages(issue) %></dd>
|
||||
<%= if translation_issue_value(issue, :file_path) do %>
|
||||
<dt><%= translated("translationValidation.field.filePath") %></dt>
|
||||
<dd><%= translation_issue_value(issue, :file_path) %></dd>
|
||||
<% end %>
|
||||
</dl>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class="translation-validation-section">
|
||||
<h3><%= translated("translationValidation.filesystemTitle") %></h3>
|
||||
|
||||
<%= if @misc_editor.invalid_filesystem_files == [] do %>
|
||||
<p class="translation-validation-empty"><%= translated("translationValidation.noneFilesystem") %></p>
|
||||
<% else %>
|
||||
<div class="translation-validation-list">
|
||||
<%= for issue <- @misc_editor.invalid_filesystem_files do %>
|
||||
<article class="translation-validation-card translation-validation-card-file" data-testid="translation-validation-card">
|
||||
<p class="translation-validation-card-title"><%= translation_issue_label(issue) %></p>
|
||||
<dl class="translation-validation-card-meta">
|
||||
<dt><%= translated("translationValidation.field.translationFor") %></dt>
|
||||
<dd><%= translation_issue_value(issue, :translation_for) %></dd>
|
||||
<%= if translation_issue_value(issue, :title) do %>
|
||||
<dt><%= translated("translationValidation.field.title") %></dt>
|
||||
<dd><%= translation_issue_value(issue, :title) %></dd>
|
||||
<% end %>
|
||||
<dt><%= translated("translationValidation.field.languages") %></dt>
|
||||
<dd><%= translation_issue_languages(issue) %></dd>
|
||||
<%= if translation_issue_value(issue, :file_path) do %>
|
||||
<dt><%= translated("translationValidation.field.filePath") %></dt>
|
||||
<dd><%= translation_issue_value(issue, :file_path) %></dd>
|
||||
<% end %>
|
||||
</dl>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<div class="translation-validation-actions">
|
||||
<button class="secondary" type="button" phx-click="rerun_misc_editor" data-testid="translation-validation-revalidate"><%= translated("translationValidation.revalidate") %></button>
|
||||
<button class="primary" type="button" phx-click="fix_translation_validation" data-testid="translation-validation-fix" disabled={not @misc_editor.can_fix?}><%= translated("translationValidation.fix") %></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% :find_duplicates -> %>
|
||||
@@ -202,7 +271,35 @@
|
||||
</div>
|
||||
|
||||
<% :git_diff -> %>
|
||||
<div class="misc-card misc-code-card"><pre><code><%= @misc_editor.diff_text %></code></pre></div>
|
||||
<div class="git-diff-view">
|
||||
<%= if @misc_editor.files == [] do %>
|
||||
<p class="git-diff-empty"><%= @misc_editor.empty_message %></p>
|
||||
<% else %>
|
||||
<form class="git-diff-toolbar" phx-change="select_git_diff_file">
|
||||
<label for="git-diff-file-select"><%= translated("gitDiff.changedFiles") %></label>
|
||||
<select id="git-diff-file-select" data-testid="git-diff-file-select" name="path">
|
||||
<%= for file_path <- @misc_editor.files do %>
|
||||
<option value={file_path} selected={file_path == @misc_editor.selected_file_path}><%= file_path %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div
|
||||
id={"git-diff-editor-#{String.replace(@misc_editor.active_diff.file_path || "working-tree", ~r/[^a-zA-Z0-9_-]+/, "-")}"}
|
||||
class="git-diff-editor"
|
||||
phx-hook="MonacoDiffEditor"
|
||||
data-monaco-diff-language={@misc_editor.active_diff.language}
|
||||
data-monaco-diff-file-path={@misc_editor.active_diff.file_path}
|
||||
data-monaco-diff-view-style={@misc_editor.preferences.view_style}
|
||||
data-monaco-diff-word-wrap={if(@misc_editor.preferences.word_wrap, do: "on", else: "off")}
|
||||
data-monaco-diff-hide-unchanged={if(@misc_editor.preferences.hide_unchanged_regions, do: "true", else: "false")}
|
||||
>
|
||||
<textarea class="monaco-diff-original" hidden><%= @misc_editor.active_diff.original %></textarea>
|
||||
<textarea class="monaco-diff-modified" hidden><%= @misc_editor.active_diff.modified %></textarea>
|
||||
<div class="monaco-diff-editor-instance"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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),
|
||||
|
||||
431
lib/bds/posts.ex
431
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,31 +1358,289 @@ 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: ""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
198
priv/ui/app.css
198
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;
|
||||
|
||||
133
priv/ui/live.js
133
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<pre><code>)
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user