chore: refactored areas around to_existing_atom/1 uses

This commit is contained in:
2026-05-01 17:25:59 +02:00
parent 3505355980
commit 07ce5f8b4d
16 changed files with 1150 additions and 373 deletions

View File

@@ -91,9 +91,9 @@ _None._ All modules previously on the queue have been split; refresh the queue i
## 8. `String.to_existing_atom/1` + `rescue ArgumentError` ## 8. `String.to_existing_atom/1` + `rescue ArgumentError`
**Status:** open, low priority. **Status:** ✅ done (2026-05-01). `String.to_existing_atom/1` call sites were replaced with explicit string→atom whitelists in `BDS.BoundedAtoms`. Session restore, LiveView view/tab/route parsing, panel tab parsing, shell command parsing, import sections, taxonomy types, AI endpoints, MCP agents, menu kinds, script kinds, and post/translation status parsing now all use bounded parsers with explicit fallbacks.
**Plan:** introduce explicit string→atom whitelists for the half-dozen call sites (`safe_existing_atom`, view-id parsing, panel-tab parsing) so the rescue clause becomes dead code, then delete it. **Rule:** user/client/file-provided strings must not be converted with `String.to_existing_atom/1` plus rescue; add a bounded parser for the relevant enum instead.
--- ---
@@ -216,6 +216,8 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`
### 2026-05-01 ### 2026-05-01
- **`String.to_existing_atom/1` + rescue**: added `BDS.BoundedAtoms` as the shared bounded string→atom parser for sidebar views, editor routes, panel tabs, post statuses, translation statuses, script/template/menu kinds, import sections, taxonomy types, AI endpoints, MCP agents, and shell commands. Replaced every `String.to_existing_atom/1` call site and removed the `safe_existing_atom` rescue helpers. Added regression coverage that scans `lib/**/*.ex` to prevent reintroducing `String.to_existing_atom/1` outside the bounded parser module. Section 8 is closed.
- **Direct `Repo.get` in `BDS.Desktop.ShellLive`**: added context helpers for primary-key reads (`Posts.get_post/1`, `Media.get_media/1`, `Templates.get_template/1`, `Scripts.get_script/1`, `AI.get_chat_conversation/1`) and introduced `BDS.Settings` for global editor settings. Replaced all ShellLive `Repo.get/2` and `Repo.get!/2` calls across the main shell, tab helpers, CLI sync, panel renderer, chat message build, code entity editor, post editor, media editor, overlay components, post metadata, and settings editor. Added a ShellLive regression test that scans source files to keep direct `Repo.get` calls out. Section 7 is closed. - **Direct `Repo.get` in `BDS.Desktop.ShellLive`**: added context helpers for primary-key reads (`Posts.get_post/1`, `Media.get_media/1`, `Templates.get_template/1`, `Scripts.get_script/1`, `AI.get_chat_conversation/1`) and introduced `BDS.Settings` for global editor settings. Replaced all ShellLive `Repo.get/2` and `Repo.get!/2` calls across the main shell, tab helpers, CLI sync, panel renderer, chat message build, code entity editor, post editor, media editor, overlay components, post metadata, and settings editor. Added a ShellLive regression test that scans source files to keep direct `Repo.get` calls out. Section 7 is closed.
- **God modules**: - **God modules**:

88
lib/bds/bounded_atoms.ex Normal file
View File

@@ -0,0 +1,88 @@
defmodule BDS.BoundedAtoms do
@moduledoc false
alias BDS.UI.Registry
@panel_tabs [:tasks, :output, :post_links, :git_log]
@post_statuses [:draft, :published, :archived]
@translation_statuses [:draft, :published]
@script_kinds [:macro, :utility, :transform]
@template_kinds [:post, :list, :not_found, :partial]
@menu_kinds [:page, :submenu, :category_archive, :home]
@import_sections [
:post_conflicts,
:page_conflicts,
:posts,
:other,
:pages,
:media,
:taxonomy,
:macros
]
@taxonomy_types [:categories, :tags]
@ai_endpoints [:online, :airplane]
@mcp_agents [
:claude_code,
:claude_desktop,
:github_copilot,
:gemini_cli,
:opencode,
:mistral_vibe,
:openai_codex
]
@shell_commands [
:toggle_sidebar,
:toggle_panel,
:toggle_assistant_sidebar,
:view_posts,
:view_media,
:edit_preferences,
:edit_menu,
:documentation,
:api_documentation,
:close_tab
]
def atom(value, allowed, fallback \\ nil)
def atom(value, allowed, fallback) when is_atom(value) do
if value in allowed, do: value, else: fallback
end
def atom(value, allowed, fallback) when is_binary(value),
do: string_atom(value, allowed, fallback)
def atom(_value, _allowed, fallback), do: fallback
def sidebar_view(value, fallback \\ nil), do: atom(value, sidebar_views(), fallback)
def editor_route(value, fallback \\ nil), do: atom(value, editor_routes(), fallback)
def panel_tab(value, fallback \\ nil), do: atom(value, @panel_tabs, fallback)
def post_status(value, fallback \\ nil), do: atom(value, @post_statuses, fallback)
def translation_status(value, fallback \\ nil), do: atom(value, @translation_statuses, fallback)
def script_kind(value, fallback \\ nil), do: atom(value, @script_kinds, fallback)
def template_kind(value, fallback \\ nil), do: atom(value, @template_kinds, fallback)
def menu_kind(value, fallback \\ nil),
do: atom(normalize_menu_kind(value), @menu_kinds, fallback)
def import_section(value, fallback \\ nil), do: atom(value, @import_sections, fallback)
def taxonomy_type(value, fallback \\ nil), do: atom(value, @taxonomy_types, fallback)
def ai_endpoint(value, fallback \\ nil), do: atom(value, @ai_endpoints, fallback)
def mcp_agent(value, fallback \\ nil), do: atom(value, @mcp_agents, fallback)
def shell_command(value, fallback \\ nil), do: atom(value, @shell_commands, fallback)
defp string_atom(value, allowed, fallback) do
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))
end
defp sidebar_views do
Enum.map(Registry.sidebar_views(), & &1.id)
end
defp editor_routes do
Enum.map(Registry.editor_routes(), & &1.id)
end
defp normalize_menu_kind("category-archive"), do: "category_archive"
defp normalize_menu_kind(value), do: value
end

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers} alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers}
alias BDS.UI.Workbench alias BDS.UI.Workbench
@@ -74,7 +75,12 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
socket socket
|> clear_action_error() |> clear_action_error()
|> callbacks.open_sidebar.( |> callbacks.open_sidebar.(
%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, %{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin :pin
) )
@@ -96,7 +102,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
) )
:switch_view -> :switch_view ->
case safe_existing_atom(Map.get(payload, "view")) do case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
nil -> nil ->
ChatEditor.set_action_error( ChatEditor.set_action_error(
socket, socket,
@@ -160,7 +166,11 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
end end
def clear_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do def clear_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do
assign(socket, :chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)) assign(
socket,
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
end end
def clear_action_error(socket), do: socket def clear_action_error(socket), do: socket
@@ -177,7 +187,8 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
defp decode_payload(_payload), do: %{} defp decode_payload(_payload), do: %{}
defp maybe_put_form_data(payload, socket, surface_id) when is_binary(surface_id) and surface_id != "" do defp maybe_put_form_data(payload, socket, surface_id)
when is_binary(surface_id) and surface_id != "" do
form_data = ChatEditor.current_surface_data(socket, surface_id) form_data = ChatEditor.current_surface_data(socket, surface_id)
if form_data == %{} do if form_data == %{} do
@@ -209,17 +220,13 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
end end
end end
defp safe_existing_atom(action) when is_binary(action) do
String.to_existing_atom(action)
rescue
ArgumentError -> nil
end
defp safe_existing_atom(_), do: nil
defp assistant_reply(socket) do defp assistant_reply(socket) do
if socket.assigns.offline_mode do if socket.assigns.offline_mode do
ShellData.translate("Automatic AI actions stay gated by airplane mode.", %{}, socket.assigns.page_language) ShellData.translate(
"Automatic AI actions stay gated by airplane mode.",
%{},
socket.assigns.page_language
)
else else
ShellData.translate( ShellData.translate(
"The assistant sidebar chat surface is ready, but model execution is not connected yet.", "The assistant sidebar chat surface is ready, but model execution is not connected yet.",

View File

@@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
alias BDS.Scripts.Script alias BDS.Scripts.Script
alias BDS.Templates.Template alias BDS.Templates.Template
embed_templates "code_entity_editor_html/*" embed_templates("code_entity_editor_html/*")
def assign_socket(socket) do def assign_socket(socket) do
socket socket
@@ -20,7 +20,10 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
socket socket
|> assign(:script_editor_drafts, Map.put(socket.assigns.script_editor_drafts, script_id, normalize_script_params(params))) |> assign(
:script_editor_drafts,
Map.put(socket.assigns.script_editor_drafts, script_id, normalize_script_params(params))
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@@ -28,7 +31,9 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do case Scripts.get_script(script_id) do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script -> %Script{} = script ->
draft = current_script_draft(socket.assigns, script) draft = current_script_draft(socket.assigns, script)
@@ -37,7 +42,10 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
case Scripts.update_script(script.id, script_attrs(draft)) do case Scripts.update_script(script.id, script_attrs(draft)) do
{:ok, _updated} -> {:ok, _updated} ->
socket socket
|> assign(:script_editor_drafts, Map.delete(socket.assigns.script_editor_drafts, script.id)) |> assign(
:script_editor_drafts,
Map.delete(socket.assigns.script_editor_drafts, script.id)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -58,11 +66,18 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do case Scripts.get_script(script_id) do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script -> %Script{} = script ->
case Scripting.validate(current_script_draft(socket.assigns, script)["content"] || "") do case Scripting.validate(current_script_draft(socket.assigns, script)["content"] || "") do
:ok -> append_output.(socket, translated("Scripts"), translated("Syntax is valid")) |> reload.(socket.assigns.workbench) :ok ->
{:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench) append_output.(socket, translated("Scripts"), translated("Syntax is valid"))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
append_output.(socket, translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end end
end end
end end
@@ -71,11 +86,18 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do case Scripts.get_script(script_id) do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script -> %Script{} = script ->
draft = current_script_draft(socket.assigns, script) draft = current_script_draft(socket.assigns, script)
case Scripting.execute_project_script(script.project_id, draft["content"] || "", draft["entrypoint"] || "main", []) do case Scripting.execute_project_script(
script.project_id,
draft["content"] || "",
draft["entrypoint"] || "main",
[]
) do
{:ok, result} -> {:ok, result} ->
socket socket
|> append_output.(translated("Scripts"), inspect(result)) |> append_output.(translated("Scripts"), inspect(result))
@@ -93,8 +115,12 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
case Scripts.delete_script(script_id) do case Scripts.delete_script(script_id) do
{:ok, _deleted} -> reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :scripts, script_id)) {:ok, _deleted} ->
{:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench) reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :scripts, script_id))
{:error, reason} ->
append_output.(socket, translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end end
end end
@@ -102,7 +128,14 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
socket socket
|> assign(:template_editor_drafts, Map.put(socket.assigns.template_editor_drafts, template_id, normalize_template_params(params))) |> assign(
:template_editor_drafts,
Map.put(
socket.assigns.template_editor_drafts,
template_id,
normalize_template_params(params)
)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@@ -110,18 +143,28 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
case Templates.get_template(template_id) do case Templates.get_template(template_id) do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
%Template{} = template -> %Template{} = template ->
draft = current_template_draft(socket.assigns, template) draft = current_template_draft(socket.assigns, template)
with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""), with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""),
{:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do {:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do
socket socket
|> assign(:template_editor_drafts, Map.delete(socket.assigns.template_editor_drafts, template.id)) |> assign(
:template_editor_drafts,
Map.delete(socket.assigns.template_editor_drafts, template.id)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
{:ok, %{valid: false, errors: errors}} -> append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") |> reload.(socket.assigns.workbench) {:ok, %{valid: false, errors: errors}} ->
{:error, reason} -> append_output.(socket, translated("Templates"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench) append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
append_output.(socket, translated("Templates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end end
end end
end end
@@ -130,11 +173,24 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
case Templates.get_template(template_id) do case Templates.get_template(template_id) do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
%Template{} = template -> %Template{} = template ->
case MCP.validate_template(current_template_draft(socket.assigns, template)["content"] || "") do case MCP.validate_template(
{:ok, %{valid: true}} -> append_output.(socket, translated("Templates"), translated("Template syntax is valid")) |> reload.(socket.assigns.workbench) current_template_draft(socket.assigns, template)["content"] || ""
{:ok, %{valid: false, errors: errors}} -> append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") |> reload.(socket.assigns.workbench) ) do
{:ok, %{valid: true}} ->
append_output.(
socket,
translated("Templates"),
translated("Template syntax is valid")
)
|> reload.(socket.assigns.workbench)
{:ok, %{valid: false, errors: errors}} ->
append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error")
|> reload.(socket.assigns.workbench)
end end
end end
end end
@@ -143,16 +199,26 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
case Templates.delete_template(template_id, force: true) do case Templates.delete_template(template_id, force: true) do
{:ok, _deleted} -> reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :templates, template_id)) {:ok, _deleted} ->
{:error, reason} -> append_output.(socket, translated("Templates"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench) reload.(
socket,
BDS.UI.Workbench.close_tab(socket.assigns.workbench, :templates, template_id)
)
{:error, reason} ->
append_output.(socket, translated("Templates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end end
end end
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
case Scripts.get_script(script_id) do case Scripts.get_script(script_id) do
nil -> nil nil ->
nil
%Script{} = script -> %Script{} = script ->
draft = current_script_draft(assigns, script) draft = current_script_draft(assigns, script)
%{ %{
id: script.id, id: script.id,
title: draft["title"], title: draft["title"],
@@ -172,9 +238,12 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
case Templates.get_template(template_id) do case Templates.get_template(template_id) do
nil -> nil nil ->
nil
%Template{} = template -> %Template{} = template ->
draft = current_template_draft(assigns, template) draft = current_template_draft(assigns, template)
%{ %{
id: template.id, id: template.id,
title: draft["title"], title: draft["title"],
@@ -190,7 +259,8 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_template(_assigns), do: nil def build_template(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def format_timestamp(nil), do: "" def format_timestamp(nil), do: ""
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp) def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)
@@ -241,17 +311,21 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{ %{
title: draft["title"], title: draft["title"],
slug: draft["slug"], slug: draft["slug"],
kind: String.to_existing_atom(draft["kind"]), kind: BDS.BoundedAtoms.script_kind(draft["kind"], :utility),
entrypoint: draft["entrypoint"], entrypoint: draft["entrypoint"],
enabled: draft["enabled"], enabled: draft["enabled"],
content: draft["content"] content: draft["content"]
} }
rescue
_error -> %{title: draft["title"], slug: draft["slug"], kind: :utility, entrypoint: draft["entrypoint"], enabled: draft["enabled"], content: draft["content"]}
end end
defp template_attrs(draft) do defp template_attrs(draft) do
%{title: draft["title"], slug: draft["slug"], kind: normalize_template_kind(draft["kind"]), enabled: draft["enabled"], content: draft["content"]} %{
title: draft["title"],
slug: draft["slug"],
kind: normalize_template_kind(draft["kind"]),
enabled: draft["enabled"],
content: draft["content"]
}
end end
defp normalize_template_kind("post"), do: :post defp normalize_template_kind("post"), do: :post
@@ -261,8 +335,13 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
defp normalize_template_kind(_kind), do: :post defp normalize_template_kind(_kind), do: :post
defp discover_entrypoints(content) do defp discover_entrypoints(content) do
["main" | Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "", capture: :all_but_first) [
"main"
| Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "",
capture: :all_but_first
)
|> List.flatten() |> List.flatten()
|> Enum.reject(&(&1 == "main"))] |> Enum.reject(&(&1 == "main"))
]
end end
end end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
alias BDS.AI alias BDS.AI
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.{ alias BDS.Desktop.ShellLive.ImportEditor.{
AnalysisState, AnalysisState,
ConflictResolution, ConflictResolution,
@@ -40,13 +41,28 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
defdelegate change_definition(socket, params, reload), to: AnalysisState defdelegate change_definition(socket, params, reload), to: AnalysisState
defdelegate select_uploads_folder(socket, reload, append_output), to: AnalysisState defdelegate select_uploads_folder(socket, reload, append_output), to: AnalysisState
defdelegate select_and_analyze(socket, reload, append_output), to: AnalysisState defdelegate select_and_analyze(socket, reload, append_output), to: AnalysisState
defdelegate note_analysis_progress(socket, definition_id, step, detail, reload), to: AnalysisState
defdelegate note_analysis_progress(socket, definition_id, step, detail, reload),
to: AnalysisState
defdelegate finish_analysis(socket, ref, result, reload, append_output), to: AnalysisState defdelegate finish_analysis(socket, ref, result, reload, append_output), to: AnalysisState
defdelegate execute_import(socket, reload, append_output), to: ProgressTracking defdelegate execute_import(socket, reload, append_output), to: ProgressTracking
defdelegate note_execution_progress(socket, definition_id, phase, current, total, detail, reload), to: ProgressTracking
defdelegate note_execution_progress(
socket,
definition_id,
phase,
current,
total,
detail,
reload
), to: ProgressTracking
defdelegate finish_execution(socket, ref, result, reload, append_output), to: ProgressTracking defdelegate finish_execution(socket, ref, result, reload, append_output), to: ProgressTracking
defdelegate handle_task_down(socket, kind, ref, reason, reload, append_output), to: ProgressTracking
defdelegate handle_task_down(socket, kind, ref, reason, reload, append_output),
to: ProgressTracking
defdelegate change_conflict_resolution(socket, params, reload), to: ConflictResolution defdelegate change_conflict_resolution(socket, params, reload), to: ConflictResolution
@@ -66,9 +82,24 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
definition -> definition ->
report = ImportDefinitions.decode_analysis_result(definition) report = ImportDefinitions.decode_analysis_result(definition)
taxonomy_terms = existing_taxonomy_terms(socket.assigns.projects.active_project_id) taxonomy_terms = existing_taxonomy_terms(socket.assigns.projects.active_project_id)
analysis_state = Map.get(socket.assigns.import_editor_analysis_states, definition.id, default_analysis_state())
execution_state = Map.get(socket.assigns.import_editor_execution_states, definition.id, default_execution_state()) analysis_state =
sections = Map.get(socket.assigns.import_editor_sections, definition.id, default_sections()) Map.get(
socket.assigns.import_editor_analysis_states,
definition.id,
default_analysis_state()
)
execution_state =
Map.get(
socket.assigns.import_editor_execution_states,
definition.id,
default_execution_state()
)
sections =
Map.get(socket.assigns.import_editor_sections, definition.id, default_sections())
selected_model = selected_model(socket.assigns, definition.id) selected_model = selected_model(socket.assigns, definition.id)
available_models = AI.available_chat_models(selected_model) available_models = AI.available_chat_models(selected_model)
@@ -86,7 +117,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
sections: sections, sections: sections,
selected_model: selected_model, selected_model: selected_model,
selected_model_label: selected_model_label(selected_model, available_models), selected_model_label: selected_model_label(selected_model, available_models),
model_selector_open?: Map.get(socket.assigns.import_editor_model_selectors_open, definition.id, false), model_selector_open?:
Map.get(socket.assigns.import_editor_model_selectors_open, definition.id, false),
available_models: available_models, available_models: available_models,
offline?: Map.get(socket.assigns, :offline_mode, true), offline?: Map.get(socket.assigns, :offline_mode, true),
is_loading: analysis_state.loading is_loading: analysis_state.loading
@@ -110,14 +142,29 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
def toggle_section(socket, section, reload) do def toggle_section(socket, section, reload) do
with %{id: definition_id} <- socket.assigns.current_tab, with %{id: definition_id} <- socket.assigns.current_tab,
section_key when section_key in ["post_conflicts", "page_conflicts", "posts", "other", "pages", "media", "taxonomy", "macros"] <- section do section_key
when section_key in [
"post_conflicts",
"page_conflicts",
"posts",
"other",
"pages",
"media",
"taxonomy",
"macros"
] <- section,
section_atom when not is_nil(section_atom) <-
BDS.BoundedAtoms.import_section(section_key) do
next_sections = next_sections =
socket.assigns.import_editor_sections socket.assigns.import_editor_sections
|> Map.get(definition_id, default_sections()) |> Map.get(definition_id, default_sections())
|> Map.update!(String.to_existing_atom(section_key), &(!&1)) |> Map.update!(section_atom, &(!&1))
socket socket
|> assign(:import_editor_sections, Map.put(socket.assigns.import_editor_sections, definition_id, next_sections)) |> assign(
:import_editor_sections,
Map.put(socket.assigns.import_editor_sections, definition_id, next_sections)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
_other -> reload.(socket, socket.assigns.workbench) _other -> reload.(socket, socket.assigns.workbench)
@@ -129,7 +176,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
current = Map.get(socket.assigns.import_editor_model_selectors_open, definition_id, false) current = Map.get(socket.assigns.import_editor_model_selectors_open, definition_id, false)
socket socket
|> assign(:import_editor_model_selectors_open, Map.put(socket.assigns.import_editor_model_selectors_open, definition_id, not current)) |> assign(
:import_editor_model_selectors_open,
Map.put(socket.assigns.import_editor_model_selectors_open, definition_id, not current)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
_other -> reload.(socket, socket.assigns.workbench) _other -> reload.(socket, socket.assigns.workbench)
@@ -139,31 +189,73 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
def select_ai_model(socket, model_id, reload) do def select_ai_model(socket, model_id, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do with %{id: definition_id} <- socket.assigns.current_tab do
socket socket
|> assign(:import_editor_selected_models, Map.put(socket.assigns.import_editor_selected_models, definition_id, model_id)) |> assign(
|> assign(:import_editor_model_selectors_open, Map.put(socket.assigns.import_editor_model_selectors_open, definition_id, false)) :import_editor_selected_models,
Map.put(socket.assigns.import_editor_selected_models, definition_id, model_id)
)
|> assign(
:import_editor_model_selectors_open,
Map.put(socket.assigns.import_editor_model_selectors_open, definition_id, false)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
_other -> reload.(socket, socket.assigns.workbench) _other -> reload.(socket, socket.assigns.workbench)
end end
end end
attr :import_editor, :map, required: true attr(:import_editor, :map, required: true)
def import_editor(assigns) do def import_editor(assigns) do
assigns = assigns =
assigns assigns
|> assign(:report, Map.get(assigns.import_editor, :report)) |> assign(:report, Map.get(assigns.import_editor, :report))
|> assign(:analysis_state, Map.get(assigns.import_editor, :analysis_state, default_analysis_state())) |> assign(
:analysis_state,
Map.get(assigns.import_editor, :analysis_state, default_analysis_state())
)
|> assign(:execution_state, Map.get(assigns.import_editor, :execution_state)) |> assign(:execution_state, Map.get(assigns.import_editor, :execution_state))
|> assign(:counts, Map.get(assigns.import_editor, :importable_counts, %{total: 0, tags: 0, posts: 0, media: 0, pages: 0})) |> assign(
:counts,
Map.get(assigns.import_editor, :importable_counts, %{
total: 0,
tags: 0,
posts: 0,
media: 0,
pages: 0
})
)
|> assign(:sections, Map.get(assigns.import_editor, :sections, default_sections())) |> assign(:sections, Map.get(assigns.import_editor, :sections, default_sections()))
|> assign(:detail_posts, detail_items(Map.get(assigns.import_editor, :report), :posts)) |> assign(:detail_posts, detail_items(Map.get(assigns.import_editor, :report), :posts))
|> assign(:detail_pages, detail_items(Map.get(assigns.import_editor, :report), :pages)) |> assign(:detail_pages, detail_items(Map.get(assigns.import_editor, :report), :pages))
|> assign(:detail_media, detail_items(Map.get(assigns.import_editor, :report), :media)) |> assign(:detail_media, detail_items(Map.get(assigns.import_editor, :report), :media))
|> assign(:post_conflicts, Enum.filter(detail_items(Map.get(assigns.import_editor, :report), :posts), &(&1.status == "conflict"))) |> assign(
|> assign(:page_conflicts, Enum.filter(detail_items(Map.get(assigns.import_editor, :report), :pages), &(&1.status == "conflict"))) :post_conflicts,
|> assign(:post_items, Enum.filter(detail_items(Map.get(assigns.import_editor, :report), :posts), &(Map.get(&1, :post_type, "post") == "post"))) Enum.filter(
|> assign(:other_items, Enum.reject(detail_items(Map.get(assigns.import_editor, :report), :posts), &(Map.get(&1, :post_type, "post") == "post"))) detail_items(Map.get(assigns.import_editor, :report), :posts),
&(&1.status == "conflict")
)
)
|> assign(
:page_conflicts,
Enum.filter(
detail_items(Map.get(assigns.import_editor, :report), :pages),
&(&1.status == "conflict")
)
)
|> assign(
:post_items,
Enum.filter(
detail_items(Map.get(assigns.import_editor, :report), :posts),
&(Map.get(&1, :post_type, "post") == "post")
)
)
|> assign(
:other_items,
Enum.reject(
detail_items(Map.get(assigns.import_editor, :report), :posts),
&(Map.get(&1, :post_type, "post") == "post")
)
)
~H""" ~H"""
<div class="import-analysis" data-testid="import-editor"> <div class="import-analysis" data-testid="import-editor">
@@ -450,10 +542,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
""" """
end end
attr :title, :string, required: true attr(:title, :string, required: true)
attr :items, :list, required: true attr(:items, :list, required: true)
attr :expanded, :boolean, required: true attr(:expanded, :boolean, required: true)
attr :section, :string, required: true attr(:section, :string, required: true)
def conflict_section(assigns) do def conflict_section(assigns) do
~H""" ~H"""
@@ -499,11 +591,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
""" """
end end
attr :title, :string, required: true attr(:title, :string, required: true)
attr :items, :list, required: true attr(:items, :list, required: true)
attr :expanded, :boolean, required: true attr(:expanded, :boolean, required: true)
attr :section, :string, required: true attr(:section, :string, required: true)
attr :show_type, :boolean, default: false attr(:show_type, :boolean, default: false)
def post_detail_section(assigns) do def post_detail_section(assigns) do
~H""" ~H"""
@@ -549,10 +641,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
""" """
end end
attr :title, :string, required: true attr(:title, :string, required: true)
attr :items, :list, required: true attr(:items, :list, required: true)
attr :expanded, :boolean, required: true attr(:expanded, :boolean, required: true)
attr :section, :string, required: true attr(:section, :string, required: true)
def media_detail_section(assigns) do def media_detail_section(assigns) do
~H""" ~H"""
@@ -590,8 +682,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
""" """
end end
attr :label, :string, required: true attr(:label, :string, required: true)
attr :stats, :map, required: true attr(:stats, :map, required: true)
def stat_card(assigns) do def stat_card(assigns) do
~H""" ~H"""
@@ -608,8 +700,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
""" """
end end
attr :label, :string, required: true attr(:label, :string, required: true)
attr :stats, :map, required: true attr(:stats, :map, required: true)
def other_stat_card(assigns) do def other_stat_card(assigns) do
~H""" ~H"""
@@ -625,8 +717,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
""" """
end end
attr :label, :string, required: true attr(:label, :string, required: true)
attr :stats, :map, required: true attr(:stats, :map, required: true)
def media_stat_card(assigns) do def media_stat_card(assigns) do
~H""" ~H"""
@@ -644,8 +736,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
""" """
end end
attr :label, :string, required: true attr(:label, :string, required: true)
attr :stats, :map, required: true attr(:stats, :map, required: true)
def taxonomy_stat_card(assigns) do def taxonomy_stat_card(assigns) do
~H""" ~H"""
@@ -661,11 +753,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
""" """
end end
attr :title, :string, required: true attr(:title, :string, required: true)
attr :items, :list, required: true attr(:items, :list, required: true)
attr :suggestions, :list, required: true attr(:suggestions, :list, required: true)
attr :edit, :map, default: nil attr(:edit, :map, default: nil)
attr :type, :string, required: true attr(:type, :string, required: true)
def taxonomy_group(assigns) do def taxonomy_group(assigns) do
~H""" ~H"""
@@ -744,7 +836,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
max(8, value / max(max_value, 1) * 100) max(8, value / max(max_value, 1) * 100)
end end
defp total_stats(stats), do: stats.new_count + stats.update_count + stats.conflict_count + stats.duplicate_count defp total_stats(stats),
do: stats.new_count + stats.update_count + stats.conflict_count + stats.duplicate_count
defp total_media_stats(stats), do: total_stats(stats) + stats.missing_count defp total_media_stats(stats), do: total_stats(stats) + stats.missing_count
defp selected_model(assigns, definition_id) do defp selected_model(assigns, definition_id) do
@@ -770,7 +864,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
end end
end end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: value in [nil, ""]
end end

View File

@@ -4,7 +4,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
alias BDS.{AI, ImportDefinitions, Metadata, Tags} alias BDS.{AI, ImportDefinitions, Metadata, Tags}
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
def start_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => mapped_to}, reload) do def start_taxonomy_edit(
socket,
%{"type" => type, "name" => name, "mapped_to" => mapped_to},
reload
) do
with %{id: definition_id} <- socket.assigns.current_tab do with %{id: definition_id} <- socket.assigns.current_tab do
socket socket
|> Phoenix.Component.assign( |> Phoenix.Component.assign(
@@ -24,22 +28,40 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def cancel_taxonomy_edit(socket, reload) do def cancel_taxonomy_edit(socket, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do with %{id: definition_id} <- socket.assigns.current_tab do
socket socket
|> Phoenix.Component.assign(:import_editor_taxonomy_edits, Map.delete(socket.assigns.import_editor_taxonomy_edits, definition_id)) |> Phoenix.Component.assign(
:import_editor_taxonomy_edits,
Map.delete(socket.assigns.import_editor_taxonomy_edits, definition_id)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
_other -> reload.(socket, socket.assigns.workbench) _other -> reload.(socket, socket.assigns.workbench)
end end
end end
def save_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => mapped_to}, reload) do def save_taxonomy_edit(
socket,
%{"type" => type, "name" => name, "mapped_to" => mapped_to},
reload
) do
with %{id: definition_id} <- socket.assigns.current_tab, with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = definition <- ImportDefinitions.get_definition(definition_id),
%{} = report <- ImportDefinitions.decode_analysis_result(definition), %{} = report <- ImportDefinitions.decode_analysis_result(definition),
normalized_value <- normalize_taxonomy_mapping_value(socket.assigns.projects.active_project_id, type, mapped_to), normalized_value <-
normalize_taxonomy_mapping_value(
socket.assigns.projects.active_project_id,
type,
mapped_to
),
updated_report <- update_taxonomy_mapping(report, type, name, normalized_value), updated_report <- update_taxonomy_mapping(report, type, name, normalized_value),
{:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{last_analysis_result: updated_report}) do {:ok, _definition} <-
ImportDefinitions.update_definition(definition_id, %{
last_analysis_result: updated_report
}) do
socket socket
|> Phoenix.Component.assign(:import_editor_taxonomy_edits, Map.delete(socket.assigns.import_editor_taxonomy_edits, definition_id)) |> Phoenix.Component.assign(
:import_editor_taxonomy_edits,
Map.delete(socket.assigns.import_editor_taxonomy_edits, definition_id)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
_other -> reload.(socket, socket.assigns.workbench) _other -> reload.(socket, socket.assigns.workbench)
@@ -57,7 +79,16 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
cond do cond do
socket.assigns.offline_mode -> socket.assigns.offline_mode ->
socket socket
|> append_output.(translated("activity.import"), ShellData.translate("Automatic AI actions stay gated by airplane mode.", %{}, socket.assigns.page_language), nil, "info") |> append_output.(
translated("activity.import"),
ShellData.translate(
"Automatic AI actions stay gated by airplane mode.",
%{},
socket.assigns.page_language
),
nil,
"info"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
true -> true ->
@@ -68,21 +99,41 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
tags: Enum.map(Map.get(report.items, :tags, []), & &1.name) tags: Enum.map(Map.get(report.items, :tags, []), & &1.name)
} }
opts = maybe_put_option([], :model, Map.get(socket.assigns.import_editor_selected_models, definition_id)) opts =
maybe_put_option(
[],
:model,
Map.get(socket.assigns.import_editor_selected_models, definition_id)
)
case AI.analyze_import_taxonomy(import_terms, taxonomy_terms, opts) do case AI.analyze_import_taxonomy(import_terms, taxonomy_terms, opts) do
{:ok, analysis} -> {:ok, analysis} ->
updated_report = apply_taxonomy_mappings(report, analysis) updated_report = apply_taxonomy_mappings(report, analysis)
{:ok, _definition} = ImportDefinitions.update_definition(definition_id, %{last_analysis_result: updated_report})
{:ok, _definition} =
ImportDefinitions.update_definition(definition_id, %{
last_analysis_result: updated_report
})
mapped_count = auto_mapped_count(report, updated_report) mapped_count = auto_mapped_count(report, updated_report)
socket socket
|> append_output.(translated("activity.import"), translated("importAnalysis.mappedCount", %{count: mapped_count}), Map.get(socket.assigns.import_editor_selected_models, definition_id), "info") |> append_output.(
translated("activity.import"),
translated("importAnalysis.mappedCount", %{count: mapped_count}),
Map.get(socket.assigns.import_editor_selected_models, definition_id),
"info"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
socket socket
|> append_output.(translated("activity.import"), inspect(reason), Map.get(socket.assigns.import_editor_selected_models, definition_id), "error") |> append_output.(
translated("activity.import"),
inspect(reason),
Map.get(socket.assigns.import_editor_selected_models, definition_id),
"error"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@@ -106,7 +157,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end) end)
end) end)
Map.put(updated_report, stat_key(bucket_key), rebuild_taxonomy_stats(get_in(updated_report, [:items, bucket_key]) || [])) Map.put(
updated_report,
stat_key(bucket_key),
rebuild_taxonomy_stats(get_in(updated_report, [:items, bucket_key]) || [])
)
end end
def rebuild_taxonomy_stats(items) do def rebuild_taxonomy_stats(items) do
@@ -122,12 +177,24 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def apply_taxonomy_mappings(report, analysis) do def apply_taxonomy_mappings(report, analysis) do
report report
|> update_in([:items, :categories], &apply_taxonomy_mapping_bucket(&1, Map.get(analysis, :category_mappings, %{}))) |> update_in(
|> update_in([:items, :tags], &apply_taxonomy_mapping_bucket(&1, Map.get(analysis, :tag_mappings, %{}))) [:items, :categories],
&apply_taxonomy_mapping_bucket(&1, Map.get(analysis, :category_mappings, %{}))
)
|> update_in(
[:items, :tags],
&apply_taxonomy_mapping_bucket(&1, Map.get(analysis, :tag_mappings, %{}))
)
|> then(fn updated_report -> |> then(fn updated_report ->
updated_report updated_report
|> Map.put(:category_stats, rebuild_taxonomy_stats(get_in(updated_report, [:items, :categories]) || [])) |> Map.put(
|> Map.put(:tag_stats, rebuild_taxonomy_stats(get_in(updated_report, [:items, :tags]) || [])) :category_stats,
rebuild_taxonomy_stats(get_in(updated_report, [:items, :categories]) || [])
)
|> Map.put(
:tag_stats,
rebuild_taxonomy_stats(get_in(updated_report, [:items, :tags]) || [])
)
end) end)
end end
@@ -159,14 +226,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
value -> value ->
project_id project_id
|> existing_taxonomy_terms() |> existing_taxonomy_terms()
|> Map.get(String.to_existing_atom(type), []) |> Map.get(BDS.BoundedAtoms.taxonomy_type(type), [])
|> Enum.find(fn term -> String.downcase(term) == String.downcase(value) end) |> Enum.find(fn term -> String.downcase(term) == String.downcase(value) end)
end end
end end
def auto_mapped_count(previous_report, next_report) do def auto_mapped_count(previous_report, next_report) do
previous_count = previous_count =
(Map.get(previous_report.items, :categories, []) ++ Map.get(previous_report.items, :tags, [])) (Map.get(previous_report.items, :categories, []) ++
Map.get(previous_report.items, :tags, []))
|> Enum.count(&present?(&1.mapped_to)) |> Enum.count(&present?(&1.mapped_to))
next_count = next_count =
@@ -199,7 +267,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def maybe_put_option(opts, _key, nil), do: opts def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value) def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
defp blank_to_nil(""), do: nil defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value defp blank_to_nil(value), do: value

View File

@@ -62,26 +62,30 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end end
defp find_mcp_agent(agent) do defp find_mcp_agent(agent) do
normalized = normalized = BDS.BoundedAtoms.mcp_agent(agent)
agent
|> to_string()
|> String.to_existing_atom()
Enum.find(@mcp_agents, &(&1.id == normalized)) Enum.find(@mcp_agents, &(&1.id == normalized))
rescue
_error -> nil
end end
defp format_config_error({:read_config, path, reason}) do defp format_config_error({:read_config, path, reason}) do
translated("Could not read MCP config %{path}: %{reason}", path: path, reason: inspect(reason)) translated("Could not read MCP config %{path}: %{reason}",
path: path,
reason: inspect(reason)
)
end end
defp format_config_error({:write_config, path, reason}) do defp format_config_error({:write_config, path, reason}) do
translated("Could not write MCP config %{path}: %{reason}", path: path, reason: inspect(reason)) translated("Could not write MCP config %{path}: %{reason}",
path: path,
reason: inspect(reason)
)
end end
defp format_config_error({:create_config_dir, path, reason}) do defp format_config_error({:create_config_dir, path, reason}) do
translated("Could not create MCP config folder %{path}: %{reason}", path: path, reason: inspect(reason)) translated("Could not create MCP config folder %{path}: %{reason}",
path: path,
reason: inspect(reason)
)
end end
defp format_config_error({:decode_config, path, _reason}) do defp format_config_error({:decode_config, path, _reason}) do
@@ -103,7 +107,9 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end end
defp mcp_config_path(%{supported?: false}), do: nil defp mcp_config_path(%{supported?: false}), do: nil
defp mcp_config_path(%{id: agent_id}), do: AgentConfig.config_path(agent_id, System.user_home!())
defp mcp_config_path(%{id: agent_id}),
do: AgentConfig.config_path(agent_id, System.user_home!())
defp mcp_server_present?(config, :github_copilot) do defp mcp_server_present?(config, :github_copilot) do
config config

View File

@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellCommands alias BDS.Desktop.ShellCommands
alias BDS.Desktop.ShellLive.{TabHelpers, TaskLocalization} alias BDS.Desktop.ShellLive.{TabHelpers, TaskLocalization}
alias BDS.UI.Workbench alias BDS.UI.Workbench
@@ -20,18 +21,34 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
apply_result(socket, result, callbacks) apply_result(socket, result, callbacks)
{:error, %{message: message}} -> {:error, %{message: message}} ->
callbacks.append_output.(socket, TaskLocalization.command_title(action), message, nil, "error") callbacks.append_output.(
socket,
TaskLocalization.command_title(action),
message,
nil,
"error"
)
{:error, reason} -> {:error, reason} ->
callbacks.append_output.(socket, TaskLocalization.command_title(action), inspect(reason), nil, "error") callbacks.append_output.(
socket,
TaskLocalization.command_title(action),
inspect(reason),
nil,
"error"
)
end end
end end
def apply_result(socket, %{kind: "task_queued", title: title, message: message, panel_tab: panel_tab}, callbacks) do def apply_result(
socket,
%{kind: "task_queued", title: title, message: message, panel_tab: panel_tab},
callbacks
) do
workbench = workbench =
socket.assigns.workbench socket.assigns.workbench
|> Workbench.set_panel_visible(true) |> Workbench.set_panel_visible(true)
|> Workbench.set_panel_tab(String.to_existing_atom(panel_tab)) |> Workbench.set_panel_tab(BoundedAtoms.panel_tab(panel_tab, :tasks))
socket socket
|> callbacks.append_output.( |> callbacks.append_output.(
@@ -53,7 +70,11 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
) )
end end
def apply_result(socket, %{kind: "open_url", title: title, message: message, url: url}, callbacks) do def apply_result(
socket,
%{kind: "open_url", title: title, message: message, url: url},
callbacks
) do
callbacks.append_output.( callbacks.append_output.(
socket, socket,
TaskLocalization.translate_for_socket(socket, title), TaskLocalization.translate_for_socket(socket, title),
@@ -63,8 +84,12 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
) )
end end
def apply_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle} = result, callbacks) do def apply_result(
route_atom = String.to_existing_atom(route) socket,
%{kind: "open_editor", route: route, title: title, subtitle: subtitle} = result,
callbacks
) do
route_atom = BoundedAtoms.editor_route(route, :dashboard)
tab_id = TabHelpers.tab_id_for_route(route_atom, route) tab_id = TabHelpers.tab_id_for_route(route_atom, route)
workbench = Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, :pin) workbench = Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, :pin)
@@ -75,7 +100,11 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
action: Map.get(result, :action), action: Map.get(result, :action),
payload: Map.get(result, :payload), payload: Map.get(result, :payload),
project_id: Map.get(result, :project_id), project_id: Map.get(result, :project_id),
editor_meta: TaskLocalization.translate_editor_meta(Map.get(result, :editorMeta, []), socket.assigns.page_language) editor_meta:
TaskLocalization.translate_editor_meta(
Map.get(result, :editorMeta, []),
socket.assigns.page_language
)
}) })
socket socket
@@ -85,11 +114,5 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
def apply_result(socket, _result, _callbacks), do: socket def apply_result(socket, _result, _callbacks), do: socket
def safe_existing_atom(action) when is_binary(action) do def shell_command_atom(action), do: BoundedAtoms.shell_command(action)
String.to_existing_atom(action)
rescue
ArgumentError -> nil
end
def safe_existing_atom(_), do: nil
end end

View File

@@ -463,7 +463,8 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
""" """
end end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp template_sidebar?(sidebar_data), do: Map.get(sidebar_data, :title) == "Templates" defp template_sidebar?(sidebar_data), do: Map.get(sidebar_data, :title) == "Templates"
@@ -474,10 +475,10 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
%{ %{
year: year, year: year,
count: Enum.reduce(months, 0, fn entry, acc -> acc + (entry.count || 0) end), count: Enum.reduce(months, 0, fn entry, acc -> acc + (entry.count || 0) end),
months: Enum.sort_by(months, &-&1.month) months: Enum.sort_by(months, &(-&1.month))
} }
end) end)
|> Enum.sort_by(&-&1.year) |> Enum.sort_by(&(-&1.year))
end end
defp sidebar_filter_tag_color(filters_config, tag) do defp sidebar_filter_tag_color(filters_config, tag) do
@@ -489,8 +490,11 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
defp sidebar_filter_chip_style(filters_config, tag) do defp sidebar_filter_chip_style(filters_config, tag) do
case sidebar_filter_tag_color(filters_config, tag) do case sidebar_filter_tag_color(filters_config, tag) do
nil -> nil nil ->
color -> "background-color: #{color}; color: #{sidebar_filter_contrast_color(color)}; border-color: #{color};" nil
color ->
"background-color: #{color}; color: #{sidebar_filter_contrast_color(color)}; border-color: #{color};"
end end
end end
@@ -544,7 +548,9 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
end end
defp sidebar_route_atom(route) when is_atom(route), do: route defp sidebar_route_atom(route) when is_atom(route), do: route
defp sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route)
defp sidebar_route_atom(route) when is_binary(route),
do: BDS.BoundedAtoms.editor_route(route, :dashboard)
defp tab_id_for_route(route, id) do defp tab_id_for_route(route, id) do
case Registry.editor_route(route) do case Registry.editor_route(route) do

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
@moduledoc false @moduledoc false
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.{Media, Posts} alias BDS.{BoundedAtoms, Media, Posts}
alias BDS.Media.Media, as: MediaRecord alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.UI.Registry alias BDS.UI.Registry
@@ -42,7 +42,9 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
def tab_icon_id(%{type: type}), do: Atom.to_string(type) def tab_icon_id(%{type: type}), do: Atom.to_string(type)
def sidebar_route_atom(route) when is_atom(route), do: route def sidebar_route_atom(route) when is_atom(route), do: route
def sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route)
def sidebar_route_atom(route) when is_binary(route),
do: BoundedAtoms.editor_route(route, :dashboard)
def tab_id_for_route(route, id) do def tab_id_for_route(route, id) do
case Registry.editor_route(route) do case Registry.editor_route(route) do

View File

@@ -72,7 +72,10 @@ defmodule BDS.MCP.Queries do
|> Util.maybe_put(:category, Util.map_get(params, :category)) |> Util.maybe_put(:category, Util.map_get(params, :category))
|> Util.maybe_put(:tags, Util.map_get(params, :tags)) |> Util.maybe_put(:tags, Util.map_get(params, :tags))
|> Util.maybe_put(:language, Util.map_get(params, :language)) |> Util.maybe_put(:language, Util.map_get(params, :language))
|> Util.maybe_put(:missing_translation_language, Util.map_get(params, :missingTranslationLanguage)) |> Util.maybe_put(
:missing_translation_language,
Util.map_get(params, :missingTranslationLanguage)
)
|> Util.maybe_put(:year, Util.map_get(params, :year)) |> Util.maybe_put(:year, Util.map_get(params, :year))
|> Util.maybe_put(:month, Util.map_get(params, :month)) |> Util.maybe_put(:month, Util.map_get(params, :month))
|> Util.maybe_put(:status, parse_status(Util.map_get(params, :status))) |> Util.maybe_put(:status, parse_status(Util.map_get(params, :status)))
@@ -82,8 +85,7 @@ defmodule BDS.MCP.Queries do
@spec parse_status(term()) :: atom() | nil @spec parse_status(term()) :: atom() | nil
def parse_status(nil), do: nil def parse_status(nil), do: nil
def parse_status(status) when is_atom(status), do: status def parse_status(status), do: BDS.BoundedAtoms.post_status(status)
def parse_status(status) when is_binary(status), do: String.to_existing_atom(status)
@spec group_rows(Post.t(), [String.t()]) :: [map()] @spec group_rows(Post.t(), [String.t()]) :: [map()]
def group_rows(_post, []), do: [%{}] def group_rows(_post, []), do: [%{}]

View File

@@ -174,7 +174,9 @@ defmodule BDS.Menu do
defp outline_kind(element), do: xml_attr(element, :type) || xml_attr(element, :kind) defp outline_kind(element), do: xml_attr(element, :type) || xml_attr(element, :kind)
defp outline_slug(element, :category_archive), do: xml_attr(element, :categoryName) || xml_attr(element, :slug) defp outline_slug(element, :category_archive),
do: xml_attr(element, :categoryName) || xml_attr(element, :slug)
defp outline_slug(element, :home), do: xml_attr(element, :pageSlug) || xml_attr(element, :slug) defp outline_slug(element, :home), do: xml_attr(element, :pageSlug) || xml_attr(element, :slug)
defp outline_slug(element, _kind), do: xml_attr(element, :pageSlug) || xml_attr(element, :slug) defp outline_slug(element, _kind), do: xml_attr(element, :pageSlug) || xml_attr(element, :slug)
@@ -203,15 +205,7 @@ defmodule BDS.Menu do
defp normalize_kind(nil), do: :page defp normalize_kind(nil), do: :page
defp normalize_kind(kind) when is_binary(kind) do defp normalize_kind(kind) when is_binary(kind) do
case kind do BDS.BoundedAtoms.menu_kind(kind, :page)
"category-archive" -> :category_archive
other ->
other
|> String.to_existing_atom()
|> normalize_kind()
end
rescue
_error -> :page
end end
defp normalize_optional_string(nil), do: nil defp normalize_optional_string(nil), do: nil

View File

@@ -262,12 +262,10 @@ defmodule BDS.Posts.RebuildFromFiles do
end end
@doc false @doc false
def parse_post_status(status) when is_atom(status), do: status def parse_post_status(status), do: BDS.BoundedAtoms.post_status(status, :draft)
def parse_post_status(status), do: String.to_existing_atom(status)
@doc false @doc false
def parse_translation_status(status) when is_atom(status), do: status def parse_translation_status(status), do: BDS.BoundedAtoms.translation_status(status, :draft)
def parse_translation_status(status), do: String.to_existing_atom(status)
@doc false @doc false
def progress_callback(opts), do: ProgressReporter.callback(opts) def progress_callback(opts), do: ProgressReporter.callback(opts)

View File

@@ -1,6 +1,7 @@
defmodule BDS.UI.Session do defmodule BDS.UI.Session do
@moduledoc false @moduledoc false
alias BDS.BoundedAtoms
alias BDS.UI.Workbench alias BDS.UI.Workbench
def serialize(state) do def serialize(state) do
@@ -32,18 +33,19 @@ defmodule BDS.UI.Session do
Workbench.new( Workbench.new(
sidebar_visible: Map.get(payload, "sidebar_visible", true), sidebar_visible: Map.get(payload, "sidebar_visible", true),
sidebar_width: Map.get(payload, "sidebar_width", 280), sidebar_width: Map.get(payload, "sidebar_width", 280),
active_view: atomize(Map.get(payload, "active_view"), :posts), active_view: BoundedAtoms.sidebar_view(Map.get(payload, "active_view"), :posts),
assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false), assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false),
assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360), assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360),
panel_visible: get_in(payload, ["panel", "visible"]) || false, panel_visible: get_in(payload, ["panel", "visible"]) || false,
panel_tab: atomize(get_in(payload, ["panel", "active_tab"]) || "tasks", :tasks), panel_tab:
BoundedAtoms.panel_tab(get_in(payload, ["panel", "active_tab"]) || "tasks", :tasks),
dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1) dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1)
) )
tabs = tabs =
Enum.map(Map.get(payload, "tabs", []), fn tab -> Enum.map(Map.get(payload, "tabs", []), fn tab ->
%{ %{
type: atomize(Map.get(tab, "type", "post"), :post), type: BoundedAtoms.editor_route(Map.get(tab, "type", "post"), :post),
id: Map.get(tab, "id"), id: Map.get(tab, "id"),
is_transient: Map.get(tab, "is_transient", false) is_transient: Map.get(tab, "is_transient", false)
} }
@@ -58,15 +60,9 @@ defmodule BDS.UI.Session do
defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id} defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id}
defp decode_tab_ref(nil), do: nil defp decode_tab_ref(nil), do: nil
defp decode_tab_ref(%{"type" => type, "id" => id}), do: {atomize(type, :post), id}
defp atomize(value, _fallback) when is_atom(value), do: value defp decode_tab_ref(%{"type" => type, "id" => id}),
do: {BoundedAtoms.editor_route(type, :post), id}
defp atomize(value, fallback) when is_binary(value) do
String.to_existing_atom(value)
rescue
ArgumentError -> fallback
end
defp active_route(nil), do: :dashboard defp active_route(nil), do: :dashboard
defp active_route({type, _id}), do: type defp active_route({type, _id}), do: type

View File

@@ -0,0 +1,54 @@
defmodule BDS.BoundedAtomsTest do
use ExUnit.Case, async: true
alias BDS.BoundedAtoms
test "parses only explicit atoms from each bounded domain" do
assert BoundedAtoms.sidebar_view("posts") == :posts
assert BoundedAtoms.editor_route("metadata_diff") == :metadata_diff
assert BoundedAtoms.panel_tab("post_links") == :post_links
assert BoundedAtoms.post_status("archived") == :archived
assert BoundedAtoms.translation_status("published") == :published
assert BoundedAtoms.script_kind("transform") == :transform
assert BoundedAtoms.template_kind("not_found") == :not_found
assert BoundedAtoms.menu_kind("category-archive") == :category_archive
assert BoundedAtoms.import_section("taxonomy") == :taxonomy
assert BoundedAtoms.taxonomy_type("tags") == :tags
assert BoundedAtoms.ai_endpoint("airplane") == :airplane
assert BoundedAtoms.mcp_agent("github_copilot") == :github_copilot
assert BoundedAtoms.shell_command("toggle_panel") == :toggle_panel
end
test "falls back without creating atoms for unknown strings" do
assert BoundedAtoms.sidebar_view("unknown", :posts) == :posts
assert BoundedAtoms.editor_route("unknown", :dashboard) == :dashboard
assert BoundedAtoms.panel_tab("unknown", :tasks) == :tasks
assert BoundedAtoms.post_status("unknown", :draft) == :draft
assert BoundedAtoms.translation_status("unknown", :draft) == :draft
assert BoundedAtoms.script_kind("unknown", :utility) == :utility
assert BoundedAtoms.template_kind("unknown", :post) == :post
assert BoundedAtoms.menu_kind("unknown", :page) == :page
assert BoundedAtoms.import_section("unknown") == nil
assert BoundedAtoms.taxonomy_type("unknown") == nil
assert BoundedAtoms.ai_endpoint("unknown") == nil
assert BoundedAtoms.mcp_agent("unknown") == nil
assert BoundedAtoms.shell_command("unknown") == nil
end
test "codebase does not use String.to_existing_atom rescues" do
lib_dir = Path.expand("../../lib", __DIR__)
offenders =
lib_dir
|> Path.join("**/*.ex")
|> Path.wildcard()
|> Enum.reject(&String.ends_with?(&1, "bounded_atoms.ex"))
|> Enum.filter(fn path ->
path
|> File.read!()
|> String.contains?("String.to_existing_atom")
end)
assert offenders == []
end
end