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)
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
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
{:ok, _definition} =
ImportDefinitions.update_definition(definition_id, %{
wxr_file_path: wxr_file_path,
last_analysis_result: nil
})
live_view_pid = 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(live_view_pid, {:import_analysis_progress, definition_id, translate_phase(step), detail})
end
)
end)
:ok = allow_repo_sandbox(task.pid)
socket
|> assign(
:import_editor_analysis_states,
Map.put(socket.assigns.import_editor_analysis_states, definition_id, %{
loading: true,
step: translated("importAnalysis.analyzingWxr"),
detail: Path.basename(wxr_file_path),
file_path: wxr_file_path,
ref: task.ref
})
)
|> assign(:import_editor_analysis_task_refs, Map.put(socket.assigns.import_editor_analysis_task_refs, task.ref, definition_id))
|> assign(:import_editor_execution_states, Map.delete(socket.assigns.import_editor_execution_states, definition_id))
|> reload.(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 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)
counts = importable_counts(report)
if counts.total == 0 do
reload.(socket, socket.assigns.workbench)
else
live_view_pid = 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(live_view_pid, {:import_execution_progress, definition_id, phase, current, total, detail})
end
)
end)
progress_phase = translate_execution_phase("posts")
:ok = allow_repo_sandbox(task.pid)
socket
|> assign(
:import_editor_execution_states,
Map.put(socket.assigns.import_editor_execution_states, definition_id, %{
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
})
)
|> assign(:import_editor_execution_task_refs, Map.put(socket.assigns.import_editor_execution_task_refs, task.ref, definition_id))
|> 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 start_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => mapped_to}, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do
socket
|> assign(
:import_editor_taxonomy_edits,
Map.put(socket.assigns.import_editor_taxonomy_edits, definition_id, %{
type: type,
name: name,
value: mapped_to |> to_string() |> blank_to_nil()
})
)
|> reload.(socket.assigns.workbench)
else
_other -> reload.(socket, socket.assigns.workbench)
end
end
def cancel_taxonomy_edit(socket, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do
socket
|> 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
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),
updated_report <- update_taxonomy_mapping(report, type, name, normalized_value),
{:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{last_analysis_result: updated_report}) do
socket
|> 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 clear_taxonomy_mapping(socket, %{"type" => type, "name" => name}, reload) do
save_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => ""}, reload)
end
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
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 ->
taxonomy_terms = existing_taxonomy_terms(socket.assigns.projects.active_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 = 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})
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)
{:error, reason} ->
socket
|> append_output.(translated("activity.import"), inspect(reason), Map.get(socket.assigns.import_editor_selected_models, definition_id), "error")
|> reload.(socket.assigns.workbench)
end
end
else
_other -> reload.(socket, socket.assigns.workbench)
end
end
def note_analysis_progress(socket, definition_id, step, detail, reload) do
socket
|> assign(
:import_editor_analysis_states,
Map.update(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state(), fn state ->
state
|> Map.put(:loading, true)
|> Map.put(:step, step)
|> Map.put(:detail, detail)
end)
)
|> reload.(socket.assigns.workbench)
end
def note_execution_progress(socket, definition_id, phase, current, total, detail, reload) do
{detail_text, eta} = decompose_progress_detail(detail)
translated_phase = translate_execution_phase(phase)
socket
|> assign(
:import_editor_execution_states,
Map.update(socket.assigns.import_editor_execution_states, definition_id, default_execution_state(), fn state ->
state
|> Map.put(:is_executing, true)
|> Map.put(:phase, translated_phase)
|> Map.put(:current, current)
|> Map.put(:total, total)
|> Map.put(:detail, detail_text)
|> Map.put(:eta, eta)
end)
)
|> reload.(socket.assigns.workbench)
end
def finish_analysis(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
nil ->
socket
definition_id ->
analysis_state = Map.get(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state())
socket =
socket
|> assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref))
|> assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id))
case result do
{:ok, report} ->
attrs =
%{
wxr_file_path: analysis_state.file_path,
last_analysis_result: report
}
|> maybe_put(:name, suggested_definition_name(report))
case ImportDefinitions.update_definition(definition_id, attrs) do
{:ok, _definition} -> reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("activity.import"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
{:error, %{message: message}} ->
socket
|> append_output.(translated("activity.import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("activity.import"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
def finish_execution(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do
nil ->
socket
definition_id ->
previous_state = Map.get(socket.assigns.import_editor_execution_states, definition_id, default_execution_state())
socket =
socket
|> assign(:import_editor_execution_task_refs, Map.delete(socket.assigns.import_editor_execution_task_refs, ref))
case result do
{:ok, execution_result} ->
socket
|> assign(
:import_editor_execution_states,
Map.put(socket.assigns.import_editor_execution_states, definition_id, %{
previous_state
| is_executing: false,
completed: true,
error: nil,
current: previous_state.total,
detail: nil,
result: execution_result,
ref: nil
})
)
|> append_output.(translated("activity.import"), translated("importAnalysis.importComplete", %{count: previous_state.count}), 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, %{
previous_state
| is_executing: false,
completed: false,
error: message,
ref: nil
})
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
message = inspect(reason)
socket
|> assign(
:import_editor_execution_states,
Map.put(socket.assigns.import_editor_execution_states, definition_id, %{
previous_state
| is_executing: false,
completed: false,
error: message,
ref: nil
})
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
def handle_task_down(socket, kind, ref, reason, reload, append_output) when reason not in [:normal, :shutdown] do
message = inspect(reason)
case kind do
:analysis ->
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
nil -> socket
definition_id ->
socket
|> assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref))
|> assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id))
|> append_output.(translated("activity.import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
:execution ->
case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do
nil -> socket
definition_id ->
previous_state = Map.get(socket.assigns.import_editor_execution_states, definition_id, default_execution_state())
socket
|> assign(:import_editor_execution_task_refs, Map.delete(socket.assigns.import_editor_execution_task_refs, ref))
|> assign(
:import_editor_execution_states,
Map.put(socket.assigns.import_editor_execution_states, definition_id, %{
previous_state
| is_executing: false,
completed: false,
error: message,
ref: nil
})
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
def handle_task_down(socket, _kind, _ref, _reason, _reload, _append_output), do: socket
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(: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
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
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
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
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 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
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 :suggestions, :list, required: true
attr :edit, :map, default: nil
attr :type, :string, required: true
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 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, "ignore") not in ["ignore", "skip"])
end)
end
defp detail_items(nil, _bucket), do: []
defp detail_items(report, bucket) do
get_in(report, [:details, bucket]) || get_in(report, [:items, bucket]) || []
end
defp execution_progress_width(state) do
current = Map.get(state, :current, 0)
total = Map.get(state, :total, 0)
cond do
total <= 0 -> 0
true -> min(current / total * 100, 100)
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 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 taxonomy_item_editing?(%{type: type, name: name}, type, name), do: true
defp taxonomy_item_editing?(_edit, _type, _name), do: false
defp taxonomy_mapping_tooltip(item) do
action =
if present?(item.mapped_to),
do: translated("importAnalysis.mappingActionEdit"),
else: translated("importAnalysis.mappingActionAdd")
translated("importAnalysis.mappingTooltip", %{action: action})
end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
defp translate_phase(step) when is_binary(step) do
case step do
"parsing" -> translated("importAnalysis.analysisPhase.parsing")
"scanning" -> translated("importAnalysis.analysisPhase.scanning")
"taxonomies" -> translated("importAnalysis.analysisPhase.taxonomies")
"posts" -> translated("importAnalysis.analysisPhase.posts")
"media" -> translated("importAnalysis.analysisPhase.media")
"complete" -> translated("importAnalysis.analysisPhase.complete")
other -> other
end
end
defp translate_phase(other), do: other
defp translate_execution_phase(phase) when is_binary(phase) do
case phase do
"tags" -> translated("importAnalysis.phase.tags")
"posts" -> translated("importAnalysis.phase.posts")
"media" -> translated("importAnalysis.phase.media")
"pages" -> translated("importAnalysis.phase.pages")
"complete" -> translated("importAnalysis.phase.complete")
other -> other
end
end
defp translate_execution_phase(other), do: other
defp decompose_progress_detail(%{detail: detail, eta: eta}), do: {to_string_or_nil(detail), eta}
defp decompose_progress_detail(detail) when is_binary(detail) or is_nil(detail), do: {detail, nil}
defp decompose_progress_detail(detail), do: {to_string_or_nil(detail), nil}
defp to_string_or_nil(nil), do: nil
defp to_string_or_nil(value) when is_binary(value), do: value
defp to_string_or_nil(value), do: inspect(value)
def format_eta(nil), do: nil
def format_eta(ms) when is_integer(ms) and ms >= 0 do
seconds = div(ms, 1000)
if seconds < 60 do
translated("importAnalysis.eta", %{value: translated("importAnalysis.etaSeconds", %{count: seconds})})
else
m = div(seconds, 60)
s = rem(seconds, 60)
translated("importAnalysis.eta", %{value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s})})
end
end
def format_eta(_other), do: nil
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_analysis_state do
%{loading: false, step: nil, detail: nil, file_path: nil, ref: nil}
end
defp default_sections do
%{
post_conflicts: true,
page_conflicts: true,
posts: false,
other: false,
pages: false,
media: false,
taxonomy: true,
macros: true
}
end
defp default_execution_state do
%{
is_executing: false,
completed: false,
error: nil,
count: 0,
result: nil,
phase: nil,
current: 0,
total: 0,
detail: nil,
eta: nil,
ref: nil
}
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_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 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, %{})))
|> 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 apply_taxonomy_mapping_bucket(items, mappings) do
Enum.map(items || [], fn item ->
case Map.fetch(mappings, item.name) do
{:ok, mapped_to} -> %{item | mapped_to: mapped_to}
:error -> item
end
end)
end
defp existing_taxonomy_terms(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
%{
categories: Enum.uniq(Map.get(metadata, :categories, []) || []),
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
}
end
defp normalize_taxonomy_mapping_value(project_id, type, mapped_to) do
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
case normalized_value do
nil -> nil
value ->
project_id
|> existing_taxonomy_terms()
|> Map.get(String.to_existing_atom(type), [])
|> Enum.find(fn term -> String.downcase(term) == String.downcase(value) end)
end
end
defp maybe_put_option(opts, _key, nil), do: opts
defp maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
defp default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author)
end
defp suggested_definition_name(report) do
get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title])
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
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
end