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`
**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
- **`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.
- **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]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers}
alias BDS.UI.Workbench
@@ -74,7 +75,12 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"},
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin
)
@@ -96,7 +102,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
)
:switch_view ->
case safe_existing_atom(Map.get(payload, "view")) do
case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
nil ->
ChatEditor.set_action_error(
socket,
@@ -160,7 +166,11 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
end
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
def clear_action_error(socket), do: socket
@@ -177,7 +187,8 @@ defmodule BDS.Desktop.ShellLive.ChatSurface 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)
if form_data == %{} do
@@ -209,17 +220,13 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
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
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
ShellData.translate(
"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.Templates.Template
embed_templates "code_entity_editor_html/*"
embed_templates("code_entity_editor_html/*")
def assign_socket(socket) do
socket
@@ -20,7 +20,10 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab
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)
end
@@ -28,7 +31,9 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil -> reload.(socket, socket.assigns.workbench)
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = 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
{:ok, _updated} ->
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)
{:error, reason} ->
@@ -58,11 +66,18 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil -> reload.(socket, socket.assigns.workbench)
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script ->
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)
{:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
:ok ->
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
@@ -71,11 +86,18 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil -> reload.(socket, socket.assigns.workbench)
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = 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} ->
socket
|> append_output.(translated("Scripts"), inspect(result))
@@ -93,8 +115,12 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: script_id} = socket.assigns.current_tab
case Scripts.delete_script(script_id) do
{:ok, _deleted} -> 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)
{:ok, _deleted} ->
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
@@ -102,7 +128,14 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: template_id} = socket.assigns.current_tab
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)
end
@@ -110,18 +143,28 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: template_id} = socket.assigns.current_tab
case Templates.get_template(template_id) do
nil -> reload.(socket, socket.assigns.workbench)
nil ->
reload.(socket, socket.assigns.workbench)
%Template{} = template ->
draft = current_template_draft(socket.assigns, template)
with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""),
{:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do
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)
else
{:ok, %{valid: false, errors: errors}} -> 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)
{:ok, %{valid: false, errors: errors}} ->
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
@@ -130,11 +173,24 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: template_id} = socket.assigns.current_tab
case Templates.get_template(template_id) do
nil -> reload.(socket, socket.assigns.workbench)
nil ->
reload.(socket, socket.assigns.workbench)
%Template{} = template ->
case MCP.validate_template(current_template_draft(socket.assigns, template)["content"] || "") 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)
case MCP.validate_template(
current_template_draft(socket.assigns, template)["content"] || ""
) 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
@@ -143,16 +199,26 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{id: template_id} = socket.assigns.current_tab
case Templates.delete_template(template_id, force: true) do
{:ok, _deleted} -> 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)
{:ok, _deleted} ->
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
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
case Scripts.get_script(script_id) do
nil -> nil
nil ->
nil
%Script{} = script ->
draft = current_script_draft(assigns, script)
%{
id: script.id,
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
case Templates.get_template(template_id) do
nil -> nil
nil ->
nil
%Template{} = template ->
draft = current_template_draft(assigns, template)
%{
id: template.id,
title: draft["title"],
@@ -190,7 +259,8 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
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(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)
@@ -241,17 +311,21 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
%{
title: draft["title"],
slug: draft["slug"],
kind: String.to_existing_atom(draft["kind"]),
kind: BDS.BoundedAtoms.script_kind(draft["kind"], :utility),
entrypoint: draft["entrypoint"],
enabled: draft["enabled"],
content: draft["content"]
}
rescue
_error -> %{title: draft["title"], slug: draft["slug"], kind: :utility, entrypoint: draft["entrypoint"], enabled: draft["enabled"], content: draft["content"]}
end
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
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 discover_entrypoints(content) do
["main" | Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "", capture: :all_but_first)
|> List.flatten()
|> Enum.reject(&(&1 == "main"))]
[
"main"
| Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "",
capture: :all_but_first
)
|> List.flatten()
|> Enum.reject(&(&1 == "main"))
]
end
end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
alias BDS.AI
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.{
AnalysisState,
ConflictResolution,
@@ -40,13 +41,28 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
defdelegate change_definition(socket, params, reload), to: AnalysisState
defdelegate select_uploads_folder(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 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 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
@@ -66,9 +82,24 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
definition ->
report = ImportDefinitions.decode_analysis_result(definition)
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())
sections = Map.get(socket.assigns.import_editor_sections, definition.id, default_sections())
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()
)
sections =
Map.get(socket.assigns.import_editor_sections, definition.id, default_sections())
selected_model = selected_model(socket.assigns, definition.id)
available_models = AI.available_chat_models(selected_model)
@@ -86,7 +117,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
sections: sections,
selected_model: selected_model,
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,
offline?: Map.get(socket.assigns, :offline_mode, true),
is_loading: analysis_state.loading
@@ -110,14 +142,29 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
def toggle_section(socket, section, reload) do
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 =
socket.assigns.import_editor_sections
|> Map.get(definition_id, default_sections())
|> Map.update!(String.to_existing_atom(section_key), &(!&1))
|> Map.update!(section_atom, &(!&1))
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)
else
_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)
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)
else
_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
with %{id: definition_id} <- socket.assigns.current_tab do
socket
|> assign(: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))
|> assign(
: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)
else
_other -> reload.(socket, socket.assigns.workbench)
end
end
attr :import_editor, :map, required: true
attr(:import_editor, :map, required: true)
def import_editor(assigns) do
assigns =
assigns
|> 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(: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(: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_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(: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")))
|> assign(
:post_conflicts,
Enum.filter(
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"""
<div class="import-analysis" data-testid="import-editor">
@@ -450,10 +542,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
"""
end
attr :title, :string, required: true
attr :items, :list, required: true
attr :expanded, :boolean, required: true
attr :section, :string, required: true
attr(:title, :string, required: true)
attr(:items, :list, required: true)
attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true)
def conflict_section(assigns) do
~H"""
@@ -499,11 +591,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
"""
end
attr :title, :string, required: true
attr :items, :list, required: true
attr :expanded, :boolean, required: true
attr :section, :string, required: true
attr :show_type, :boolean, default: false
attr(:title, :string, required: true)
attr(:items, :list, required: true)
attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true)
attr(:show_type, :boolean, default: false)
def post_detail_section(assigns) do
~H"""
@@ -549,10 +641,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
"""
end
attr :title, :string, required: true
attr :items, :list, required: true
attr :expanded, :boolean, required: true
attr :section, :string, required: true
attr(:title, :string, required: true)
attr(:items, :list, required: true)
attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true)
def media_detail_section(assigns) do
~H"""
@@ -590,8 +682,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
"""
end
attr :label, :string, required: true
attr :stats, :map, required: true
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
def stat_card(assigns) do
~H"""
@@ -608,8 +700,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
"""
end
attr :label, :string, required: true
attr :stats, :map, required: true
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
def other_stat_card(assigns) do
~H"""
@@ -625,8 +717,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
"""
end
attr :label, :string, required: true
attr :stats, :map, required: true
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
def media_stat_card(assigns) do
~H"""
@@ -644,8 +736,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
"""
end
attr :label, :string, required: true
attr :stats, :map, required: true
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
def taxonomy_stat_card(assigns) do
~H"""
@@ -661,11 +753,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
"""
end
attr :title, :string, required: true
attr :items, :list, required: true
attr :suggestions, :list, required: true
attr :edit, :map, default: nil
attr :type, :string, required: true
attr(:title, :string, required: true)
attr(:items, :list, required: true)
attr(:suggestions, :list, required: true)
attr(:edit, :map, default: nil)
attr(:type, :string, required: true)
def taxonomy_group(assigns) do
~H"""
@@ -744,7 +836,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
max(8, value / max(max_value, 1) * 100)
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 selected_model(assigns, definition_id) do
@@ -770,7 +864,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor 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 present?(value), do: value not in [nil, ""]
defp blank?(value), do: value in [nil, ""]
end

View File

@@ -4,7 +4,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
alias BDS.{AI, ImportDefinitions, Metadata, Tags}
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
socket
|> Phoenix.Component.assign(
@@ -24,22 +28,40 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def cancel_taxonomy_edit(socket, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do
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)
else
_other -> reload.(socket, socket.assigns.workbench)
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,
%{} = definition <- ImportDefinitions.get_definition(definition_id),
%{} = 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),
{: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
|> 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)
else
_other -> reload.(socket, socket.assigns.workbench)
@@ -57,7 +79,16 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
cond do
socket.assigns.offline_mode ->
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)
true ->
@@ -68,21 +99,41 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
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
{:ok, 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)
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)
{:error, reason} ->
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)
end
end
@@ -106,7 +157,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
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
def rebuild_taxonomy_stats(items) do
@@ -122,12 +177,24 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def apply_taxonomy_mappings(report, analysis) do
report
|> update_in([: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, %{})))
|> update_in(
[: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 ->
updated_report
|> Map.put(:category_stats, rebuild_taxonomy_stats(get_in(updated_report, [:items, :categories]) || []))
|> Map.put(:tag_stats, rebuild_taxonomy_stats(get_in(updated_report, [:items, :tags]) || []))
|> Map.put(
: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
@@ -159,14 +226,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
value ->
project_id
|> 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)
end
end
def auto_mapped_count(previous_report, next_report) do
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))
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, 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 blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value

View File

@@ -62,26 +62,30 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end
defp find_mcp_agent(agent) do
normalized =
agent
|> to_string()
|> String.to_existing_atom()
normalized = BDS.BoundedAtoms.mcp_agent(agent)
Enum.find(@mcp_agents, &(&1.id == normalized))
rescue
_error -> nil
end
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
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
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
defp format_config_error({:decode_config, path, _reason}) do
@@ -103,7 +107,9 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end
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
config

View File

@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellCommands
alias BDS.Desktop.ShellLive.{TabHelpers, TaskLocalization}
alias BDS.UI.Workbench
@@ -20,18 +21,34 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
apply_result(socket, result, callbacks)
{: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} ->
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
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 =
socket.assigns.workbench
|> 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
|> callbacks.append_output.(
@@ -53,7 +70,11 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
)
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.(
socket,
TaskLocalization.translate_for_socket(socket, title),
@@ -63,8 +84,12 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
)
end
def apply_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle} = result, callbacks) do
route_atom = String.to_existing_atom(route)
def apply_result(
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)
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),
payload: Map.get(result, :payload),
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
@@ -85,11 +114,5 @@ defmodule BDS.Desktop.ShellLive.ShellCommandRunner do
def apply_result(socket, _result, _callbacks), do: socket
def safe_existing_atom(action) when is_binary(action) do
String.to_existing_atom(action)
rescue
ArgumentError -> nil
end
def safe_existing_atom(_), do: nil
def shell_command_atom(action), do: BoundedAtoms.shell_command(action)
end

View File

@@ -463,7 +463,8 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
"""
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"
@@ -474,10 +475,10 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
%{
year: year,
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)
|> Enum.sort_by(&-&1.year)
|> Enum.sort_by(&(-&1.year))
end
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
case sidebar_filter_tag_color(filters_config, tag) do
nil -> nil
color -> "background-color: #{color}; color: #{sidebar_filter_contrast_color(color)}; border-color: #{color};"
nil ->
nil
color ->
"background-color: #{color}; color: #{sidebar_filter_contrast_color(color)}; border-color: #{color};"
end
end
@@ -544,7 +548,9 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
end
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
case Registry.editor_route(route) do

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
@moduledoc false
alias BDS.Desktop.ShellData
alias BDS.{Media, Posts}
alias BDS.{BoundedAtoms, Media, Posts}
alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.Post
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 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
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(:tags, Util.map_get(params, :tags))
|> 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(:month, Util.map_get(params, :month))
|> 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
def parse_status(nil), do: nil
def parse_status(status) when is_atom(status), do: status
def parse_status(status) when is_binary(status), do: String.to_existing_atom(status)
def parse_status(status), do: BDS.BoundedAtoms.post_status(status)
@spec group_rows(Post.t(), [String.t()]) :: [map()]
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_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, _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(kind) when is_binary(kind) do
case kind do
"category-archive" -> :category_archive
other ->
other
|> String.to_existing_atom()
|> normalize_kind()
end
rescue
_error -> :page
BDS.BoundedAtoms.menu_kind(kind, :page)
end
defp normalize_optional_string(nil), do: nil

View File

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

View File

@@ -1,6 +1,7 @@
defmodule BDS.UI.Session do
@moduledoc false
alias BDS.BoundedAtoms
alias BDS.UI.Workbench
def serialize(state) do
@@ -32,18 +33,19 @@ defmodule BDS.UI.Session do
Workbench.new(
sidebar_visible: Map.get(payload, "sidebar_visible", true),
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_width: Map.get(payload, "assistant_sidebar_width", 360),
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)
)
tabs =
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"),
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 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 atomize(value, fallback) when is_binary(value) do
String.to_existing_atom(value)
rescue
ArgumentError -> fallback
end
defp decode_tab_ref(%{"type" => type, "id" => id}),
do: {BoundedAtoms.editor_route(type, :post), id}
defp active_route(nil), do: :dashboard
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