From 0f193929da748a128b73137904a6295ea73d45a1 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 3 May 2026 17:57:43 +0200 Subject: [PATCH] chore: converted import editor to LiveComponent --- lib/bds/desktop/shell_live.ex | 170 +-- lib/bds/desktop/shell_live/import_editor.ex | 979 ++++++++++++++---- lib/bds/desktop/shell_live/index.html.heex | 4 +- test/bds/desktop/import_shell_live_test.exs | 8 +- .../desktop/shell_live/chat_editor_test.exs | 9 + .../desktop/shell_live/import_editor_test.exs | 11 + 6 files changed, 806 insertions(+), 375 deletions(-) create mode 100644 test/bds/desktop/shell_live/chat_editor_test.exs create mode 100644 test/bds/desktop/shell_live/import_editor_test.exs diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index d6bf563..9feeb4c 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -164,15 +164,7 @@ defmodule BDS.Desktop.ShellLive do |> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filter_panels, %{}) |> assign(:chat_editor_request_refs, %{}) - |> 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_selected_pairs, %{}) |> assign(:misc_editor_git_selected_files, %{}) |> assign(:metadata_diff_active_tabs, %{}) |> assign(:metadata_diff_field_filters, %{}) @@ -323,59 +315,6 @@ defmodule BDS.Desktop.ShellLive do {:noreply, apply_shell_command(socket, action)} end - def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do - {:noreply, ImportEditor.change_definition(socket, params, &reload_shell/2)} - end - - def handle_event("select_import_uploads_folder", _params, socket) do - {:noreply, - ImportEditor.select_uploads_folder(socket, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event("select_import_wxr_file", _params, socket) do - {:noreply, ImportEditor.select_and_analyze(socket, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event("execute_import_editor", _params, socket) do - {:noreply, ImportEditor.execute_import(socket, &reload_shell/2, &append_output_entry/5)} - end - - def handle_event("change_import_conflict_resolution", params, socket) do - {:noreply, ImportEditor.change_conflict_resolution(socket, params, &reload_shell/2)} - end - - def handle_event("start_import_taxonomy_edit", params, socket) do - {:noreply, ImportEditor.start_taxonomy_edit(socket, params, &reload_shell/2)} - end - - def handle_event("save_import_taxonomy_edit", params, socket) do - {:noreply, ImportEditor.save_taxonomy_edit(socket, params, &reload_shell/2)} - end - - def handle_event("cancel_import_taxonomy_edit", _params, socket) do - {:noreply, ImportEditor.cancel_taxonomy_edit(socket, &reload_shell/2)} - end - - def handle_event("clear_import_taxonomy_mapping", params, socket) do - {:noreply, ImportEditor.clear_taxonomy_mapping(socket, params, &reload_shell/2)} - end - - def handle_event("toggle_import_section", %{"section" => section}, socket) do - {:noreply, ImportEditor.toggle_section(socket, section, &reload_shell/2)} - end - - def handle_event("toggle_import_ai_model_selector", _params, socket) do - {:noreply, ImportEditor.toggle_model_selector(socket, &reload_shell/2)} - end - - def handle_event("select_import_ai_model", %{"model" => model_id}, socket) do - {:noreply, ImportEditor.select_ai_model(socket, model_id, &reload_shell/2)} - end - - def handle_event("analyze_import_taxonomy_ai", _params, socket) do - {:noreply, ImportEditor.analyze_taxonomy_ai(socket, &reload_shell/2, &append_output_entry/5)} - end - def handle_event("rerun_misc_editor", _params, socket) do case MiscEditor.rerun(socket) do {:command, action} -> {:noreply, apply_shell_command(socket, action)} @@ -870,26 +809,6 @@ defmodule BDS.Desktop.ShellLive do Process.demonitor(ref, [:flush]) 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 - )} - - 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 - )} - Map.has_key?(socket.assigns.chat_editor_request_refs, ref) -> {conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref) @@ -909,26 +828,6 @@ defmodule BDS.Desktop.ShellLive do def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) 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 - ) - - 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 - ) - Map.has_key?(socket.assigns.chat_editor_request_refs, ref) -> {conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref) @@ -951,25 +850,21 @@ defmodule BDS.Desktop.ShellLive do {:noreply, next_socket} 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)} + def handle_info({:import_editor_output, title, message, level}, socket) do + {:noreply, append_output_entry(socket, title, message, nil, level)} end - def handle_info( - {:import_execution_progress, definition_id, phase, current, total, detail}, - socket - ) do + def handle_info({:import_editor_tab_meta, definition_id, title, subtitle}, socket) do + tab_meta = + Map.put(socket.assigns.tab_meta, {:import, definition_id}, %{ + title: title, + subtitle: subtitle || "" + }) + {:noreply, - ImportEditor.note_execution_progress( - socket, - definition_id, - phase, - current, - total, - detail, - &reload_shell/2 - )} + socket + |> assign(:tab_meta, tab_meta) + |> reload_shell(socket.assigns.workbench)} end def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do @@ -1311,7 +1206,6 @@ defmodule BDS.Desktop.ShellLive do |> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups()) |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) |> assign(:current_tab, current_tab(workbench)) - |> assign_import_editor() |> assign_misc_editor() end @@ -1354,10 +1248,6 @@ defmodule BDS.Desktop.ShellLive do Enum.find(tabs, &(&1.type == type and &1.id == id)) end - defp assign_import_editor(socket) do - ImportEditor.assign_socket(socket) - end - defp assign_misc_editor(socket) do MiscEditor.assign_socket(socket) end @@ -1769,7 +1659,6 @@ defmodule BDS.Desktop.ShellLive do socket |> assign(:shell_overlay, nil) |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:import, definition_id})) - |> clear_import_editor_state(definition_id) |> reload_shell(workbench) {:error, reason} -> @@ -1780,39 +1669,6 @@ defmodule BDS.Desktop.ShellLive do end end - defp clear_import_editor_state(socket, definition_id) do - socket - |> assign( - :import_editor_analysis_states, - Map.delete(socket.assigns.import_editor_analysis_states, definition_id) - ) - |> assign( - :import_editor_analysis_task_refs, - Map.delete(socket.assigns.import_editor_analysis_task_refs, definition_id) - ) - |> assign( - :import_editor_execution_states, - Map.delete(socket.assigns.import_editor_execution_states, definition_id) - ) - |> assign( - :import_editor_execution_task_refs, - Map.delete(socket.assigns.import_editor_execution_task_refs, definition_id) - ) - |> assign(:import_editor_sections, Map.delete(socket.assigns.import_editor_sections, definition_id)) - |> assign( - :import_editor_taxonomy_edits, - Map.delete(socket.assigns.import_editor_taxonomy_edits, definition_id) - ) - |> assign( - :import_editor_model_selectors_open, - Map.delete(socket.assigns.import_editor_model_selectors_open, definition_id) - ) - |> assign( - :import_editor_selected_models, - Map.delete(socket.assigns.import_editor_selected_models, definition_id) - ) - end - defp sidebar_delete_target(socket, route, id, fallback_title) do active_project_id = socket.assigns.projects.active_project_id diff --git a/lib/bds/desktop/shell_live/import_editor.ex b/lib/bds/desktop/shell_live/import_editor.ex index 71b1031..849c8b2 100644 --- a/lib/bds/desktop/shell_live/import_editor.ex +++ b/lib/bds/desktop/shell_live/import_editor.ex @@ -1,10 +1,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do @moduledoc false - use Phoenix.Component + use Phoenix.LiveComponent - alias BDS.AI - alias BDS.Desktop.ShellData + alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution} + alias BDS.Desktop.{FilePicker, FolderPicker, ShellData} alias BDS.Desktop.ShellLive.ImportEditor.{ AnalysisState, @@ -13,21 +13,21 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do TaxonomyEditing } - alias BDS.ImportDefinitions - import AnalysisState, only: [ default_analysis_state: 0, default_sections: 0, detail_items: 2, - importable_counts: 1 + importable_counts: 1, + translate_phase: 1 ] import ProgressTracking, only: [ default_execution_state: 0, execution_progress_width: 1, - format_eta: 1 + format_eta: 1, + translate_execution_phase: 1 ] import TaxonomyEditing, @@ -38,179 +38,721 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do taxonomy_pill_class: 1 ] - 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 + # ── LiveComponent lifecycle ──────────────────────────────────────────────── - defdelegate note_analysis_progress(socket, definition_id, step, detail, reload), - to: AnalysisState + @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} + @impl true + def update(%{action: :finish_analysis, ref: ref, result: result}, socket) do + socket = + case socket.assigns.analysis_state do + %{ref: ^ref} = _state -> do_finish_analysis(socket, result) + _other -> socket + end - defdelegate finish_analysis(socket, ref, result, reload, append_output), to: AnalysisState + {:ok, build_data(socket)} + end - defdelegate execute_import(socket, reload, append_output), to: ProgressTracking + def update(%{action: :finish_execution, ref: ref, result: result}, socket) do + socket = + case socket.assigns.execution_state do + %{ref: ^ref} = _state -> do_finish_execution(socket, result) + _other -> socket + end - defdelegate note_execution_progress( - socket, - definition_id, - phase, - current, - total, - detail, - reload - ), - to: ProgressTracking + {:ok, build_data(socket)} + end - defdelegate finish_execution(socket, ref, result, reload, append_output), to: ProgressTracking + def update(%{action: :note_analysis_progress, step: step, detail: detail}, socket) do + socket = + socket + |> assign( + :analysis_state, + Map.merge(socket.assigns.analysis_state, %{loading: true, step: step, detail: detail}) + ) + |> build_data() - defdelegate handle_task_down(socket, kind, ref, reason, reload, append_output), - to: ProgressTracking + {:ok, socket} + end - defdelegate change_conflict_resolution(socket, params, reload), to: ConflictResolution + def update( + %{ + action: :note_execution_progress, + phase: phase, + current: current, + total: total, + detail: detail + }, + socket + ) do + {detail_text, eta} = ProgressTracking.decompose_progress_detail(detail) - defdelegate start_taxonomy_edit(socket, params, reload), to: TaxonomyEditing - defdelegate cancel_taxonomy_edit(socket, reload), to: TaxonomyEditing - defdelegate save_taxonomy_edit(socket, params, reload), to: TaxonomyEditing - defdelegate clear_taxonomy_mapping(socket, params, reload), to: TaxonomyEditing - defdelegate analyze_taxonomy_ai(socket, reload, append_output), to: TaxonomyEditing + socket = + socket + |> assign( + :execution_state, + Map.merge(socket.assigns.execution_state, %{ + is_executing: true, + phase: translate_execution_phase(phase), + current: current, + total: total, + detail: detail_text, + eta: eta + }) + ) + |> build_data() - @spec assign_socket(term()) :: term() - def assign_socket(socket) do - case socket.assigns[:current_tab] do - %{type: :import, id: definition_id} -> - case ImportDefinitions.get_definition(definition_id) do - nil -> - assign(socket, :import_editor, nil) + {:ok, socket} + end - definition -> - report = ImportDefinitions.decode_analysis_result(definition) - taxonomy_terms = existing_taxonomy_terms(socket.assigns.projects.active_project_id) + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> ensure_state() + |> build_data() - analysis_state = - Map.get( - socket.assigns.import_editor_analysis_states, - definition.id, - default_analysis_state() + {:ok, socket} + end + + @spec render(map()) :: Phoenix.LiveView.Rendered.t() + @impl true + def render(assigns) do + import_editor = %{ + id: assigns.definition_id, + definition_name: assigns.definition_name, + uploads_folder_path: assigns.uploads_folder_path, + wxr_file_path: assigns.wxr_file_path, + report: assigns.report, + taxonomy_terms: assigns.taxonomy_terms, + taxonomy_edit: assigns.taxonomy_edit, + analysis_state: assigns.analysis_state, + execution_state: assigns.execution_state, + importable_counts: assigns.importable_counts, + sections: assigns.sections, + selected_model: assigns.selected_model, + selected_model_label: assigns.selected_model_label, + model_selector_open?: assigns.model_selector_open?, + available_models: assigns.available_models, + offline?: assigns.offline_mode?, + is_loading: assigns.analysis_state.loading + } + + assigns = Map.put(assigns, :import_editor, import_editor) + import_editor(assigns) + end + + # ── Event handlers ───────────────────────────────────────────────────────── + + @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + @impl true + def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do + definition_id = socket.assigns.definition_id + + socket = + case ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do + {:ok, _definition} -> build_data(socket) + _other -> build_data(socket) + end + + {:noreply, socket} + end + + def handle_event("select_import_uploads_folder", _params, socket) do + definition_id = socket.assigns.definition_id + + socket = + case FolderPicker.choose_directory(translated("importAnalysis.uploadsFolder")) do + {:ok, uploads_folder_path} -> + {:ok, _definition} = + ImportDefinitions.update_definition(definition_id, %{ + uploads_folder_path: uploads_folder_path + }) + + build_data(socket) + + :cancel -> + build_data(socket) + + {:error, %{message: message}} -> + notify_output(translated("activity.import"), message, "error") + build_data(socket) + end + + {:noreply, socket} + end + + def handle_event("select_import_wxr_file", _params, socket) do + definition_id = socket.assigns.definition_id + project_id = socket.assigns.project_id + + socket = + case FilePicker.choose_file(translated("importAnalysis.wxrFile")) do + {:ok, wxr_file_path} -> + {:ok, definition} = + ImportDefinitions.update_definition(definition_id, %{ + wxr_file_path: wxr_file_path, + last_analysis_result: nil + }) + + parent = self() + + task = + Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> + ImportAnalysis.analyze_wxr( + project_id, + wxr_file_path, + definition.uploads_folder_path, + on_progress: fn step, detail -> + send(parent, {:import_analysis_progress, translate_phase(step), detail}) + end + ) + end) + + :ok = allow_repo_sandbox(task.pid) + + socket + |> assign(:analysis_state, %{ + loading: true, + step: translated("importAnalysis.analyzingWxr"), + detail: Path.basename(wxr_file_path), + file_path: wxr_file_path, + ref: task.ref + }) + |> assign(:execution_state, default_execution_state()) + |> build_data() + + :cancel -> + build_data(socket) + + {:error, %{message: message}} -> + notify_output(translated("activity.import"), message, "error") + build_data(socket) + end + + {:noreply, socket} + end + + def handle_event("execute_import_editor", _params, socket) do + definition_id = socket.assigns.definition_id + project_id = socket.assigns.project_id + + socket = + with %{} = definition <- ImportDefinitions.get_definition(definition_id), + %{} = report <- ImportDefinitions.decode_analysis_result(definition) do + default_author = AnalysisState.default_author(project_id) + counts = importable_counts(report) + + if counts.total == 0 do + build_data(socket) + else + parent = self() + + task = + Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> + ImportExecution.execute_import(project_id, report, + uploads_folder_path: definition.uploads_folder_path, + default_author: default_author, + on_progress: fn phase, current, total, detail -> + send( + parent, + {:import_execution_progress, phase, current, total, detail} + ) + end + ) + end) + + :ok = AnalysisState.allow_repo_sandbox(task.pid) + + progress_phase = translate_execution_phase("posts") + + socket + |> assign(:execution_state, %{ + is_executing: true, + completed: false, + error: nil, + count: counts.total, + result: nil, + phase: progress_phase, + current: 0, + total: counts.total, + detail: nil, + eta: nil, + ref: task.ref + }) + |> build_data() + end + else + _other -> build_data(socket) + end + + {:noreply, socket} + end + + def handle_event("change_import_conflict_resolution", params, socket) do + definition_id = socket.assigns.definition_id + + socket = + with %{} = definition <- ImportDefinitions.get_definition(definition_id), + %{} = report <- ImportDefinitions.decode_analysis_result(definition), + updated_report <- + ConflictResolution.update_conflict_resolution( + report, + Map.get(params, "item_type"), + Map.get(params, "item_name"), + Map.get(params, "resolution") + ), + {:ok, _definition} <- + ImportDefinitions.update_definition(definition_id, %{ + last_analysis_result: updated_report + }) do + build_data(socket) + else + _other -> build_data(socket) + end + + {:noreply, socket} + end + + def handle_event( + "start_import_taxonomy_edit", + %{"type" => type, "name" => name, "mapped_to" => mapped_to}, + socket + ) do + socket = + socket + |> assign(:taxonomy_edit, %{ + type: type, + name: name, + value: mapped_to |> to_string() |> blank_to_nil() + }) + |> build_data() + + {:noreply, socket} + end + + def handle_event("save_import_taxonomy_edit", params, socket) do + definition_id = socket.assigns.definition_id + project_id = socket.assigns.project_id + + socket = + with %{} = definition <- ImportDefinitions.get_definition(definition_id), + %{} = report <- ImportDefinitions.decode_analysis_result(definition), + type <- Map.get(params, "type"), + name <- Map.get(params, "name"), + mapped_to <- Map.get(params, "mapped_to"), + normalized_value <- + TaxonomyEditing.normalize_taxonomy_mapping_value(project_id, type, mapped_to), + updated_report <- TaxonomyEditing.update_taxonomy_mapping(report, type, name, normalized_value), + {:ok, _definition} <- + ImportDefinitions.update_definition(definition_id, %{ + last_analysis_result: updated_report + }) do + socket + |> assign(:taxonomy_edit, nil) + |> build_data() + else + _other -> + socket + |> assign(:taxonomy_edit, nil) + |> build_data() + end + + {:noreply, socket} + end + + def handle_event("cancel_import_taxonomy_edit", _params, socket) do + {:noreply, assign(socket, :taxonomy_edit, nil) |> build_data()} + end + + def handle_event("clear_import_taxonomy_mapping", %{"type" => type, "name" => name}, socket) do + definition_id = socket.assigns.definition_id + project_id = socket.assigns.project_id + + socket = + with %{} = definition <- ImportDefinitions.get_definition(definition_id), + %{} = report <- ImportDefinitions.decode_analysis_result(definition), + normalized_value <- + TaxonomyEditing.normalize_taxonomy_mapping_value(project_id, type, ""), + updated_report <- TaxonomyEditing.update_taxonomy_mapping(report, type, name, normalized_value), + {:ok, _definition} <- + ImportDefinitions.update_definition(definition_id, %{ + last_analysis_result: updated_report + }) do + socket + |> assign(:taxonomy_edit, nil) + |> build_data() + else + _other -> + socket + |> assign(:taxonomy_edit, nil) + |> build_data() + end + + {:noreply, socket} + end + + def handle_event("toggle_import_section", %{"section" => section}, socket) do + section_atom = BDS.BoundedAtoms.import_section(section) + + socket = + if section_atom do + next_sections = Map.update!(socket.assigns.sections, section_atom, &(!&1)) + assign(socket, :sections, next_sections) |> build_data() + else + build_data(socket) + end + + {:noreply, socket} + end + + def handle_event("toggle_import_ai_model_selector", _params, socket) do + {:noreply, + assign(socket, :model_selector_open?, not socket.assigns.model_selector_open?) |> build_data()} + end + + def handle_event("select_import_ai_model", %{"model" => model_id}, socket) do + socket = + socket + |> assign(:selected_model, model_id) + |> assign(:model_selector_open?, false) + |> build_data() + + {:noreply, socket} + end + + def handle_event("analyze_import_taxonomy_ai", _params, socket) do + definition_id = socket.assigns.definition_id + project_id = socket.assigns.project_id + + socket = + with %{} = definition <- ImportDefinitions.get_definition(definition_id), + %{} = report <- ImportDefinitions.decode_analysis_result(definition) do + if socket.assigns.offline_mode? do + notify_output( + translated("activity.import"), + ShellData.translate( + "Automatic AI actions stay gated by airplane mode.", + %{}, + socket.assigns[:page_language] || ShellData.ui_language() + ), + "info" + ) + + build_data(socket) + else + taxonomy_terms = existing_taxonomy_terms(project_id) + + import_terms = %{ + categories: Enum.map(Map.get(report.items, :categories, []), & &1.name), + tags: Enum.map(Map.get(report.items, :tags, []), & &1.name) + } + + opts = + if socket.assigns.selected_model do + [model: socket.assigns.selected_model] + else + [] + end + + case AI.analyze_import_taxonomy(import_terms, taxonomy_terms, opts) do + {:ok, analysis} -> + updated_report = TaxonomyEditing.apply_taxonomy_mappings(report, analysis) + + {:ok, _definition} = + ImportDefinitions.update_definition(definition_id, %{ + last_analysis_result: updated_report + }) + + mapped_count = TaxonomyEditing.auto_mapped_count(report, updated_report) + + notify_output( + translated("activity.import"), + translated("importAnalysis.mappedCount", %{count: mapped_count}), + "info" ) - execution_state = - Map.get( - socket.assigns.import_editor_execution_states, - definition.id, - default_execution_state() - ) + build_data(socket) - sections = - Map.get(socket.assigns.import_editor_sections, definition.id, default_sections()) + {:error, reason} -> + notify_output(translated("activity.import"), inspect(reason), "error") + build_data(socket) + end + end + else + _other -> build_data(socket) + end - selected_model = selected_model(socket.assigns, definition.id) - available_models = AI.available_chat_models(selected_model) + {:noreply, socket} + end - import_editor = %{ - definition_id: definition.id, - definition_name: definition.name, - uploads_folder_path: definition.uploads_folder_path, - wxr_file_path: definition.wxr_file_path, - report: report, - taxonomy_terms: taxonomy_terms, - taxonomy_edit: Map.get(socket.assigns.import_editor_taxonomy_edits, definition.id), - analysis_state: analysis_state, - execution_state: execution_state, - importable_counts: importable_counts(report), - 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), - available_models: available_models, - offline?: Map.get(socket.assigns, :offline_mode, true), - is_loading: analysis_state.loading - } + # ── handle_info for async tasks ──────────────────────────────────────────── + @spec handle_info({:import_analysis_progress, atom() | String.t(), String.t()}, Phoenix.LiveView.Socket.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + def handle_info({:import_analysis_progress, step, detail}, socket) do + socket = + socket + |> assign( + :analysis_state, + Map.merge(socket.assigns.analysis_state, %{loading: true, step: step, detail: detail}) + ) + |> build_data() + + {:noreply, socket} + end + + def handle_info( + {:import_execution_progress, phase, current, total, detail}, + socket + ) do + {detail_text, eta} = ProgressTracking.decompose_progress_detail(detail) + + socket = + socket + |> assign( + :execution_state, + Map.merge(socket.assigns.execution_state, %{ + is_executing: true, + phase: translate_execution_phase(phase), + current: current, + total: total, + detail: detail_text, + eta: eta + }) + ) + |> build_data() + + {:noreply, socket} + end + + def handle_info({ref, result}, socket) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + + socket = + cond do + match?(%{ref: ^ref}, socket.assigns.analysis_state) -> + do_finish_analysis(socket, result) + + match?(%{ref: ^ref}, socket.assigns.execution_state) -> + do_finish_execution(socket, result) + + true -> + socket + end + + {:noreply, build_data(socket)} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do + socket = + cond do + match?(%{ref: ^ref}, socket.assigns.analysis_state) and reason not in [:normal, :shutdown] -> + message = if is_binary(reason), do: reason, else: inspect(reason) + + socket + |> assign(:analysis_state, default_analysis_state()) + |> notify_output(translated("activity.import"), message, "error") + + match?(%{ref: ^ref}, socket.assigns.execution_state) and reason not in [:normal, :shutdown] -> + message = if is_binary(reason), do: reason, else: inspect(reason) + + socket + |> assign( + :execution_state, + Map.merge(socket.assigns.execution_state, %{ + is_executing: false, + completed: false, + error: message, + ref: nil + }) + ) + |> notify_output(translated("activity.import"), message, "error") + + true -> + socket + end + + {:noreply, build_data(socket)} + end + + # ── State helpers ────────────────────────────────────────────────────────── + + defp ensure_state(socket) do + definition_id = socket.assigns.current_tab.id + + defaults = %{ + definition_id: definition_id, + analysis_state: default_analysis_state(), + execution_state: default_execution_state(), + sections: default_sections(), + taxonomy_edit: nil, + model_selector_open?: false, + selected_model: nil, + project_id: socket.assigns[:project_id], + offline_mode?: socket.assigns[:offline_mode] || true + } + + Enum.reduce(defaults, socket, fn {key, default}, acc -> + if is_nil(Map.get(acc.assigns, key)) do + assign(acc, key, default) + else + acc + end + end) + end + + defp build_data(socket) do + definition_id = socket.assigns.definition_id + + case ImportDefinitions.get_definition(definition_id) do + nil -> + assign(socket, :report, nil) + + definition -> + report = ImportDefinitions.decode_analysis_result(definition) + taxonomy_terms = existing_taxonomy_terms(socket.assigns.project_id) + selected_model = resolve_selected_model(socket) + available_models = AI.available_chat_models(selected_model) + + socket + |> assign(:definition_name, definition.name) + |> assign(:uploads_folder_path, definition.uploads_folder_path) + |> assign(:wxr_file_path, definition.wxr_file_path) + |> assign(:report, report) + |> assign(:taxonomy_terms, taxonomy_terms) + |> assign(:importable_counts, importable_counts(report)) + |> assign(:selected_model, selected_model) + |> assign(:selected_model_label, selected_model_label(selected_model, available_models)) + |> assign(:available_models, available_models) + |> maybe_update_tab_meta(definition.name) + end + end + + defp maybe_update_tab_meta(socket, name) do + title = name || translated("importAnalysis.untitledImport") + + notify_parent( + {:import_editor_tab_meta, socket.assigns.definition_id, title, + translated("importAnalysis.headerDescription")} + ) + + socket + end + + defp resolve_selected_model(socket) do + socket.assigns.selected_model || preferred_model(socket) + end + + defp preferred_model(socket) do + preference_key = if socket.assigns.offline_mode?, do: :airplane_chat, else: :chat + + case AI.get_model_preference(preference_key) do + {:ok, model} when is_binary(model) and model != "" -> model + _other -> nil + end + end + + defp selected_model_label(nil, []), do: translated("importAnalysis.analyzeWith") + defp selected_model_label(nil, [model | _rest]), do: model.name || model.id + + defp selected_model_label(model_id, available_models) do + case Enum.find(available_models, &(&1.id == model_id)) do + nil -> model_id + model -> model.name || model.id + end + end + + # ── Task finishers ───────────────────────────────────────────────────────── + + defp do_finish_analysis(socket, result) do + analysis_state = socket.assigns.analysis_state + definition_id = socket.assigns.definition_id + + socket = assign(socket, :analysis_state, default_analysis_state()) + + case result do + {:ok, report} -> + attrs = + %{ + wxr_file_path: analysis_state.file_path, + last_analysis_result: report + } + |> maybe_put(:name, AnalysisState.suggested_definition_name(report)) + + case ImportDefinitions.update_definition(definition_id, attrs) do + {:ok, _definition} -> socket - |> assign(:import_editor, import_editor) - |> assign( - :tab_meta, - Map.put(socket.assigns.tab_meta, {:import, definition.id}, %{ - title: definition.name || translated("importAnalysis.untitledImport"), - subtitle: translated("importAnalysis.headerDescription") - }) - ) + + {:error, reason} -> + notify_output(socket, translated("activity.import"), inspect(reason), "error") end - _other -> - assign(socket, :import_editor, nil) + {:error, %{message: message}} -> + notify_output(socket, translated("activity.import"), message, "error") + + {:error, reason} -> + notify_output(socket, translated("activity.import"), inspect(reason), "error") end end - @spec toggle_section(term(), term(), term()) :: term() - 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, - 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!(section_atom, &(!&1)) + defp do_finish_execution(socket, result) do + previous_state = socket.assigns.execution_state - socket - |> 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) - end + socket = + case result do + {:ok, _execution_result} -> + socket + |> assign(:execution_state, %{ + previous_state + | is_executing: false, + completed: true, + error: nil, + current: previous_state.total, + detail: nil, + ref: nil + }) + |> notify_output( + translated("activity.import"), + translated("importAnalysis.importComplete", %{count: previous_state.count}), + "info" + ) + + {:error, %{message: message}} -> + socket + |> assign(:execution_state, %{ + previous_state + | is_executing: false, + completed: false, + error: message, + ref: nil + }) + |> notify_output(translated("activity.import"), message, "error") + + {:error, reason} -> + message = inspect(reason) + + socket + |> assign(:execution_state, %{ + previous_state + | is_executing: false, + completed: false, + error: message, + ref: nil + }) + |> notify_output(translated("activity.import"), message, "error") + end + + # Allow DB connections to settle before rebuilding + Process.sleep(20) + socket end - @spec toggle_model_selector(term(), term()) :: term() - def toggle_model_selector(socket, reload) do - with %{id: definition_id} <- socket.assigns.current_tab 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) - ) - |> reload.(socket.assigns.workbench) - else - _other -> reload.(socket, socket.assigns.workbench) - end - end - - @spec select_ai_model(term(), term(), term()) :: term() - 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) - ) - |> reload.(socket.assigns.workbench) - else - _other -> reload.(socket, socket.assigns.workbench) - end - end + # ── Rendering (existing function components) ─────────────────────────────── attr(:import_editor, :map, required: true) - @spec import_editor(term()) :: term() + @spec import_editor(map()) :: Phoenix.LiveView.Rendered.t() def import_editor(assigns) do assigns = assigns @@ -265,7 +807,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do ~H"""
-
+ <%= @import_editor.uploads_folder_path || translated("importAnalysis.noFolderSelected") %>
- +
@@ -290,7 +832,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
<%= @import_editor.wxr_file_path || translated("importAnalysis.selectFileToAnalyze") %>
- +
@@ -386,7 +928,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do <%= if @counts.pages > 0 do %><%= @counts.pages %> <%= translated("importAnalysis.pages") %><% end %> - @@ -442,11 +984,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do <%= if @sections.taxonomy do %>
- + <%= if @import_editor.model_selector_open? do %>
<%= for model <- @import_editor.available_models do %> - <% end %> @@ -454,7 +996,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do <% end %>
- @@ -468,6 +1010,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do suggestions={Map.get(@import_editor.taxonomy_terms, :categories, [])} edit={@import_editor.taxonomy_edit} type="categories" + myself={@myself} /> <.taxonomy_group title={translated("importAnalysis.tags")} @@ -475,6 +1018,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do suggestions={Map.get(@import_editor.taxonomy_terms, :tags, [])} edit={@import_editor.taxonomy_edit} type="tags" + myself={@myself} />
<% end %> @@ -484,7 +1028,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do <% macros = Map.get(@report, :macros, %{}) %> <%= if Enum.any?(Map.get(macros, :discovered, [])) do %>
- @@ -552,12 +1096,13 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:items, :list, required: true) attr(:expanded, :boolean, required: true) attr(:section, :string, required: true) + attr(:myself, :any, required: true) - @spec conflict_section(term()) :: term() + @spec conflict_section(map()) :: Phoenix.LiveView.Rendered.t() def conflict_section(assigns) do ~H"""
- @@ -579,7 +1124,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do <%= Map.get(item, :title) %> <%= Map.get(item, :existing_title) || translated("importAnalysis.none") %> - + <%= item.name %> @@ -799,9 +1347,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do autocomplete="off" /> - + <%= if present?(item.mapped_to) do %> - + <% end %> <% else %> @@ -813,6 +1361,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do class={taxonomy_pill_class(item)} type="button" phx-click="start_import_taxonomy_edit" + phx-target={@myself} phx-value-type={@type} phx-value-name={item.name} phx-value-mapped_to={Map.get(item, :mapped_to) || ""} @@ -826,11 +1375,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do class="import-taxonomy-pill mapped-target" type="button" phx-click="start_import_taxonomy_edit" + phx-target={@myself} phx-value-type={@type} phx-value-name={item.name} phx-value-mapped_to={Map.get(item, :mapped_to) || ""} ><%= item.mapped_to %> - + <% end %>
<% end %> @@ -840,6 +1390,31 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do """ end + # ── Private helpers ─────────────────────────────────────────────────────── + + defp notify_parent(message) do + send(self(), message) + end + + defp notify_output(socket, title, message, level \\ "info") do + notify_parent({:import_editor_output, title, message, level}) + socket + end + + defp allow_repo_sandbox(pid) when is_pid(pid) do + if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do + try do + Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid) + rescue + _error -> :ok + end + else + :ok + end + + :ok + end + defp joined_or_none(values) when is_list(values) and values != [], do: Enum.join(values, ", ") defp joined_or_none(_values), do: translated("importAnalysis.none") @@ -855,35 +1430,6 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do defp total_media_stats(stats), do: total_stats(stats) + stats.missing_count - defp selected_model(assigns, definition_id) do - Map.get(assigns.import_editor_selected_models, definition_id) || preferred_model(assigns) - end - - defp preferred_model(assigns) do - preference_key = if Map.get(assigns, :offline_mode, true), do: :airplane_chat, else: :chat - - case AI.get_model_preference(preference_key) do - {:ok, model} when is_binary(model) and model != "" -> model - _other -> nil - end - end - - defp selected_model_label(nil, []), do: translated("importAnalysis.analyzeWith") - defp selected_model_label(nil, [model | _rest]), do: model.name || model.id - - defp selected_model_label(model_id, available_models) do - case Enum.find(available_models, &(&1.id == model_id)) do - nil -> model_id - model -> model.name || model.id - end - end - - 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, ""] - defp conflict_resolution_selected?(item, "ignore") do Map.get(item, :resolution, "ignore") in ["ignore", "skip"] end @@ -891,4 +1437,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do defp conflict_resolution_selected?(item, "overwrite") do Map.get(item, :resolution) in ["overwrite", "merge"] end + + defp present?(value), do: value not in [nil, ""] + defp blank?(value), do: value in [nil, ""] + defp blank_to_nil(""), do: nil + defp blank_to_nil(value), do: value + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) end diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index 7265af4..ad52355 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -418,8 +418,8 @@ <% @current_tab.type == :chat -> %> <.live_component module={ChatEditor} id={"chat-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} /> - <% @current_tab.type == :import and @import_editor -> %> - + <% @current_tab.type == :import -> %> + <.live_component module={ImportEditor} id={"import-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} /> <% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %> diff --git a/test/bds/desktop/import_shell_live_test.exs b/test/bds/desktop/import_shell_live_test.exs index 813d303..4d0a60b 100644 --- a/test/bds/desktop/import_shell_live_test.exs +++ b/test/bds/desktop/import_shell_live_test.exs @@ -70,11 +70,9 @@ defmodule BDS.Desktop.ImportShellLiveTest do refute html =~ "Desktop workbench content routed through the Elixir shell." _html = - render_change(view, "change_import_conflict_resolution", %{ - "item_type" => "post", - "item_name" => "conflict-me", - "resolution" => "overwrite" - }) + view + |> element("form:has(input[value='conflict-me'])") + |> render_change(%{"resolution" => "overwrite"}) updated_definition = ImportDefinitions.get_definition(definition.id) updated_report = ImportDefinitions.decode_analysis_result(updated_definition) diff --git a/test/bds/desktop/shell_live/chat_editor_test.exs b/test/bds/desktop/shell_live/chat_editor_test.exs new file mode 100644 index 0000000..27b5161 --- /dev/null +++ b/test/bds/desktop/shell_live/chat_editor_test.exs @@ -0,0 +1,9 @@ +defmodule BDS.Desktop.ShellLive.ChatEditorTest do + use ExUnit.Case, async: false + + test "ChatEditor exports LiveComponent callbacks" do + assert function_exported?(BDS.Desktop.ShellLive.ChatEditor, :update, 2) + assert function_exported?(BDS.Desktop.ShellLive.ChatEditor, :handle_event, 3) + assert function_exported?(BDS.Desktop.ShellLive.ChatEditor, :render, 1) + end +end diff --git a/test/bds/desktop/shell_live/import_editor_test.exs b/test/bds/desktop/shell_live/import_editor_test.exs new file mode 100644 index 0000000..3f4d130 --- /dev/null +++ b/test/bds/desktop/shell_live/import_editor_test.exs @@ -0,0 +1,11 @@ +defmodule BDS.Desktop.ShellLive.ImportEditorTest do + use ExUnit.Case, async: false + + test "ImportEditor exports LiveComponent callbacks" do + module = BDS.Desktop.ShellLive.ImportEditor + assert Code.ensure_loaded?(module) + assert function_exported?(module, :update, 2) + assert function_exported?(module, :handle_event, 3) + assert function_exported?(module, :render, 1) + end +end