defmodule BDS.Desktop.ShellLive.ImportEditor do
@moduledoc false
use Phoenix.Component
alias BDS.AI
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.{
AnalysisState,
ConflictResolution,
ProgressTracking,
TaxonomyEditing
}
alias BDS.ImportDefinitions
import AnalysisState,
only: [
default_analysis_state: 0,
default_sections: 0,
detail_items: 2,
importable_counts: 1
]
import ProgressTracking,
only: [
default_execution_state: 0,
execution_progress_width: 1,
format_eta: 1
]
import TaxonomyEditing,
only: [
existing_taxonomy_terms: 1,
taxonomy_item_editing?: 3,
taxonomy_mapping_tooltip: 1,
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
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 finish_execution(socket, ref, result, reload, append_output), to: ProgressTracking
defdelegate handle_task_down(socket, kind, ref, reason, reload, append_output),
to: ProgressTracking
defdelegate change_conflict_resolution(socket, params, reload), to: ConflictResolution
defdelegate 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
@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)
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())
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,
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
}
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
@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))
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
@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
attr(:import_editor, :map, required: true)
@spec import_editor(term()) :: term()
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(: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()))
|> 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")
)
)
~H"""
<%= translated("importAnalysis.uploadsFolder") %>
<%= @import_editor.uploads_folder_path || translated("importAnalysis.noFolderSelected") %>
<%= translated("Open") %>
<%= translated("importAnalysis.wxrFile") %>
<%= @import_editor.wxr_file_path || translated("importAnalysis.selectFileToAnalyze") %>
<%= translated("importAnalysis.selectAndAnalyze") %>
<%= if @import_editor.is_loading do %>
<%= @analysis_state.step || translated("importAnalysis.analyzingWxr") %>
<%= if present?(@analysis_state.detail) do %>
<%= @analysis_state.detail %>
<% end %>
<% end %>
<%= if not is_nil(@report) and not @import_editor.is_loading 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} />
<%= if Map.get(@report, :other_stats) && Map.get(@report.other_stats, :total, 0) > 0 do %>
<.other_stat_card label={translated("importAnalysis.other")} stats={@report.other_stats} />
<% end %>
<.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 %>
<%= if @execution_state.is_executing do %>
<%= @execution_state.phase || translated("importAnalysis.executionStarting") %>
<%= if present?(@execution_state.detail) do %>
<%= @execution_state.detail %>
<% end %>
<%= @execution_state.current || 0 %> / <%= @execution_state.total || @counts.total %>
<%= if eta = format_eta(Map.get(@execution_state, :eta)) do %>
<%= eta %>
<% end %>
<% end %>
<%= if not @execution_state.is_executing and not @execution_state.completed do %>
<%= 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 @counts.total == 0 do %>
<%= translated("importAnalysis.nothingToImport") %>
<% else %>
<%= translated("importAnalysis.importItems", %{count: @counts.total}) %>
<% end %>
<% 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?(@post_conflicts) do %>
<.conflict_section title={translated("importAnalysis.postSlugConflicts")} items={@post_conflicts} expanded={@sections.post_conflicts} section="post_conflicts" />
<% end %>
<%= if Enum.any?(@page_conflicts) do %>
<.conflict_section title={translated("importAnalysis.pageSlugConflicts")} items={@page_conflicts} expanded={@sections.page_conflicts} section="page_conflicts" />
<% end %>
<%= if Enum.any?(@post_items) do %>
<.post_detail_section title={translated("importAnalysis.postsWithCount", %{count: length(@post_items)})} items={@post_items} expanded={@sections.posts} section="posts" />
<% end %>
<%= if Enum.any?(@other_items) do %>
<.post_detail_section title={translated("importAnalysis.otherWithCount", %{count: length(@other_items)})} items={@other_items} expanded={@sections.other} section="other" show_type={true} />
<% end %>
<%= if Enum.any?(@detail_pages) do %>
<.post_detail_section title={translated("importAnalysis.pagesWithCount", %{count: length(@detail_pages)})} items={@detail_pages} expanded={@sections.pages} section="pages" />
<% end %>
<%= if Enum.any?(@detail_media) do %>
<.media_detail_section title={translated("importAnalysis.mediaWithCount", %{count: length(@detail_media)})} items={@detail_media} expanded={@sections.media} section="media" />
<% end %>
<%= if Enum.any?(Map.get(@report.items, :categories, [])) or Enum.any?(Map.get(@report.items, :tags, [])) do %>
<%= translated("importAnalysis.taxonomyTitle") %>
<%= if @sections.taxonomy, do: "▾", else: "▸" %>
<%= if @sections.taxonomy do %>
<%= translated("importAnalysis.analyzeWith") %>
<%= if @import_editor.model_selector_open? do %>
<%= for model <- @import_editor.available_models do %>
<%= model.provider_name || model.provider || translated("importAnalysis.unknown") %>: <%= model.name || model.id %>
<% end %>
<% end %>
<%= @import_editor.selected_model_label %>
<%= translated("importAnalysis.aiMappingHint") %>
<.taxonomy_group
title={translated("importAnalysis.categories")}
items={Map.get(@report.items, :categories, [])}
suggestions={Map.get(@import_editor.taxonomy_terms, :categories, [])}
edit={@import_editor.taxonomy_edit}
type="categories"
/>
<.taxonomy_group
title={translated("importAnalysis.tags")}
items={Map.get(@report.items, :tags, [])}
suggestions={Map.get(@import_editor.taxonomy_terms, :tags, [])}
edit={@import_editor.taxonomy_edit}
type="tags"
/>
<% end %>
<% end %>
<% macros = Map.get(@report, :macros, %{}) %>
<%= if Enum.any?(Map.get(macros, :discovered, [])) do %>
<%= translated("importAnalysis.macrosWithCount", %{count: macros.total || length(macros.discovered)}) %>
<%= if @sections.macros, do: "▾", else: "▸" %>
<%= if @sections.macros do %>
<%= translated("importAnalysis.mappedCount", %{count: macros.mapped_count || 0}) %>
<%= translated("importAnalysis.unmappedCount", %{count: macros.unmapped_count || 0}) %>
<%= for macro <- macros.discovered do %>
<%= if Enum.any?(Map.get(macro, :usages, [])) do %>
<%= for usage <- macro.usages do %>
<%= if Enum.any?(Map.get(usage, :params, %{})) do %>
<%= for {k, v} <- usage.params do %>
<%= k %>=<%= v %>
<% end %>
<% else %>
<%= translated("importAnalysis.noParameters") %>
<% end %>
<%= translated("importAnalysis.macroUses", %{count: usage.count}) %>
<% end %>
<% end %>
<%= if Enum.any?(Map.get(macro, :post_slugs, [])) do %>
<%= translated("importAnalysis.usedIn", %{items: Enum.join(Enum.take(macro.post_slugs, 5), ", "), more: if(length(macro.post_slugs) > 5, do: translated("importAnalysis.moreSuffix", %{count: length(macro.post_slugs) - 5}), else: "")}) %>
<% end %>
<% end %>
<% end %>
<% end %>
<% else %>
<%= if @import_editor.is_loading do %>
<% else %>
<%= translated("importAnalysis.emptyState") %>
<% end %>
<% end %>
"""
end
attr(:title, :string, required: true)
attr(:items, :list, required: true)
attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true)
@spec conflict_section(term()) :: term()
def conflict_section(assigns) do
~H"""
<%= @title %>
<%= if @expanded, do: "▾", else: "▸" %>
<%= if @expanded do %>
<%= translated("importAnalysis.slug") %>
<%= translated("importAnalysis.newEntryWxr") %>
<%= translated("importAnalysis.existingEntry") %>
<%= translated("importAnalysis.resolution") %>
<%= for item <- @items do %>
<%= Map.get(item, :slug) %>
<%= Map.get(item, :title) %>
<%= Map.get(item, :existing_title) || translated("importAnalysis.none") %>
<% end %>
<% end %>
"""
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)
@spec post_detail_section(term()) :: term()
def post_detail_section(assigns) do
~H"""
<%= @title %>
<%= if @expanded, do: "▾", else: "▸" %>
<%= if @expanded do %>
<%= translated("importAnalysis.status") %>
<%= if @show_type do %>
<%= translated("importAnalysis.type") %>
<% end %>
<%= translated("importAnalysis.title") %>
<%= translated("importAnalysis.slug") %>
<%= translated("importAnalysis.categories") %>
<%= translated("importAnalysis.wpStatus") %>
<%= translated("importAnalysis.existingMatch") %>
<%= for item <- @items do %>
<%= item.status %>
<%= if @show_type do %>
<%= Map.get(item, :post_type, Map.get(item, :item_type)) %>
<% end %>
<%= Map.get(item, :title) %>
<%= Map.get(item, :slug) %>
<%= joined_or_none(Map.get(item, :categories)) %>
<%= Map.get(item, :wp_status) || translated("importAnalysis.none") %>
<%= Map.get(item, :existing_title) || translated("importAnalysis.none") %>
<% end %>
<% end %>
"""
end
attr(:title, :string, required: true)
attr(:items, :list, required: true)
attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true)
@spec media_detail_section(term()) :: term()
def media_detail_section(assigns) do
~H"""
<%= @title %>
<%= if @expanded, do: "▾", else: "▸" %>
<%= if @expanded do %>
<%= translated("importAnalysis.status") %>
<%= translated("importAnalysis.filename") %>
<%= translated("importAnalysis.type") %>
<%= translated("importAnalysis.path") %>
<%= translated("importAnalysis.existingMatch") %>
<%= for item <- @items do %>
<%= item.status %>
<%= Map.get(item, :filename) %>
<%= Map.get(item, :mime_type) || translated("importAnalysis.none") %>
<%= Map.get(item, :relative_path) %>
<%= Map.get(item, :existing_title) || translated("importAnalysis.none") %>
<% end %>
<% end %>
"""
end
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
@spec stat_card(term()) :: term()
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)
@spec other_stat_card(term()) :: term()
def other_stat_card(assigns) do
~H"""
<%= @label %>
<%= Map.get(@stats, :total, 0) %>
<%= for type <- Map.get(@stats, :types, []) do %>
<%= type %>
<% end %>
"""
end
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
@spec media_stat_card(term()) :: term()
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)
@spec taxonomy_stat_card(term()) :: term()
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(:suggestions, :list, required: true)
attr(:edit, :map, default: nil)
attr(:type, :string, required: true)
@spec taxonomy_group(term()) :: term()
def taxonomy_group(assigns) do
~H"""
<%= @title %>
<%= for term <- @suggestions do %>
<% end %>
<%= for item <- @items do %>
<%= if taxonomy_item_editing?(@edit, @type, item.name) do %>
<% else %>
<%= if item.exists_in_project do %>
<%= item.name %>
<% else %>
<%= item.name %>
<% end %>
<%= if present?(item.mapped_to) do %>
→
<%= item.mapped_to %>
×
<% end %>
<% end %>
<% end %>
"""
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")
defp status_badge_class(status), do: ["status-badge", status]
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 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
defp conflict_resolution_selected?(item, "overwrite") do
Map.get(item, :resolution) in ["overwrite", "merge"]
end
end