feat: step 12 is done again. huh?

This commit is contained in:
2026-04-29 20:33:59 +02:00
parent f178b5b207
commit a6033cb86a
11 changed files with 1369 additions and 205 deletions

View File

@@ -83,8 +83,8 @@ Only these two audit tracks matter for the current pass. The follow-on missing-f
11. Restore desktop-side CLI mutation watching parity. Completed 2026-04-29. 11. Restore desktop-side CLI mutation watching parity. Completed 2026-04-29.
A supervised CLI-sync watcher now polls the persisted notification store on the old-app timing budget, broadcasts `entity:changed` events through PubSub, and the LiveView shell refreshes sidebar/editor state while closing stale post/media tabs on external deletes. A supervised CLI-sync watcher now polls the persisted notification store on the old-app timing budget, broadcasts `entity:changed` events through PubSub, and the LiveView shell refreshes sidebar/editor state while closing stale post/media tabs on external deletes.
12. Restore import execution and editor parity. 12. Restore import execution and editor parity. Completed 2026-04-29.
Extend the existing stored import definitions into the old WXR analysis/execution pipeline and add the dedicated editor surface so import behavior, workflow, and look and feel match the old app. The stored import-definition flow now runs through the old analysis/execution pipeline again with progress callbacks, dedicated import-editor detail sections, inline taxonomy mapping pills plus AI-backed mapping, and focused import proof plus clean compile, dialyzer, and full-suite validation.
## Batch 3 Audit Matrix ## Batch 3 Audit Matrix

View File

