From f178b5b20776389fd234de2f213da82a9b97211f Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 29 Apr 2026 20:07:01 +0200 Subject: [PATCH] feat: step 12 done --- lib/bds/desktop/shell_live.ex | 51 +- lib/bds/desktop/shell_live/import_editor.ex | 745 ++++++++++++++++++++ lib/bds/desktop/shell_live/index.html.heex | 3 + lib/bds/import_analysis.ex | 359 ++++++++++ lib/bds/import_definitions.ex | 68 +- lib/bds/import_execution.ex | 306 ++++++++ lib/bds/wxr_parser.ex | 206 ++++++ priv/i18n/locales/de.json | 91 +++ priv/i18n/locales/en.json | 91 +++ priv/i18n/locales/es.json | 91 +++ priv/i18n/locales/fr.json | 91 +++ priv/i18n/locales/it.json | 91 +++ priv/ui/app.css | 503 +++++++++++++ test/bds/desktop/import_shell_live_test.exs | 108 +++ test/bds/import_analysis_test.exs | 316 +++++++++ test/bds/import_definitions_test.exs | 57 ++ test/bds/import_execution_test.exs | 208 ++++++ test/bds/wxr_parser_test.exs | 111 +++ 18 files changed, 3494 insertions(+), 2 deletions(-) create mode 100644 lib/bds/desktop/shell_live/import_editor.ex create mode 100644 lib/bds/import_analysis.ex create mode 100644 lib/bds/import_execution.ex create mode 100644 lib/bds/wxr_parser.ex create mode 100644 test/bds/desktop/import_shell_live_test.exs create mode 100644 test/bds/import_analysis_test.exs create mode 100644 test/bds/import_definitions_test.exs create mode 100644 test/bds/import_execution_test.exs create mode 100644 test/bds/wxr_parser_test.exs diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 0c9d660..07e6b8f 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive do alias BDS.AI alias BDS.CliSync.Watcher alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData} - alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor} + alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, ImportEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor} alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents @@ -105,6 +105,10 @@ defmodule BDS.Desktop.ShellLive do |> assign(:chat_editor_surface_data, %{}) |> assign(:chat_editor_surface_tabs, %{}) |> assign(:chat_editor_action_errors, %{}) + |> assign(:import_editor_execution_states, %{}) + |> assign(:import_editor_sections, %{}) + |> assign(:import_editor_model_selectors_open, %{}) + |> assign(:import_editor_selected_models, %{}) |> assign(:misc_editor_selected_pairs, %{}) |> assign(:misc_editor_git_selected_files, %{}) |> assign(:metadata_diff_active_tabs, %{}) @@ -767,6 +771,46 @@ defmodule BDS.Desktop.ShellLive do {:noreply, handle_chat_surface_action(socket, params)} 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("change_import_taxonomy_mapping", params, socket) do + {:noreply, ImportEditor.change_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)} @@ -1255,6 +1299,7 @@ defmodule BDS.Desktop.ShellLive do |> assign_tags_editor() |> assign_code_entity_editor() |> assign_chat_editor() + |> assign_import_editor() |> assign_misc_editor() end @@ -1618,6 +1663,10 @@ defmodule BDS.Desktop.ShellLive do ChatEditor.assign_socket(socket) end + defp assign_import_editor(socket) do + ImportEditor.assign_socket(socket) + end + defp assign_misc_editor(socket) do MiscEditor.assign_socket(socket) end diff --git a/lib/bds/desktop/shell_live/import_editor.ex b/lib/bds/desktop/shell_live/import_editor.ex new file mode 100644 index 0000000..c96920c --- /dev/null +++ b/lib/bds/desktop/shell_live/import_editor.ex @@ -0,0 +1,745 @@ +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 diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index 2a96bc4..5c651a2 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -409,6 +409,9 @@ <% @current_tab.type == :chat and @chat_editor -> %> + <% @current_tab.type == :import and @import_editor -> %> + + <% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %> diff --git a/lib/bds/import_analysis.ex b/lib/bds/import_analysis.ex new file mode 100644 index 0000000..89da898 --- /dev/null +++ b/lib/bds/import_analysis.ex @@ -0,0 +1,359 @@ +defmodule BDS.ImportAnalysis do + @moduledoc false + + import Ecto.Query + + alias BDS.Media.Media + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.Tags.Tag + alias BDS.WxrParser + + @shortcode_regex ~r/(? {:error, %{message: Exception.message(error)}} + end + + defp build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path) do + existing_posts = Repo.all(from post in Post, where: post.project_id == ^project_id) + existing_media = Repo.all(from media in Media, where: media.project_id == ^project_id) + existing_tag_names = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name) + existing_tag_set = existing_tag_names |> Enum.map(&String.downcase/1) |> MapSet.new() + + posts_by_slug = Map.new(existing_posts, &{&1.slug, &1}) + + posts_by_checksum = + existing_posts + |> Enum.reject(&is_nil(&1.checksum)) + |> Map.new(&{&1.checksum, &1}) + + media_by_name = Map.new(existing_media, &{String.downcase(&1.original_name), &1}) + + media_by_checksum = + existing_media + |> Enum.reject(&is_nil(&1.checksum)) + |> Map.new(&{&1.checksum, &1}) + + analyzed_posts = Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post")) + analyzed_pages = Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page")) + + analyzed_media = + Enum.map(wxr_data.media, &analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum)) + + category_items = Enum.map(wxr_data.categories, &analyze_taxonomy_item(&1, existing_tag_set)) + tag_items = Enum.map(wxr_data.tags, &analyze_taxonomy_item(&1, existing_tag_set)) + + %{ + source_file: wxr_file_path, + site_info: %{ + title: wxr_data.site.title, + url: wxr_data.site.link, + language: wxr_data.site.language, + source_file: wxr_file_path + }, + post_stats: summarize_post_items(analyzed_posts), + page_stats: summarize_post_items(analyzed_pages), + media_stats: summarize_media_items(analyzed_media), + category_stats: summarize_taxonomy_items(category_items), + tag_stats: summarize_taxonomy_items(tag_items), + date_distribution: date_distribution(analyzed_posts, analyzed_pages, analyzed_media), + conflicts: conflicts(analyzed_posts, analyzed_pages, analyzed_media), + macros: macros(wxr_data.posts ++ wxr_data.pages), + items: %{ + posts: Enum.map(analyzed_posts, &summary_item/1), + pages: Enum.map(analyzed_pages, &summary_item/1), + media: Enum.map(analyzed_media, &summary_item/1), + categories: category_items, + tags: tag_items + }, + details: %{ + posts: analyzed_posts, + pages: analyzed_pages, + media: analyzed_media + } + } + end + + defp analyze_post_item(wxr_post, posts_by_slug, posts_by_checksum, item_type) do + content_markdown = html_to_markdown(wxr_post.content || "") + content_checksum = sha256(content_markdown) + existing_by_slug = Map.get(posts_by_slug, wxr_post.slug) + existing_by_checksum = Map.get(posts_by_checksum, content_checksum) + + {status, existing} = + cond do + existing_by_slug && existing_by_slug.checksum == content_checksum && not is_nil(existing_by_slug.checksum) -> {"update", existing_by_slug} + existing_by_slug -> {"conflict", existing_by_slug} + existing_by_checksum -> {"duplicate", existing_by_checksum} + true -> {"new", nil} + end + + %{ + item_type: item_type, + wp_id: wxr_post.wp_id, + title: wxr_post.title, + slug: wxr_post.slug, + status: status, + resolution: if(status == "conflict", do: "skip", else: nil), + existing_id: existing && existing.id, + existing_title: existing && existing.title, + author: blank_to_nil(wxr_post.creator), + excerpt: blank_to_nil(wxr_post.excerpt), + categories: wxr_post.categories, + tags: wxr_post.tags, + wp_status: blank_to_nil(wxr_post.status), + content_markdown: content_markdown, + content_checksum: content_checksum, + content_preview: String.slice(content_markdown, 0, 200), + created_at: wxr_post.post_date || wxr_post.pub_date, + updated_at: wxr_post.post_modified || wxr_post.post_date || wxr_post.pub_date, + published_at: wxr_post.pub_date + } + end + + defp analyze_media_item(wxr_media, uploads_folder_path, media_by_name, media_by_checksum) do + source_file = + case uploads_folder_path do + nil -> nil + "" -> nil + path -> Path.join(path, wxr_media.relative_path) + end + + {status, checksum, existing} = + cond do + is_nil(source_file) or not File.exists?(source_file) -> + {"missing", nil, nil} + + true -> + binary = File.read!(source_file) + file_checksum = md5(binary) + existing_by_name = Map.get(media_by_name, String.downcase(wxr_media.filename)) + existing_by_checksum = Map.get(media_by_checksum, file_checksum) + + cond do + existing_by_name && existing_by_name.checksum == file_checksum && not is_nil(existing_by_name.checksum) -> {"update", file_checksum, existing_by_name} + existing_by_name -> {"conflict", file_checksum, existing_by_name} + existing_by_checksum -> {"duplicate", file_checksum, existing_by_checksum} + true -> {"new", file_checksum, nil} + end + end + + %{ + item_type: "media", + wp_id: wxr_media.wp_id, + title: wxr_media.title, + filename: wxr_media.filename, + relative_path: wxr_media.relative_path, + status: status, + resolution: if(status == "conflict", do: "skip", else: nil), + existing_id: existing && existing.id, + existing_title: existing && existing.title, + mime_type: wxr_media.mime_type, + description: blank_to_nil(wxr_media.description), + parent_wp_id: wxr_media.parent_id, + source_file: source_file, + checksum: checksum, + created_at: wxr_media.pub_date + } + end + + defp analyze_taxonomy_item(item, existing_tag_set) do + exists_in_project = MapSet.member?(existing_tag_set, String.downcase(item.name)) + + %{ + name: item.name, + slug: item.slug, + exists_in_project: exists_in_project, + mapped_to: nil + } + end + + defp summary_item(%{item_type: "media"} = item) do + base = %{ + item_type: item.item_type, + title: item.title, + filename: item.filename, + relative_path: item.relative_path, + status: item.status + } + + maybe_put(base, :resolution, item.resolution) + end + + defp summary_item(item) do + base = %{ + item_type: item.item_type, + title: item.title, + slug: item.slug, + status: item.status + } + + maybe_put(base, :resolution, item.resolution) + end + + defp summarize_post_items(items) do + %{ + new_count: count_status(items, "new"), + update_count: count_status(items, "update"), + conflict_count: count_status(items, "conflict"), + duplicate_count: count_status(items, "duplicate") + } + end + + defp summarize_media_items(items) do + %{ + new_count: count_status(items, "new"), + update_count: count_status(items, "update"), + conflict_count: count_status(items, "conflict"), + duplicate_count: count_status(items, "duplicate"), + missing_count: count_status(items, "missing") + } + end + + defp summarize_taxonomy_items(items) do + %{ + existing_count: Enum.count(items, & &1.exists_in_project), + mapped_count: Enum.count(items, &(not &1.exists_in_project and not is_nil(&1.mapped_to))), + new_count: Enum.count(items, &(not &1.exists_in_project and is_nil(&1.mapped_to))) + } + end + + defp date_distribution(posts, pages, media) do + combined_posts = posts ++ pages + + post_counts = Enum.reduce(combined_posts, %{}, &increment_year(&1.created_at || &1.published_at, &2)) + media_counts = Enum.reduce(media, %{}, &increment_year(&1.created_at, &2)) + + post_counts + |> Map.keys() + |> Enum.concat(Map.keys(media_counts)) + |> Enum.uniq() + |> Enum.sort() + |> Enum.map(fn year -> + %{ + year: year, + post_count: Map.get(post_counts, year, 0), + media_count: Map.get(media_counts, year, 0) + } + end) + end + + defp conflicts(posts, pages, media) do + (posts ++ pages ++ media) + |> Enum.filter(&(&1.status == "conflict")) + |> Enum.map(fn item -> + %{ + item_type: item.item_type, + item_name: Map.get(item, :slug) || Map.get(item, :filename), + resolution: item.resolution || "skip", + source_title: item.title, + existing_title: item.existing_title + } + end) + end + + defp macros(items) do + items + |> Enum.flat_map(&discover_item_macros/1) + |> Enum.group_by(& &1.name) + |> Enum.map(fn {name, usages} -> + %{ + name: name, + usage_count: length(usages), + parameters: usages |> Enum.flat_map(& &1.parameters) |> Enum.uniq() |> Enum.sort(), + validation_status: "unknown" + } + end) + |> Enum.sort_by(& &1.name) + end + + defp discover_item_macros(item) do + Regex.scan(@shortcode_regex, item.content || "") + |> Enum.map(fn [_match, name, raw_params] -> + %{ + name: String.downcase(name), + parameters: macro_parameters(raw_params) + } + end) + end + + defp macro_parameters(raw_params) do + Regex.scan(@param_regex, raw_params) + |> Enum.map(fn [_, key | _rest] -> key end) + |> Enum.uniq() + |> Enum.sort() + end + + defp increment_year(nil, acc), do: acc + + defp increment_year(value, acc) do + case year_from(value) do + nil -> acc + year -> Map.update(acc, year, 1, &(&1 + 1)) + end + end + + defp year_from(value) when is_integer(value), do: value + + defp year_from(value) when is_binary(value) do + case Regex.run(~r/(\d{4})/, value) do + [_, year] -> String.to_integer(year) + _other -> nil + end + end + + defp year_from(_value), do: nil + + defp count_status(items, status), do: Enum.count(items, &(&1.status == status)) + + defp sha256(value) do + :sha256 + |> :crypto.hash(value) + |> Base.encode16(case: :lower) + end + + defp md5(binary) do + :md5 + |> :crypto.hash(binary) + |> Base.encode16(case: :lower) + end + + defp html_to_markdown(content) do + content + |> to_string() + |> String.replace(~r//i, "\n") + |> String.replace(~r|

|i, "\n\n") + |> String.replace(~r|]*>|i, "") + |> String.replace(~r|(.*?)|is, "**\\1**") + |> String.replace(~r|(.*?)|is, "**\\1**") + |> String.replace(~r|(.*?)|is, "*\\1*") + |> String.replace(~r|(.*?)|is, "*\\1*") + |> String.replace(~r|(.*?)|is, "`\\1`") + |> String.replace(~r|<[^>]+>|u, "") + |> HtmlEntities.decode() + |> transform_shortcodes() + |> String.replace(~r/[ \t]+\n/u, "\n") + |> String.replace(~r/\n{3,}/u, "\n\n") + |> String.trim() + end + + defp transform_shortcodes(content) do + Regex.replace(@shortcode_regex, content, fn _match, name, raw_params -> + inner = String.trim("#{name}#{raw_params}") + "[[#{inner}]]" + end) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp blank_to_nil(nil), do: nil + defp blank_to_nil(""), do: nil + defp blank_to_nil(value), do: value +end diff --git a/lib/bds/import_definitions.ex b/lib/bds/import_definitions.ex index 3599ca0..7ba3423 100644 --- a/lib/bds/import_definitions.ex +++ b/lib/bds/import_definitions.ex @@ -17,13 +17,60 @@ defmodule BDS.ImportDefinitions do name: attr(attrs, :name) || "", wxr_file_path: attr(attrs, :wxr_file_path), uploads_folder_path: attr(attrs, :uploads_folder_path), - last_analysis_result: attr(attrs, :last_analysis_result), + last_analysis_result: normalize_analysis_result(attr(attrs, :last_analysis_result)), created_at: now, updated_at: now }) |> Repo.insert() end + def get_definition(definition_id) when is_binary(definition_id) do + Repo.get(ImportDefinition, definition_id) + end + + def update_definition(definition_id, attrs) when is_binary(definition_id) and is_map(attrs) do + case Repo.get(ImportDefinition, definition_id) do + nil -> + {:error, :not_found} + + %ImportDefinition{} = definition -> + updates = + %{} + |> maybe_put(:name, attr(attrs, :name)) + |> maybe_put(:wxr_file_path, attr(attrs, :wxr_file_path)) + |> maybe_put(:uploads_folder_path, attr(attrs, :uploads_folder_path)) + |> maybe_put(:last_analysis_result, normalize_analysis_result(attr(attrs, :last_analysis_result))) + |> Map.put(:updated_at, Persistence.now_ms()) + + definition + |> ImportDefinition.changeset(updates) + |> Repo.update() + end + end + + def delete_definition(definition_id) when is_binary(definition_id) do + case Repo.get(ImportDefinition, definition_id) do + nil -> {:error, :not_found} + %ImportDefinition{} = definition -> + Repo.delete(definition) + |> case do + {:ok, _deleted} -> {:ok, :deleted} + error -> error + end + end + end + + def decode_analysis_result(%ImportDefinition{last_analysis_result: result}), do: decode_analysis_result(result) + + def decode_analysis_result(result) when is_binary(result) do + case Jason.decode(result) do + {:ok, value} -> atomize_keys(value) + {:error, _reason} -> nil + end + end + + def decode_analysis_result(_result), do: nil + def list_definitions(project_id) do Repo.all( from definition in ImportDefinition, @@ -34,4 +81,23 @@ defmodule BDS.ImportDefinitions do end defp attr(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp normalize_analysis_result(nil), do: nil + defp normalize_analysis_result(value) when is_binary(value), do: value + defp normalize_analysis_result(value), do: Jason.encode!(value) + + defp atomize_keys(value) when is_map(value) do + value + |> Enum.map(fn {key, nested_value} -> + normalized_key = if(is_binary(key), do: String.to_atom(key), else: key) + {normalized_key, atomize_keys(nested_value)} + end) + |> Map.new() + end + + defp atomize_keys(value) when is_list(value), do: Enum.map(value, &atomize_keys/1) + defp atomize_keys(value), do: value end diff --git a/lib/bds/import_execution.ex b/lib/bds/import_execution.ex new file mode 100644 index 0000000..21dd01a --- /dev/null +++ b/lib/bds/import_execution.ex @@ -0,0 +1,306 @@ +defmodule BDS.ImportExecution do + @moduledoc false + + alias BDS.Media + alias BDS.Metadata + alias BDS.Posts + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.Tags + + def execute_import(project_id, report, opts \\ []) when is_binary(project_id) and is_map(report) do + normalized_report = normalize_report(report) + default_author = Keyword.get(opts, :default_author) || project_default_author(project_id) + + result = %{ + success: true, + tags: %{created: 0, skipped: 0}, + posts: %{imported: 0, skipped: 0, errors: 0}, + media: %{imported: 0, skipped: 0, errors: 0}, + pages: %{imported: 0, skipped: 0, errors: 0}, + errors: [] + } + + result = execute_taxonomies(normalized_report, project_id, result) + result = execute_posts(normalized_report, project_id, default_author, result) + result = execute_pages(normalized_report, project_id, default_author, result) + + {:ok, execute_media(normalized_report, project_id, default_author, result)} + rescue + error -> {:error, %{message: Exception.message(error)}} + end + + defp execute_taxonomies(report, project_id, result) do + taxonomies = List.wrap(get_in(report, [:items, :categories])) ++ List.wrap(get_in(report, [:items, :tags])) + + Enum.reduce(taxonomies, result, fn item, acc -> + if item.exists_in_project || item.mapped_to do + put_in(acc, [:tags, :skipped], acc.tags.skipped + 1) + else + case Tags.create_tag(%{project_id: project_id, name: item.name}) do + {:ok, _tag} -> put_in(acc, [:tags, :created], acc.tags.created + 1) + {:error, _reason} -> put_in(acc, [:tags, :skipped], acc.tags.skipped + 1) + end + end + end) + end + + defp execute_posts(report, project_id, default_author, result) do + items = import_items(report, :posts) + + Enum.reduce(items, result, fn item, acc -> + execute_post_item(project_id, item, acc, :posts, default_author) + end) + end + + defp execute_pages(report, project_id, default_author, result) do + items = import_items(report, :pages) + + Enum.reduce(items, result, fn item, acc -> + execute_post_item(project_id, ensure_page_category(item), acc, :pages, default_author) + end) + end + + defp execute_media(report, project_id, default_author, result) do + import_items(report, :media) + |> Enum.reduce(result, fn item, acc -> + cond do + item.status in ["update", "duplicate", "missing"] -> + put_in(acc, [:media, :skipped], acc.media.skipped + 1) + + item.status == "conflict" and item.resolution != "import" and item.resolution != "merge" -> + put_in(acc, [:media, :skipped], acc.media.skipped + 1) + + true -> + case import_media_item(project_id, item, default_author) do + {:ok, _media} -> put_in(acc, [:media, :imported], acc.media.imported + 1) + {:error, reason} -> + acc + |> put_in([:media, :errors], acc.media.errors + 1) + |> Map.update!(:errors, &(&1 ++ [inspect(reason)])) + |> Map.put(:success, false) + end + end + end) + end + + defp execute_post_item(project_id, item, result, bucket, default_author) do + cond do + item.status in ["update", "duplicate"] -> + put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1) + + item.status == "conflict" and item.resolution not in ["import", "merge"] -> + put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1) + + item.status == "conflict" and item.resolution == "merge" -> + case merge_post_item(item, default_author) do + {:ok, _post} -> put_in(result, [bucket, :imported], get_in(result, [bucket, :imported]) + 1) + {:error, reason} -> + result + |> put_in([bucket, :errors], get_in(result, [bucket, :errors]) + 1) + |> Map.update!(:errors, &(&1 ++ [inspect(reason)])) + |> Map.put(:success, false) + end + + true -> + case create_post_item(project_id, item, default_author) do + {:ok, _post} -> put_in(result, [bucket, :imported], get_in(result, [bucket, :imported]) + 1) + {:error, reason} -> + result + |> put_in([bucket, :errors], get_in(result, [bucket, :errors]) + 1) + |> Map.update!(:errors, &(&1 ++ [inspect(reason)])) + |> Map.put(:success, false) + end + end + end + + defp create_post_item(project_id, item, default_author) do + attrs = post_create_attrs(project_id, item, default_author) + + with {:ok, post} <- Posts.create_post(attrs), + :ok <- prepare_created_post(post.id, item), + {:ok, published_post} <- maybe_publish(post.id, item) do + {:ok, published_post} + end + end + + defp merge_post_item(item, default_author) do + case Repo.get(Post, item.existing_id) do + nil -> {:error, :not_found} + + %Post{} = post -> + Posts.update_post(post.id, %{ + title: item.title, + excerpt: item.excerpt, + content: item.content_markdown, + author: item.author || default_author, + tags: item.tags, + categories: item.categories, + checksum: item.content_checksum + }) + end + end + + defp import_media_item(project_id, item, default_author) do + source_path = item.source_file || Path.join("", item.relative_path) + checksum = if(source_path != nil and File.exists?(source_path), do: md5(File.read!(source_path)), else: nil) + + if source_path && File.exists?(source_path) do + case item.status do + "conflict" when item.resolution == "merge" and item.existing_id -> + with {:ok, _updated_media} <- Media.update_media(item.existing_id, %{title: item.title, alt: item.description, author: default_author}) do + {:ok, Repo.get!(Media.Media, item.existing_id)} + end + + _other -> + Media.import_media(%{ + project_id: project_id, + source_path: source_path, + title: item.title, + alt: item.description, + author: default_author, + checksum: checksum + }) + end + else + {:error, :missing_source_file} + end + end + + defp maybe_publish(post_id, item) do + case item.wp_status do + "publish" -> Posts.publish_post(post_id) + _other -> {:ok, Repo.get!(Post, post_id)} + end + end + + defp prepare_created_post(post_id, item) do + case Repo.get(Post, post_id) do + nil -> + {:error, :not_found} + + %Post{} = post -> + desired_slug = desired_slug(post, item) + created_at = parse_timestamp(item.created_at) || post.created_at + updated_at = parse_timestamp(item.updated_at) || created_at + published_at = parse_timestamp(item.published_at) || created_at + + post + |> Post.changeset(%{ + slug: desired_slug, + title: item.title, + excerpt: item.excerpt, + content: item.content_markdown, + author: item.author, + tags: item.tags, + categories: item.categories, + checksum: item.content_checksum, + created_at: created_at, + updated_at: updated_at, + published_at: if(item.wp_status == "publish", do: published_at, else: nil) + }) + |> Repo.update() + |> case do + {:ok, _updated} -> :ok + error -> error + end + end + end + + defp desired_slug(post, item) do + if item.status == "conflict" and item.resolution == "import" do + post.slug + else + item.slug || post.slug + end + end + + defp post_create_attrs(project_id, item, default_author) do + %{ + project_id: project_id, + title: item.title, + excerpt: item.excerpt, + content: item.content_markdown, + author: item.author || default_author, + tags: item.tags, + categories: item.categories, + checksum: item.content_checksum + } + end + + defp ensure_page_category(item) do + categories = (item.categories || []) |> Enum.uniq() |> Enum.concat(["page"]) |> Enum.uniq() + %{item | categories: categories} + end + + defp import_items(report, bucket) do + items = get_in(report, [:items, bucket]) || [] + details = get_in(report, [:details, bucket]) || [] + + if details == [] do + Enum.map(items, &normalize_item/1) + else + detail_index = + details + |> Enum.map(&normalize_item/1) + |> Map.new(fn item -> {item_identity(item), item} end) + + Enum.map(items, fn item -> + normalized_item = normalize_item(item) + identity = item_identity(normalized_item) + detail_item = Map.get(detail_index, identity, normalized_item) + + if Map.has_key?(normalized_item, :resolution) do + %{detail_item | resolution: normalized_item.resolution} + else + detail_item + end + end) + end + end + + defp item_identity(%{item_type: "media", filename: filename}), do: {:media, filename} + defp item_identity(%{item_type: item_type, slug: slug}), do: {item_type, slug} + + defp normalize_report(report) when is_map(report) do + report + |> Enum.map(fn {key, value} -> + normalized_key = if(is_binary(key), do: String.to_atom(key), else: key) + {normalized_key, normalize_report(value)} + end) + |> Map.new() + end + + defp normalize_report(report) when is_list(report), do: Enum.map(report, &normalize_report/1) + defp normalize_report(report), do: report + + defp normalize_item(item) do + normalize_report(item) + end + + defp parse_timestamp(nil), do: nil + defp parse_timestamp(value) when is_integer(value), do: value + + defp parse_timestamp(value) when is_binary(value) do + value + |> String.replace(" ", "T") + |> NaiveDateTime.from_iso8601() + |> case do + {:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC") |> DateTime.to_unix(:millisecond) + _other -> nil + end + end + + defp parse_timestamp(_value), do: nil + + defp md5(binary) do + :md5 + |> :crypto.hash(binary) + |> Base.encode16(case: :lower) + end + + defp project_default_author(project_id) do + {:ok, metadata} = Metadata.get_project_metadata(project_id) + Map.get(metadata, :default_author) + end +end diff --git a/lib/bds/wxr_parser.ex b/lib/bds/wxr_parser.ex new file mode 100644 index 0000000..f27dc32 --- /dev/null +++ b/lib/bds/wxr_parser.ex @@ -0,0 +1,206 @@ +defmodule BDS.WxrParser do + @moduledoc false + + require Record + + Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl")) + Record.defrecord(:xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")) + Record.defrecord(:xmlText, Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl")) + + def parse_file(file_path) when is_binary(file_path) do + file_path + |> File.read!() + |> parse_xml() + end + + def parse_xml(xml_content) when is_binary(xml_content) do + {document, _rest} = :xmerl_scan.string(String.to_charlist(xml_content)) + + case :xmerl_xpath.string(~c"/rss/channel", document) do + [channel] -> + %{ + site: parse_site(channel), + posts: parse_items(channel, "post"), + pages: parse_items(channel, "page"), + media: parse_media(channel), + categories: parse_categories(channel), + tags: parse_tags(channel) + } + + _other -> + raise RuntimeError, "Invalid WXR file: no element found" + end + end + + defp parse_site(channel) do + %{ + title: child_text(channel, "title"), + link: child_text(channel, "link"), + description: child_text(channel, "description"), + language: child_text(channel, "language") + } + end + + defp parse_categories(channel) do + channel + |> direct_children() + |> Enum.filter(&(full_name(&1) == "wp:category")) + |> Enum.map(fn element -> + %{ + name: child_text(element, "cat_name"), + slug: child_text(element, "category_nicename"), + parent: child_text(element, "category_parent") + } + end) + end + + defp parse_tags(channel) do + channel + |> direct_children() + |> Enum.filter(&(full_name(&1) == "wp:tag")) + |> Enum.map(fn element -> + %{ + name: child_text(element, "tag_name"), + slug: child_text(element, "tag_slug") + } + end) + end + + defp parse_items(channel, expected_type) do + channel + |> direct_children_named("item") + |> Enum.filter(&(child_text(&1, "post_type") == expected_type)) + |> Enum.map(&parse_post_item/1) + end + + defp parse_media(channel) do + channel + |> direct_children_named("item") + |> Enum.filter(&(child_text(&1, "post_type") == "attachment")) + |> Enum.map(&parse_media_item/1) + end + + defp parse_post_item(item) do + %{ + wp_id: parse_integer(child_text(item, "post_id")), + title: child_text(item, "title"), + slug: child_text(item, "post_name"), + content: child_text_by_full_name(item, "content:encoded"), + excerpt: child_text_by_full_name(item, "excerpt:encoded"), + pub_date: blank_to_nil(child_text(item, "pubDate")), + post_date: blank_to_nil(child_text(item, "post_date")), + post_modified: blank_to_nil(child_text(item, "post_modified")), + creator: child_text_by_full_name(item, "dc:creator"), + status: child_text(item, "status"), + post_type: child_text(item, "post_type"), + categories: item_taxonomy(item, "category"), + tags: item_taxonomy(item, "post_tag") + } + end + + defp parse_media_item(item) do + attachment_url = child_text(item, "attachment_url") + filename = attachment_url |> Path.basename() |> blank_to_nil() || "" + + %{ + wp_id: parse_integer(child_text(item, "post_id")), + title: child_text(item, "title"), + url: attachment_url, + filename: filename, + relative_path: relative_upload_path(attachment_url), + pub_date: blank_to_nil(child_text(item, "pubDate")), + parent_id: parse_integer(child_text(item, "post_parent")), + mime_type: MIME.from_path(filename), + description: child_text_by_full_name(item, "content:encoded") + } + end + + defp item_taxonomy(item, domain) do + item + |> direct_children_named("category") + |> Enum.filter(&(xml_attr(&1, :domain) == domain)) + |> Enum.map(&text_content/1) + |> Enum.reject(&(&1 == "")) + end + + defp relative_upload_path(url) when is_binary(url) do + marker = "/wp-content/uploads/" + + case String.split(url, marker, parts: 2) do + [_prefix, suffix] -> suffix + _other -> Path.basename(url) + end + end + + defp direct_children(element) do + Enum.filter(xmlElement(element, :content), fn child -> + is_tuple(child) and tuple_size(child) > 0 and elem(child, 0) == :xmlElement + end) + end + + defp direct_children_named(element, name) do + Enum.filter(direct_children(element), &(local_name(&1) == name)) + end + + defp child_text(element, name) do + element + |> direct_children_named(name) + |> List.first() + |> text_content() + end + + defp child_text_by_full_name(element, name) do + element + |> direct_children() + |> Enum.find(&(full_name(&1) == name)) + |> text_content() + end + + defp text_content(nil), do: "" + + defp text_content(element) do + element + |> xmlElement(:content) + |> Enum.map_join("", fn + child when is_tuple(child) and tuple_size(child) > 0 and elem(child, 0) == :xmlText -> + child + |> xmlText(:value) + |> to_string() + + child when is_tuple(child) and tuple_size(child) > 0 and elem(child, 0) == :xmlElement -> + text_content(child) + + _other -> "" + end) + |> String.trim() + end + + defp xml_attr(element, name) do + element + |> xmlElement(:attributes) + |> Enum.find_value(fn attribute -> + if xmlAttribute(attribute, :name) == name do + attribute |> xmlAttribute(:value) |> to_string() + end + end) + end + + defp full_name(element), do: element |> xmlElement(:name) |> to_string() + + defp local_name(element) do + element + |> full_name() + |> String.split(":") + |> List.last() + end + + defp parse_integer(value) do + case Integer.parse(to_string(value)) do + {parsed, _rest} -> parsed + :error -> 0 + end + end + + defp blank_to_nil(""), do: nil + defp blank_to_nil(value), do: value +end diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index 68f951a..b3eb04b 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -126,6 +126,97 @@ "sidebar.newPost": "Neuer Beitrag", "sidebar.importMedia": "Medien importieren", "sidebar.import.newDefinition": "Neue Importdefinition", + "importAnalysis.loadingDefinition": "Importdefinition wird geladen...", + "importAnalysis.namePlaceholder": "Importname...", + "importAnalysis.headerDescription": "Wähle eine WordPress-Exportdatei (WXR) und einen Upload-Ordner, um den Import zu analysieren.", + "importAnalysis.uploadsFolder": "Uploads-Ordner", + "importAnalysis.noFolderSelected": "Kein Ordner ausgewählt", + "importAnalysis.wxrFile": "WXR-Datei", + "importAnalysis.selectFileToAnalyze": "Datei zur Analyse auswählen", + "importAnalysis.analyzing": "Analysiere...", + "importAnalysis.selectAndAnalyze": "Auswählen & analysieren", + "importAnalysis.analyzingWxr": "WXR-Datei wird analysiert...", + "importAnalysis.emptyState": "Wähle eine WordPress-Exportdatei, um die Analyse zu starten.", + "importAnalysis.importing": "Import läuft...", + "importAnalysis.importComplete": "Import erfolgreich abgeschlossen!", + "importAnalysis.importFailed": "Import fehlgeschlagen: %{error}", + "importAnalysis.untitledImport": "Unbenannter Import", + "importAnalysis.executionStarting": "Starte...", + "importAnalysis.unknownError": "Unbekannter Fehler", + "importAnalysis.readyToImport": "Bereit zum Import:", + "importAnalysis.tagsCategories": "Tags/Kategorien", + "importAnalysis.posts": "Beiträge", + "importAnalysis.media": "Medien", + "importAnalysis.pages": "Seiten", + "importAnalysis.nothingToImport": "Nichts zu importieren", + "importAnalysis.importItems": "%{count} Elemente importieren", + "importAnalysis.postSlugConflicts": "Beitrags-Slug-Konflikte", + "importAnalysis.pageSlugConflicts": "Seiten-Slug-Konflikte", + "importAnalysis.postsWithCount": "Beiträge (%{count})", + "importAnalysis.otherWithCount": "Andere (%{count})", + "importAnalysis.pagesWithCount": "Seiten (%{count})", + "importAnalysis.mediaWithCount": "Medien (%{count})", + "importAnalysis.site": "Website", + "importAnalysis.untitled": "Ohne Titel", + "importAnalysis.url": "URL", + "importAnalysis.language": "Sprache", + "importAnalysis.file": "Datei", + "importAnalysis.notAvailable": "k. A.", + "importAnalysis.new": "neu", + "importAnalysis.update": "Aktualisierung", + "importAnalysis.conflict": "Konflikt", + "importAnalysis.duplicate": "Duplikat", + "importAnalysis.missing": "fehlend", + "importAnalysis.categories": "Kategorien", + "importAnalysis.existing": "vorhanden", + "importAnalysis.mapped": "zugeordnet", + "importAnalysis.tags": "Tags", + "importAnalysis.dateDistribution": "Datumsverteilung", + "importAnalysis.postsPages": "Beiträge/Seiten", + "importAnalysis.total": "gesamt", + "importAnalysis.wordpressId": "WordPress-ID", + "importAnalysis.type": "Typ", + "importAnalysis.author": "Autor", + "importAnalysis.unknown": "Unbekannt", + "importAnalysis.published": "Veröffentlicht", + "importAnalysis.excerpt": "Auszug", + "importAnalysis.content": "Inhalt", + "importAnalysis.loading": "Lade...", + "importAnalysis.mimeType": "MIME-Typ", + "importAnalysis.uploaded": "Hochgeladen", + "importAnalysis.parentPostId": "Elternbeitrags-ID", + "importAnalysis.description": "Beschreibung", + "importAnalysis.slug": "Slug", + "importAnalysis.newEntryWxr": "Neuer Eintrag (WXR)", + "importAnalysis.existingEntry": "Vorhandener Eintrag", + "importAnalysis.resolution": "Lösung", + "importAnalysis.ignore": "Ignorieren", + "importAnalysis.overwrite": "Überschreiben", + "importAnalysis.importNewSlug": "Importieren (neuer Slug)", + "importAnalysis.status": "Status", + "importAnalysis.title": "Titel", + "importAnalysis.wpStatus": "WP-Status", + "importAnalysis.existingMatch": "Vorhandene Übereinstimmung", + "importAnalysis.none": "--", + "importAnalysis.filename": "Dateiname", + "importAnalysis.path": "Pfad", + "importAnalysis.taxonomyTitle": "Kategorien & Tags", + "importAnalysis.mappedCount": "%{count} zugeordnet", + "importAnalysis.analyzeWith": "Analysieren mit...", + "importAnalysis.aiMappingHint": "KI schlägt Zuordnungen von neuen zu vorhandenen Einträgen vor, um Duplikate zu vermeiden", + "importAnalysis.mapToPlaceholder": "Zuordnen zu...", + "importAnalysis.mappingTooltip": "Klicken, um Zuordnung zu %{action}", + "importAnalysis.mappingActionEdit": "bearbeiten", + "importAnalysis.mappingActionAdd": "hinzuzufügen", + "importAnalysis.clearMapping": "Zuordnung entfernen", + "importAnalysis.macrosWithCount": "Makros (%{count})", + "importAnalysis.unmappedCount": "%{count} nicht zugeordnet", + "importAnalysis.macroStatusMapped": "Zugeordnet", + "importAnalysis.macroStatusUnknown": "Unbekannt", + "importAnalysis.macroUses": "%{count} Verwendungen", + "importAnalysis.usedIn": "Verwendet in: %{items}%{more}", + "importAnalysis.moreSuffix": ", +%{count} weitere", + "importAnalysis.noParameters": "(keine Parameter)", "sidebar.scripts.newScript": "Neues Skript", "sidebar.templates.newTemplate": "Neue Vorlage", "sidebar.results": "%{count} Ergebnisse", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index 20b0258..1e6c866 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -126,6 +126,97 @@ "sidebar.newPost": "New Post", "sidebar.importMedia": "Import media", "sidebar.import.newDefinition": "New Import Definition", + "importAnalysis.loadingDefinition": "Loading import definition...", + "importAnalysis.namePlaceholder": "Import name...", + "importAnalysis.headerDescription": "Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.", + "importAnalysis.uploadsFolder": "Uploads Folder", + "importAnalysis.noFolderSelected": "No folder selected", + "importAnalysis.wxrFile": "WXR File", + "importAnalysis.selectFileToAnalyze": "Select a file to analyze", + "importAnalysis.analyzing": "Analyzing...", + "importAnalysis.selectAndAnalyze": "Select & Analyze", + "importAnalysis.analyzingWxr": "Analyzing WXR file...", + "importAnalysis.emptyState": "Select a WordPress export file to begin analysis.", + "importAnalysis.importing": "Importing...", + "importAnalysis.importComplete": "Import completed successfully!", + "importAnalysis.importFailed": "Import failed: %{error}", + "importAnalysis.untitledImport": "Untitled Import", + "importAnalysis.executionStarting": "Starting...", + "importAnalysis.unknownError": "Unknown error", + "importAnalysis.readyToImport": "Ready to import:", + "importAnalysis.tagsCategories": "tags/categories", + "importAnalysis.posts": "posts", + "importAnalysis.media": "media", + "importAnalysis.pages": "pages", + "importAnalysis.nothingToImport": "Nothing to Import", + "importAnalysis.importItems": "Import %{count} Items", + "importAnalysis.postSlugConflicts": "Post Slug Conflicts", + "importAnalysis.pageSlugConflicts": "Page Slug Conflicts", + "importAnalysis.postsWithCount": "Posts (%{count})", + "importAnalysis.otherWithCount": "Other (%{count})", + "importAnalysis.pagesWithCount": "Pages (%{count})", + "importAnalysis.mediaWithCount": "Media (%{count})", + "importAnalysis.site": "Site", + "importAnalysis.untitled": "Untitled", + "importAnalysis.url": "URL", + "importAnalysis.language": "Language", + "importAnalysis.file": "File", + "importAnalysis.notAvailable": "N/A", + "importAnalysis.new": "new", + "importAnalysis.update": "update", + "importAnalysis.conflict": "conflict", + "importAnalysis.duplicate": "duplicate", + "importAnalysis.missing": "missing", + "importAnalysis.categories": "Categories", + "importAnalysis.existing": "existing", + "importAnalysis.mapped": "mapped", + "importAnalysis.tags": "Tags", + "importAnalysis.dateDistribution": "Date Distribution", + "importAnalysis.postsPages": "Posts/Pages", + "importAnalysis.total": "total", + "importAnalysis.wordpressId": "WordPress ID", + "importAnalysis.type": "Type", + "importAnalysis.author": "Author", + "importAnalysis.unknown": "Unknown", + "importAnalysis.published": "Published", + "importAnalysis.excerpt": "Excerpt", + "importAnalysis.content": "Content", + "importAnalysis.loading": "Loading...", + "importAnalysis.mimeType": "MIME Type", + "importAnalysis.uploaded": "Uploaded", + "importAnalysis.parentPostId": "Parent Post ID", + "importAnalysis.description": "Description", + "importAnalysis.slug": "Slug", + "importAnalysis.newEntryWxr": "New Entry (WXR)", + "importAnalysis.existingEntry": "Existing Entry", + "importAnalysis.resolution": "Resolution", + "importAnalysis.ignore": "Ignore", + "importAnalysis.overwrite": "Overwrite", + "importAnalysis.importNewSlug": "Import (new slug)", + "importAnalysis.status": "Status", + "importAnalysis.title": "Title", + "importAnalysis.wpStatus": "WP Status", + "importAnalysis.existingMatch": "Existing Match", + "importAnalysis.none": "--", + "importAnalysis.filename": "Filename", + "importAnalysis.path": "Path", + "importAnalysis.taxonomyTitle": "Categories & Tags", + "importAnalysis.mappedCount": "%{count} mapped", + "importAnalysis.analyzeWith": "Analyze with...", + "importAnalysis.aiMappingHint": "AI will suggest mappings from new to existing items to avoid duplicates", + "importAnalysis.mapToPlaceholder": "Map to...", + "importAnalysis.mappingTooltip": "Click to %{action} mapping", + "importAnalysis.mappingActionEdit": "edit", + "importAnalysis.mappingActionAdd": "add", + "importAnalysis.clearMapping": "Clear mapping", + "importAnalysis.macrosWithCount": "Macros (%{count})", + "importAnalysis.unmappedCount": "%{count} unmapped", + "importAnalysis.macroStatusMapped": "Mapped", + "importAnalysis.macroStatusUnknown": "Unknown", + "importAnalysis.macroUses": "%{count} uses", + "importAnalysis.usedIn": "Used in: %{items}%{more}", + "importAnalysis.moreSuffix": ", +%{count} more", + "importAnalysis.noParameters": "(no parameters)", "sidebar.scripts.newScript": "New Script", "sidebar.templates.newTemplate": "New Template", "sidebar.results": "%{count} results", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index 213ee7e..95ee478 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -126,6 +126,97 @@ "sidebar.newPost": "Nueva entrada", "sidebar.importMedia": "Importar medios", "sidebar.import.newDefinition": "Nueva definición", + "importAnalysis.loadingDefinition": "Cargando definición de importación…", + "importAnalysis.namePlaceholder": "Nombre de la definición de importación", + "importAnalysis.headerDescription": "Analiza un archivo WXR antes de importar.", + "importAnalysis.uploadsFolder": "Carpeta uploads", + "importAnalysis.noFolderSelected": "Ninguna carpeta seleccionada", + "importAnalysis.wxrFile": "Archivo WXR", + "importAnalysis.selectFileToAnalyze": "Selecciona un archivo para analizar", + "importAnalysis.analyzing": "Analizando…", + "importAnalysis.selectAndAnalyze": "Seleccionar y analizar", + "importAnalysis.analyzingWxr": "Analizando archivo WXR…", + "importAnalysis.emptyState": "Selecciona un archivo WXR e inicia el análisis.", + "importAnalysis.importing": "Importando…", + "importAnalysis.importComplete": "Importación completada: %{count}", + "importAnalysis.importFailed": "La importación falló: %{error}", + "importAnalysis.untitledImport": "Importación sin título", + "importAnalysis.executionStarting": "Iniciando...", + "importAnalysis.unknownError": "Error desconocido", + "importAnalysis.readyToImport": "Listo para importar:", + "importAnalysis.tagsCategories": "etiquetas/categorías", + "importAnalysis.posts": "publicaciones", + "importAnalysis.media": "medios", + "importAnalysis.pages": "páginas", + "importAnalysis.nothingToImport": "Nada para importar", + "importAnalysis.importItems": "Importar %{count} elementos", + "importAnalysis.postSlugConflicts": "Conflictos de slug de publicaciones", + "importAnalysis.pageSlugConflicts": "Conflictos de slug de páginas", + "importAnalysis.postsWithCount": "Publicaciones (%{count})", + "importAnalysis.otherWithCount": "Otros (%{count})", + "importAnalysis.pagesWithCount": "Páginas (%{count})", + "importAnalysis.mediaWithCount": "Medios (%{count})", + "importAnalysis.site": "Sitio", + "importAnalysis.untitled": "Sin título", + "importAnalysis.url": "URL", + "importAnalysis.language": "Idioma", + "importAnalysis.file": "Archivo", + "importAnalysis.notAvailable": "N/D", + "importAnalysis.new": "nuevo", + "importAnalysis.update": "actualización", + "importAnalysis.conflict": "conflicto", + "importAnalysis.duplicate": "duplicado", + "importAnalysis.missing": "faltante", + "importAnalysis.categories": "Categorías", + "importAnalysis.existing": "existente", + "importAnalysis.mapped": "mapeado", + "importAnalysis.tags": "Etiquetas", + "importAnalysis.dateDistribution": "Distribución por fecha", + "importAnalysis.postsPages": "Publicaciones/Páginas", + "importAnalysis.total": "total", + "importAnalysis.wordpressId": "ID de WordPress", + "importAnalysis.type": "Tipo", + "importAnalysis.author": "Autor", + "importAnalysis.unknown": "Desconocido", + "importAnalysis.published": "Publicado", + "importAnalysis.excerpt": "Extracto", + "importAnalysis.content": "Contenido", + "importAnalysis.loading": "Cargando...", + "importAnalysis.mimeType": "Tipo MIME", + "importAnalysis.uploaded": "Subido", + "importAnalysis.parentPostId": "ID de publicación padre", + "importAnalysis.description": "Descripción", + "importAnalysis.slug": "Slug", + "importAnalysis.newEntryWxr": "Nueva entrada (WXR)", + "importAnalysis.existingEntry": "Entrada existente", + "importAnalysis.resolution": "Resolución", + "importAnalysis.ignore": "Ignorar", + "importAnalysis.overwrite": "Sobrescribir", + "importAnalysis.importNewSlug": "Importar (nuevo slug)", + "importAnalysis.status": "Estado", + "importAnalysis.title": "Título", + "importAnalysis.wpStatus": "Estado WP", + "importAnalysis.existingMatch": "Coincidencia existente", + "importAnalysis.none": "--", + "importAnalysis.filename": "Nombre de archivo", + "importAnalysis.path": "Ruta", + "importAnalysis.taxonomyTitle": "Categorías y Etiquetas", + "importAnalysis.mappedCount": "%{count} mapeados", + "importAnalysis.analyzeWith": "Analizar con...", + "importAnalysis.aiMappingHint": "La IA sugerirá mapeos de elementos nuevos a existentes para evitar duplicados", + "importAnalysis.mapToPlaceholder": "Mapear a...", + "importAnalysis.mappingTooltip": "Haz clic para %{action} el mapeo", + "importAnalysis.mappingActionEdit": "editar", + "importAnalysis.mappingActionAdd": "agregar", + "importAnalysis.clearMapping": "Borrar mapeo", + "importAnalysis.macrosWithCount": "Macros (%{count})", + "importAnalysis.unmappedCount": "%{count} sin mapear", + "importAnalysis.macroStatusMapped": "Mapeado", + "importAnalysis.macroStatusUnknown": "Desconocido", + "importAnalysis.macroUses": "%{count} usos", + "importAnalysis.usedIn": "Usado en: %{items}%{more}", + "importAnalysis.moreSuffix": ", +%{count} más", + "importAnalysis.noParameters": "(sin parámetros)", "sidebar.scripts.newScript": "Nuevo script", "sidebar.templates.newTemplate": "Nueva plantilla", "sidebar.results": "%{count} resultados", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index a4cfbab..41396d7 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -126,6 +126,97 @@ "sidebar.newPost": "Nouvel article", "sidebar.importMedia": "Importer des médias", "sidebar.import.newDefinition": "Nouvelle définition", + "importAnalysis.loadingDefinition": "Chargement de la définition d’import…", + "importAnalysis.namePlaceholder": "Nom de la définition d’import", + "importAnalysis.headerDescription": "Analysez un fichier WXR avant import.", + "importAnalysis.uploadsFolder": "Dossier d’uploads", + "importAnalysis.noFolderSelected": "Aucun dossier sélectionné", + "importAnalysis.wxrFile": "Fichier WXR", + "importAnalysis.selectFileToAnalyze": "Sélectionnez un fichier à analyser", + "importAnalysis.analyzing": "Analyse…", + "importAnalysis.selectAndAnalyze": "Sélectionner et analyser", + "importAnalysis.analyzingWxr": "Analyse du fichier WXR…", + "importAnalysis.emptyState": "Sélectionnez un fichier WXR et lancez l’analyse.", + "importAnalysis.importing": "Import en cours…", + "importAnalysis.importComplete": "Import terminé : %{count}", + "importAnalysis.importFailed": "Échec de l’import : %{error}", + "importAnalysis.untitledImport": "Import sans titre", + "importAnalysis.executionStarting": "Démarrage...", + "importAnalysis.unknownError": "Erreur inconnue", + "importAnalysis.readyToImport": "Prêt à importer :", + "importAnalysis.tagsCategories": "tags/catégories", + "importAnalysis.posts": "articles", + "importAnalysis.media": "médias", + "importAnalysis.pages": "pages", + "importAnalysis.nothingToImport": "Rien à importer", + "importAnalysis.importItems": "Importer %{count} éléments", + "importAnalysis.postSlugConflicts": "Conflits de slug d’article", + "importAnalysis.pageSlugConflicts": "Conflits de slug de page", + "importAnalysis.postsWithCount": "Articles (%{count})", + "importAnalysis.otherWithCount": "Autres (%{count})", + "importAnalysis.pagesWithCount": "Pages (%{count})", + "importAnalysis.mediaWithCount": "Médias (%{count})", + "importAnalysis.site": "Site", + "importAnalysis.untitled": "Sans titre", + "importAnalysis.url": "URL", + "importAnalysis.language": "Langue", + "importAnalysis.file": "Fichier", + "importAnalysis.notAvailable": "N/D", + "importAnalysis.new": "nouveau", + "importAnalysis.update": "mise à jour", + "importAnalysis.conflict": "conflit", + "importAnalysis.duplicate": "doublon", + "importAnalysis.missing": "manquant", + "importAnalysis.categories": "Catégories", + "importAnalysis.existing": "existant", + "importAnalysis.mapped": "mappé", + "importAnalysis.tags": "Tags", + "importAnalysis.dateDistribution": "Répartition par date", + "importAnalysis.postsPages": "Articles/Pages", + "importAnalysis.total": "total", + "importAnalysis.wordpressId": "ID WordPress", + "importAnalysis.type": "Type", + "importAnalysis.author": "Auteur", + "importAnalysis.unknown": "Inconnu", + "importAnalysis.published": "Publié", + "importAnalysis.excerpt": "Extrait", + "importAnalysis.content": "Contenu", + "importAnalysis.loading": "Chargement...", + "importAnalysis.mimeType": "Type MIME", + "importAnalysis.uploaded": "Téléversé", + "importAnalysis.parentPostId": "ID du post parent", + "importAnalysis.description": "Description", + "importAnalysis.slug": "Slug", + "importAnalysis.newEntryWxr": "Nouvelle entrée (WXR)", + "importAnalysis.existingEntry": "Entrée existante", + "importAnalysis.resolution": "Résolution", + "importAnalysis.ignore": "Ignorer", + "importAnalysis.overwrite": "Écraser", + "importAnalysis.importNewSlug": "Importer (nouveau slug)", + "importAnalysis.status": "Statut", + "importAnalysis.title": "Titre", + "importAnalysis.wpStatus": "Statut WP", + "importAnalysis.existingMatch": "Correspondance existante", + "importAnalysis.none": "--", + "importAnalysis.filename": "Nom de fichier", + "importAnalysis.path": "Chemin", + "importAnalysis.taxonomyTitle": "Catégories & Tags", + "importAnalysis.mappedCount": "%{count} mappé(s)", + "importAnalysis.analyzeWith": "Analyser avec...", + "importAnalysis.aiMappingHint": "L’IA suggère des correspondances entre nouveaux éléments et éléments existants pour éviter les doublons", + "importAnalysis.mapToPlaceholder": "Mapper vers...", + "importAnalysis.mappingTooltip": "Cliquer pour %{action} le mapping", + "importAnalysis.mappingActionEdit": "modifier", + "importAnalysis.mappingActionAdd": "ajouter", + "importAnalysis.clearMapping": "Effacer le mapping", + "importAnalysis.macrosWithCount": "Macros (%{count})", + "importAnalysis.unmappedCount": "%{count} non mappé(s)", + "importAnalysis.macroStatusMapped": "Mappé", + "importAnalysis.macroStatusUnknown": "Inconnu", + "importAnalysis.macroUses": "%{count} utilisations", + "importAnalysis.usedIn": "Utilisé dans : %{items}%{more}", + "importAnalysis.moreSuffix": ", +%{count} de plus", + "importAnalysis.noParameters": "(aucun paramètre)", "sidebar.scripts.newScript": "Nouveau script", "sidebar.templates.newTemplate": "Nouveau modèle", "sidebar.results": "%{count} résultats", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index c1740ac..15b5708 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -126,6 +126,97 @@ "sidebar.newPost": "Nuovo post", "sidebar.importMedia": "Importa media", "sidebar.import.newDefinition": "Nuova definizione", + "importAnalysis.loadingDefinition": "Caricamento definizione di importazione…", + "importAnalysis.namePlaceholder": "Nome definizione di importazione", + "importAnalysis.headerDescription": "Analizza un file WXR prima dell’importazione.", + "importAnalysis.uploadsFolder": "Cartella uploads", + "importAnalysis.noFolderSelected": "Nessuna cartella selezionata", + "importAnalysis.wxrFile": "File WXR", + "importAnalysis.selectFileToAnalyze": "Seleziona un file da analizzare", + "importAnalysis.analyzing": "Analisi…", + "importAnalysis.selectAndAnalyze": "Seleziona e analizza", + "importAnalysis.analyzingWxr": "Analisi del file WXR…", + "importAnalysis.emptyState": "Seleziona un file WXR e avvia l’analisi.", + "importAnalysis.importing": "Importazione in corso…", + "importAnalysis.importComplete": "Importazione completata: %{count}", + "importAnalysis.importFailed": "Importazione non riuscita: %{error}", + "importAnalysis.untitledImport": "Importazione senza titolo", + "importAnalysis.executionStarting": "Avvio...", + "importAnalysis.unknownError": "Errore sconosciuto", + "importAnalysis.readyToImport": "Pronto per importare:", + "importAnalysis.tagsCategories": "tag/categorie", + "importAnalysis.posts": "articoli", + "importAnalysis.media": "media", + "importAnalysis.pages": "pagine", + "importAnalysis.nothingToImport": "Niente da importare", + "importAnalysis.importItems": "Importa %{count} elementi", + "importAnalysis.postSlugConflicts": "Conflitti slug articoli", + "importAnalysis.pageSlugConflicts": "Conflitti slug pagine", + "importAnalysis.postsWithCount": "Articoli (%{count})", + "importAnalysis.otherWithCount": "Altro (%{count})", + "importAnalysis.pagesWithCount": "Pagine (%{count})", + "importAnalysis.mediaWithCount": "Media (%{count})", + "importAnalysis.site": "Sito", + "importAnalysis.untitled": "Senza titolo", + "importAnalysis.url": "URL", + "importAnalysis.language": "Lingua", + "importAnalysis.file": "File", + "importAnalysis.notAvailable": "N/D", + "importAnalysis.new": "nuovo", + "importAnalysis.update": "aggiornamento", + "importAnalysis.conflict": "conflitto", + "importAnalysis.duplicate": "duplicato", + "importAnalysis.missing": "mancante", + "importAnalysis.categories": "Categorie", + "importAnalysis.existing": "esistente", + "importAnalysis.mapped": "mappato", + "importAnalysis.tags": "Tag", + "importAnalysis.dateDistribution": "Distribuzione per data", + "importAnalysis.postsPages": "Articoli/Pagine", + "importAnalysis.total": "totale", + "importAnalysis.wordpressId": "ID WordPress", + "importAnalysis.type": "Tipo", + "importAnalysis.author": "Autore", + "importAnalysis.unknown": "Sconosciuto", + "importAnalysis.published": "Pubblicato", + "importAnalysis.excerpt": "Estratto", + "importAnalysis.content": "Contenuto", + "importAnalysis.loading": "Caricamento...", + "importAnalysis.mimeType": "Tipo MIME", + "importAnalysis.uploaded": "Caricato", + "importAnalysis.parentPostId": "ID articolo padre", + "importAnalysis.description": "Descrizione", + "importAnalysis.slug": "Slug", + "importAnalysis.newEntryWxr": "Nuova voce (WXR)", + "importAnalysis.existingEntry": "Voce esistente", + "importAnalysis.resolution": "Risoluzione", + "importAnalysis.ignore": "Ignora", + "importAnalysis.overwrite": "Sovrascrivi", + "importAnalysis.importNewSlug": "Importa (nuovo slug)", + "importAnalysis.status": "Stato", + "importAnalysis.title": "Titolo", + "importAnalysis.wpStatus": "Stato WP", + "importAnalysis.existingMatch": "Corrispondenza esistente", + "importAnalysis.none": "--", + "importAnalysis.filename": "Nome file", + "importAnalysis.path": "Percorso", + "importAnalysis.taxonomyTitle": "Categorie & Tag", + "importAnalysis.mappedCount": "%{count} mappati", + "importAnalysis.analyzeWith": "Analizza con...", + "importAnalysis.aiMappingHint": "L’IA suggerirà mappature da elementi nuovi a quelli esistenti per evitare duplicati", + "importAnalysis.mapToPlaceholder": "Mappa a...", + "importAnalysis.mappingTooltip": "Clicca per %{action} la mappatura", + "importAnalysis.mappingActionEdit": "modificare", + "importAnalysis.mappingActionAdd": "aggiungere", + "importAnalysis.clearMapping": "Cancella mappatura", + "importAnalysis.macrosWithCount": "Macro (%{count})", + "importAnalysis.unmappedCount": "%{count} non mappati", + "importAnalysis.macroStatusMapped": "Mappato", + "importAnalysis.macroStatusUnknown": "Sconosciuto", + "importAnalysis.macroUses": "%{count} utilizzi", + "importAnalysis.usedIn": "Usato in: %{items}%{more}", + "importAnalysis.moreSuffix": ", +%{count} altri", + "importAnalysis.noParameters": "(nessun parametro)", "sidebar.scripts.newScript": "Nuovo script", "sidebar.templates.newTemplate": "Nuovo modello", "sidebar.results": "%{count} risultati", diff --git a/priv/ui/app.css b/priv/ui/app.css index 85e8608..4835086 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -7158,6 +7158,509 @@ button svg * { transition: background-color 0.2s; } +.import-analysis { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 20px 26px; + color: var(--vscode-foreground); +} + +.import-analysis-header { + display: flex; + flex-direction: column; + gap: 8px; +} + +.import-analysis-header p { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 13px; + line-height: 1.5; +} + +.import-definition-name { + width: min(480px, 100%); + border: 1px solid var(--vscode-input-border, transparent); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + border-radius: 6px; + padding: 10px 12px; + font-size: 18px; + font-weight: 600; +} + +.import-file-selectors { + display: grid; + gap: 12px; +} + +.import-file-row { + display: grid; + grid-template-columns: 150px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 12px 14px; + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + background: color-mix(in srgb, var(--vscode-editor-background) 76%, var(--vscode-input-background)); +} + +.import-file-row label { + font-size: 12px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.import-file-path { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 12px; +} + +.import-file-path.placeholder { + color: var(--vscode-descriptionForeground); +} + +.import-analysis button, +.import-analysis select { + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 6px; + font-size: 12px; +} + +.import-analysis button { + background: var(--vscode-button-secondaryBackground, var(--vscode-button-background)); + color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground)); + padding: 8px 12px; + cursor: pointer; +} + +.import-analysis button:hover:not(:disabled) { + background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground)); +} + +.import-analyze-btn, +.import-execute-btn { + background: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +.import-analysis button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.import-site-info { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.import-site-info-item, +.import-stat-card, +.import-date-distribution, +.import-detail-section, +.import-execute-section { + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background)); +} + +.import-site-info-item { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px; +} + +.info-label { + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.info-value { + font-size: 13px; + font-weight: 500; + overflow-wrap: anywhere; +} + +.import-stat-cards { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.import-stat-card { + padding: 14px; +} + +.import-stat-card h3, +.import-date-distribution h3, +.import-detail-section h3, +.taxonomy-group h4 { + margin: 0; +} + +.import-stat-number { + margin-top: 10px; + font-size: 28px; + font-weight: 700; + line-height: 1; +} + +.import-stat-breakdown, +.import-execute-summary, +.import-taxonomy-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.import-stat-breakdown { + margin-top: 12px; +} + +.import-stat-tag, +.import-count-tag, +.import-taxonomy-pill, +.macro-status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} + +.stat-new, +.import-taxonomy-pill.new-tax { + background: rgba(117, 190, 255, 0.16); + color: #75beff; +} + +.stat-update, +.stat-mapped, +.import-taxonomy-pill.exists, +.import-taxonomy-pill.mapped, +.macro-status-badge.mapped, +.import-execution-complete { + background: rgba(115, 201, 145, 0.16); + color: #73c991; +} + +.stat-conflict { + background: rgba(255, 166, 87, 0.16); + color: #ffb169; +} + +.stat-duplicate, +.stat-missing, +.macro-status-badge.unmapped, +.import-execution-error { + background: rgba(204, 167, 0, 0.16); + color: #cca700; +} + +.import-date-distribution, +.import-detail-section, +.import-execute-section { + padding: 16px; +} + +.import-section-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0; + border: none !important; + background: transparent !important; + color: inherit !important; + font-size: 16px !important; + font-weight: 600; + text-align: left; +} + +.import-section-toggle:hover { + background: transparent !important; + opacity: 0.9; +} + +.toggle-icon { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.distribution-bars { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.distribution-row { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) 72px; + gap: 10px; + align-items: center; +} + +.distribution-year, +.distribution-count, +.slug-cell { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 11px; +} + +.distribution-bar-container { + height: 10px; + border-radius: 999px; + overflow: hidden; + background: var(--vscode-input-background); +} + +.distribution-bar { + height: 100%; + min-width: 8px; + border-radius: inherit; +} + +.distribution-bar-posts { + background: linear-gradient(90deg, rgba(117, 190, 255, 0.8), rgba(117, 190, 255, 0.35)); +} + +.import-execute-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.import-execute-summary { + color: var(--vscode-descriptionForeground); +} + +.import-execution-complete, +.import-execution-error { + padding: 10px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; +} + +.import-detail-table { + width: 100%; + border-collapse: collapse; + margin-top: 14px; +} + +.import-detail-table th, +.import-detail-table td { + padding: 10px 8px; + text-align: left; + border-bottom: 1px solid var(--vscode-panel-border); + vertical-align: middle; + font-size: 12px; +} + +.import-detail-table th { + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.resolution-select, +.import-taxonomy-form select { + min-width: 150px; + background: var(--vscode-dropdown-background, var(--vscode-input-background)); + color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + padding: 6px 8px; +} + +.taxonomy-analyze-row { + display: flex; + align-items: center; + gap: 12px; + padding: 0 0 12px; + margin-top: 12px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.taxonomy-analyze-dropdown { + position: relative; +} + +.taxonomy-analyze-btn { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.taxonomy-model-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + max-height: 280px; + overflow-y: auto; + background: var(--vscode-dropdown-background, var(--vscode-sideBar-background)); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + border-radius: 6px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24); + z-index: 20; +} + +.taxonomy-model-option { + width: 100%; + display: block; + border: none !important; + border-radius: 0 !important; + background: transparent !important; + color: var(--vscode-foreground) !important; + text-align: left; + padding: 8px 12px !important; +} + +.taxonomy-model-option:hover { + background: var(--vscode-list-hoverBackground) !important; +} + +.taxonomy-analyze-hint { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.import-taxonomy-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 14px; +} + +.taxonomy-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.import-taxonomy-form { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.macros-list { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.macro-item { + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + background: var(--vscode-input-background); +} + +.macro-item.unmapped { + border-left: 3px solid #cca700; +} + +.macro-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; +} + +.macro-name, +.import-taxonomy-pill { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); +} + +.macro-count { + margin-left: auto; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.import-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 56px 20px; + color: var(--vscode-descriptionForeground); + border: 1px dashed var(--vscode-panel-border); + border-radius: 12px; +} + +.import-empty-state p { + margin: 0; + font-size: 13px; +} + +@media (max-width: 1100px) { + .import-site-info, + .import-stat-cards, + .import-taxonomy-groups { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 780px) { + .import-analysis { + padding: 14px; + } + + .import-file-row, + .distribution-row, + .import-execute-section, + .import-site-info, + .import-stat-cards, + .import-taxonomy-groups { + grid-template-columns: 1fr; + } + + .import-execute-section { + align-items: stretch; + } + + .import-file-row { + align-items: stretch; + } + + .import-analysis button, + .resolution-select, + .import-taxonomy-form select { + width: 100%; + } + + .taxonomy-analyze-row { + flex-direction: column; + align-items: stretch; + } + + .import-taxonomy-form { + width: 100%; + flex-direction: column; + align-items: stretch; + } +} + .load-more-button:hover:not(:disabled) { background-color: var(--vscode-button-secondaryHoverBackground); } diff --git a/test/bds/desktop/import_shell_live_test.exs b/test/bds/desktop/import_shell_live_test.exs new file mode 100644 index 0000000..6b9e0c7 --- /dev/null +++ b/test/bds/desktop/import_shell_live_test.exs @@ -0,0 +1,108 @@ +defmodule BDS.Desktop.ImportShellLiveTest do + use ExUnit.Case, async: false + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + alias BDS.ImportDefinitions + alias BDS.Projects + + @endpoint BDS.Desktop.Endpoint + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) + + temp_dir = Path.join(System.tmp_dir!(), "bds-import-shell-live-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = Projects.create_project(%{name: "Import Shell", data_path: temp_dir}) + {:ok, _project} = Projects.set_active_project(project.id) + + %{project: project, temp_dir: temp_dir} + end + + test "opening an import definition renders the dedicated import analysis editor instead of the fallback shell frame", %{project: project, temp_dir: temp_dir} do + uploads_dir = Path.join(temp_dir, "uploads") + wxr_path = Path.join(temp_dir, "legacy.xml") + + assert {:ok, definition} = + ImportDefinitions.create_definition(%{ + project_id: project.id, + name: "Legacy Import", + wxr_file_path: wxr_path, + uploads_folder_path: uploads_dir, + last_analysis_result: Jason.encode!(cached_report(wxr_path, uploads_dir)) + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + _html = render_click(view, "select_view", %{"view" => "import"}) + + html = + view + |> element("[data-testid='sidebar-open-item'][data-item-id='#{definition.id}']") + |> render_click() + + assert html =~ ~s(data-testid="import-editor") + assert html =~ ~s(data-testid="import-editor-form") + assert html =~ "Legacy Import" + assert html =~ "Uploads Folder" + assert html =~ "WXR File" + assert html =~ "Ready to import:" + assert html =~ "Import 5 Items" + assert html =~ "Post Slug Conflicts" + assert html =~ "Analyze with..." + refute html =~ "Desktop workbench content routed through the Elixir shell." + end + + defp cached_report(wxr_path, uploads_dir) do + %{ + source_file: wxr_path, + site_info: %{ + title: "Legacy Blog", + url: "https://legacy.example", + language: "en", + source_file: wxr_path + }, + post_stats: %{new_count: 1, update_count: 0, conflict_count: 1, duplicate_count: 0}, + page_stats: %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0}, + media_stats: %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0, missing_count: 0}, + category_stats: %{existing_count: 0, mapped_count: 0, new_count: 1}, + tag_stats: %{existing_count: 0, mapped_count: 0, new_count: 1}, + date_distribution: [%{year: 2024, post_count: 2, media_count: 1}], + conflicts: [ + %{ + item_type: "post", + item_name: "hello-world", + resolution: "skip", + source_title: "Hello World", + existing_title: "Existing Hello" + } + ], + macros: [%{name: "gallery", usage_count: 1, parameters: ["ids"], validation_status: "unknown"}], + items: %{ + posts: [ + %{item_type: "post", title: "Hello World", slug: "hello-world", status: "new"}, + %{item_type: "post", title: "Conflict Me", slug: "conflict-me", status: "conflict", resolution: "skip"} + ], + pages: [ + %{item_type: "page", title: "About", slug: "about", status: "new"} + ], + media: [ + %{ + item_type: "media", + title: "Import Asset", + filename: "import-asset.txt", + relative_path: "2024/05/import-asset.txt", + source_file: Path.join(uploads_dir, "2024/05/import-asset.txt"), + status: "new" + } + ], + categories: [%{name: "General", exists_in_project: false, mapped_to: nil}], + tags: [%{name: "News", exists_in_project: false, mapped_to: nil}] + } + } + end +end diff --git a/test/bds/import_analysis_test.exs b/test/bds/import_analysis_test.exs new file mode 100644 index 0000000..f12a071 --- /dev/null +++ b/test/bds/import_analysis_test.exs @@ -0,0 +1,316 @@ +defmodule BDS.ImportAnalysisTest do + use ExUnit.Case, async: false + + alias BDS.ImportAnalysis + alias BDS.Media + alias BDS.Posts + alias BDS.Tags + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_dir = Path.join(System.tmp_dir!(), "bds-import-analysis-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "Import Analysis", data_path: temp_dir}) + %{project: project, temp_dir: temp_dir} + end + + test "analyze_wxr summarizes new items, date distribution, and macros", %{project: project, temp_dir: temp_dir} do + uploads_dir = Path.join(temp_dir, "uploads") + File.mkdir_p!(Path.join(uploads_dir, "2024/05")) + File.write!(Path.join(uploads_dir, "2024/05/import-asset.txt"), "legacy attachment") + + wxr_path = Path.join(temp_dir, "legacy.xml") + File.write!(wxr_path, basic_wxr_xml()) + + assert {:ok, report} = ImportAnalysis.analyze_wxr(project.id, wxr_path, uploads_dir) + + assert report.site_info.title == "Legacy Blog" + assert report.site_info.url == "https://legacy.example" + assert report.site_info.language == "en" + assert report.site_info.source_file == wxr_path + + assert report.post_stats == %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0} + assert report.page_stats == %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0} + + assert report.media_stats == %{ + new_count: 1, + update_count: 0, + conflict_count: 0, + duplicate_count: 0, + missing_count: 0 + } + + assert report.category_stats == %{existing_count: 0, mapped_count: 0, new_count: 1} + assert report.tag_stats == %{existing_count: 0, mapped_count: 0, new_count: 1} + + assert Enum.any?(report.date_distribution, fn row -> + row.year == 2024 and row.post_count == 2 and row.media_count == 1 + end) + + assert [%{name: "gallery", usage_count: 1, parameters: ["ids"], validation_status: "unknown"}] = report.macros + assert report.conflicts == [] + + assert report.items.posts == [ + %{ + title: "Hello World", + slug: "hello-world", + status: "new", + item_type: "post" + } + ] + + assert report.items.pages == [ + %{ + title: "About", + slug: "about", + status: "new", + item_type: "page" + } + ] + + assert report.items.media == [ + %{ + title: "Import Asset", + filename: "import-asset.txt", + relative_path: "2024/05/import-asset.txt", + status: "new", + item_type: "media" + } + ] + end + + test "analyze_wxr detects update, conflict, duplicate, existing taxonomy, and missing uploads", %{project: project, temp_dir: temp_dir} do + assert {:ok, _category} = Tags.create_tag(%{project_id: project.id, name: "General"}) + assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "News"}) + + assert {:ok, _update_post} = + Posts.create_post(%{ + project_id: project.id, + title: "Update Me", + content: "Update body", + checksum: sha256("Update body") + }) + + assert {:ok, _conflict_post} = + Posts.create_post(%{ + project_id: project.id, + title: "Conflict Me", + content: "Local body", + checksum: sha256("Local body") + }) + + assert {:ok, _duplicate_post} = + Posts.create_post(%{ + project_id: project.id, + title: "Existing Duplicate", + content: "Duplicate body", + checksum: sha256("Duplicate body") + }) + + existing_media_source = Path.join(temp_dir, "update-asset.txt") + File.write!(existing_media_source, "shared bytes") + + assert {:ok, _existing_media} = + Media.import_media(%{ + project_id: project.id, + source_path: existing_media_source, + title: "Update Asset" + }) + + wxr_path = Path.join(temp_dir, "conflicts.xml") + File.write!(wxr_path, conflict_wxr_xml()) + + assert {:ok, report} = ImportAnalysis.analyze_wxr(project.id, wxr_path, nil) + + assert report.post_stats == %{new_count: 0, update_count: 1, conflict_count: 1, duplicate_count: 1} + assert report.page_stats == %{new_count: 0, update_count: 0, conflict_count: 0, duplicate_count: 0} + + assert report.media_stats == %{ + new_count: 0, + update_count: 0, + conflict_count: 0, + duplicate_count: 0, + missing_count: 1 + } + + assert report.category_stats == %{existing_count: 1, mapped_count: 0, new_count: 0} + assert report.tag_stats == %{existing_count: 1, mapped_count: 0, new_count: 0} + + assert Enum.any?(report.conflicts, fn conflict -> + conflict.item_type == "post" and conflict.item_name == "conflict-me" and conflict.resolution == "skip" + end) + + assert Enum.any?(report.items.posts, &(&1.slug == "update-me" and &1.status == "update")) + assert Enum.any?(report.items.posts, &(&1.slug == "conflict-me" and &1.status == "conflict")) + assert Enum.any?(report.items.posts, &(&1.slug == "duplicate-me" and &1.status == "duplicate")) + assert Enum.any?(report.items.media, &(&1.filename == "missing-asset.txt" and &1.status == "missing")) + end + + defp sha256(value) do + :sha256 + |> :crypto.hash(value) + |> Base.encode16(case: :lower) + end + + defp basic_wxr_xml do + """ + + + + Legacy Blog + https://legacy.example + Imported from the legacy desktop app + en + + + general + + + + news + + + + Hello World + https://legacy.example/2024/05/hello-world + Wed, 01 May 2024 12:00:00 +0000 + + Hello world

[gallery ids="1,2"]

]]>
+ + 101 + 2024-05-01 12:00:00 + 2024-05-01 12:30:00 + hello-world + publish + post + + +
+ + About + https://legacy.example/about + Thu, 02 May 2024 12:00:00 +0000 + + About page

]]>
+ + 201 + 2024-05-02 12:00:00 + 2024-05-02 12:30:00 + about + publish + page + +
+ + Import Asset + https://legacy.example/wp-content/uploads/2024/05/import-asset.txt + Fri, 03 May 2024 12:00:00 +0000 + + + 301 + 101 + import-asset + inherit + attachment + + +
+
+ """ + end + + defp conflict_wxr_xml do + """ + + + + Legacy Blog + https://legacy.example + Imported from the legacy desktop app + en + + + general + + + + news + + + + Update Me + https://legacy.example/update-me + Wed, 01 May 2024 12:00:00 +0000 + + Update body

]]>
+ + 401 + 2024-05-01 12:00:00 + 2024-05-01 12:30:00 + update-me + publish + post + + +
+ + Conflict Me + https://legacy.example/conflict-me + Thu, 02 May 2024 12:00:00 +0000 + + Incoming conflict body

]]>
+ + 402 + 2024-05-02 12:00:00 + 2024-05-02 12:30:00 + conflict-me + publish + post + + +
+ + Duplicate Me + https://legacy.example/duplicate-me + Fri, 03 May 2024 12:00:00 +0000 + + Duplicate body

]]>
+ + 403 + 2024-05-03 12:00:00 + 2024-05-03 12:30:00 + duplicate-me + publish + post + + +
+ + Missing Asset + https://legacy.example/wp-content/uploads/2024/05/missing-asset.txt + Sat, 04 May 2024 12:00:00 +0000 + + + 404 + 401 + missing-asset + inherit + attachment + + +
+
+ """ + end +end diff --git a/test/bds/import_definitions_test.exs b/test/bds/import_definitions_test.exs new file mode 100644 index 0000000..5a50075 --- /dev/null +++ b/test/bds/import_definitions_test.exs @@ -0,0 +1,57 @@ +defmodule BDS.ImportDefinitionsTest do + use ExUnit.Case, async: false + + alias BDS.ImportDefinitions + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_dir = Path.join(System.tmp_dir!(), "bds-import-definitions-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "Import Definitions", data_path: temp_dir}) + %{project: project, temp_dir: temp_dir} + end + + test "get, update, and delete round-trip import definition editor state", %{project: project, temp_dir: temp_dir} do + uploads_folder_path = Path.join(temp_dir, "uploads") + wxr_file_path = Path.join(temp_dir, "legacy.xml") + + assert {:ok, definition} = + ImportDefinitions.create_definition(%{ + project_id: project.id, + name: "Legacy Import", + wxr_file_path: wxr_file_path, + uploads_folder_path: uploads_folder_path, + last_analysis_result: Jason.encode!(%{site_info: %{title: "Legacy Blog"}}) + }) + + fetched = ImportDefinitions.get_definition(definition.id) + assert fetched.id == definition.id + assert fetched.project_id == project.id + assert fetched.name == "Legacy Import" + assert fetched.wxr_file_path == wxr_file_path + assert fetched.uploads_folder_path == uploads_folder_path + assert fetched.last_analysis_result == Jason.encode!(%{site_info: %{title: "Legacy Blog"}}) + + assert {:ok, updated} = + ImportDefinitions.update_definition(definition.id, %{ + name: "Renamed Import", + wxr_file_path: Path.join(temp_dir, "renamed.xml"), + uploads_folder_path: Path.join(temp_dir, "renamed-uploads"), + last_analysis_result: %{site_info: %{title: "Renamed Blog"}, post_stats: %{new_count: 2}} + }) + + assert updated.name == "Renamed Import" + assert updated.wxr_file_path == Path.join(temp_dir, "renamed.xml") + assert updated.uploads_folder_path == Path.join(temp_dir, "renamed-uploads") + assert updated.last_analysis_result == Jason.encode!(%{site_info: %{title: "Renamed Blog"}, post_stats: %{new_count: 2}}) + + assert [%{id: listed_id, title: "Renamed Import"}] = ImportDefinitions.list_definitions(project.id) + assert listed_id == definition.id + + assert {:ok, :deleted} = ImportDefinitions.delete_definition(definition.id) + assert ImportDefinitions.get_definition(definition.id) == nil + end +end diff --git a/test/bds/import_execution_test.exs b/test/bds/import_execution_test.exs new file mode 100644 index 0000000..3748ce2 --- /dev/null +++ b/test/bds/import_execution_test.exs @@ -0,0 +1,208 @@ +defmodule BDS.ImportExecutionTest do + use ExUnit.Case, async: false + + import Ecto.Query + + alias BDS.ImportAnalysis + alias BDS.ImportExecution + alias BDS.Posts + alias BDS.Repo + alias BDS.Tags + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + + temp_dir = Path.join(System.tmp_dir!(), "bds-import-execution-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "Import Execution", data_path: temp_dir}) + %{project: project, temp_dir: temp_dir} + end + + test "execute_import creates tags, posts, pages, and media from the analysis report", %{project: project, temp_dir: temp_dir} do + uploads_dir = Path.join(temp_dir, "uploads") + File.mkdir_p!(Path.join(uploads_dir, "2024/05")) + File.write!(Path.join(uploads_dir, "2024/05/import-asset.txt"), "legacy attachment") + + wxr_path = Path.join(temp_dir, "legacy.xml") + File.write!(wxr_path, basic_wxr_xml()) + + assert {:ok, report} = ImportAnalysis.analyze_wxr(project.id, wxr_path, uploads_dir) + + assert {:ok, result} = + ImportExecution.execute_import(project.id, report, + uploads_folder_path: uploads_dir, + default_author: "Imported Author" + ) + + assert result.success + assert result.tags == %{created: 2, skipped: 0} + assert result.posts == %{imported: 1, skipped: 0, errors: 0} + assert result.pages == %{imported: 1, skipped: 0, errors: 0} + assert result.media == %{imported: 1, skipped: 0, errors: 0} + assert result.errors == [] + + tag_names = project.id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.sort() + assert tag_names == ["General", "News"] + + posts = Repo.all(from post in Posts.Post, where: post.project_id == ^project.id, order_by: [asc: post.slug]) + assert Enum.map(posts, & &1.slug) == ["about", "hello-world"] + + hello_world = Enum.find(posts, &(&1.slug == "hello-world")) + about = Enum.find(posts, &(&1.slug == "about")) + + assert hello_world.status == :published + assert hello_world.author == "Importer" + assert hello_world.content == nil + assert hello_world.file_path != "" + assert File.exists?(Path.join(temp_dir, hello_world.file_path)) + assert File.read!(Path.join(temp_dir, hello_world.file_path)) =~ "Hello World" + + assert about.status == :published + assert about.content == nil + assert "page" in about.categories + + imported_media = Repo.one!(from media in BDS.Media.Media, where: media.project_id == ^project.id) + assert imported_media.original_name == "import-asset.txt" + assert File.exists?(Path.join(temp_dir, imported_media.file_path)) + end + + test "execute_import skips conflicts by default and can import them with a new slug", %{project: project, temp_dir: temp_dir} do + assert {:ok, _existing_post} = + Posts.create_post(%{ + project_id: project.id, + title: "Conflict Me", + content: "Local body", + checksum: sha256("Local body") + }) + + wxr_path = Path.join(temp_dir, "conflict.xml") + File.write!(wxr_path, conflict_only_wxr_xml()) + + assert {:ok, report} = ImportAnalysis.analyze_wxr(project.id, wxr_path, nil) + + assert {:ok, skipped_result} = ImportExecution.execute_import(project.id, report, default_author: "Imported Author") + assert skipped_result.posts == %{imported: 0, skipped: 1, errors: 0} + assert Repo.aggregate(Posts.Post, :count, :id) == 1 + + import_report = put_in(report.items.posts, [%{List.first(report.items.posts) | resolution: "import"}]) + + assert {:ok, imported_result} = ImportExecution.execute_import(project.id, import_report, default_author: "Imported Author") + assert imported_result.posts == %{imported: 1, skipped: 0, errors: 0} + + slugs = Repo.all(from post in Posts.Post, where: post.project_id == ^project.id, select: post.slug, order_by: [asc: post.slug]) + assert length(slugs) == 2 + assert "conflict-me" in slugs + assert Enum.any?(slugs, &(&1 != "conflict-me")) + end + + defp sha256(value) do + :sha256 + |> :crypto.hash(value) + |> Base.encode16(case: :lower) + end + + defp basic_wxr_xml do + """ + + + + Legacy Blog + https://legacy.example + Imported from the legacy desktop app + en + + + general + + + + news + + + + Hello World + https://legacy.example/2024/05/hello-world + Wed, 01 May 2024 12:00:00 +0000 + + Hello world

]]>
+ + 101 + 2024-05-01 12:00:00 + 2024-05-01 12:30:00 + hello-world + publish + post + + +
+ + About + https://legacy.example/about + Thu, 02 May 2024 12:00:00 +0000 + + About page

]]>
+ + 201 + 2024-05-02 12:00:00 + 2024-05-02 12:30:00 + about + publish + page + +
+ + Import Asset + https://legacy.example/wp-content/uploads/2024/05/import-asset.txt + Fri, 03 May 2024 12:00:00 +0000 + + + 301 + 101 + import-asset + inherit + attachment + + +
+
+ """ + end + + defp conflict_only_wxr_xml do + """ + + + + Legacy Blog + https://legacy.example + Imported from the legacy desktop app + en + + Conflict Me + https://legacy.example/conflict-me + Thu, 02 May 2024 12:00:00 +0000 + + Incoming conflict body

]]>
+ + 402 + 2024-05-02 12:00:00 + 2024-05-02 12:30:00 + conflict-me + publish + post +
+
+
+ """ + end +end diff --git a/test/bds/wxr_parser_test.exs b/test/bds/wxr_parser_test.exs new file mode 100644 index 0000000..4286f42 --- /dev/null +++ b/test/bds/wxr_parser_test.exs @@ -0,0 +1,111 @@ +defmodule BDS.WxrParserTest do + use ExUnit.Case, async: true + + alias BDS.WxrParser + + test "parse_xml extracts site info, posts, pages, media, categories, and tags" do + parsed = WxrParser.parse_xml(sample_wxr_xml()) + + assert parsed.site.title == "Legacy Blog" + assert parsed.site.link == "https://legacy.example" + assert parsed.site.description == "Imported from the legacy desktop app" + assert parsed.site.language == "en" + + assert parsed.categories == [%{name: "General", slug: "general", parent: ""}] + assert parsed.tags == [%{name: "News", slug: "news"}] + + assert [%{wp_id: 101, title: "Hello World", slug: "hello-world", creator: "Importer", status: "publish", post_type: "post", categories: ["General"], tags: ["News"]}] = parsed.posts + + assert [%{wp_id: 201, title: "About", slug: "about", post_type: "page", categories: ["General"], tags: []}] = parsed.pages + + assert [media] = parsed.media + assert media.wp_id == 301 + assert media.title == "Import Asset" + assert media.filename == "import-asset.txt" + assert media.relative_path == "2024/05/import-asset.txt" + assert media.parent_id == 101 + assert media.mime_type == "text/plain" + end + + test "parse_xml raises when the WXR file has no channel" do + assert_raise RuntimeError, ~r/no element found/, fn -> + WxrParser.parse_xml("") + end + end + + defp sample_wxr_xml do + """ + + + + Legacy Blog + https://legacy.example + Imported from the legacy desktop app + en + + + + general + + + + + news + + + + + Hello World + https://legacy.example/2024/05/hello-world + Wed, 01 May 2024 12:00:00 +0000 + + Hello world.

[gallery ids="1,2"]

]]>
+ + 101 + 2024-05-01 14:00:00 + 2024-05-02 15:00:00 + hello-world + publish + post + + +
+ + + About + https://legacy.example/about + Thu, 02 May 2024 12:00:00 +0000 + + About page

]]>
+ + 201 + 2024-05-02 12:00:00 + 2024-05-02 12:30:00 + about + publish + page + +
+ + + Import Asset + https://legacy.example/wp-content/uploads/2024/05/import-asset.txt + Fri, 03 May 2024 12:00:00 +0000 + + + 301 + 101 + import-asset + inherit + attachment + + +
+
+ """ + end +end