From 07ce5f8b4d065a8ddddf80fe91342bbab3b6297d Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 17:25:59 +0200 Subject: [PATCH] chore: refactored areas around to_existing_atom/1 uses --- CODESMELL.md | 6 +- lib/bds/bounded_atoms.ex | 88 ++ lib/bds/desktop/shell_live.ex | 750 +++++++++++++----- lib/bds/desktop/shell_live/chat_surface.ex | 33 +- .../desktop/shell_live/code_entity_editor.ex | 143 +++- lib/bds/desktop/shell_live/import_editor.ex | 192 +++-- .../import_editor/taxonomy_editing.ex | 108 ++- .../shell_live/settings_editor/mcp_config.ex | 26 +- .../shell_live/shell_command_runner.ex | 53 +- .../desktop/shell_live/sidebar_components.ex | 18 +- lib/bds/desktop/shell_live/tab_helpers.ex | 6 +- lib/bds/mcp/queries.ex | 8 +- lib/bds/menu.ex | 14 +- lib/bds/posts/rebuild_from_files.ex | 6 +- lib/bds/ui/session.ex | 18 +- test/bds/bounded_atoms_test.exs | 54 ++ 16 files changed, 1150 insertions(+), 373 deletions(-) create mode 100644 lib/bds/bounded_atoms.ex create mode 100644 test/bds/bounded_atoms_test.exs diff --git a/CODESMELL.md b/CODESMELL.md index 9a098c0..4957ad3 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -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**: diff --git a/lib/bds/bounded_atoms.ex b/lib/bds/bounded_atoms.ex new file mode 100644 index 0000000..4c0b049 --- /dev/null +++ b/lib/bds/bounded_atoms.ex @@ -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 diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 973e752..3128503 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -5,20 +5,44 @@ defmodule BDS.Desktop.ShellLive do import Phoenix.HTML - alias BDS.AI + alias BDS.{AI, BoundedAtoms} alias BDS.CliSync.Watcher alias BDS.Desktop.{FolderPicker, Overlay, ShellData, UILocale} - alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, ImportEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor} + + alias BDS.Desktop.ShellLive.{ + ChatEditor, + CodeEntityEditor, + ImportEditor, + MediaEditor, + MenuEditor, + MiscEditor, + SettingsEditor, + TagsEditor + } + alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState - alias BDS.Desktop.ShellLive.{ChatSurface, CliSync, Layout, SessionUtil, ShellCommandRunner, SidebarCreate, TabHelpers, TaskLocalization, TitlebarMenu} + + alias BDS.Desktop.ShellLive.{ + ChatSurface, + CliSync, + Layout, + SessionUtil, + ShellCommandRunner, + SidebarCreate, + TabHelpers, + TaskLocalization, + TitlebarMenu + } + import TaskLocalization, only: [ localize_task_status: 2, translate_for_socket: 2 ] + import TabHelpers, only: [ tab_title: 2, @@ -28,6 +52,7 @@ defmodule BDS.Desktop.ShellLive do sidebar_route_atom: 1, parse_integer: 1 ] + alias BDS.Projects alias BDS.Templates alias BDS.UI.{Commands, MenuBar, Session, Workbench} @@ -35,19 +60,19 @@ defmodule BDS.Desktop.ShellLive do @refresh_interval 1_500 @output_entry_limit 20 @local_menu_actions MapSet.new([ - :toggle_sidebar, - :toggle_panel, - :toggle_assistant_sidebar, - :view_posts, - :view_media, - :edit_preferences, - :edit_menu, - :documentation, - :api_documentation, - :close_tab - ]) + :toggle_sidebar, + :toggle_panel, + :toggle_assistant_sidebar, + :view_posts, + :view_media, + :edit_preferences, + :edit_menu, + :documentation, + :api_documentation, + :close_tab + ]) - embed_templates "shell_live/*" + embed_templates("shell_live/*") @impl true def mount(_params, _session, socket) do @@ -64,67 +89,67 @@ defmodule BDS.Desktop.ShellLive do socket |> assign(:page_title, ShellData.title()) |> assign(:page_language, ShellData.ui_language()) - |> assign(:client_shortcuts, Commands.client_shortcuts()) - |> assign(:offline_mode, if(connected, do: AI.airplane_mode?(true), else: true)) - |> assign(:handled_task_results, SessionUtil.initial_handled_task_results()) - |> assign(:assistant_prompt, "") - |> assign(:assistant_messages, []) - |> assign(:is_mac_ui, mac_ui?()) - |> assign(:menu_groups, TitlebarMenu.groups()) - |> assign(:titlebar_menu_group, nil) - |> assign(:titlebar_menu_item_index, nil) + |> assign(:client_shortcuts, Commands.client_shortcuts()) + |> assign(:offline_mode, if(connected, do: AI.airplane_mode?(true), else: true)) + |> assign(:handled_task_results, SessionUtil.initial_handled_task_results()) + |> assign(:assistant_prompt, "") + |> assign(:assistant_messages, []) + |> assign(:is_mac_ui, mac_ui?()) + |> assign(:menu_groups, TitlebarMenu.groups()) + |> assign(:titlebar_menu_group, nil) + |> assign(:titlebar_menu_item_index, nil) |> assign(:tab_meta, %{}) - |> assign(:project_menu_open, false) - |> assign(:sidebar_filters_by_view, %{}) - |> assign(:sidebar_filter_panels, %{}) - |> assign(:post_editor_drafts, %{}) - |> assign(:post_editor_active_languages, %{}) - |> assign(:post_editor_tag_queries, %{}) - |> assign(:post_editor_category_queries, %{}) - |> assign(:post_editor_quick_actions_open, %{}) - |> assign(:post_editor_modes, %{}) - |> assign(:post_editor_expanded, %{}) - |> assign(:post_editor_save_states, %{}) - |> assign(:media_editor_drafts, %{}) - |> assign(:media_editor_quick_actions_open, %{}) - |> assign(:media_editor_post_pickers_open, %{}) - |> assign(:media_editor_post_picker_queries, %{}) - |> assign(:media_editor_save_states, %{}) - |> assign(:media_editor_translation_forms, %{}) - |> assign(:settings_editor_search, "") - |> assign(:settings_editor_project_draft, %{}) - |> assign(:settings_editor_endpoint_models, %{}) - |> assign(:settings_editor_publishing_draft, %{}) - |> assign(:settings_editor_new_category, "") - |> assign(:style_editor_theme, nil) - |> assign(:style_editor_preview_mode, "auto") - |> assign(:tags_editor_selected, []) - |> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""}) - |> assign(:tags_editor_edit_draft, %{}) - |> assign(:tags_editor_merge_target, "") - |> assign(:script_editor_drafts, %{}) - |> assign(:template_editor_drafts, %{}) - |> assign(:chat_editor_inputs, %{}) - |> assign(:chat_model_selectors_open, %{}) - |> assign(:chat_editor_requests, %{}) - |> assign(:chat_editor_request_refs, %{}) - |> assign(:chat_editor_surface_data, %{}) - |> assign(:chat_editor_surface_tabs, %{}) - |> assign(:chat_editor_action_errors, %{}) - |> assign(:import_editor_analysis_states, %{}) - |> assign(:import_editor_analysis_task_refs, %{}) - |> assign(:import_editor_execution_states, %{}) - |> assign(:import_editor_execution_task_refs, %{}) - |> assign(:import_editor_sections, %{}) - |> assign(:import_editor_taxonomy_edits, %{}) - |> assign(:import_editor_model_selectors_open, %{}) - |> assign(:import_editor_selected_models, %{}) - |> assign(:misc_editor_selected_pairs, %{}) - |> assign(:misc_editor_git_selected_files, %{}) - |> assign(:metadata_diff_active_tabs, %{}) - |> assign(:metadata_diff_field_filters, %{}) - |> assign(:shell_overlay, nil) - |> assign(:output_entries, []) + |> assign(:project_menu_open, false) + |> assign(:sidebar_filters_by_view, %{}) + |> assign(:sidebar_filter_panels, %{}) + |> assign(:post_editor_drafts, %{}) + |> assign(:post_editor_active_languages, %{}) + |> assign(:post_editor_tag_queries, %{}) + |> assign(:post_editor_category_queries, %{}) + |> assign(:post_editor_quick_actions_open, %{}) + |> assign(:post_editor_modes, %{}) + |> assign(:post_editor_expanded, %{}) + |> assign(:post_editor_save_states, %{}) + |> assign(:media_editor_drafts, %{}) + |> assign(:media_editor_quick_actions_open, %{}) + |> assign(:media_editor_post_pickers_open, %{}) + |> assign(:media_editor_post_picker_queries, %{}) + |> assign(:media_editor_save_states, %{}) + |> assign(:media_editor_translation_forms, %{}) + |> assign(:settings_editor_search, "") + |> assign(:settings_editor_project_draft, %{}) + |> assign(:settings_editor_endpoint_models, %{}) + |> assign(:settings_editor_publishing_draft, %{}) + |> assign(:settings_editor_new_category, "") + |> assign(:style_editor_theme, nil) + |> assign(:style_editor_preview_mode, "auto") + |> assign(:tags_editor_selected, []) + |> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""}) + |> assign(:tags_editor_edit_draft, %{}) + |> assign(:tags_editor_merge_target, "") + |> assign(:script_editor_drafts, %{}) + |> assign(:template_editor_drafts, %{}) + |> assign(:chat_editor_inputs, %{}) + |> assign(:chat_model_selectors_open, %{}) + |> assign(:chat_editor_requests, %{}) + |> assign(:chat_editor_request_refs, %{}) + |> assign(:chat_editor_surface_data, %{}) + |> assign(:chat_editor_surface_tabs, %{}) + |> assign(:chat_editor_action_errors, %{}) + |> assign(:import_editor_analysis_states, %{}) + |> assign(:import_editor_analysis_task_refs, %{}) + |> assign(:import_editor_execution_states, %{}) + |> assign(:import_editor_execution_task_refs, %{}) + |> assign(:import_editor_sections, %{}) + |> assign(:import_editor_taxonomy_edits, %{}) + |> assign(:import_editor_model_selectors_open, %{}) + |> assign(:import_editor_selected_models, %{}) + |> assign(:misc_editor_selected_pairs, %{}) + |> assign(:misc_editor_git_selected_files, %{}) + |> assign(:metadata_diff_active_tabs, %{}) + |> assign(:metadata_diff_field_filters, %{}) + |> assign(:shell_overlay, nil) + |> assign(:output_entries, []) |> reload_shell(workbench)} end @@ -142,7 +167,12 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("select_view", %{"view" => view_id}, socket) do - workbench = Workbench.click_activity(socket.assigns.workbench, String.to_existing_atom(view_id)) + workbench = + Workbench.click_activity( + socket.assigns.workbench, + BoundedAtoms.sidebar_view(view_id, :posts) + ) + {:noreply, reload_shell(socket, workbench)} end @@ -150,7 +180,7 @@ defmodule BDS.Desktop.ShellLive do workbench = socket.assigns.workbench |> Workbench.set_panel_visible(true) - |> Workbench.set_panel_tab(String.to_existing_atom(tab)) + |> Workbench.set_panel_tab(BoundedAtoms.panel_tab(tab, :tasks)) {:noreply, reload_shell(socket, workbench)} end @@ -177,7 +207,13 @@ defmodule BDS.Desktop.ShellLive do if state.visible do %{state | visible: false} else - %{visible: true, archive_collapsed: true, tags_collapsed: true, categories_collapsed: true, expanded_year: nil} + %{ + visible: true, + archive_collapsed: true, + tags_collapsed: true, + categories_collapsed: true, + expanded_year: nil + } end end) @@ -189,63 +225,79 @@ defmodule BDS.Desktop.ShellLive do def handle_event("toggle_sidebar_archive", _params, socket) do {:noreply, socket - |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | archive_collapsed: not state.archive_collapsed} end) + |> ShellSidebarState.put_filter_panel_state(fn state -> + %{state | archive_collapsed: not state.archive_collapsed} + end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_tags", _params, socket) do {:noreply, socket - |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | tags_collapsed: not state.tags_collapsed} end) + |> ShellSidebarState.put_filter_panel_state(fn state -> + %{state | tags_collapsed: not state.tags_collapsed} + end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_categories", _params, socket) do {:noreply, socket - |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | categories_collapsed: not state.categories_collapsed} end) + |> ShellSidebarState.put_filter_panel_state(fn state -> + %{state | categories_collapsed: not state.categories_collapsed} + end) |> reload_shell(socket.assigns.workbench)} end def handle_event("update_sidebar_search", %{"sidebar_filters" => params}, socket) do {:noreply, socket - |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, ShellSidebarState.normalize_filter_string(Map.get(params, "search"))) end) + |> ShellSidebarState.put_filters(fn filters -> + Map.put( + filters, + :search, + ShellSidebarState.normalize_filter_string(Map.get(params, "search")) + ) + end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_search", _params, socket) do {:noreply, socket - |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, nil) end) + |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, nil) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_tags", _params, socket) do {:noreply, socket - |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :tags, []) end) + |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :tags, []) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("clear_sidebar_categories", _params, socket) do {:noreply, socket - |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :categories, []) end) + |> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :categories, []) end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_tag", %{"tag" => tag}, socket) do {:noreply, socket - |> ShellSidebarState.put_filters(fn filters -> ShellSidebarState.toggle_filter_value(filters, :tags, tag) end) + |> ShellSidebarState.put_filters(fn filters -> + ShellSidebarState.toggle_filter_value(filters, :tags, tag) + end) |> reload_shell(socket.assigns.workbench)} end def handle_event("toggle_sidebar_category", %{"category" => category}, socket) do {:noreply, socket - |> ShellSidebarState.put_filters(fn filters -> ShellSidebarState.toggle_filter_value(filters, :categories, category) end) + |> ShellSidebarState.put_filters(fn filters -> + ShellSidebarState.toggle_filter_value(filters, :categories, category) + end) |> reload_shell(socket.assigns.workbench)} end @@ -255,9 +307,10 @@ defmodule BDS.Desktop.ShellLive do {:noreply, socket |> ShellSidebarState.put_filter_panel_state(fn state -> - %{state | - archive_collapsed: false, - expanded_year: if(state.expanded_year == parsed_year, do: nil, else: parsed_year) + %{ + state + | archive_collapsed: false, + expanded_year: if(state.expanded_year == parsed_year, do: nil, else: parsed_year) } end) |> ShellSidebarState.put_filters(fn filters -> @@ -272,7 +325,11 @@ defmodule BDS.Desktop.ShellLive do {:noreply, socket |> ShellSidebarState.put_filter_panel_state(fn state -> - %{state | archive_collapsed: false, expanded_year: ShellSidebarState.parse_optional_integer(year)} + %{ + state + | archive_collapsed: false, + expanded_year: ShellSidebarState.parse_optional_integer(year) + } end) |> ShellSidebarState.put_filters(fn filters -> filters @@ -285,8 +342,12 @@ defmodule BDS.Desktop.ShellLive do def handle_event("clear_sidebar_month", _params, socket) do {:noreply, socket - |> ShellSidebarState.put_filter_panel_state(fn state -> %{state | archive_collapsed: false} end) - |> ShellSidebarState.put_filters(fn filters -> filters |> Map.put(:year, nil) |> Map.put(:month, nil) end) + |> ShellSidebarState.put_filter_panel_state(fn state -> + %{state | archive_collapsed: false} + end) + |> ShellSidebarState.put_filters(fn filters -> + filters |> Map.put(:year, nil) |> Map.put(:month, nil) + end) |> reload_shell(socket.assigns.workbench)} end @@ -300,7 +361,10 @@ defmodule BDS.Desktop.ShellLive do |> Map.put(:month, nil) |> Map.put(:tags, []) |> Map.put(:categories, []) - |> Map.put(:display_limit, ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data)) + |> Map.put( + :display_limit, + ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data) + ) end) |> reload_shell(socket.assigns.workbench)} end @@ -309,7 +373,12 @@ defmodule BDS.Desktop.ShellLive do {:noreply, socket |> ShellSidebarState.put_filters(fn filters -> - Map.update(filters, :display_limit, ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data), &(&1 + ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data))) + Map.update( + filters, + :display_limit, + ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data), + &(&1 + ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data)) + ) end) |> reload_shell(socket.assigns.workbench)} end @@ -328,13 +397,18 @@ defmodule BDS.Desktop.ShellLive do def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do workbench = - Workbench.open_tab(socket.assigns.workbench, String.to_existing_atom(type), id, :preview) + Workbench.open_tab( + socket.assigns.workbench, + BoundedAtoms.editor_route(type, :post), + id, + :preview + ) {:noreply, reload_shell(socket, workbench)} end def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do - type_atom = String.to_existing_atom(type) + type_atom = BoundedAtoms.editor_route(type, :post) workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id) tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id}) @@ -346,7 +420,8 @@ defmodule BDS.Desktop.ShellLive do def handle_event("delete_sidebar_template", %{"id" => template_id}, socket) do case Templates.get_template(template_id) do - %Templates.Template{project_id: project_id} when project_id == socket.assigns.projects.active_project_id -> + %Templates.Template{project_id: project_id} + when project_id == socket.assigns.projects.active_project_id -> case Templates.delete_template(template_id) do {:ok, :deleted} -> workbench = Workbench.close_tab(socket.assigns.workbench, :templates, template_id) @@ -360,14 +435,24 @@ defmodule BDS.Desktop.ShellLive do {:error, reason} -> {:noreply, socket - |> append_output_entry(translated("Delete") <> " " <> translated("Template"), inspect(reason), nil, "error") + |> append_output_entry( + translated("Delete") <> " " <> translated("Template"), + inspect(reason), + nil, + "error" + ) |> reload_shell(socket.assigns.workbench)} end _other -> {:noreply, socket - |> append_output_entry(translated("Delete") <> " " <> translated("Template"), inspect(:not_found), nil, "error") + |> append_output_entry( + translated("Delete") <> " " <> translated("Template"), + inspect(:not_found), + nil, + "error" + ) |> reload_shell(socket.assigns.workbench)} end end @@ -394,7 +479,10 @@ defmodule BDS.Desktop.ShellLive do else socket |> assign(:assistant_prompt, "") - |> assign(:assistant_messages, socket.assigns.assistant_messages ++ ChatSurface.assistant_turn(prompt, socket)) + |> assign( + :assistant_messages, + socket.assigns.assistant_messages ++ ChatSurface.assistant_turn(prompt, socket) + ) end {:noreply, socket} @@ -414,15 +502,18 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("save_post_editor", %{"id" => post_id}, socket) do - {:noreply, PostEditor.persist_socket(socket, post_id, :save, &reload_shell/2, &append_output_entry/5)} + {:noreply, + PostEditor.persist_socket(socket, post_id, :save, &reload_shell/2, &append_output_entry/5)} end def handle_event("publish_post_editor", %{"id" => post_id}, socket) do - {:noreply, PostEditor.persist_socket(socket, post_id, :publish, &reload_shell/2, &append_output_entry/5)} + {:noreply, + PostEditor.persist_socket(socket, post_id, :publish, &reload_shell/2, &append_output_entry/5)} end def handle_event("discard_post_editor", %{"id" => post_id}, socket) do - {:noreply, PostEditor.discard_socket(socket, post_id, &reload_shell/2, &append_output_entry/5)} + {:noreply, + PostEditor.discard_socket(socket, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("delete_post_editor", %{"id" => post_id}, socket) do @@ -441,7 +532,11 @@ defmodule BDS.Desktop.ShellLive do {:noreply, PostEditor.toggle_section(socket, post_id, :excerpt, &reload_shell/2)} end - def handle_event("select_post_editor_language", %{"id" => post_id, "language" => language}, socket) do + def handle_event( + "select_post_editor_language", + %{"id" => post_id, "language" => language}, + socket + ) do {:noreply, PostEditor.select_language(socket, post_id, language, &reload_shell/2)} end @@ -450,7 +545,8 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("detect_post_editor_language", %{"id" => post_id}, socket) do - {:noreply, PostEditor.detect_language(socket, post_id, &reload_shell/2, &append_output_entry/5)} + {:noreply, + PostEditor.detect_language(socket, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("add_post_editor_tag", %{"id" => post_id, "tag" => tag}, socket) do @@ -465,8 +561,13 @@ defmodule BDS.Desktop.ShellLive do {:noreply, PostEditor.add_list_value(socket, post_id, :categories, category, &reload_shell/2)} end - def handle_event("remove_post_editor_category", %{"id" => post_id, "category" => category}, socket) do - {:noreply, PostEditor.remove_list_value(socket, post_id, :categories, category, &reload_shell/2)} + def handle_event( + "remove_post_editor_category", + %{"id" => post_id, "category" => category}, + socket + ) do + {:noreply, + PostEditor.remove_list_value(socket, post_id, :categories, category, &reload_shell/2)} end def handle_event("change_media_editor", %{"media_editor" => params}, socket) do @@ -474,7 +575,8 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("save_media_editor", %{"id" => media_id}, socket) do - {:noreply, MediaEditor.persist_socket(socket, media_id, &reload_shell/2, &append_output_entry/5)} + {:noreply, + MediaEditor.persist_socket(socket, media_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("toggle_media_editor_quick_actions", %{"id" => media_id}, socket) do @@ -482,27 +584,35 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("replace_media_editor_file", %{"id" => media_id}, socket) do - {:noreply, MediaEditor.replace_file(socket, media_id, &reload_shell/2, &append_output_entry/5)} + {:noreply, + MediaEditor.replace_file(socket, media_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("detect_media_editor_language", %{"id" => media_id}, socket) do - {:noreply, MediaEditor.detect_language(socket, media_id, &reload_shell/2, &append_output_entry/5)} + {:noreply, + MediaEditor.detect_language(socket, media_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("toggle_media_post_picker", %{"id" => media_id}, socket) do {:noreply, MediaEditor.toggle_post_picker(socket, media_id, &reload_shell/2)} end - def handle_event("change_media_post_picker", %{"id" => media_id, "media_post_picker" => %{"query" => query}}, socket) do + def handle_event( + "change_media_post_picker", + %{"id" => media_id, "media_post_picker" => %{"query" => query}}, + socket + ) do {:noreply, MediaEditor.set_post_picker_query(socket, media_id, query, &reload_shell/2)} end def handle_event("link_media_to_post", %{"id" => media_id, "post-id" => post_id}, socket) do - {:noreply, MediaEditor.link_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)} + {:noreply, + MediaEditor.link_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("unlink_media_from_post", %{"id" => media_id, "post-id" => post_id}, socket) do - {:noreply, MediaEditor.unlink_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)} + {:noreply, + MediaEditor.unlink_post(socket, media_id, post_id, &reload_shell/2, &append_output_entry/5)} end def handle_event("edit_media_translation", %{"id" => media_id, "language" => language}, socket) do @@ -511,21 +621,47 @@ defmodule BDS.Desktop.ShellLive do def handle_event("change_media_translation", %{"media_translation" => params}, socket) do case socket.assigns.current_tab do - %{type: :media, id: media_id} -> {:noreply, MediaEditor.update_translation(socket, media_id, params, &reload_shell/2)} - _other -> {:noreply, socket} + %{type: :media, id: media_id} -> + {:noreply, MediaEditor.update_translation(socket, media_id, params, &reload_shell/2)} + + _other -> + {:noreply, socket} end end def handle_event("save_media_translation", %{"id" => media_id}, socket) do - {:noreply, MediaEditor.save_translation(socket, media_id, &reload_shell/2, &append_output_entry/5)} + {:noreply, + MediaEditor.save_translation(socket, media_id, &reload_shell/2, &append_output_entry/5)} end - def handle_event("refresh_media_translation", %{"id" => media_id, "language" => language}, socket) do - {:noreply, MediaEditor.refresh_translation(socket, media_id, language, &reload_shell/2, &append_output_entry/5)} + def handle_event( + "refresh_media_translation", + %{"id" => media_id, "language" => language}, + socket + ) do + {:noreply, + MediaEditor.refresh_translation( + socket, + media_id, + language, + &reload_shell/2, + &append_output_entry/5 + )} end - def handle_event("delete_media_translation", %{"id" => media_id, "language" => language}, socket) do - {:noreply, MediaEditor.delete_translation(socket, media_id, language, &reload_shell/2, &append_output_entry/5)} + def handle_event( + "delete_media_translation", + %{"id" => media_id, "language" => language}, + socket + ) do + {:noreply, + MediaEditor.delete_translation( + socket, + media_id, + language, + &reload_shell/2, + &append_output_entry/5 + )} end def handle_event("close_media_translation_editor", _params, socket) do @@ -533,7 +669,10 @@ defmodule BDS.Desktop.ShellLive do %{type: :media, id: media_id} -> {:noreply, socket - |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) + |> assign( + :media_editor_translation_forms, + Map.delete(socket.assigns.media_editor_translation_forms, media_id) + ) |> reload_shell(socket.assigns.workbench)} _other -> @@ -570,8 +709,19 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("refresh_settings_ai_models", %{"endpoint" => endpoint}, socket) do - endpoint_key = String.to_existing_atom(endpoint) - {:noreply, SettingsEditor.refresh_ai_models(socket, endpoint_key, &reload_shell/2, &append_output_entry/5)} + case BoundedAtoms.ai_endpoint(endpoint) do + nil -> + {:noreply, reload_shell(socket, socket.assigns.workbench)} + + endpoint_key -> + {:noreply, + SettingsEditor.refresh_ai_models( + socket, + endpoint_key, + &reload_shell/2, + &append_output_entry/5 + )} + end end def handle_event("save_settings_ai", _params, socket) do @@ -603,11 +753,13 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("save_settings_category", %{"category_settings" => params}, socket) do - {:noreply, SettingsEditor.save_category(socket, params, &reload_shell/2, &append_output_entry/5)} + {:noreply, + SettingsEditor.save_category(socket, params, &reload_shell/2, &append_output_entry/5)} end def handle_event("remove_settings_category", %{"category" => category}, socket) do - {:noreply, SettingsEditor.remove_category(socket, category, &reload_shell/2, &append_output_entry/5)} + {:noreply, + SettingsEditor.remove_category(socket, category, &reload_shell/2, &append_output_entry/5)} end def handle_event("settings_shell_command", %{"action" => action}, socket) do @@ -615,7 +767,8 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("toggle_settings_mcp_agent", %{"agent" => agent}, socket) do - {:noreply, SettingsEditor.toggle_mcp_agent(socket, agent, &reload_shell/2, &append_output_entry/5)} + {:noreply, + SettingsEditor.toggle_mcp_agent(socket, agent, &reload_shell/2, &append_output_entry/5)} end def handle_event("select_style_theme", %{"theme" => theme}, socket) do @@ -660,10 +813,15 @@ defmodule BDS.Desktop.ShellLive do def handle_event( "menu_editor_drop_item", - %{"drag_item_id" => drag_item_id, "target_item_id" => target_item_id, "position" => position}, + %{ + "drag_item_id" => drag_item_id, + "target_item_id" => target_item_id, + "position" => position + }, socket ) do - {:noreply, MenuEditor.drop_item(socket, drag_item_id, target_item_id, position, &reload_shell/2)} + {:noreply, + MenuEditor.drop_item(socket, drag_item_id, target_item_id, position, &reload_shell/2)} end def handle_event("menu_editor_keydown", %{"key" => key}, socket) do @@ -735,7 +893,8 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("validate_template_editor", _params, socket) do - {:noreply, CodeEntityEditor.validate_template(socket, &reload_shell/2, &append_output_entry/5)} + {:noreply, + CodeEntityEditor.validate_template(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("delete_template_editor", _params, socket) do @@ -766,15 +925,27 @@ defmodule BDS.Desktop.ShellLive do {:noreply, socket |> ChatSurface.clear_action_error() - |> open_sidebar_item(%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, :pin)} + |> open_sidebar_item( + %{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, + :pin + )} end - def handle_event("change_chat_surface_form", %{"surface" => %{"id" => surface_id, "fields" => fields}}, socket) do + def handle_event( + "change_chat_surface_form", + %{"surface" => %{"id" => surface_id, "fields" => fields}}, + socket + ) do {:noreply, ChatEditor.update_surface_form(socket, surface_id, fields, &reload_shell/2)} end - def handle_event("select_chat_surface_tab", %{"surface-id" => surface_id, "index" => index}, socket) do - {:noreply, ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)} + def handle_event( + "select_chat_surface_tab", + %{"surface-id" => surface_id, "index" => index}, + socket + ) do + {:noreply, + ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)} end def handle_event("chat_surface_action", params, socket) do @@ -790,7 +961,8 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("select_import_uploads_folder", _params, socket) do - {:noreply, ImportEditor.select_uploads_folder(socket, &reload_shell/2, &append_output_entry/5)} + {:noreply, + ImportEditor.select_uploads_folder(socket, &reload_shell/2, &append_output_entry/5)} end def handle_event("select_import_wxr_file", _params, socket) do @@ -853,8 +1025,11 @@ defmodule BDS.Desktop.ShellLive do def handle_event("fix_translation_validation", _params, socket) do case MiscEditor.fix_translation_validation(socket, &append_output_entry/5) do - {:rerun, next_socket} -> {:noreply, apply_shell_command(next_socket, "validate_translations")} - {:socket, next_socket} -> {:noreply, next_socket} + {:rerun, next_socket} -> + {:noreply, apply_shell_command(next_socket, "validate_translations")} + + {:socket, next_socket} -> + {:noreply, next_socket} end end @@ -866,8 +1041,19 @@ defmodule BDS.Desktop.ShellLive do {:noreply, MiscEditor.toggle_duplicate(socket, pair_id, &reload_shell/2)} end - def handle_event("dismiss_duplicate_pair", %{"post-id-a" => post_id_a, "post-id-b" => post_id_b}, socket) do - {:noreply, MiscEditor.dismiss_duplicate(socket, post_id_a, post_id_b, &reload_shell/2, &append_output_entry/5)} + def handle_event( + "dismiss_duplicate_pair", + %{"post-id-a" => post_id_a, "post-id-b" => post_id_b}, + socket + ) do + {:noreply, + MiscEditor.dismiss_duplicate( + socket, + post_id_a, + post_id_b, + &reload_shell/2, + &append_output_entry/5 + )} end def handle_event("dismiss_selected_duplicates", _params, socket) do @@ -876,15 +1062,35 @@ defmodule BDS.Desktop.ShellLive do def handle_event("repair_metadata_diff", %{"field" => field, "direction" => direction}, socket) do case MiscEditor.metadata_diff_repair_request(socket, field, direction) do - {:ok, params} -> {:noreply, apply_shell_command(socket, "repair_metadata_diff", params)} - {:error, message} -> {:noreply, append_output_entry(socket, translate_for_socket(socket, "Metadata Diff"), message, nil, "error")} + {:ok, params} -> + {:noreply, apply_shell_command(socket, "repair_metadata_diff", params)} + + {:error, message} -> + {:noreply, + append_output_entry( + socket, + translate_for_socket(socket, "Metadata Diff"), + message, + nil, + "error" + )} end end def handle_event("import_metadata_diff_orphans", _params, socket) do case MiscEditor.metadata_diff_orphan_import_request(socket) do - {:ok, params} -> {:noreply, apply_shell_command(socket, "import_metadata_diff_orphans", params)} - {:error, message} -> {:noreply, append_output_entry(socket, translate_for_socket(socket, "Metadata Diff"), message, nil, "error")} + {:ok, params} -> + {:noreply, apply_shell_command(socket, "import_metadata_diff_orphans", params)} + + {:error, message} -> + {:noreply, + append_output_entry( + socket, + translate_for_socket(socket, "Metadata Diff"), + message, + nil, + "error" + )} end end @@ -893,8 +1099,14 @@ defmodule BDS.Desktop.ShellLive do socket = socket - |> assign(:metadata_diff_active_tabs, Map.put(socket.assigns.metadata_diff_active_tabs, tab_id, tab)) - |> assign(:metadata_diff_field_filters, Map.delete(socket.assigns.metadata_diff_field_filters, tab_id)) + |> assign( + :metadata_diff_active_tabs, + Map.put(socket.assigns.metadata_diff_active_tabs, tab_id, tab) + ) + |> assign( + :metadata_diff_field_filters, + Map.delete(socket.assigns.metadata_diff_field_filters, tab_id) + ) |> assign_misc_editor() {:noreply, socket} @@ -911,21 +1123,36 @@ defmodule BDS.Desktop.ShellLive do Map.put(socket.assigns.metadata_diff_field_filters, tab_id, field) end - {:noreply, socket |> assign(:metadata_diff_field_filters, next_filters) |> assign_misc_editor()} + {:noreply, + socket |> assign(:metadata_diff_field_filters, next_filters) |> assign_misc_editor()} end def handle_event("open_duplicate_post", %{"id" => id, "title" => title}, socket) do - {:noreply, open_sidebar_item(socket, %{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"}, :preview)} + {:noreply, + open_sidebar_item( + socket, + %{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"}, + :preview + )} end def handle_event("open_overlay", %{"kind" => kind}, socket) do socket = case socket.assigns[:current_tab] do %{type: :post, id: post_id} when kind in ["ai_suggestions", "language_picker"] -> - assign(socket, :post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)) + assign( + socket, + :post_editor_quick_actions_open, + Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false) + ) - %{type: :media, id: media_id} when kind in ["ai_suggestions", "language_picker", "confirm_delete"] -> - assign(socket, :media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false)) + %{type: :media, id: media_id} + when kind in ["ai_suggestions", "language_picker", "confirm_delete"] -> + assign( + socket, + :media_editor_quick_actions_open, + Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false) + ) _other -> socket @@ -937,7 +1164,12 @@ defmodule BDS.Desktop.ShellLive do tab = socket.assigns.current_tab title = tab_title(tab, socket.assigns.tab_meta) subtitle = tab_subtitle(tab, socket.assigns.tab_meta) - Overlay.open(route, overlay_kind, ShellOverlayComponents.context(socket.assigns, title, subtitle)) + + Overlay.open( + route, + overlay_kind, + ShellOverlayComponents.context(socket.assigns, title, subtitle) + ) end {:noreply, assign(socket, :shell_overlay, overlay)} @@ -950,11 +1182,20 @@ defmodule BDS.Desktop.ShellLive do def handle_event("overlay_keydown", %{"key" => key}, socket) do socket = case {socket.assigns[:shell_overlay], key} do - {nil, _other} -> socket - {_overlay, "Escape"} -> assign(socket, :shell_overlay, nil) - {%{kind: :gallery} = overlay, "ArrowLeft"} -> assign(socket, :shell_overlay, Overlay.lightbox_previous(overlay)) - {%{kind: :gallery} = overlay, "ArrowRight"} -> assign(socket, :shell_overlay, Overlay.lightbox_next(overlay)) - _other -> socket + {nil, _other} -> + socket + + {_overlay, "Escape"} -> + assign(socket, :shell_overlay, nil) + + {%{kind: :gallery} = overlay, "ArrowLeft"} -> + assign(socket, :shell_overlay, Overlay.lightbox_previous(overlay)) + + {%{kind: :gallery} = overlay, "ArrowRight"} -> + assign(socket, :shell_overlay, Overlay.lightbox_next(overlay)) + + _other -> + socket end {:noreply, socket} @@ -969,14 +1210,19 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("overlay_set_tab", %{"tab" => tab}, socket) do - {:noreply, update_shell_overlay(socket, &Overlay.set_active_tab(&1, ShellOverlayComponents.tab(tab)))} + {:noreply, + update_shell_overlay(socket, &Overlay.set_active_tab(&1, ShellOverlayComponents.tab(tab)))} end def handle_event("overlay_update_form", %{"overlay" => params}, socket) do socket = socket - |> update_shell_overlay(&Overlay.update_form_value(&1, :external_url, Map.get(params, "url", ""))) - |> update_shell_overlay(&Overlay.update_form_value(&1, :external_text, Map.get(params, "text", ""))) + |> update_shell_overlay( + &Overlay.update_form_value(&1, :external_url, Map.get(params, "url", "")) + ) + |> update_shell_overlay( + &Overlay.update_form_value(&1, :external_text, Map.get(params, "text", "")) + ) {:noreply, socket} end @@ -989,13 +1235,23 @@ defmodule BDS.Desktop.ShellLive do case {overlay, current_tab} do {%{kind: :insert_link}, %{type: :post, id: post_id}} -> case Overlay.insert_link_result(overlay, id) do - nil -> socket - result -> PostEditor.insert_content(socket, post_id, ShellOverlayComponents.markdown_link(result.title, result.canonical_url), &reload_shell/2) + nil -> + socket + + result -> + PostEditor.insert_content( + socket, + post_id, + ShellOverlayComponents.markdown_link(result.title, result.canonical_url), + &reload_shell/2 + ) end {%{kind: :insert_media}, %{type: :post, id: post_id}} -> case Overlay.insert_media_result(overlay, id) do - nil -> socket + nil -> + socket + result -> syntax = if result.is_image do @@ -1051,7 +1307,8 @@ defmodule BDS.Desktop.ShellLive do {%{kind: :language_picker}, %{type: :media, id: media_id}} -> MediaEditor.translate(socket, media_id, code, &reload_shell/2, &append_output_entry/5) - _other -> socket + _other -> + socket end {:noreply, socket} @@ -1121,7 +1378,10 @@ defmodule BDS.Desktop.ShellLive do end def handle_event("select_project", %{"project_id" => project_id}, socket) do - {:noreply, activate_project(socket, project_id, "Select Project", fn project -> "Activated #{project.name}" end)} + {:noreply, + activate_project(socket, project_id, "Select Project", fn project -> + "Activated #{project.name}" + end)} end def handle_event("create_project", _params, socket) do @@ -1129,8 +1389,13 @@ defmodule BDS.Desktop.ShellLive do socket = case Projects.create_project(attrs) do - {:ok, project} -> activate_project(socket, project.id, "New Project", fn created -> "Activated #{created.name}" end) - {:error, reason} -> append_output_entry(socket, "New Project", inspect(reason), nil, "error") + {:ok, project} -> + activate_project(socket, project.id, "New Project", fn created -> + "Activated #{created.name}" + end) + + {:error, reason} -> + append_output_entry(socket, "New Project", inspect(reason), nil, "error") end {:noreply, socket} @@ -1140,18 +1405,30 @@ defmodule BDS.Desktop.ShellLive do socket = case FolderPicker.choose_directory("Open Existing Blog") do {:ok, path} -> - name = path |> Path.basename() |> String.trim() |> case do - "" -> "Imported Blog" - value -> value - end + name = + path + |> Path.basename() + |> String.trim() + |> case do + "" -> "Imported Blog" + value -> value + end case Projects.create_project(%{name: name, data_path: path}) do - {:ok, project} -> activate_project(socket, project.id, "Open Existing Blog", fn imported -> "Activated #{imported.name}" end) - {:error, reason} -> append_output_entry(socket, "Open Existing Blog", inspect(reason), nil, "error") + {:ok, project} -> + activate_project(socket, project.id, "Open Existing Blog", fn imported -> + "Activated #{imported.name}" + end) + + {:error, reason} -> + append_output_entry(socket, "Open Existing Blog", inspect(reason), nil, "error") end - :cancel -> assign(socket, :project_menu_open, false) - {:error, %{message: message}} -> append_output_entry(socket, "Open Existing Blog", message, nil, "error") + :cancel -> + assign(socket, :project_menu_open, false) + + {:error, %{message: message}} -> + append_output_entry(socket, "Open Existing Blog", message, nil, "error") end {:noreply, socket} @@ -1165,7 +1442,8 @@ defmodule BDS.Desktop.ShellLive do {:noreply, set_page_language(socket, language)} end - def handle_event("restore_workbench_session", %{"session" => session_payload}, socket) when is_map(session_payload) do + def handle_event("restore_workbench_session", %{"session" => session_payload}, socket) + when is_map(session_payload) do {:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))} end @@ -1202,13 +1480,28 @@ defmodule BDS.Desktop.ShellLive do cond do Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) -> - {:noreply, ImportEditor.finish_analysis(socket, ref, result, &reload_shell/2, &append_output_entry/5)} + {:noreply, + ImportEditor.finish_analysis( + socket, + ref, + result, + &reload_shell/2, + &append_output_entry/5 + )} Map.has_key?(socket.assigns.import_editor_execution_task_refs, ref) -> - {:noreply, ImportEditor.finish_execution(socket, ref, result, &reload_shell/2, &append_output_entry/5)} + {:noreply, + ImportEditor.finish_execution( + socket, + ref, + result, + &reload_shell/2, + &append_output_entry/5 + )} true -> - {:noreply, ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)} + {:noreply, + ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)} end end @@ -1216,15 +1509,38 @@ defmodule BDS.Desktop.ShellLive do next_socket = cond do Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) -> - ImportEditor.handle_task_down(socket, :analysis, ref, reason, &reload_shell/2, &append_output_entry/5) + ImportEditor.handle_task_down( + socket, + :analysis, + ref, + reason, + &reload_shell/2, + &append_output_entry/5 + ) Map.has_key?(socket.assigns.import_editor_execution_task_refs, ref) -> - ImportEditor.handle_task_down(socket, :execution, ref, reason, &reload_shell/2, &append_output_entry/5) + ImportEditor.handle_task_down( + socket, + :execution, + ref, + reason, + &reload_shell/2, + &append_output_entry/5 + ) true -> case reason do - :normal -> socket - _other -> ChatEditor.finish_request(socket, ref, {:error, :cancelled}, &reload_shell/2, &append_output_entry/5) + :normal -> + socket + + _other -> + ChatEditor.finish_request( + socket, + ref, + {:error, :cancelled}, + &reload_shell/2, + &append_output_entry/5 + ) end end @@ -1232,11 +1548,24 @@ defmodule BDS.Desktop.ShellLive do end def handle_info({:import_analysis_progress, definition_id, step, detail}, socket) do - {:noreply, ImportEditor.note_analysis_progress(socket, definition_id, step, detail, &reload_shell/2)} + {:noreply, + ImportEditor.note_analysis_progress(socket, definition_id, step, detail, &reload_shell/2)} end - def handle_info({:import_execution_progress, definition_id, phase, current, total, detail}, socket) do - {:noreply, ImportEditor.note_execution_progress(socket, definition_id, phase, current, total, detail, &reload_shell/2)} + def handle_info( + {:import_execution_progress, definition_id, phase, current, total, detail}, + socket + ) do + {:noreply, + ImportEditor.note_execution_progress( + socket, + definition_id, + phase, + current, + total, + detail, + &reload_shell/2 + )} end def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do @@ -1248,7 +1577,8 @@ defmodule BDS.Desktop.ShellLive do end def handle_info({:chat_streaming_content, conversation_id, content}, socket) do - {:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)} + {:noreply, + ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)} end def handle_info({:entity_changed, payload}, socket) when is_map(payload) do @@ -1293,11 +1623,19 @@ defmodule BDS.Desktop.ShellLive do dashboard = ShellData.dashboard(projects.active_project_id) git_badge_count = ShellData.git_badge_count(projects.active_project_id) active_view_id = Atom.to_string(workbench.active_view) - sidebar_data = ShellData.sidebar_view(projects.active_project_id, active_view_id, ShellSidebarState.current_filters(socket, active_view_id)) + + sidebar_data = + ShellData.sidebar_view( + projects.active_project_id, + active_view_id, + ShellSidebarState.current_filters(socket, active_view_id) + ) + sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data) raw_task_status = BDS.Tasks.status_snapshot() activity_buttons = Workbench.activity_buttons(workbench, git_badge_count) page_language = socket.assigns[:page_language] || ShellData.ui_language() + offline_mode = if connected?(socket) do Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true)) @@ -1315,9 +1653,15 @@ defmodule BDS.Desktop.ShellLive do |> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, [])) |> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, [])) |> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, [])) - |> assign(:dashboard_tag_cloud_items, ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))) + |> assign( + :dashboard_tag_cloud_items, + ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, [])) + ) |> assign(:sidebar_data, sidebar_data) - |> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)) + |> assign( + :sidebar_header, + active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data) + ) |> assign(:assistant_cards, ShellData.assistant_cards()) |> assign(:editor_meta, ShellData.editor_meta(task_status)) |> assign(:task_status, task_status) @@ -1345,7 +1689,8 @@ defmodule BDS.Desktop.ShellLive do |> assign_misc_editor() end - defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, UILocale.current()) + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, UILocale.current()) defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts) @@ -1374,7 +1719,7 @@ defmodule BDS.Desktop.ShellLive do |> Enum.map(&(&1.count || 0)) |> Enum.max(fn -> 1 end) - max(4, ((entry.count || 0) / max_count) * 100) + max(4, (entry.count || 0) / max_count * 100) end defp current_tab(%{active_tab: nil}), do: nil @@ -1419,7 +1764,6 @@ defmodule BDS.Desktop.ShellLive do MiscEditor.assign_socket(socket) end - defp create_sidebar_item(socket, kind), do: SidebarCreate.create(socket, kind, sidebar_create_callbacks()) @@ -1436,7 +1780,12 @@ defmodule BDS.Desktop.ShellLive do tab_id = tab_id_for_route(route_atom, Map.fetch!(params, "id")) workbench = - Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, tab_intent(route_atom, intent)) + Workbench.open_tab( + socket.assigns.workbench, + route_atom, + tab_id, + tab_intent(route_atom, intent) + ) tab_meta = Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{ @@ -1453,7 +1802,11 @@ defmodule BDS.Desktop.ShellLive do defp sidebar_create_action(view), do: SidebarCreate.action(view) defp set_page_language(socket, language) do - codes = Enum.map(socket.assigns[:supported_ui_languages] || ShellData.supported_ui_languages(), & &1.code) + codes = + Enum.map( + socket.assigns[:supported_ui_languages] || ShellData.supported_ui_languages(), + & &1.code + ) normalized = language @@ -1474,7 +1827,9 @@ defmodule BDS.Desktop.ShellLive do defp activate_project(socket, project_id, title, message_fun) do cond do - project_id == socket.assigns.projects.active_project_id -> assign(socket, :project_menu_open, false) + project_id == socket.assigns.projects.active_project_id -> + assign(socket, :project_menu_open, false) + true -> case Projects.set_active_project(project_id) do {:ok, project} -> @@ -1499,7 +1854,7 @@ defmodule BDS.Desktop.ShellLive do end defp handle_native_menu_action(socket, action) do - with action_atom when not is_nil(action_atom) <- safe_existing_atom(action) do + with action_atom when not is_nil(action_atom) <- shell_command_atom(action) do if MapSet.member?(@local_menu_actions, action_atom) do reload_shell(socket, MenuBar.execute(socket.assigns.workbench, action_atom)) else @@ -1523,7 +1878,7 @@ defmodule BDS.Desktop.ShellLive do } end - defp safe_existing_atom(action), do: ShellCommandRunner.safe_existing_atom(action) + defp shell_command_atom(action), do: ShellCommandRunner.shell_command_atom(action) defp mac_ui? do case Application.get_env(:bds, :shell_platform) do @@ -1540,5 +1895,4 @@ defmodule BDS.Desktop.ShellLive do |> append_output_entry(title, translated("Command completed"), details) |> assign(:shell_overlay, nil) end - end diff --git a/lib/bds/desktop/shell_live/chat_surface.ex b/lib/bds/desktop/shell_live/chat_surface.ex index 7aa2984..e02a8bf 100644 --- a/lib/bds/desktop/shell_live/chat_surface.ex +++ b/lib/bds/desktop/shell_live/chat_surface.ex @@ -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.", diff --git a/lib/bds/desktop/shell_live/code_entity_editor.ex b/lib/bds/desktop/shell_live/code_entity_editor.ex index ba540b0..dc93d83 100644 --- a/lib/bds/desktop/shell_live/code_entity_editor.ex +++ b/lib/bds/desktop/shell_live/code_entity_editor.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/import_editor.ex b/lib/bds/desktop/shell_live/import_editor.ex index e79ca65..097c040 100644 --- a/lib/bds/desktop/shell_live/import_editor.ex +++ b/lib/bds/desktop/shell_live/import_editor.ex @@ -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"""
@@ -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 diff --git a/lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex b/lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex index aefad7d..3a16b62 100644 --- a/lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex +++ b/lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/settings_editor/mcp_config.ex b/lib/bds/desktop/shell_live/settings_editor/mcp_config.ex index db33e45..29fb5d5 100644 --- a/lib/bds/desktop/shell_live/settings_editor/mcp_config.ex +++ b/lib/bds/desktop/shell_live/settings_editor/mcp_config.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/shell_command_runner.ex b/lib/bds/desktop/shell_live/shell_command_runner.ex index 6cb7556..0704541 100644 --- a/lib/bds/desktop/shell_live/shell_command_runner.ex +++ b/lib/bds/desktop/shell_live/shell_command_runner.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/sidebar_components.ex b/lib/bds/desktop/shell_live/sidebar_components.ex index a2f697d..5ac02f5 100644 --- a/lib/bds/desktop/shell_live/sidebar_components.ex +++ b/lib/bds/desktop/shell_live/sidebar_components.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/tab_helpers.ex b/lib/bds/desktop/shell_live/tab_helpers.ex index 20e701f..510304d 100644 --- a/lib/bds/desktop/shell_live/tab_helpers.ex +++ b/lib/bds/desktop/shell_live/tab_helpers.ex @@ -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 diff --git a/lib/bds/mcp/queries.ex b/lib/bds/mcp/queries.ex index bd4384f..b9680de 100644 --- a/lib/bds/mcp/queries.ex +++ b/lib/bds/mcp/queries.ex @@ -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: [%{}] diff --git a/lib/bds/menu.ex b/lib/bds/menu.ex index c1485a2..063cac2 100644 --- a/lib/bds/menu.ex +++ b/lib/bds/menu.ex @@ -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 diff --git a/lib/bds/posts/rebuild_from_files.ex b/lib/bds/posts/rebuild_from_files.ex index c6fd02a..22abaef 100644 --- a/lib/bds/posts/rebuild_from_files.ex +++ b/lib/bds/posts/rebuild_from_files.ex @@ -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) diff --git a/lib/bds/ui/session.ex b/lib/bds/ui/session.ex index c507378..5d43ddf 100644 --- a/lib/bds/ui/session.ex +++ b/lib/bds/ui/session.ex @@ -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 diff --git a/test/bds/bounded_atoms_test.exs b/test/bds/bounded_atoms_test.exs new file mode 100644 index 0000000..e5e49c8 --- /dev/null +++ b/test/bds/bounded_atoms_test.exs @@ -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