@@ -212,6 +212,40 @@ defmodule BDS.AI do
end end
end end
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
payload = %{
import_categories: normalize_string_list(Map.get(import_terms, :categories) || Map.get(import_terms, "categories")),
import_tags: normalize_string_list(Map.get(import_terms, :tags) || Map.get(import_terms, "tags")),
existing_categories: normalize_string_list(Map.get(existing_terms, :categories) || Map.get(existing_terms, "categories")),
existing_tags: normalize_string_list(Map.get(existing_terms, :tags) || Map.get(existing_terms, "tags"))
}
run_one_shot(
:import_taxonomy_mapping,
payload,
opts,
fn json, usage ->
{:ok,
%{
category_mappings:
filter_taxonomy_mapping_response(
json["categoryMappings"] || json["category_mappings"],
payload.import_categories,
payload.existing_categories
),
tag_mappings:
filter_taxonomy_mapping_response(
json["tagMappings"] || json["tag_mappings"],
payload.import_tags,
payload.existing_tags
),
usage: usage
}}
end
)
end
def analyze_post(post_input, opts \\ []) when is_list(opts) do def analyze_post(post_input, opts \\ []) when is_list(opts) do
with {:ok, post} <- normalize_post_input(post_input) do with {:ok, post} <- normalize_post_input(post_input) do
run_one_shot( run_one_shot(
@@ -559,7 +593,7 @@ defmodule BDS.AI do
defp run_one_shot(operation, payload, opts, formatter) do defp run_one_shot(operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- resolve_runtime_target(operation, secret_backend: Keyword.get(opts, :secret_backend, SecretBackend)), with {:ok, endpoint, model, mode} <- resolve_runtime_target(operation, opts),
:ok <- validate_runtime_target(operation, model, mode), :ok <- validate_runtime_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model), request <- build_one_shot_request(operation, payload, model),
{:ok, response} <- runtime.generate(endpoint_with_model(endpoint, model), request, opts), {:ok, response} <- runtime.generate(endpoint_with_model(endpoint, model), request, opts),
@@ -903,20 +937,20 @@ defmodule BDS.AI do
{:ok, get_model_preference_value(:chat) || endpoint.model} {:ok, get_model_preference_value(:chat) || endpoint.model}
end end
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, _extra) do defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
{:ok, get_model_preference_value(:airplane_image_analysis) || endpoint.model} {:ok, Keyword.get(extra, :model) || get_model_preference_value(:airplane_image_analysis) || endpoint.model}
end end
defp resolve_model_for_operation(:analyze_image, :online, endpoint, _extra) do defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
{:ok, get_model_preference_value(:image_analysis) || endpoint.model} {:ok, Keyword.get(extra, :model) || get_model_preference_value(:image_analysis) || endpoint.model}
end end
defp resolve_model_for_operation(_operation, :airplane, endpoint, _extra) do defp resolve_model_for_operation(_operation, :airplane, endpoint, extra) do
{:ok, get_model_preference_value(:airplane_title) || endpoint.model} {:ok, Keyword.get(extra, :model) || get_model_preference_value(:airplane_title) || endpoint.model}
end end
defp resolve_model_for_operation(_operation, :online, endpoint, _extra) do defp resolve_model_for_operation(_operation, :online, endpoint, extra) do
{:ok, get_model_preference_value(:title) || endpoint.model} {:ok, Keyword.get(extra, :model) || get_model_preference_value(:title) || endpoint.model}
end end
defp validate_runtime_target(:analyze_image, model, _mode) do defp validate_runtime_target(:analyze_image, model, _mode) do
@@ -990,6 +1024,49 @@ defmodule BDS.AI do
|> MapSet.size() |> MapSet.size()
end end
defp normalize_string_list(values) do
values
|> List.wrap()
|> Enum.map(&to_string/1)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms) when is_map(mappings) do
import_lookup = canonical_term_lookup(import_terms)
existing_lookup = canonical_term_lookup(existing_terms)
Enum.reduce(mappings, %{}, fn {source, target}, acc ->
with {:ok, canonical_source} <- resolve_canonical_term(source, import_lookup),
{:ok, canonical_target} <- resolve_canonical_term(target, existing_lookup) do
Map.put(acc, canonical_source, canonical_target)
else
_other -> acc
end
end)
end
defp filter_taxonomy_mapping_response(_mappings, _import_terms, _existing_terms), do: %{}
defp canonical_term_lookup(terms) do
Map.new(terms, fn term -> {normalize_term(term), term} end)
end
defp resolve_canonical_term(term, lookup) do
case Map.get(lookup, normalize_term(term)) do
nil -> :error
canonical -> {:ok, canonical}
end
end
defp normalize_term(term) do
term
|> to_string()
|> String.trim()
|> String.downcase()
end
defp one_shot_system_prompt(:detect_language) do defp one_shot_system_prompt(:detect_language) do
"Return JSON with exactly one key: language_code." "Return JSON with exactly one key: language_code."
end end
@@ -998,6 +1075,10 @@ defmodule BDS.AI do
"Return JSON with keys tags and categories, each an array of short strings." "Return JSON with keys tags and categories, each an array of short strings."
end end
defp one_shot_system_prompt(:import_taxonomy_mapping) do
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
end
defp one_shot_system_prompt(:analyze_post) do defp one_shot_system_prompt(:analyze_post) do
"Return JSON with keys title, excerpt, and slug." "Return JSON with keys title, excerpt, and slug."
end end
@@ -1022,6 +1103,27 @@ defmodule BDS.AI do
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end
defp one_shot_user_content(:import_taxonomy_mapping, payload) do
[
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
"",
"Imported categories:",
Enum.join(payload.import_categories, ", "),
"",
"Imported tags:",
Enum.join(payload.import_tags, ", "),
"",
"Existing project categories:",
Enum.join(payload.existing_categories, ", "),
"",
"Existing project tags:",
Enum.join(payload.existing_tags, ", "),
"",
"Return JSON only."
]
|> Enum.join("\n")
end
defp one_shot_user_content(:analyze_post, post) do defp one_shot_user_content(:analyze_post, post) do
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end

View File

