feat: step 12 done
This commit is contained in:
@@ -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
|
||||
|
||||
745
lib/bds/desktop/shell_live/import_editor.ex
Normal file
745
lib/bds/desktop/shell_live/import_editor.ex
Normal file
@@ -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"""
|
||||
<div class="import-analysis" data-testid="import-editor">
|
||||
<form class="import-analysis-header" data-testid="import-editor-form" phx-change="change_import_editor_definition">
|
||||
<input
|
||||
class="import-definition-name"
|
||||
type="text"
|
||||
name="import_definition[name]"
|
||||
value={@import_editor.definition_name || translated("importAnalysis.untitledImport")}
|
||||
placeholder={translated("importAnalysis.namePlaceholder")}
|
||||
/>
|
||||
<p><%= translated("importAnalysis.headerDescription") %></p>
|
||||
</form>
|
||||
|
||||
<div class="import-file-selectors">
|
||||
<div class="import-file-row">
|
||||
<label><%= translated("importAnalysis.uploadsFolder") %></label>
|
||||
<div class={["import-file-path", if(blank?(@import_editor.uploads_folder_path), do: "placeholder")]}>
|
||||
<%= @import_editor.uploads_folder_path || translated("importAnalysis.noFolderSelected") %>
|
||||
</div>
|
||||
<button type="button" phx-click="select_import_uploads_folder"><%= translated("Open") %></button>
|
||||
</div>
|
||||
|
||||
<div class="import-file-row">
|
||||
<label><%= translated("importAnalysis.wxrFile") %></label>
|
||||
<div class={["import-file-path", if(blank?(@import_editor.wxr_file_path), do: "placeholder")]}>
|
||||
<%= @import_editor.wxr_file_path || translated("importAnalysis.selectFileToAnalyze") %>
|
||||
</div>
|
||||
<button class="import-analyze-btn" type="button" phx-click="select_import_wxr_file"><%= translated("importAnalysis.selectAndAnalyze") %></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @report do %>
|
||||
<div class="import-site-info">
|
||||
<div class="import-site-info-item">
|
||||
<span class="info-label"><%= translated("importAnalysis.site") %></span>
|
||||
<span class="info-value"><%= get_in(@report, [:site_info, :title]) || translated("importAnalysis.untitled") %></span>
|
||||
</div>
|
||||
<div class="import-site-info-item">
|
||||
<span class="info-label"><%= translated("importAnalysis.url") %></span>
|
||||
<span class="info-value"><%= get_in(@report, [:site_info, :url]) || translated("importAnalysis.notAvailable") %></span>
|
||||
</div>
|
||||
<div class="import-site-info-item">
|
||||
<span class="info-label"><%= translated("importAnalysis.language") %></span>
|
||||
<span class="info-value"><%= get_in(@report, [:site_info, :language]) || translated("importAnalysis.notAvailable") %></span>
|
||||
</div>
|
||||
<div class="import-site-info-item">
|
||||
<span class="info-label"><%= translated("importAnalysis.file") %></span>
|
||||
<span class="info-value"><%= @import_editor.wxr_file_path |> to_string() |> Path.basename() %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-stat-cards">
|
||||
<.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} />
|
||||
</div>
|
||||
|
||||
<%= if Enum.any?(Map.get(@report, :date_distribution, [])) do %>
|
||||
<div class="import-date-distribution">
|
||||
<h3><%= translated("importAnalysis.dateDistribution") %></h3>
|
||||
<div class="distribution-bars">
|
||||
<%= for row <- @report.date_distribution do %>
|
||||
<div class="distribution-row">
|
||||
<span class="distribution-year"><%= row.year %></span>
|
||||
<div class="distribution-bar-container">
|
||||
<div class="distribution-bar distribution-bar-posts" style={"width: #{distribution_width(row.post_count, @report.date_distribution, :post_count)}%;"}></div>
|
||||
</div>
|
||||
<span class="distribution-count"><%= row.post_count %> / <%= row.media_count %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="import-execute-section">
|
||||
<div class="import-execute-summary">
|
||||
<%= translated("importAnalysis.readyToImport") %>
|
||||
<%= if @counts.tags > 0 do %><span class="import-count-tag"><%= @counts.tags %> <%= translated("importAnalysis.tagsCategories") %></span><% end %>
|
||||
<%= if @counts.posts > 0 do %><span class="import-count-tag"><%= @counts.posts %> <%= translated("importAnalysis.posts") %></span><% end %>
|
||||
<%= if @counts.media > 0 do %><span class="import-count-tag"><%= @counts.media %> <%= translated("importAnalysis.media") %></span><% end %>
|
||||
<%= if @counts.pages > 0 do %><span class="import-count-tag"><%= @counts.pages %> <%= translated("importAnalysis.pages") %></span><% end %>
|
||||
</div>
|
||||
|
||||
<button class="import-execute-btn" type="button" phx-click="execute_import_editor" disabled={@counts.total == 0}>
|
||||
<%= if @counts.total == 0 do %>
|
||||
<%= translated("importAnalysis.nothingToImport") %>
|
||||
<% else %>
|
||||
<%= translated("importAnalysis.importItems", %{count: @counts.total}) %>
|
||||
<% end %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @execution_state.completed do %>
|
||||
<div class="import-execution-complete">
|
||||
<span><%= translated("importAnalysis.importComplete", %{count: @execution_state.count || @counts.total}) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if present?(@execution_state.error) do %>
|
||||
<div class="import-execution-error">
|
||||
<span><%= translated("importAnalysis.importFailed", %{error: @execution_state.error}) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.any?(Map.get(@report, :conflicts, [])) do %>
|
||||
<section class="import-detail-section conflicts-section">
|
||||
<button class="import-section-toggle" type="button" phx-click="toggle_import_section" phx-value-section="conflicts">
|
||||
<span><%= translated("importAnalysis.postSlugConflicts") %></span>
|
||||
<span class="toggle-icon"><%= if @sections.conflicts, do: "▾", else: "▸" %></span>
|
||||
</button>
|
||||
|
||||
<%= if @sections.conflicts do %>
|
||||
<table class="import-detail-table conflicts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= translated("importAnalysis.slug") %></th>
|
||||
<th><%= translated("importAnalysis.newEntryWxr") %></th>
|
||||
<th><%= translated("importAnalysis.existingEntry") %></th>
|
||||
<th><%= translated("importAnalysis.resolution") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for conflict <- @report.conflicts do %>
|
||||
<tr>
|
||||
<td class="slug-cell"><%= conflict.item_name %></td>
|
||||
<td><%= conflict.source_title %></td>
|
||||
<td><%= conflict.existing_title || translated("importAnalysis.none") %></td>
|
||||
<td>
|
||||
<form phx-change="change_import_conflict_resolution">
|
||||
<input type="hidden" name="item_type" value={conflict.item_type} />
|
||||
<input type="hidden" name="item_name" value={conflict.item_name} />
|
||||
<select class="resolution-select" name="resolution">
|
||||
<option value="skip" selected={conflict.resolution == "skip"}><%= translated("importAnalysis.ignore") %></option>
|
||||
<option value="merge" selected={conflict.resolution == "merge"}><%= translated("importAnalysis.overwrite") %></option>
|
||||
<option value="import" selected={conflict.resolution == "import"}><%= translated("importAnalysis.importNewSlug") %></option>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.any?(Map.get(@report.items, :categories, [])) or Enum.any?(Map.get(@report.items, :tags, [])) do %>
|
||||
<section class="import-detail-section">
|
||||
<button class="import-section-toggle" type="button" phx-click="toggle_import_section" phx-value-section="taxonomy">
|
||||
<span><%= translated("importAnalysis.taxonomyTitle") %></span>
|
||||
<span class="toggle-icon"><%= if @sections.taxonomy, do: "▾", else: "▸" %></span>
|
||||
</button>
|
||||
|
||||
<%= if @sections.taxonomy do %>
|
||||
<div class="taxonomy-analyze-row">
|
||||
<div class="taxonomy-analyze-dropdown">
|
||||
<button class="taxonomy-analyze-btn" type="button" phx-click="toggle_import_ai_model_selector"><%= translated("importAnalysis.analyzeWith") %></button>
|
||||
<%= if @import_editor.model_selector_open? do %>
|
||||
<div class="taxonomy-model-dropdown">
|
||||
<%= for model <- @import_editor.available_models do %>
|
||||
<button class="taxonomy-model-option" type="button" phx-click="select_import_ai_model" phx-value-model={model.id}>
|
||||
<%= model.provider_name || model.provider || translated("importAnalysis.unknown") %>: <%= model.name || model.id %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<button class="taxonomy-analyze-btn" type="button" phx-click="analyze_import_taxonomy_ai" disabled={Enum.empty?(@import_editor.available_models) and not @import_editor.offline?}>
|
||||
<%= @import_editor.selected_model_label %>
|
||||
</button>
|
||||
|
||||
<span class="taxonomy-analyze-hint"><%= translated("importAnalysis.aiMappingHint") %></span>
|
||||
</div>
|
||||
|
||||
<div class="import-taxonomy-groups">
|
||||
<.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" />
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.any?(Map.get(@report, :macros, [])) do %>
|
||||
<section class="import-detail-section">
|
||||
<button class="import-section-toggle" type="button" phx-click="toggle_import_section" phx-value-section="macros">
|
||||
<span><%= translated("importAnalysis.macrosWithCount", %{count: length(@report.macros)}) %></span>
|
||||
<span class="toggle-icon"><%= if @sections.macros, do: "▾", else: "▸" %></span>
|
||||
</button>
|
||||
|
||||
<%= if @sections.macros do %>
|
||||
<div class="macros-list">
|
||||
<%= for macro <- @report.macros do %>
|
||||
<div class="macro-item unmapped">
|
||||
<div class="macro-header">
|
||||
<span class="macro-name"><%= macro.name %></span>
|
||||
<span class="macro-status-badge unmapped"><%= translated("importAnalysis.macroStatusUnknown") %></span>
|
||||
<span class="macro-count"><%= translated("importAnalysis.macroUses", %{count: macro.usage_count}) %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="import-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path>
|
||||
</svg>
|
||||
<p><%= translated("importAnalysis.emptyState") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :stats, :map, required: true
|
||||
|
||||
def stat_card(assigns) do
|
||||
~H"""
|
||||
<div class="import-stat-card">
|
||||
<h3><%= @label %></h3>
|
||||
<div class="import-stat-number"><%= total_stats(@stats) %></div>
|
||||
<div class="import-stat-breakdown">
|
||||
<%= if @stats.new_count > 0 do %><span class="import-stat-tag stat-new"><%= @stats.new_count %> <%= translated("importAnalysis.new") %></span><% end %>
|
||||
<%= if @stats.update_count > 0 do %><span class="import-stat-tag stat-update"><%= @stats.update_count %> <%= translated("importAnalysis.update") %></span><% end %>
|
||||
<%= if @stats.conflict_count > 0 do %><span class="import-stat-tag stat-conflict"><%= @stats.conflict_count %> <%= translated("importAnalysis.conflict") %></span><% end %>
|
||||
<%= if @stats.duplicate_count > 0 do %><span class="import-stat-tag stat-duplicate"><%= @stats.duplicate_count %> <%= translated("importAnalysis.duplicate") %></span><% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :stats, :map, required: true
|
||||
|
||||
def media_stat_card(assigns) do
|
||||
~H"""
|
||||
<div class="import-stat-card">
|
||||
<h3><%= @label %></h3>
|
||||
<div class="import-stat-number"><%= total_media_stats(@stats) %></div>
|
||||
<div class="import-stat-breakdown">
|
||||
<%= if @stats.new_count > 0 do %><span class="import-stat-tag stat-new"><%= @stats.new_count %> <%= translated("importAnalysis.new") %></span><% end %>
|
||||
<%= if @stats.update_count > 0 do %><span class="import-stat-tag stat-update"><%= @stats.update_count %> <%= translated("importAnalysis.update") %></span><% end %>
|
||||
<%= if @stats.conflict_count > 0 do %><span class="import-stat-tag stat-conflict"><%= @stats.conflict_count %> <%= translated("importAnalysis.conflict") %></span><% end %>
|
||||
<%= if @stats.duplicate_count > 0 do %><span class="import-stat-tag stat-duplicate"><%= @stats.duplicate_count %> <%= translated("importAnalysis.duplicate") %></span><% end %>
|
||||
<%= if @stats.missing_count > 0 do %><span class="import-stat-tag stat-missing"><%= @stats.missing_count %> <%= translated("importAnalysis.missing") %></span><% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :stats, :map, required: true
|
||||
|
||||
def taxonomy_stat_card(assigns) do
|
||||
~H"""
|
||||
<div class="import-stat-card">
|
||||
<h3><%= @label %></h3>
|
||||
<div class="import-stat-number"><%= @stats.existing_count + @stats.mapped_count + @stats.new_count %></div>
|
||||
<div class="import-stat-breakdown">
|
||||
<%= if @stats.existing_count > 0 do %><span class="import-stat-tag stat-update"><%= @stats.existing_count %> <%= translated("importAnalysis.existing") %></span><% end %>
|
||||
<%= if @stats.mapped_count > 0 do %><span class="import-stat-tag stat-mapped"><%= @stats.mapped_count %> <%= translated("importAnalysis.mapped") %></span><% end %>
|
||||
<%= if @stats.new_count > 0 do %><span class="import-stat-tag stat-new"><%= @stats.new_count %> <%= translated("importAnalysis.new") %></span><% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<div class="taxonomy-group">
|
||||
<h4><%= @title %></h4>
|
||||
<div class="import-taxonomy-list">
|
||||
<%= for item <- @items do %>
|
||||
<form class="import-taxonomy-form" phx-change="change_import_taxonomy_mapping">
|
||||
<input type="hidden" name="type" value={@type} />
|
||||
<input type="hidden" name="name" value={item.name} />
|
||||
<span class={taxonomy_pill_class(item)}><%= item.name %></span>
|
||||
<select name="mapped_to">
|
||||
<option value=""><%= translated("importAnalysis.mapToPlaceholder") %></option>
|
||||
<%= for term <- @existing_terms do %>
|
||||
<option value={term} selected={item.mapped_to == term}><%= term %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
@@ -409,6 +409,9 @@
|
||||
<% @current_tab.type == :chat and @chat_editor -> %>
|
||||
<ChatEditor.chat_editor chat_editor={@chat_editor} />
|
||||
|
||||
<% @current_tab.type == :import and @import_editor -> %>
|
||||
<ImportEditor.import_editor import_editor={@import_editor} />
|
||||
|
||||
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %>
|
||||
<MiscEditor.misc_editor misc_editor={@misc_editor} />
|
||||
|
||||
|
||||
359
lib/bds/import_analysis.ex
Normal file
359
lib/bds/import_analysis.ex
Normal file
@@ -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/(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/u
|
||||
@param_regex ~r/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s\]"']+))/u
|
||||
|
||||
def analyze_wxr(project_id, wxr_file_path, uploads_folder_path \\ nil)
|
||||
when is_binary(project_id) and is_binary(wxr_file_path) do
|
||||
wxr_data = WxrParser.parse_file(wxr_file_path)
|
||||
{:ok, build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path)}
|
||||
rescue
|
||||
error -> {: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/<br\s*\/?>/i, "\n")
|
||||
|> String.replace(~r|</p>|i, "\n\n")
|
||||
|> String.replace(~r|<p[^>]*>|i, "")
|
||||
|> String.replace(~r|<strong>(.*?)</strong>|is, "**\\1**")
|
||||
|> String.replace(~r|<b>(.*?)</b>|is, "**\\1**")
|
||||
|> String.replace(~r|<em>(.*?)</em>|is, "*\\1*")
|
||||
|> String.replace(~r|<i>(.*?)</i>|is, "*\\1*")
|
||||
|> String.replace(~r|<code>(.*?)</code>|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
|
||||
@@ -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
|
||||
|
||||
306
lib/bds/import_execution.ex
Normal file
306
lib/bds/import_execution.ex
Normal file
@@ -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
|
||||
206
lib/bds/wxr_parser.ex
Normal file
206
lib/bds/wxr_parser.ex
Normal file
@@ -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 <channel> 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
503
priv/ui/app.css
503
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);
|
||||
}
|
||||
|
||||
108
test/bds/desktop/import_shell_live_test.exs
Normal file
108
test/bds/desktop/import_shell_live_test.exs
Normal file
@@ -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
|
||||
316
test/bds/import_analysis_test.exs
Normal file
316
test/bds/import_analysis_test.exs
Normal file
@@ -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
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<title>Legacy Blog</title>
|
||||
<link>https://legacy.example</link>
|
||||
<description>Imported from the legacy desktop app</description>
|
||||
<language>en</language>
|
||||
<wp:category>
|
||||
<wp:cat_name><![CDATA[General]]></wp:cat_name>
|
||||
<wp:category_nicename>general</wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
</wp:category>
|
||||
<wp:tag>
|
||||
<wp:tag_slug>news</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[News]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
<item>
|
||||
<title>Hello World</title>
|
||||
<link>https://legacy.example/2024/05/hello-world</link>
|
||||
<pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Hello world</p><p>[gallery ids="1,2"]</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Legacy hello]]></excerpt:encoded>
|
||||
<wp:post_id>101</wp:post_id>
|
||||
<wp:post_date>2024-05-01 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-01 12:30:00</wp:post_modified>
|
||||
<wp:post_name>hello-world</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
|
||||
</item>
|
||||
<item>
|
||||
<title>About</title>
|
||||
<link>https://legacy.example/about</link>
|
||||
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>About page</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>201</wp:post_id>
|
||||
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
|
||||
<wp:post_name>about</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>page</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
</item>
|
||||
<item>
|
||||
<title>Import Asset</title>
|
||||
<link>https://legacy.example/wp-content/uploads/2024/05/import-asset.txt</link>
|
||||
<pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[Legacy text attachment]]></content:encoded>
|
||||
<wp:post_id>301</wp:post_id>
|
||||
<wp:post_parent>101</wp:post_parent>
|
||||
<wp:post_name>import-asset</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:attachment_url><![CDATA[https://legacy.example/wp-content/uploads/2024/05/import-asset.txt]]></wp:attachment_url>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
end
|
||||
|
||||
defp conflict_wxr_xml do
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<title>Legacy Blog</title>
|
||||
<link>https://legacy.example</link>
|
||||
<description>Imported from the legacy desktop app</description>
|
||||
<language>en</language>
|
||||
<wp:category>
|
||||
<wp:cat_name><![CDATA[General]]></wp:cat_name>
|
||||
<wp:category_nicename>general</wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
</wp:category>
|
||||
<wp:tag>
|
||||
<wp:tag_slug>news</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[News]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
<item>
|
||||
<title>Update Me</title>
|
||||
<link>https://legacy.example/update-me</link>
|
||||
<pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Update body</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>401</wp:post_id>
|
||||
<wp:post_date>2024-05-01 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-01 12:30:00</wp:post_modified>
|
||||
<wp:post_name>update-me</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
|
||||
</item>
|
||||
<item>
|
||||
<title>Conflict Me</title>
|
||||
<link>https://legacy.example/conflict-me</link>
|
||||
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Incoming conflict body</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>402</wp:post_id>
|
||||
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
|
||||
<wp:post_name>conflict-me</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
|
||||
</item>
|
||||
<item>
|
||||
<title>Duplicate Me</title>
|
||||
<link>https://legacy.example/duplicate-me</link>
|
||||
<pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Duplicate body</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>403</wp:post_id>
|
||||
<wp:post_date>2024-05-03 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-03 12:30:00</wp:post_modified>
|
||||
<wp:post_name>duplicate-me</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
|
||||
</item>
|
||||
<item>
|
||||
<title>Missing Asset</title>
|
||||
<link>https://legacy.example/wp-content/uploads/2024/05/missing-asset.txt</link>
|
||||
<pubDate>Sat, 04 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[Missing attachment]]></content:encoded>
|
||||
<wp:post_id>404</wp:post_id>
|
||||
<wp:post_parent>401</wp:post_parent>
|
||||
<wp:post_name>missing-asset</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:attachment_url><![CDATA[https://legacy.example/wp-content/uploads/2024/05/missing-asset.txt]]></wp:attachment_url>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
end
|
||||
end
|
||||
57
test/bds/import_definitions_test.exs
Normal file
57
test/bds/import_definitions_test.exs
Normal file
@@ -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
|
||||
208
test/bds/import_execution_test.exs
Normal file
208
test/bds/import_execution_test.exs
Normal file
@@ -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
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<title>Legacy Blog</title>
|
||||
<link>https://legacy.example</link>
|
||||
<description>Imported from the legacy desktop app</description>
|
||||
<language>en</language>
|
||||
<wp:category>
|
||||
<wp:cat_name><![CDATA[General]]></wp:cat_name>
|
||||
<wp:category_nicename>general</wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
</wp:category>
|
||||
<wp:tag>
|
||||
<wp:tag_slug>news</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[News]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
<item>
|
||||
<title>Hello World</title>
|
||||
<link>https://legacy.example/2024/05/hello-world</link>
|
||||
<pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Hello world</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Legacy hello]]></excerpt:encoded>
|
||||
<wp:post_id>101</wp:post_id>
|
||||
<wp:post_date>2024-05-01 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-01 12:30:00</wp:post_modified>
|
||||
<wp:post_name>hello-world</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
|
||||
</item>
|
||||
<item>
|
||||
<title>About</title>
|
||||
<link>https://legacy.example/about</link>
|
||||
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>About page</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>201</wp:post_id>
|
||||
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
|
||||
<wp:post_name>about</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>page</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
</item>
|
||||
<item>
|
||||
<title>Import Asset</title>
|
||||
<link>https://legacy.example/wp-content/uploads/2024/05/import-asset.txt</link>
|
||||
<pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[Legacy text attachment]]></content:encoded>
|
||||
<wp:post_id>301</wp:post_id>
|
||||
<wp:post_parent>101</wp:post_parent>
|
||||
<wp:post_name>import-asset</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:attachment_url><![CDATA[https://legacy.example/wp-content/uploads/2024/05/import-asset.txt]]></wp:attachment_url>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
end
|
||||
|
||||
defp conflict_only_wxr_xml do
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<title>Legacy Blog</title>
|
||||
<link>https://legacy.example</link>
|
||||
<description>Imported from the legacy desktop app</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>Conflict Me</title>
|
||||
<link>https://legacy.example/conflict-me</link>
|
||||
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Incoming conflict body</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>402</wp:post_id>
|
||||
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
|
||||
<wp:post_name>conflict-me</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
end
|
||||
end
|
||||
111
test/bds/wxr_parser_test.exs
Normal file
111
test/bds/wxr_parser_test.exs
Normal file
@@ -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 <channel> element found/, fn ->
|
||||
WxrParser.parse_xml("<rss version=\"2.0\"></rss>")
|
||||
end
|
||||
end
|
||||
|
||||
defp sample_wxr_xml do
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<title>Legacy Blog</title>
|
||||
<link>https://legacy.example</link>
|
||||
<description>Imported from the legacy desktop app</description>
|
||||
<language>en</language>
|
||||
|
||||
<wp:category>
|
||||
<wp:cat_name><![CDATA[General]]></wp:cat_name>
|
||||
<wp:category_nicename>general</wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
</wp:category>
|
||||
|
||||
<wp:tag>
|
||||
<wp:tag_slug>news</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[News]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
|
||||
<item>
|
||||
<title>Hello World</title>
|
||||
<link>https://legacy.example/2024/05/hello-world</link>
|
||||
<pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Hello <strong>world</strong>.</p><p>[gallery ids="1,2"]</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Legacy hello]]></excerpt:encoded>
|
||||
<wp:post_id>101</wp:post_id>
|
||||
<wp:post_date>2024-05-01 14:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-02 15:00:00</wp:post_modified>
|
||||
<wp:post_name>hello-world</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<title>About</title>
|
||||
<link>https://legacy.example/about</link>
|
||||
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>About page</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>201</wp:post_id>
|
||||
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
|
||||
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
|
||||
<wp:post_name>about</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>page</wp:post_type>
|
||||
<category domain="category" nicename="general"><![CDATA[General]]></category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<title>Import Asset</title>
|
||||
<link>https://legacy.example/wp-content/uploads/2024/05/import-asset.txt</link>
|
||||
<pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[Importer]]></dc:creator>
|
||||
<content:encoded><![CDATA[Legacy text attachment]]></content:encoded>
|
||||
<wp:post_id>301</wp:post_id>
|
||||
<wp:post_parent>101</wp:post_parent>
|
||||
<wp:post_name>import-asset</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:attachment_url><![CDATA[https://legacy.example/wp-content/uploads/2024/05/import-asset.txt]]></wp:attachment_url>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user