defmodule BDS.Desktop.ShellLive.ImportEditor do @moduledoc false use Phoenix.Component alias BDS.Desktop.{FilePicker, FolderPicker, ShellData} alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution, Metadata, Tags} 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) definition -> report = ImportDefinitions.decode_analysis_result(definition) existing_terms = socket.assigns.projects.active_project_id |> Tags.list_tags() |> Enum.map(& &1.name) 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) 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, existing_terms: existing_terms, 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: false } 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") }) ) end _other -> assign(socket, :import_editor, nil) end end def change_definition(socket, params, reload) do with %{id: definition_id} <- socket.assigns.current_tab, {:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do reload.(socket, socket.assigns.workbench) else _other -> reload.(socket, socket.assigns.workbench) end end def select_uploads_folder(socket, reload, append_output) do with %{id: definition_id} <- socket.assigns.current_tab do 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}) reload.(socket, socket.assigns.workbench) :cancel -> reload.(socket, socket.assigns.workbench) {:error, %{message: message}} -> socket |> append_output.(translated("activity.import"), message, nil, "error") |> reload.(socket.assigns.workbench) end else _other -> reload.(socket, socket.assigns.workbench) end end def select_and_analyze(socket, reload, append_output) do with %{id: definition_id} <- socket.assigns.current_tab, %{} = definition <- ImportDefinitions.get_definition(definition_id) do case FilePicker.choose_file(translated("importAnalysis.wxrFile")) do {:ok, wxr_file_path} -> project_id = socket.assigns.projects.active_project_id case ImportAnalysis.analyze_wxr(project_id, wxr_file_path, definition.uploads_folder_path) do {:ok, report} -> {:ok, _definition} = ImportDefinitions.update_definition(definition_id, %{ wxr_file_path: wxr_file_path, last_analysis_result: report }) socket |> assign(:import_editor_execution_states, Map.delete(socket.assigns.import_editor_execution_states, definition_id)) |> append_output.(translated("activity.import"), translated("importAnalysis.analyzingWxr"), Path.basename(wxr_file_path), "info") |> reload.(socket.assigns.workbench) {:error, %{message: message}} -> socket |> append_output.(translated("activity.import"), message, nil, "error") |> reload.(socket.assigns.workbench) end :cancel -> reload.(socket, socket.assigns.workbench) {:error, %{message: message}} -> socket |> append_output.(translated("activity.import"), message, nil, "error") |> reload.(socket.assigns.workbench) end else _other -> reload.(socket, socket.assigns.workbench) end end def execute_import(socket, reload, append_output) do with %{id: definition_id} <- socket.assigns.current_tab, %{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = report <- ImportDefinitions.decode_analysis_result(definition) do project_id = socket.assigns.projects.active_project_id default_author = default_author(project_id) case ImportExecution.execute_import(project_id, report, uploads_folder_path: definition.uploads_folder_path, default_author: default_author ) do {:ok, result} -> counts = importable_counts(report) socket |> assign(:import_editor_execution_states, Map.put(socket.assigns.import_editor_execution_states, definition_id, %{completed: true, error: nil, count: counts.total, result: result})) |> append_output.(translated("activity.import"), translated("importAnalysis.importComplete", %{count: counts.total}), nil, "info") |> reload.(socket.assigns.workbench) {:error, %{message: message}} -> socket |> assign(:import_editor_execution_states, Map.put(socket.assigns.import_editor_execution_states, definition_id, %{completed: false, error: message, count: 0, result: nil})) |> append_output.(translated("activity.import"), message, nil, "error") |> reload.(socket.assigns.workbench) end else _other -> reload.(socket, socket.assigns.workbench) end end def change_conflict_resolution(socket, %{"item_type" => item_type, "item_name" => item_name, "resolution" => resolution}, reload) do with %{id: definition_id} <- socket.assigns.current_tab, %{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = report <- ImportDefinitions.decode_analysis_result(definition), updated_report <- update_conflict_resolution(report, item_type, item_name, resolution), {:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{last_analysis_result: updated_report}) do reload.(socket, socket.assigns.workbench) else _other -> reload.(socket, socket.assigns.workbench) end end def change_taxonomy_mapping(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), updated_report <- update_taxonomy_mapping(report, type, name, mapped_to), {:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{last_analysis_result: updated_report}) do reload.(socket, socket.assigns.workbench) else _other -> reload.(socket, socket.assigns.workbench) end end def toggle_section(socket, section, reload) do with %{id: definition_id} <- socket.assigns.current_tab, section_key when section_key in ["conflicts", "taxonomy", "macros"] <- section do next_sections = socket.assigns.import_editor_sections |> Map.get(definition_id, default_sections()) |> Map.update!(String.to_existing_atom(section_key), &(!&1)) 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 end 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 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 def analyze_taxonomy_ai(socket, reload, append_output) do with %{id: definition_id} <- socket.assigns.current_tab, %{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = report <- ImportDefinitions.decode_analysis_result(definition) 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") |> reload.(socket.assigns.workbench) true -> updated_report = auto_map_taxonomies(report, socket.assigns.projects.active_project_id |> Tags.list_tags() |> Enum.map(& &1.name)) {: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") |> reload.(socket.assigns.workbench) end else _other -> reload.(socket, socket.assigns.workbench) end end attr :import_editor, :map, required: true def import_editor(assigns) do assigns = assigns |> assign(:report, Map.get(assigns.import_editor, :report)) |> 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(:sections, Map.get(assigns.import_editor, :sections, default_sections())) ~H"""

<%= translated("importAnalysis.headerDescription") %>

<%= @import_editor.uploads_folder_path || translated("importAnalysis.noFolderSelected") %>
<%= @import_editor.wxr_file_path || translated("importAnalysis.selectFileToAnalyze") %>
<%= if @report do %>
<%= translated("importAnalysis.site") %> <%= get_in(@report, [:site_info, :title]) || translated("importAnalysis.untitled") %>
<%= translated("importAnalysis.url") %> <%= get_in(@report, [:site_info, :url]) || translated("importAnalysis.notAvailable") %>
<%= translated("importAnalysis.language") %> <%= get_in(@report, [:site_info, :language]) || translated("importAnalysis.notAvailable") %>
<%= translated("importAnalysis.file") %> <%= @import_editor.wxr_file_path |> to_string() |> Path.basename() %>
<.stat_card label={translated("importAnalysis.posts")} stats={@report.post_stats} /> <.stat_card label={translated("importAnalysis.pages")} stats={@report.page_stats} /> <.media_stat_card label={translated("importAnalysis.media")} stats={@report.media_stats} /> <.taxonomy_stat_card label={translated("importAnalysis.categories")} stats={@report.category_stats} /> <.taxonomy_stat_card label={translated("importAnalysis.tags")} stats={@report.tag_stats} />
<%= if Enum.any?(Map.get(@report, :date_distribution, [])) do %>

<%= translated("importAnalysis.dateDistribution") %>

<%= for row <- @report.date_distribution do %>
<%= row.year %>
<%= row.post_count %> / <%= row.media_count %>
<% end %>
<% end %>
<%= translated("importAnalysis.readyToImport") %> <%= if @counts.tags > 0 do %><%= @counts.tags %> <%= translated("importAnalysis.tagsCategories") %><% end %> <%= if @counts.posts > 0 do %><%= @counts.posts %> <%= translated("importAnalysis.posts") %><% end %> <%= if @counts.media > 0 do %><%= @counts.media %> <%= translated("importAnalysis.media") %><% end %> <%= if @counts.pages > 0 do %><%= @counts.pages %> <%= translated("importAnalysis.pages") %><% end %>
<%= if @execution_state.completed do %>
<%= translated("importAnalysis.importComplete", %{count: @execution_state.count || @counts.total}) %>
<% end %> <%= if present?(@execution_state.error) do %>
<%= translated("importAnalysis.importFailed", %{error: @execution_state.error}) %>
<% end %> <%= if Enum.any?(Map.get(@report, :conflicts, [])) do %>
<%= if @sections.conflicts do %> <%= for conflict <- @report.conflicts do %> <% end %>
<%= translated("importAnalysis.slug") %> <%= translated("importAnalysis.newEntryWxr") %> <%= translated("importAnalysis.existingEntry") %> <%= translated("importAnalysis.resolution") %>
<%= conflict.item_name %> <%= conflict.source_title %> <%= conflict.existing_title || translated("importAnalysis.none") %>
<% end %>
<% end %> <%= if Enum.any?(Map.get(@report.items, :categories, [])) or Enum.any?(Map.get(@report.items, :tags, [])) do %>
<%= if @sections.taxonomy do %>
<%= if @import_editor.model_selector_open? do %>
<%= for model <- @import_editor.available_models do %> <% end %>
<% end %>
<%= translated("importAnalysis.aiMappingHint") %>
<.taxonomy_group title={translated("importAnalysis.categories")} items={Map.get(@report.items, :categories, [])} existing_terms={@import_editor.existing_terms} type="categories" /> <.taxonomy_group title={translated("importAnalysis.tags")} items={Map.get(@report.items, :tags, [])} existing_terms={@import_editor.existing_terms} type="tags" />
<% end %>
<% end %> <%= if Enum.any?(Map.get(@report, :macros, [])) do %>
<%= if @sections.macros do %>
<%= for macro <- @report.macros do %>
<%= macro.name %> <%= translated("importAnalysis.macroStatusUnknown") %> <%= translated("importAnalysis.macroUses", %{count: macro.usage_count}) %>
<% end %>
<% end %>
<% end %> <% else %>

<%= translated("importAnalysis.emptyState") %>

<% end %>
""" end attr :label, :string, required: true attr :stats, :map, required: true def stat_card(assigns) do ~H"""

<%= @label %>

<%= total_stats(@stats) %>
<%= if @stats.new_count > 0 do %><%= @stats.new_count %> <%= translated("importAnalysis.new") %><% end %> <%= if @stats.update_count > 0 do %><%= @stats.update_count %> <%= translated("importAnalysis.update") %><% end %> <%= if @stats.conflict_count > 0 do %><%= @stats.conflict_count %> <%= translated("importAnalysis.conflict") %><% end %> <%= if @stats.duplicate_count > 0 do %><%= @stats.duplicate_count %> <%= translated("importAnalysis.duplicate") %><% end %>
""" end attr :label, :string, required: true attr :stats, :map, required: true def media_stat_card(assigns) do ~H"""

<%= @label %>

<%= total_media_stats(@stats) %>
<%= if @stats.new_count > 0 do %><%= @stats.new_count %> <%= translated("importAnalysis.new") %><% end %> <%= if @stats.update_count > 0 do %><%= @stats.update_count %> <%= translated("importAnalysis.update") %><% end %> <%= if @stats.conflict_count > 0 do %><%= @stats.conflict_count %> <%= translated("importAnalysis.conflict") %><% end %> <%= if @stats.duplicate_count > 0 do %><%= @stats.duplicate_count %> <%= translated("importAnalysis.duplicate") %><% end %> <%= if @stats.missing_count > 0 do %><%= @stats.missing_count %> <%= translated("importAnalysis.missing") %><% end %>
""" end attr :label, :string, required: true attr :stats, :map, required: true def taxonomy_stat_card(assigns) do ~H"""

<%= @label %>

<%= @stats.existing_count + @stats.mapped_count + @stats.new_count %>
<%= if @stats.existing_count > 0 do %><%= @stats.existing_count %> <%= translated("importAnalysis.existing") %><% end %> <%= if @stats.mapped_count > 0 do %><%= @stats.mapped_count %> <%= translated("importAnalysis.mapped") %><% end %> <%= if @stats.new_count > 0 do %><%= @stats.new_count %> <%= translated("importAnalysis.new") %><% end %>
""" end attr :title, :string, required: true attr :items, :list, required: true attr :existing_terms, :list, required: true attr :type, :string, required: true def taxonomy_group(assigns) do ~H"""

<%= @title %>

<%= for item <- @items do %>
<%= item.name %>
<% end %>
""" end defp update_conflict_resolution(report, item_type, item_name, resolution) do report |> update_in([:conflicts], fn conflicts -> Enum.map(conflicts || [], fn conflict -> if conflict.item_type == item_type and conflict.item_name == item_name do %{conflict | resolution: resolution} else conflict end end) end) |> update_in([:items], &update_conflict_bucket(&1, item_type, item_name, resolution)) |> update_in([:details], &update_conflict_bucket(&1, item_type, item_name, resolution)) end defp update_conflict_bucket(nil, _item_type, _item_name, _resolution), do: nil defp update_conflict_bucket(buckets, item_type, item_name, resolution) do bucket_key = if(item_type == "page", do: :pages, else: if(item_type == "media", do: :media, else: :posts)) update_in(buckets, [bucket_key], fn items -> Enum.map(items || [], fn item -> identity = Map.get(item, :slug) || Map.get(item, :filename) if identity == item_name do Map.put(item, :resolution, resolution) else item end end) end) end defp update_taxonomy_mapping(report, type, name, mapped_to) do bucket_key = if(type == "categories", do: :categories, else: :tags) normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil() updated_report = update_in(report, [:items, bucket_key], fn items -> Enum.map(items || [], fn item -> if item.name == name do %{item | mapped_to: normalized_value} else item end end) end) Map.put(updated_report, stat_key(bucket_key), rebuild_taxonomy_stats(get_in(updated_report, [:items, bucket_key]) || [])) end defp rebuild_taxonomy_stats(items) do %{ existing_count: Enum.count(items, & &1.exists_in_project), mapped_count: Enum.count(items, &(not &1.exists_in_project and present?(&1.mapped_to))), new_count: Enum.count(items, &(not &1.exists_in_project and not present?(&1.mapped_to))) } end defp stat_key(:categories), do: :category_stats defp stat_key(:tags), do: :tag_stats defp importable_counts(nil), do: %{total: 0, tags: 0, posts: 0, media: 0, pages: 0} defp importable_counts(report) do tag_count = (Map.get(report.items, :categories, []) ++ Map.get(report.items, :tags, [])) |> Enum.count(&(not &1.exists_in_project and not present?(&1.mapped_to))) posts = importable_entity_count(Map.get(report.items, :posts, [])) pages = importable_entity_count(Map.get(report.items, :pages, [])) media = importable_entity_count(Map.get(report.items, :media, [])) %{total: tag_count + posts + pages + media, tags: tag_count, posts: posts, media: media, pages: pages} end defp importable_entity_count(items) do Enum.count(items || [], fn item -> item.status == "new" or (item.status == "conflict" and Map.get(item, :resolution, "skip") != "skip") end) end defp distribution_width(value, rows, key) do max_value = rows |> Enum.map(&Map.get(&1, key, 0)) |> Enum.max(fn -> 1 end) 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_media_stats(stats), do: total_stats(stats) + stats.missing_count defp taxonomy_pill_class(item) do cond do item.exists_in_project -> "import-taxonomy-pill exists" present?(item.mapped_to) -> "import-taxonomy-pill mapped" true -> "import-taxonomy-pill new-tax" end end defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) 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 default_execution_state do %{completed: false, error: nil, count: 0, result: nil} end defp default_sections do %{conflicts: true, taxonomy: true, macros: true} end 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 auto_map_taxonomies(report, existing_terms) do report |> update_in([:items, :categories], &auto_map_taxonomy_items(&1, existing_terms)) |> update_in([:items, :tags], &auto_map_taxonomy_items(&1, existing_terms)) |> 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]) || [])) end) end defp auto_map_taxonomy_items(items, existing_terms) do Enum.map(items || [], fn item -> cond do item.exists_in_project or present?(item.mapped_to) -> item suggestion = best_taxonomy_match(item.name, existing_terms) -> %{item | mapped_to: suggestion} true -> item end end) end defp best_taxonomy_match(term, existing_terms) do normalized_term = normalize_term(term) existing_terms |> Enum.map(fn candidate -> {candidate, String.jaro_distance(normalized_term, normalize_term(candidate))} end) |> Enum.max_by(fn {_candidate, score} -> score end, fn -> {nil, 0.0} end) |> case do {candidate, score} when is_binary(candidate) and score >= 0.94 -> candidate _other -> nil end end defp auto_mapped_count(previous_report, next_report) do previous_count = (Map.get(previous_report.items, :categories, []) ++ Map.get(previous_report.items, :tags, [])) |> Enum.count(&present?(&1.mapped_to)) next_count = (Map.get(next_report.items, :categories, []) ++ Map.get(next_report.items, :tags, [])) |> Enum.count(&present?(&1.mapped_to)) max(next_count - previous_count, 0) end defp normalize_term(term) do term |> to_string() |> String.downcase() |> String.replace(~r/[^a-z0-9]+/u, "") end defp default_author(project_id) do {:ok, metadata} = Metadata.get_project_metadata(project_id) Map.get(metadata, :default_author) end end