@@ -105,8 +105,12 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:chat_editor_surface_data, %{}) |> assign(:chat_editor_surface_data, %{})
|> assign(:chat_editor_surface_tabs, %{}) |> assign(:chat_editor_surface_tabs, %{})
|> assign(:chat_editor_action_errors, %{}) |> assign(:chat_editor_action_errors, %{})
|> assign(:import_editor_analysis_states, %{})
|> assign(:import_editor_analysis_task_refs, %{})
|> assign(:import_editor_execution_states, %{}) |> assign(:import_editor_execution_states, %{})
|> assign(:import_editor_execution_task_refs, %{})
|> assign(:import_editor_sections, %{}) |> assign(:import_editor_sections, %{})
|> assign(:import_editor_taxonomy_edits, %{})
|> assign(:import_editor_model_selectors_open, %{}) |> assign(:import_editor_model_selectors_open, %{})
|> assign(:import_editor_selected_models, %{}) |> assign(:import_editor_selected_models, %{})
|> assign(:misc_editor_selected_pairs, %{}) |> assign(:misc_editor_selected_pairs, %{})
@@ -791,8 +795,20 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, ImportEditor.change_conflict_resolution(socket, params, &reload_shell/2)} {:noreply, ImportEditor.change_conflict_resolution(socket, params, &reload_shell/2)}
end end
def handle_event("change_import_taxonomy_mapping", params, socket) do def handle_event("start_import_taxonomy_edit", params, socket) do
{:noreply, ImportEditor.change_taxonomy_mapping(socket, params, &reload_shell/2)} {:noreply, ImportEditor.start_taxonomy_edit(socket, params, &reload_shell/2)}
end
def handle_event("save_import_taxonomy_edit", params, socket) do
{:noreply, ImportEditor.save_taxonomy_edit(socket, params, &reload_shell/2)}
end
def handle_event("cancel_import_taxonomy_edit", _params, socket) do
{:noreply, ImportEditor.cancel_taxonomy_edit(socket, &reload_shell/2)}
end
def handle_event("clear_import_taxonomy_mapping", params, socket) do
{:noreply, ImportEditor.clear_taxonomy_mapping(socket, params, &reload_shell/2)}
end end
def handle_event("toggle_import_section", %{"section" => section}, socket) do def handle_event("toggle_import_section", %{"section" => section}, socket) do
@@ -1184,19 +1200,46 @@ defmodule BDS.Desktop.ShellLive do
@impl true @impl true
def handle_info({ref, result}, socket) when is_reference(ref) do def handle_info({ref, result}, socket) when is_reference(ref) do
Process.demonitor(ref, [:flush]) Process.demonitor(ref, [:flush])
cond do
Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) ->
{:noreply, ImportEditor.finish_analysis(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
Map.has_key?(socket.assigns.import_editor_execution_task_refs, ref) ->
{:noreply, ImportEditor.finish_execution(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
true ->
{:noreply, ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)} {:noreply, ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
end end
end
def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
next_socket = next_socket =
cond do
Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) ->
ImportEditor.handle_task_down(socket, :analysis, ref, reason, &reload_shell/2, &append_output_entry/5)
Map.has_key?(socket.assigns.import_editor_execution_task_refs, ref) ->
ImportEditor.handle_task_down(socket, :execution, ref, reason, &reload_shell/2, &append_output_entry/5)
true ->
case reason do case reason do
:normal -> socket :normal -> socket
_other -> ChatEditor.finish_request(socket, ref, {:error, :cancelled}, &reload_shell/2, &append_output_entry/5) _other -> ChatEditor.finish_request(socket, ref, {:error, :cancelled}, &reload_shell/2, &append_output_entry/5)
end end
end
{:noreply, next_socket} {:noreply, next_socket}
end end
def handle_info({:import_analysis_progress, definition_id, step, detail}, socket) do
{:noreply, ImportEditor.note_analysis_progress(socket, definition_id, step, detail, &reload_shell/2)}
end
def handle_info({:import_execution_progress, definition_id, phase, current, total, detail}, socket) do
{:noreply, ImportEditor.note_execution_progress(socket, definition_id, phase, current, total, detail, &reload_shell/2)}
end
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do
{:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)} {:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)}
end end

File diff suppressed because it is too large Load Diff

View File

@@ -12,17 +12,30 @@ defmodule BDS.ImportAnalysis do
@shortcode_regex ~r/(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/u @shortcode_regex ~r/(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/u
@param_regex ~r/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s\]"']+))/u @param_regex ~r/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s\]"']+))/u
def analyze_wxr(project_id, wxr_file_path, uploads_folder_path \\ nil) def analyze_wxr(project_id, wxr_file_path), do: analyze_wxr(project_id, wxr_file_path, nil, [])
def analyze_wxr(project_id, wxr_file_path, uploads_folder_path)
when is_binary(project_id) and is_binary(wxr_file_path) do when is_binary(project_id) and is_binary(wxr_file_path) do
analyze_wxr(project_id, wxr_file_path, uploads_folder_path, [])
end
def analyze_wxr(project_id, wxr_file_path, uploads_folder_path, opts)
when is_binary(project_id) and is_binary(wxr_file_path) and is_list(opts) do
on_progress = Keyword.get(opts, :on_progress, fn _step, _detail -> :ok end)
wxr_data = WxrParser.parse_file(wxr_file_path) wxr_data = WxrParser.parse_file(wxr_file_path)
{:ok, build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path)} {:ok, build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path, on_progress)}
rescue rescue
error -> {:error, %{message: Exception.message(error)}} error -> {:error, %{message: Exception.message(error)}}
end end
defp build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path) do defp build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path, on_progress) do
notify_progress(on_progress, "Loading existing posts...")
existing_posts = Repo.all(from post in Post, where: post.project_id == ^project_id) existing_posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
notify_progress(on_progress, "Loading existing media...", "#{length(existing_posts)} posts in project")
existing_media = Repo.all(from media in Media, where: media.project_id == ^project_id) existing_media = Repo.all(from media in Media, where: media.project_id == ^project_id)
notify_progress(on_progress, "Loading existing tags...", "#{length(existing_media)} media in project")
existing_tag_names = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name) 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() existing_tag_set = existing_tag_names |> Enum.map(&String.downcase/1) |> MapSet.new()
@@ -40,15 +53,22 @@ defmodule BDS.ImportAnalysis do
|> Enum.reject(&is_nil(&1.checksum)) |> Enum.reject(&is_nil(&1.checksum))
|> Map.new(&{&1.checksum, &1}) |> Map.new(&{&1.checksum, &1})
notify_progress(on_progress, "Analyzing posts...", "#{length(wxr_data.posts)} posts to analyze")
analyzed_posts = Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post")) analyzed_posts = Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post"))
notify_progress(on_progress, "Analyzing pages...", "#{length(wxr_data.pages)} pages to analyze")
analyzed_pages = Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page")) analyzed_pages = Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page"))
notify_progress(on_progress, "Analyzing media files...", "#{length(wxr_data.media)} media files to analyze")
analyzed_media = analyzed_media =
Enum.map(wxr_data.media, &analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum)) Enum.map(wxr_data.media, &analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum))
notify_progress(on_progress, "Processing categories and tags...")
category_items = Enum.map(wxr_data.categories, &analyze_taxonomy_item(&1, existing_tag_set)) 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)) tag_items = Enum.map(wxr_data.tags, &analyze_taxonomy_item(&1, existing_tag_set))
notify_progress(on_progress, "Discovering macros...")
%{ %{
source_file: wxr_file_path, source_file: wxr_file_path,
site_info: %{ site_info: %{
@@ -312,6 +332,16 @@ defmodule BDS.ImportAnalysis do
defp count_status(items, status), do: Enum.count(items, &(&1.status == status)) defp count_status(items, status), do: Enum.count(items, &(&1.status == status))
defp notify_progress(callback, step, detail \\ nil) when is_function(callback, 2) do
try do
callback.(step, detail)
rescue
_error -> :ok
end
:ok
end
defp sha256(value) do defp sha256(value) do
:sha256 :sha256
|> :crypto.hash(value) |> :crypto.hash(value)

View File

@@ -11,6 +11,12 @@ defmodule BDS.ImportExecution do
def execute_import(project_id, report, opts \\ []) when is_binary(project_id) and is_map(report) do def execute_import(project_id, report, opts \\ []) when is_binary(project_id) and is_map(report) do
normalized_report = normalize_report(report) normalized_report = normalize_report(report)
default_author = Keyword.get(opts, :default_author) || project_default_author(project_id) default_author = Keyword.get(opts, :default_author) || project_default_author(project_id)
uploads_folder_path = Keyword.get(opts, :uploads_folder_path)
on_progress = Keyword.get(opts, :on_progress, fn _phase, _current, _total, _detail -> :ok end)
taxonomies = taxonomy_items(normalized_report)
post_items = import_items(normalized_report, :posts)
page_items = import_items(normalized_report, :pages)
media_items = import_items(normalized_report, :media)
result = %{ result = %{
success: true, success: true,
@@ -21,49 +27,73 @@ defmodule BDS.ImportExecution do
errors: [] errors: []
} }
result = execute_taxonomies(normalized_report, project_id, result) notify_progress(on_progress, "tags", 0, length(taxonomies), "Creating tags...")
result = execute_posts(normalized_report, project_id, default_author, result) result = execute_taxonomies(taxonomies, project_id, result, on_progress)
result = execute_pages(normalized_report, project_id, default_author, result)
{:ok, execute_media(normalized_report, project_id, default_author, result)} notify_progress(on_progress, "posts", 0, length(post_items), "Importing posts...")
result = execute_posts(post_items, project_id, default_author, result, on_progress)
notify_progress(on_progress, "pages", 0, length(page_items), "Importing pages...")
result = execute_pages(page_items, project_id, default_author, result, on_progress)
notify_progress(on_progress, "media", 0, length(media_items), "Importing media...")
result = execute_media(media_items, project_id, default_author, result, on_progress, uploads_folder_path)
notify_progress(on_progress, "complete", 1, 1, "Import complete")
{:ok, result}
rescue rescue
error -> {:error, %{message: Exception.message(error)}} error -> {:error, %{message: Exception.message(error)}}
end end
defp execute_taxonomies(report, project_id, result) do defp execute_taxonomies(taxonomies, project_id, result, on_progress) do
taxonomies = List.wrap(get_in(report, [:items, :categories])) ++ List.wrap(get_in(report, [:items, :tags]))
Enum.reduce(taxonomies, result, fn item, acc -> Enum.reduce(taxonomies, result, fn item, acc ->
current = acc.tags.created + acc.tags.skipped + 1
if item.exists_in_project || item.mapped_to do if item.exists_in_project || item.mapped_to do
notify_progress(on_progress, "tags", current, length(taxonomies), "Skipping tag: #{item.name}")
put_in(acc, [:tags, :skipped], acc.tags.skipped + 1) put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
else else
case Tags.create_tag(%{project_id: project_id, name: item.name}) do case Tags.create_tag(%{project_id: project_id, name: item.name}) do
{:ok, _tag} -> put_in(acc, [:tags, :created], acc.tags.created + 1) {:ok, _tag} ->
{:error, _reason} -> put_in(acc, [:tags, :skipped], acc.tags.skipped + 1) notify_progress(on_progress, "tags", current, length(taxonomies), "Created tag: #{item.name}")
put_in(acc, [:tags, :created], acc.tags.created + 1)
{:error, _reason} ->
notify_progress(on_progress, "tags", current, length(taxonomies), "Skipping tag: #{item.name}")
put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
end end
end end
end) end)
end end
defp execute_posts(report, project_id, default_author, result) do defp execute_posts(items, project_id, default_author, result, on_progress) do
items = import_items(report, :posts) total = length(items)
Enum.reduce(items, result, fn item, acc -> Enum.with_index(items, 1)
|> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, "posts", index, total, "Processing: #{item.title}")
execute_post_item(project_id, item, acc, :posts, default_author) execute_post_item(project_id, item, acc, :posts, default_author)
end) end)
end end
defp execute_pages(report, project_id, default_author, result) do defp execute_pages(items, project_id, default_author, result, on_progress) do
items = import_items(report, :pages) total = length(items)
Enum.reduce(items, result, fn item, acc -> Enum.with_index(items, 1)
|> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, "pages", index, total, "Processing: #{item.title}")
execute_post_item(project_id, ensure_page_category(item), acc, :pages, default_author) execute_post_item(project_id, ensure_page_category(item), acc, :pages, default_author)
end) end)
end end
defp execute_media(report, project_id, default_author, result) do defp execute_media(items, project_id, default_author, result, on_progress, uploads_folder_path) do
import_items(report, :media) total = length(items)
|> Enum.reduce(result, fn item, acc ->
items
|> Enum.with_index(1)
|> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, "media", index, total, "Processing: #{item.filename}")
cond do cond do
item.status in ["update", "duplicate", "missing"] -> item.status in ["update", "duplicate", "missing"] ->
put_in(acc, [:media, :skipped], acc.media.skipped + 1) put_in(acc, [:media, :skipped], acc.media.skipped + 1)
@@ -72,7 +102,7 @@ defmodule BDS.ImportExecution do
put_in(acc, [:media, :skipped], acc.media.skipped + 1) put_in(acc, [:media, :skipped], acc.media.skipped + 1)
true -> true ->
case import_media_item(project_id, item, default_author) do case import_media_item(project_id, item, default_author, uploads_folder_path) do
{:ok, _media} -> put_in(acc, [:media, :imported], acc.media.imported + 1) {:ok, _media} -> put_in(acc, [:media, :imported], acc.media.imported + 1)
{:error, reason} -> {:error, reason} ->
acc acc
@@ -141,8 +171,8 @@ defmodule BDS.ImportExecution do
end end
end end
defp import_media_item(project_id, item, default_author) do defp import_media_item(project_id, item, default_author, uploads_folder_path) do
source_path = item.source_file || Path.join("", item.relative_path) source_path = item.source_file || uploads_source_path(item.relative_path, uploads_folder_path)
checksum = if(source_path != nil and File.exists?(source_path), do: md5(File.read!(source_path)), else: nil) 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 if source_path && File.exists?(source_path) do
@@ -293,6 +323,29 @@ defmodule BDS.ImportExecution do
defp parse_timestamp(_value), do: nil defp parse_timestamp(_value), do: nil
defp taxonomy_items(report) do
List.wrap(get_in(report, [:items, :categories])) ++ List.wrap(get_in(report, [:items, :tags]))
end
defp uploads_source_path(relative_path, uploads_folder_path)
defp uploads_source_path(relative_path, uploads_folder_path)
when is_binary(relative_path) and is_binary(uploads_folder_path) and uploads_folder_path != "" do
Path.join(uploads_folder_path, relative_path)
end
defp uploads_source_path(_relative_path, _uploads_folder_path), do: nil
defp notify_progress(callback, phase, current, total, detail) when is_function(callback, 4) do
try do
callback.(phase, current, total, detail)
rescue
_error -> :ok
end
:ok
end
defp md5(binary) do defp md5(binary) do
:md5 :md5
|> :crypto.hash(binary) |> :crypto.hash(binary)

View File

@@ -7256,6 +7256,48 @@ button svg * {
cursor: not-allowed; cursor: not-allowed;
} }
.import-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
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-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-button-background);
border-radius: 50%;
animation: import-spinner-rotate 0.8s linear infinite;
flex-shrink: 0;
}
.import-progress {
display: flex;
flex-direction: column;
gap: 2px;
}
.import-progress-step {
font-size: 13px;
color: var(--vscode-foreground);
}
.import-progress-detail {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
@keyframes import-spinner-rotate {
to {
transform: rotate(360deg);
}
}
.import-site-info { .import-site-info {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -7458,6 +7500,56 @@ button svg * {
font-weight: 600; font-weight: 600;
} }
.import-execution-progress {
display: grid;
gap: 10px;
padding: 16px;
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-execution-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.import-execution-header h3 {
margin: 0;
font-size: 14px;
}
.import-progress-bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.import-progress-fill {
height: 100%;
background: linear-gradient(90deg, rgba(117, 190, 255, 0.85), rgba(117, 190, 255, 0.45));
}
.import-progress-info {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
}
.import-phase {
font-weight: 600;
}
.import-detail,
.import-counter {
color: var(--vscode-descriptionForeground);
}
.import-detail-table { .import-detail-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -7480,8 +7572,55 @@ button svg * {
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.import-detail-table .status-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge.new {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.import-detail-table .status-badge.update {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.import-detail-table .status-badge.conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.import-detail-table .status-badge.duplicate,
.import-detail-table .status-badge.missing {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.categories-cell,
.existing-match,
.mime-type-cell,
.post-type-cell {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.mime-type-cell,
.post-type-cell,
.existing-match,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.resolution-select, .resolution-select,
.import-taxonomy-form select { .taxonomy-mapping-input {
min-width: 150px; min-width: 150px;
background: var(--vscode-dropdown-background, var(--vscode-input-background)); background: var(--vscode-dropdown-background, var(--vscode-input-background));
color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
@@ -7556,12 +7695,58 @@ button svg * {
gap: 12px; gap: 12px;
} }
.import-taxonomy-form { .import-taxonomy-entry,
.import-taxonomy-edit-form {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.import-taxonomy-entry,
.import-taxonomy-edit-form {
flex-wrap: wrap;
}
.import-taxonomy-pill {
border: none;
cursor: default;
}
button.import-taxonomy-pill {
cursor: pointer;
}
.mapped-target {
background: rgba(115, 201, 145, 0.1);
}
.taxonomy-mapping-arrow {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.taxonomy-mapping-input {
min-width: 170px;
border-radius: 6px;
}
.taxonomy-edit-btn,
.taxonomy-clear-btn {
min-width: 28px;
min-height: 28px;
padding: 0 8px !important;
display: inline-flex;
align-items: center;
justify-content: center;
}
.taxonomy-edit-btn.ghost,
.taxonomy-clear-btn {
background: transparent !important;
border: 1px solid var(--vscode-panel-border) !important;
color: var(--vscode-descriptionForeground) !important;
}
.macros-list { .macros-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -7645,7 +7830,7 @@ button svg * {
.import-analysis button, .import-analysis button,
.resolution-select, .resolution-select,
.import-taxonomy-form select { .taxonomy-mapping-input {
width: 100%; width: 100%;
} }
@@ -7654,7 +7839,8 @@ button svg * {
align-items: stretch; align-items: stretch;
} }
.import-taxonomy-form { .import-taxonomy-entry,
.import-taxonomy-edit-form {
width: 100%; width: 100%;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

View File

@@ -119,6 +119,16 @@ defmodule BDS.AITest do
usage: usage(13, 9, 0, 0) usage: usage(13, 9, 0, 0)
}} }}
:import_taxonomy_mapping ->
{:ok,
%{
json: %{
"categoryMappings" => %{"General" => "article", "Unknown" => "missing"},
"tagMappings" => %{"News" => "updates", "Ghost" => "missing"}
},
usage: usage(19, 7, 0, 0)
}}
:chat -> :chat ->
if Enum.any?(request.messages, &(&1["role"] == "tool")) do if Enum.any?(request.messages, &(&1["role"] == "tool")) do
{:ok, {:ok,
@@ -309,6 +319,36 @@ defmodule BDS.AITest do
assert request.model == "gpt-4.1-mini" assert request.model == "gpt-4.1-mini"
end end
test "analyze_import_taxonomy uses the selected model override and returns only valid existing-term mappings" do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(:online, %{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
}, secret_backend: FakeSecretBackend)
assert :ok = BDS.AI.set_airplane_mode(false)
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
assert {:ok, result} =
BDS.AI.analyze_import_taxonomy(
%{categories: ["General"], tags: ["News"]},
%{categories: ["article", "page"], tags: ["updates"]},
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend,
model: "gpt-4o"
)
assert result.category_mappings == %{"General" => "article"}
assert result.tag_mappings == %{"News" => "updates"}
assert_received {:runtime_request, endpoint, request}
assert endpoint.kind == :online
assert request.operation == :import_taxonomy_mapping
assert request.model == "gpt-4o"
end
test "analyze_image requires a vision-capable airplane model before sending image input" do test "analyze_image requires a vision-capable airplane model before sending image input" do
assert {:ok, _endpoint} = assert {:ok, _endpoint} =
BDS.AI.put_endpoint(:airplane, %{ BDS.AI.put_endpoint(:airplane, %{

View File

@@ -54,7 +54,28 @@ defmodule BDS.Desktop.ImportShellLiveTest do
assert html =~ "Import 5 Items" assert html =~ "Import 5 Items"
assert html =~ "Post Slug Conflicts" assert html =~ "Post Slug Conflicts"
assert html =~ "Analyze with..." assert html =~ "Analyze with..."
assert html =~ "Posts (2)"
assert html =~ "Pages (1)"
assert html =~ "Media (1)"
assert html =~ "Click to add mapping"
refute html =~ ~s(name="mapped_to")
refute html =~ "Desktop workbench content routed through the Elixir shell." refute html =~ "Desktop workbench content routed through the Elixir shell."
posts_html =
view
|> element("button[phx-value-section='posts']")
|> render_click()
assert posts_html =~ "Existing Match"
assert posts_html =~ "WP Status"
media_html =
view
|> element("button[phx-value-section='media']")
|> render_click()
assert media_html =~ "Filename"
assert media_html =~ "Path"
end end
defp cached_report(wxr_path, uploads_dir) do defp cached_report(wxr_path, uploads_dir) do
@@ -102,6 +123,70 @@ defmodule BDS.Desktop.ImportShellLiveTest do
], ],
categories: [%{name: "General", exists_in_project: false, mapped_to: nil}], categories: [%{name: "General", exists_in_project: false, mapped_to: nil}],
tags: [%{name: "News", exists_in_project: false, mapped_to: nil}] tags: [%{name: "News", exists_in_project: false, mapped_to: nil}]
},
details: %{
posts: [
%{
item_type: "post",
title: "Hello World",
slug: "hello-world",
status: "new",
wp_status: "publish",
author: "Importer",
categories: ["General"],
tags: ["News"],
published_at: "2024-05-01 12:00:00",
excerpt: "Legacy hello",
content_markdown: "Hello world",
content_preview: "Hello world"
},
%{
item_type: "post",
title: "Conflict Me",
slug: "conflict-me",
status: "conflict",
resolution: "skip",
wp_status: "publish",
author: "Importer",
categories: ["General"],
tags: ["News"],
published_at: "2024-05-02 12:00:00",
excerpt: "Legacy conflict",
existing_title: "Existing Conflict",
content_markdown: "Incoming conflict body",
content_preview: "Incoming conflict body"
}
],
pages: [
%{
item_type: "page",
title: "About",
slug: "about",
status: "new",
wp_status: "publish",
author: "Importer",
categories: ["General"],
tags: [],
published_at: "2024-05-03 12:00:00",
excerpt: "About page",
content_markdown: "About page",
content_preview: "About page"
}
],
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",
mime_type: "text/plain",
description: "Legacy text attachment",
parent_wp_id: 101,
created_at: "2024-05-03 12:00:00"
}
]
} }
} }
end end

View File

@@ -149,6 +149,28 @@ defmodule BDS.ImportAnalysisTest do
assert Enum.any?(report.items.media, &(&1.filename == "missing-asset.txt" and &1.status == "missing")) assert Enum.any?(report.items.media, &(&1.filename == "missing-asset.txt" and &1.status == "missing"))
end end
test "analyze_wxr reports legacy progress steps while building the import 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,
on_progress: fn step, detail ->
send(self(), {:analysis_progress, step, detail})
end
)
assert_received {:analysis_progress, "Loading existing posts...", nil}
assert_received {:analysis_progress, "Analyzing posts...", "1 posts to analyze"}
assert_received {:analysis_progress, "Analyzing pages...", "1 pages to analyze"}
assert_received {:analysis_progress, "Analyzing media files...", "1 media files to analyze"}
assert_received {:analysis_progress, "Discovering macros...", nil}
end
defp sha256(value) do defp sha256(value) do
:sha256 :sha256
|> :crypto.hash(value) |> :crypto.hash(value)

View File

@@ -97,6 +97,32 @@ defmodule BDS.ImportExecutionTest do
assert Enum.any?(slugs, &(&1 != "conflict-me")) assert Enum.any?(slugs, &(&1 != "conflict-me"))
end end
test "execute_import reports phase progress while importing", %{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",
on_progress: fn phase, current, total, detail ->
send(self(), {:execution_progress, phase, current, total, detail})
end
)
assert_received {:execution_progress, "tags", 0, 2, "Creating tags..."}
assert_received {:execution_progress, "posts", 0, 1, "Importing posts..."}
assert_received {:execution_progress, "media", 0, 1, "Importing media..."}
assert_received {:execution_progress, "pages", 0, 1, "Importing pages..."}
assert_received {:execution_progress, "complete", 1, 1, "Import complete"}
end
defp sha256(value) do defp sha256(value) do
:sha256 :sha256
|> :crypto.hash(value) |> :crypto.hash(value)