From e7ccf02d403801133ceaef82043d64fa4b553c79 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 27 Apr 2026 08:18:02 +0200 Subject: [PATCH] feat: metadata diff hopefully implemented now --- lib/bds/desktop/shell_commands.ex | 54 ++ lib/bds/desktop/shell_live.ex | 46 +- lib/bds/desktop/shell_live/misc_editor.ex | 231 ++++++- .../misc_editor_html/misc_editor.html.heex | 151 ++++- lib/bds/frontmatter.ex | 5 +- lib/bds/maintenance.ex | 141 +++++ lib/bds/media.ex | 100 +++ lib/bds/metadata.ex | 9 + lib/bds/posts.ex | 133 ++++ lib/bds/scripts.ex | 63 ++ lib/bds/templates.ex | 47 ++ priv/i18n/locales/de.json | 14 + priv/i18n/locales/en.json | 14 + priv/i18n/locales/es.json | 14 + priv/i18n/locales/fr.json | 14 + priv/i18n/locales/it.json | 14 + priv/ui/app.css | 170 +++++ test/bds/desktop/shell_live_test.exs | 215 ++++++- test/bds/maintenance_test.exs | 588 ++++++++++++++++++ 19 files changed, 2006 insertions(+), 17 deletions(-) diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex index 2a02d8a..bbedbfe 100644 --- a/lib/bds/desktop/shell_commands.ex +++ b/lib/bds/desktop/shell_commands.ex @@ -222,6 +222,41 @@ defmodule BDS.Desktop.ShellCommands do end) end + defp dispatch("repair_metadata_diff", project, params) do + items = normalize_metadata_diff_items(Map.get(params, "items", Map.get(params, :items, []))) + direction = Map.get(params, "direction", Map.get(params, :direction)) + + if items == [] do + {:error, %{action: "repair_metadata_diff", message: "No metadata diff items selected"}} + else + queue_task(project, "repair_metadata_diff", "Repair Metadata Diff", "Maintenance", fn report -> + report.(0.2, "Repairing metadata differences") + {:ok, _repair} = Maintenance.repair_metadata_diff(project.id, direction, items) + report.(0.9, "Refreshing metadata diff") + {:ok, metadata_diff} = Maintenance.metadata_diff(project.id) + report.(1.0, "Metadata diff repair complete") + metadata_diff_result(project.id, metadata_diff) + end) + end + end + + defp dispatch("import_metadata_diff_orphans", project, params) do + orphans = normalize_metadata_diff_orphans(Map.get(params, "orphans", Map.get(params, :orphans, []))) + + if orphans == [] do + {:error, %{action: "import_metadata_diff_orphans", message: "No orphan files selected"}} + else + queue_task(project, "import_metadata_diff_orphans", "Import Metadata Diff Orphans", "Maintenance", fn report -> + report.(0.2, "Importing orphan files") + {:ok, _import} = Maintenance.import_metadata_diff_orphans(project.id, orphans) + report.(0.9, "Refreshing metadata diff") + {:ok, metadata_diff} = Maintenance.metadata_diff(project.id) + report.(1.0, "Metadata diff import complete") + metadata_diff_result(project.id, metadata_diff) + end) + end + end + defp dispatch("validate_translations", project, _params) do queue_task(project, "validate_translations", "Validate Translations", "Validation", fn report -> report.(0.2, "Checking published translations") @@ -519,6 +554,25 @@ defmodule BDS.Desktop.ShellCommands do Map.new(map, fn {key, value} -> {to_string(key), stringify_value(value)} end) end + defp normalize_metadata_diff_items(items) when is_list(items) do + Enum.map(items, fn item -> + %{ + entity_type: Map.get(item, :entity_type) || Map.get(item, "entity_type"), + entity_id: Map.get(item, :entity_id) || Map.get(item, "entity_id") + } + end) + end + + defp normalize_metadata_diff_items(_items), do: [] + + defp normalize_metadata_diff_orphans(orphans) when is_list(orphans) do + Enum.map(orphans, fn orphan -> + %{file_path: Map.get(orphan, :file_path) || Map.get(orphan, "file_path")} + end) + end + + defp normalize_metadata_diff_orphans(_orphans), do: [] + defp stringify_value(value) when is_map(value), do: stringify_map(value) defp stringify_value(value) when is_list(value), do: Enum.map(value, &stringify_value/1) defp stringify_value(value) when is_atom(value), do: Atom.to_string(value) diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 8c29a98..c517c36 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -98,6 +98,8 @@ defmodule BDS.Desktop.ShellLive do |> assign(:template_editor_drafts, %{}) |> assign(:chat_editor_inputs, %{}) |> assign(:misc_editor_selected_pairs, %{}) + |> assign(:metadata_diff_active_tabs, %{}) + |> assign(:metadata_diff_field_filters, %{}) |> assign(:shell_overlay, nil) |> assign(:output_entries, []) |> reload_shell(workbench)} @@ -711,6 +713,46 @@ defmodule BDS.Desktop.ShellLive do {:noreply, MiscEditor.dismiss_selected(socket, &reload_shell/2, &append_output_entry/5)} end + def handle_event("repair_metadata_diff", %{"field" => field, "direction" => direction}, socket) do + case MiscEditor.metadata_diff_repair_request(socket, field, direction) do + {:ok, params} -> {:noreply, apply_shell_command(socket, "repair_metadata_diff", params)} + {:error, message} -> {:noreply, append_output_entry(socket, translate_for_socket(socket, "Metadata Diff"), message, nil, "error")} + end + end + + def handle_event("import_metadata_diff_orphans", _params, socket) do + case MiscEditor.metadata_diff_orphan_import_request(socket) do + {:ok, params} -> {:noreply, apply_shell_command(socket, "import_metadata_diff_orphans", params)} + {:error, message} -> {:noreply, append_output_entry(socket, translate_for_socket(socket, "Metadata Diff"), message, nil, "error")} + end + end + + def handle_event("select_metadata_diff_tab", %{"tab" => tab}, socket) do + tab_id = socket.assigns.current_tab.id + + socket = + socket + |> assign(:metadata_diff_active_tabs, Map.put(socket.assigns.metadata_diff_active_tabs, tab_id, tab)) + |> assign(:metadata_diff_field_filters, Map.delete(socket.assigns.metadata_diff_field_filters, tab_id)) + |> assign_misc_editor() + + {:noreply, socket} + end + + def handle_event("toggle_metadata_diff_field", %{"field" => field}, socket) do + tab_id = socket.assigns.current_tab.id + current = Map.get(socket.assigns.metadata_diff_field_filters, tab_id) + + next_filters = + if current == field do + Map.delete(socket.assigns.metadata_diff_field_filters, tab_id) + else + Map.put(socket.assigns.metadata_diff_field_filters, tab_id, field) + end + + {:noreply, socket |> assign(:metadata_diff_field_filters, next_filters) |> assign_misc_editor()} + end + def handle_event("open_duplicate_post", %{"id" => id, "title" => title}, socket) do {:noreply, open_sidebar_item(socket, %{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"}, :preview)} end @@ -1586,8 +1628,8 @@ defmodule BDS.Desktop.ShellLive do ArgumentError -> nil end - defp apply_shell_command(socket, action) do - case ShellCommands.execute(action) do + defp apply_shell_command(socket, action, params \\ %{}) do + case ShellCommands.execute(action, params) do {:ok, result} -> apply_shell_command_result(socket, result) {:error, %{message: message}} -> append_output_entry(socket, command_title(action), message, nil, "error") {:error, reason} -> append_output_entry(socket, command_title(action), inspect(reason), nil, "error") diff --git a/lib/bds/desktop/shell_live/misc_editor.ex b/lib/bds/desktop/shell_live/misc_editor.ex index c0396c5..65e9ed4 100644 --- a/lib/bds/desktop/shell_live/misc_editor.ex +++ b/lib/bds/desktop/shell_live/misc_editor.ex @@ -101,13 +101,64 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + def metadata_diff_repair_request(socket, field, direction) do + meta = meta(socket.assigns) + payload = Map.get(meta, :payload, %{}) + items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1) + tabs = metadata_diff_tabs(items, []) + active_tab = metadata_diff_active_tab(socket.assigns, tabs) + + repair_items = + items + |> Enum.filter(&(&1.tab_id == active_tab)) + |> Enum.filter(fn item -> Enum.any?(item.differences, &(diff_name(&1) == field)) end) + |> Enum.map(&%{"entity_type" => &1.entity_type, "entity_id" => &1.entity_id}) + + cond do + not metadata_diff_repairable_tab?(active_tab) -> + {:error, translated("No repair action available")} + + repair_items == [] -> + {:error, translated("No metadata diff items selected")} + + true -> + {:ok, + %{ + "direction" => direction, + "field" => field, + "tab" => active_tab, + "items" => repair_items + }} + end + end + + def metadata_diff_orphan_import_request(socket) do + meta = meta(socket.assigns) + payload = Map.get(meta, :payload, %{}) + items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1) + orphan_files = Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1) + tabs = metadata_diff_tabs(items, orphan_files) + active_tab = metadata_diff_active_tab(socket.assigns, tabs) + + selected_orphans = + orphan_files + |> Enum.filter(&(&1.tab_id == active_tab)) + |> Enum.map(&%{"file_path" => &1.file_path}) + + if selected_orphans == [] do + {:error, translated("No orphan files selected")} + else + {:ok, %{"tab" => active_tab, "orphans" => selected_orphans}} + end + end + def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do meta = meta(assigns) payload = Map.get(meta, :payload, %{}) case type do :site_validation -> build_site_validation(meta, payload) - :metadata_diff -> build_metadata_diff(meta, payload) + :metadata_diff -> build_metadata_diff(assigns, meta, payload) :translation_validation -> build_translation_validation(meta, payload) :find_duplicates -> build_duplicates(assigns, meta, payload) :git_diff -> build_git_diff(assigns, meta) @@ -151,17 +202,28 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do } end - defp build_metadata_diff(meta, payload) do - items = Map.get(payload, :diff_reports, []) + defp build_metadata_diff(assigns, meta, payload) do + items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1) + orphan_files = Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1) + tabs = metadata_diff_tabs(items, orphan_files) + active_tab = metadata_diff_active_tab(assigns, tabs) + active_field = metadata_diff_active_field(assigns) + current_tab = Enum.find(tabs, &(&1.id == active_tab)) || List.first(tabs) || empty_metadata_diff_tab() + filtered_items = metadata_diff_filtered_items(current_tab.items, active_field) %{ kind: :metadata_diff, title: Map.get(meta, :title, translated("Metadata Diff")), subtitle: Map.get(meta, :subtitle, ""), summary: Map.get(payload, :summary, %{}), - field_summaries: field_summaries(items), - items: items, - orphan_files: Map.get(payload, :orphan_reports, []) + tabs: Enum.map(tabs, &Map.take(&1, [:id, :label, :badge_count, :diff_count, :orphan_count])), + active_tab: current_tab.id, + active_field: active_field, + repair_enabled: metadata_diff_repairable_tab?(current_tab.id), + field_summaries: field_summaries(current_tab.items), + items: filtered_items, + orphan_files: if(is_nil(active_field), do: current_tab.orphan_files, else: []), + empty_message: translated("No items") } end @@ -247,8 +309,163 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do defp field_summaries(items) do items |> Enum.flat_map(fn item -> Map.get(item, :differences) || Map.get(item, "differences") || [] end) - |> Enum.group_by(fn diff -> Map.get(diff, :field) || Map.get(diff, "field") end) + |> Enum.group_by(&diff_name/1) |> Enum.map(fn {field, diffs} -> %{field_name: field, diff_count: length(diffs)} end) |> Enum.sort_by(&{&1.diff_count * -1, &1.field_name}) end + + defp metadata_diff_tabs(items, orphan_files) do + tab_ids = + (Enum.map(items, & &1.tab_id) ++ Enum.map(orphan_files, & &1.tab_id)) + |> Enum.uniq() + |> Enum.sort_by(&metadata_diff_tab_sort_key/1) + + if tab_ids == [] do + [empty_metadata_diff_tab()] + else + Enum.map(tab_ids, fn tab_id -> + tab_items = Enum.filter(items, &(&1.tab_id == tab_id)) + tab_orphans = Enum.filter(orphan_files, &(&1.tab_id == tab_id)) + + %{ + id: tab_id, + label: metadata_diff_tab_label(tab_id), + items: tab_items, + orphan_files: tab_orphans, + diff_count: length(tab_items), + orphan_count: length(tab_orphans), + badge_count: length(tab_items) + length(tab_orphans) + } + end) + end + end + + defp empty_metadata_diff_tab do + %{id: "posts", label: translated("Posts"), items: [], orphan_files: [], diff_count: 0, orphan_count: 0, badge_count: 0} + end + + defp metadata_diff_active_tab(assigns, tabs) do + tab_id = Map.get(assigns.metadata_diff_active_tabs || %{}, assigns.current_tab.id) + + if Enum.any?(tabs, &(&1.id == tab_id)) do + tab_id + else + tabs |> List.first() |> Map.get(:id) + end + end + + defp metadata_diff_active_field(assigns) do + Map.get(assigns.metadata_diff_field_filters || %{}, assigns.current_tab.id) + end + + defp metadata_diff_filtered_items(items, nil), do: items + + defp metadata_diff_filtered_items(items, field) do + Enum.filter(items, fn item -> Enum.any?(item.differences, &(diff_name(&1) == field)) end) + end + + defp normalize_metadata_diff_item(item) do + entity_type = Map.get(item, :entity_type) || Map.get(item, "entity_type") || "post" + entity_id = Map.get(item, :entity_id) || Map.get(item, "entity_id") || "" + differences = + item + |> Map.get(:differences, Map.get(item, "differences", [])) + |> Enum.map(&normalize_metadata_diff_difference/1) + + %{ + tab_id: metadata_diff_tab_id(entity_type), + entity_type: entity_type, + entity_id: entity_id, + label: metadata_diff_item_label(item, entity_id), + display_entity_type: metadata_diff_item_type_label(entity_type), + differences: differences + } + end + + defp normalize_metadata_diff_difference(diff) do + %{ + field: diff_name(diff), + db_value: format_metadata_diff_value(Map.get(diff, :db_value) || Map.get(diff, "db_value")), + file_value: format_metadata_diff_value(Map.get(diff, :file_value) || Map.get(diff, "file_value")) + } + end + + defp normalize_metadata_diff_orphan(orphan) do + path = Map.get(orphan, :file_path) || Map.get(orphan, "file_path") || Map.get(orphan, :path) || Map.get(orphan, "path") || "" + entity_type = Map.get(orphan, :entity_type) || Map.get(orphan, "entity_type") || metadata_diff_orphan_entity_type(path) + + %{ + tab_id: metadata_diff_tab_id(entity_type), + entity_type: entity_type, + file_path: path, + slug: Path.basename(path) |> String.trim(), + id: Map.get(orphan, :id) || Map.get(orphan, "id") + } + end + + defp metadata_diff_item_label(item, entity_id) do + Map.get(item, :label) || Map.get(item, "label") || Map.get(item, :title) || Map.get(item, "title") || Map.get(item, :slug) || Map.get(item, "slug") || entity_id + end + + defp metadata_diff_item_type_label("post"), do: translated("Post") + defp metadata_diff_item_type_label("post_translation"), do: translated("Translations") + defp metadata_diff_item_type_label("media"), do: translated("Media") + defp metadata_diff_item_type_label("media_translation"), do: translated("Translations") + defp metadata_diff_item_type_label("script"), do: translated("Script") + defp metadata_diff_item_type_label("template"), do: translated("Template") + defp metadata_diff_item_type_label("project"), do: translated("Project") + defp metadata_diff_item_type_label("publishing"), do: translated("Publishing") + defp metadata_diff_item_type_label("categories"), do: translated("Categories") + defp metadata_diff_item_type_label("category_meta"), do: translated("Categories") + defp metadata_diff_item_type_label("embedding"), do: translated("Embeddings") + defp metadata_diff_item_type_label(entity_type), do: entity_type |> String.replace("_", " ") |> String.capitalize() + + defp metadata_diff_tab_id("post"), do: "posts" + defp metadata_diff_tab_id("post_translation"), do: "posts" + defp metadata_diff_tab_id("media"), do: "media" + defp metadata_diff_tab_id("media_translation"), do: "media" + defp metadata_diff_tab_id("script"), do: "scripts" + defp metadata_diff_tab_id("template"), do: "templates" + defp metadata_diff_tab_id("project"), do: "project" + defp metadata_diff_tab_id("publishing"), do: "project" + defp metadata_diff_tab_id("categories"), do: "project" + defp metadata_diff_tab_id("category_meta"), do: "project" + defp metadata_diff_tab_id("embedding"), do: "embeddings" + defp metadata_diff_tab_id(_entity_type), do: "project" + + defp metadata_diff_tab_label("posts"), do: translated("Posts") + defp metadata_diff_tab_label("media"), do: translated("Media") + defp metadata_diff_tab_label("scripts"), do: translated("Scripts") + defp metadata_diff_tab_label("templates"), do: translated("Templates") + defp metadata_diff_tab_label("project"), do: translated("Project") + defp metadata_diff_tab_label("embeddings"), do: translated("Embeddings") + defp metadata_diff_tab_label(tab_id), do: tab_id |> String.replace("_", " ") |> String.capitalize() + + defp metadata_diff_tab_sort_key("posts"), do: 0 + defp metadata_diff_tab_sort_key("media"), do: 1 + defp metadata_diff_tab_sort_key("scripts"), do: 2 + defp metadata_diff_tab_sort_key("templates"), do: 3 + defp metadata_diff_tab_sort_key("project"), do: 4 + defp metadata_diff_tab_sort_key("embeddings"), do: 5 + defp metadata_diff_tab_sort_key(other), do: {6, other} + + defp metadata_diff_orphan_entity_type(path) do + cond do + String.starts_with?(path, "posts/") -> "post" + String.starts_with?(path, "media/") -> "media" + String.starts_with?(path, "scripts/") -> "script" + String.starts_with?(path, "templates/") -> "template" + true -> "project" + end + end + + defp metadata_diff_repairable_tab?(tab_id), do: tab_id in ["posts", "media", "scripts", "templates", "project"] + + defp format_metadata_diff_value(nil), do: "-" + defp format_metadata_diff_value(""), do: "-" + defp format_metadata_diff_value(value), do: to_string(value) + + defp diff_name(diff) do + Map.get(diff, :field) || Map.get(diff, "field") || Map.get(diff, :name) || Map.get(diff, "name") || "value" + end end diff --git a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex index ea8c7cb..c98749c 100644 --- a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex +++ b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex @@ -31,10 +31,153 @@ <% :metadata_diff -> %> -
-

<%= translated("Field Summary") %>

<%= for field <- @misc_editor.field_summaries do %><%= field.field_name %> <%= field.diff_count %><% end %>
-

<%= translated("Diff Items") %>

<%= for item <- @misc_editor.items do %>
<%= Map.get(item, :entity_type) || Map.get(item, "entity_type") %> <%= Map.get(item, :entity_id) || Map.get(item, "entity_id") %>
    <%= for diff <- Map.get(item, :differences) || Map.get(item, "differences") || [] do %>
  • <%= Map.get(diff, :field) || Map.get(diff, "field") %><%= inspect(Map.get(diff, :db_value) || Map.get(diff, "db_value")) %><%= inspect(Map.get(diff, :file_value) || Map.get(diff, "file_value")) %>
  • <% end %>
<% end %>
-

<%= translated("Orphan Files") %>

    <%= for orphan <- @misc_editor.orphan_files do %>
  • <%= inspect(orphan) %>
  • <% end %>
+ <% :translation_validation -> %> diff --git a/lib/bds/frontmatter.ex b/lib/bds/frontmatter.ex index d87a5ea..553f26c 100644 --- a/lib/bds/frontmatter.ex +++ b/lib/bds/frontmatter.ex @@ -34,7 +34,10 @@ defmodule BDS.Frontmatter do defp serialize_field({_key, nil}), do: [] defp serialize_field({_key, ""}), do: [] - defp serialize_field({_key, false}), do: [] + + defp serialize_field({key, false}) do + ["#{key}: false"] + end defp serialize_field({key, true}) do ["#{key}: true"] diff --git a/lib/bds/maintenance.ex b/lib/bds/maintenance.ex index 34b8a97..8e8bdb5 100644 --- a/lib/bds/maintenance.ex +++ b/lib/bds/maintenance.ex @@ -16,6 +16,57 @@ defmodule BDS.Maintenance do alias BDS.Sidecar alias BDS.Templates.Template + def repair_metadata_diff(project_id, direction, items, opts \\ []) + + def repair_metadata_diff(project_id, direction, items, opts) + when is_binary(project_id) and is_list(items) do + on_progress = progress_callback(opts) + total = length(items) + :ok = report_started(on_progress, total, "Repairing metadata differences") + + result = + items + |> Enum.with_index(1) + |> Enum.reduce(%{repaired: 0, failed: 0}, fn {item, index}, acc -> + next_acc = + case repair_metadata_diff_item(project_id, direction, item) do + :ok -> %{acc | repaired: acc.repaired + 1} + {:ok, _value} -> %{acc | repaired: acc.repaired + 1} + _error -> %{acc | failed: acc.failed + 1} + end + + :ok = report_progress(on_progress, index, total, "Repairing metadata differences") + next_acc + end) + + {:ok, result} + end + + def import_metadata_diff_orphans(project_id, orphans, opts \\ []) + + def import_metadata_diff_orphans(project_id, orphans, opts) + when is_binary(project_id) and is_list(orphans) do + on_progress = progress_callback(opts) + total = length(orphans) + :ok = report_started(on_progress, total, "Importing orphan files") + + result = + orphans + |> Enum.with_index(1) + |> Enum.reduce(%{imported: 0, failed: 0}, fn {orphan, index}, acc -> + next_acc = + case import_metadata_diff_orphan(project_id, orphan) do + {:ok, _value} -> %{acc | imported: acc.imported + 1} + _error -> %{acc | failed: acc.failed + 1} + end + + :ok = report_progress(on_progress, index, total, "Importing orphan files") + next_acc + end) + + {:ok, result} + end + def rebuild_from_filesystem(project_id, entity_type, opts \\ []) do case normalize_entity_type(entity_type) do :post -> BDS.Posts.rebuild_posts_from_files(project_id, opts) @@ -547,4 +598,94 @@ defmodule BDS.Maintenance do file_path -> "#{file_path}.#{translation.language}.meta" end end + + defp repair_metadata_diff_item(project_id, direction, item) do + entity_type = Map.get(item, :entity_type) || Map.get(item, "entity_type") + entity_id = Map.get(item, :entity_id) || Map.get(item, "entity_id") + + case {normalize_repair_direction(direction), entity_type} do + {:file_to_db, entity_type} when entity_type in ["project", "categories", "category_meta", "publishing"] -> + Metadata.sync_project_metadata_from_filesystem(project_id) + + {:db_to_file, entity_type} when entity_type in ["project", "categories", "category_meta", "publishing"] -> + Metadata.flush_project_metadata_to_filesystem(project_id) + + {:file_to_db, "post"} -> BDS.Posts.sync_post_from_file(entity_id) + {:db_to_file, "post"} -> BDS.Posts.rewrite_published_post(entity_id) + {:file_to_db, "post_translation"} -> BDS.Posts.sync_post_translation_from_file(entity_id) + {:db_to_file, "post_translation"} -> BDS.Posts.rewrite_published_post_translation(entity_id) + {:file_to_db, "media"} -> BDS.Media.sync_media_from_sidecar(entity_id) + {:db_to_file, "media"} -> BDS.Media.sync_media_sidecar(entity_id) + {:file_to_db, "media_translation"} -> BDS.Media.sync_media_translation_from_sidecar(entity_id) + {:db_to_file, "media_translation"} -> BDS.Media.sync_media_translation_sidecar(entity_id) + {:file_to_db, "script"} -> BDS.Scripts.sync_script_from_file(entity_id) + {:db_to_file, "script"} -> BDS.Scripts.sync_published_script_file(entity_id) + {:file_to_db, "template"} -> BDS.Templates.sync_template_from_file(entity_id) + {:db_to_file, "template"} -> BDS.Templates.sync_published_template_file(entity_id) + _other -> {:error, :unsupported} + end + end + + defp import_metadata_diff_orphan(project_id, orphan) do + file_path = Map.get(orphan, :file_path) || Map.get(orphan, "file_path") + + cond do + is_nil(file_path) -> + {:error, :not_found} + + translation_post_file?(file_path) -> + BDS.Posts.import_orphan_post_translation_file(project_id, file_path) + + String.ends_with?(file_path, ".md") -> + BDS.Posts.import_orphan_post_file(project_id, file_path) + + translation_media_sidecar?(file_path) -> + BDS.Media.import_orphan_media_translation_sidecar(project_id, file_path) + + canonical_media_sidecar?(file_path) and String.ends_with?(file_path, ".meta") -> + BDS.Media.import_orphan_media_sidecar(project_id, file_path) + + String.ends_with?(file_path, ".lua") -> + BDS.Scripts.import_orphan_script_file(project_id, file_path) + + String.ends_with?(file_path, ".liquid") -> + BDS.Templates.import_orphan_template_file(project_id, file_path) + + true -> + {:error, :unsupported} + end + end + + defp normalize_repair_direction(:file_to_db), do: :file_to_db + defp normalize_repair_direction(:db_to_file), do: :db_to_file + defp normalize_repair_direction("file_to_db"), do: :file_to_db + defp normalize_repair_direction("db_to_file"), do: :db_to_file + defp normalize_repair_direction(_direction), do: :unsupported + + defp progress_callback(opts) do + case Keyword.get(opts, :on_progress) do + callback when is_function(callback, 2) -> callback + _other -> nil + end + end + + defp report_started(nil, _total, _label), do: :ok + + defp report_started(callback, 0, label) do + callback.(1.0, label) + :ok + end + + defp report_started(callback, total, label) do + callback.(0.05, "#{label} (0/#{total})") + :ok + end + + defp report_progress(nil, _current, _total, _label), do: :ok + defp report_progress(_callback, _current, 0, _label), do: :ok + + defp report_progress(callback, current, total, label) do + callback.(0.05 + 0.95 * (current / total), "#{label} (#{current}/#{total})") + :ok + end end diff --git a/lib/bds/media.ex b/lib/bds/media.ex index 24a0e96..bdda560 100644 --- a/lib/bds/media.ex +++ b/lib/bds/media.ex @@ -113,6 +113,106 @@ defmodule BDS.Media do end end + def sync_media_from_sidecar(media_id) do + case Repo.get(Media, media_id) do + nil -> + {:error, :not_found} + + %Media{} = media -> + project = Projects.get_project!(media.project_id) + sidecar_path = Path.join(Projects.project_data_dir(project), media.sidecar_path) + + if File.exists?(sidecar_path) do + {:ok, upsert_media_from_sidecar(project, parse_canonical_sidecar(project, sidecar_path), sync_search: true)} + else + {:error, :not_found} + end + end + end + + def sync_media_translation_sidecar(translation_id) do + case Repo.get(Translation, translation_id) do + nil -> + {:error, :not_found} + + %Translation{} = translation -> + media = Repo.get!(Media, translation.translation_for) + project = Projects.get_project!(media.project_id) + :ok = write_translation_sidecar(project, media, translation) + {:ok, translation} + end + end + + def sync_media_translation_from_sidecar(translation_id) do + case Repo.get(Translation, translation_id) do + nil -> + {:error, :not_found} + + %Translation{} = translation -> + media = Repo.get!(Media, translation.translation_for) + project = Projects.get_project!(media.project_id) + sidecar_path = Path.join(Projects.project_data_dir(project), translation_sidecar_path(media, translation.language)) + + if File.exists?(sidecar_path) do + sidecar = parse_translation_sidecar(sidecar_path) + + case upsert_media_translation(media.id, Map.fetch!(sidecar.fields, "language"), %{ + title: Map.get(sidecar.fields, "title"), + alt: Map.get(sidecar.fields, "alt"), + caption: Map.get(sidecar.fields, "caption") + }) do + {:ok, updated_translation} -> {:ok, updated_translation} + error -> error + end + else + {:error, :not_found} + end + end + end + + def import_orphan_media_sidecar(project_id, relative_path) do + project = Projects.get_project!(project_id) + sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) + + if File.exists?(sidecar_path) do + {:ok, upsert_media_from_sidecar(project, parse_canonical_sidecar(project, sidecar_path), sync_search: true)} + else + {:error, :not_found} + end + end + + def import_orphan_media_translation_sidecar(project_id, relative_path) do + project = Projects.get_project!(project_id) + sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) + + if File.exists?(sidecar_path) do + sidecar = parse_translation_sidecar(sidecar_path) + + case Repo.get(Media, Map.get(sidecar.fields, "translationFor")) do + nil -> + {:error, :not_found} + + media -> + case Repo.get_by(Translation, + translation_for: media.id, + language: Map.fetch!(sidecar.fields, "language") + ) do + nil -> + upsert_media_translation(media.id, Map.fetch!(sidecar.fields, "language"), %{ + title: Map.get(sidecar.fields, "title"), + alt: Map.get(sidecar.fields, "alt"), + caption: Map.get(sidecar.fields, "caption") + }) + + _translation -> + {:error, :conflict} + end + end + else + {:error, :not_found} + end + end + def delete_media(media_id) do case Repo.get(Media, media_id) do nil -> diff --git a/lib/bds/metadata.ex b/lib/bds/metadata.ex index 027c804..95cb7db 100644 --- a/lib/bds/metadata.ex +++ b/lib/bds/metadata.ex @@ -167,6 +167,15 @@ defmodule BDS.Metadata do |> unwrap_transaction() end + def flush_project_metadata_to_filesystem(project_id) do + project = Projects.get_project!(project_id) + state = load_state(project) + + write_project_metadata_files(project, state, state) + + {:ok, state} + end + defp update_state(project_id, updater) do project = Projects.get_project!(project_id) state = load_state(project) diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index a978090..bd7f5fa 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -239,6 +239,119 @@ defmodule BDS.Posts do def editor_body(_record), do: "" + def sync_post_from_file(post_id) do + case Repo.get(Post, post_id) do + nil -> + {:error, :not_found} + + %Post{file_path: file_path} when file_path in [nil, ""] -> + {:error, :not_found} + + %Post{} = post -> + project = Projects.get_project!(post.project_id) + full_path = Path.join(Projects.project_data_dir(project), post.file_path) + + if File.exists?(full_path) do + repaired_post = upsert_post_from_file(post.project_id, project, full_path) + :ok = PostLinks.sync_post_links(repaired_post) + {:ok, repaired_post} + else + {:error, :not_found} + end + end + end + + def sync_post_translation_from_file(translation_id) do + case Repo.get(Translation, translation_id) do + nil -> + {:error, :not_found} + + %Translation{file_path: file_path} when file_path in [nil, ""] -> + {:error, :not_found} + + %Translation{} = translation -> + project = Projects.get_project!(translation.project_id) + full_path = Path.join(Projects.project_data_dir(project), translation.file_path) + + if File.exists?(full_path) do + rebuild_file = parse_rebuild_file(project, full_path) + {:ok, upsert_post_translation_from_rebuild_file(translation.project_id, rebuild_file, sync_search: true)} + else + {:error, :not_found} + end + end + end + + def rewrite_published_post_translation(translation_id) do + case Repo.get(Translation, translation_id) do + nil -> + {:error, :not_found} + + %Translation{file_path: file_path, status: status} = translation + when file_path not in [nil, ""] and status == :published -> + post = Repo.get!(Post, translation.translation_for) + :ok = publish_translation(post, translation) + {:ok, Repo.get!(Translation, translation_id)} + + %Translation{} -> + {:error, :not_found} + end + end + + def import_orphan_post_file(project_id, relative_path) do + project = Projects.get_project!(project_id) + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + if File.exists?(full_path) do + rebuild_file = parse_rebuild_file(project, full_path) + + if translation_rebuild_file?(rebuild_file) do + {:error, :unsupported_file} + else + fields = + rebuild_file.fields + |> Map.put("id", unique_post_id(Map.get(rebuild_file.fields, "id"))) + |> Map.put("slug", unique_slug_for_import(project_id, Map.fetch!(rebuild_file.fields, "slug"))) + + {:ok, upsert_post_from_rebuild_file(project_id, %{rebuild_file | fields: fields})} + end + else + {:error, :not_found} + end + end + + def import_orphan_post_translation_file(project_id, relative_path) do + project = Projects.get_project!(project_id) + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + if File.exists?(full_path) do + rebuild_file = parse_rebuild_file(project, full_path) + + if translation_rebuild_file?(rebuild_file) do + source_post_id = Map.fetch!(rebuild_file.fields, "translationFor") + language = normalize_language(Map.fetch!(rebuild_file.fields, "language")) + + case Repo.get(Post, source_post_id) do + nil -> + {:error, :not_found} + + %Post{} = post -> + if normalize_language(post.language) == language or + Repo.get_by(Translation, translation_for: source_post_id, language: language) do + {:error, :conflict} + else + fields = Map.put(rebuild_file.fields, "id", Ecto.UUID.generate()) + {:ok, upsert_post_translation_from_rebuild_file(project_id, %{rebuild_file | fields: fields}, sync_search: true)} + end + end + else + {:error, :unsupported_file} + end + else + {:error, :not_found} + end + end + def delete_post(post_id) do case Repo.get(Post, post_id) do nil -> @@ -632,6 +745,26 @@ defmodule BDS.Posts do defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) + defp unique_slug_for_import(project_id, slug) do + normalized = default_slug_source(slug) |> Slug.slugify() + + if slug_available?(project_id, normalized) do + normalized + else + find_unique_slug(project_id, normalized, 2) + end + end + + defp unique_post_id(nil), do: Ecto.UUID.generate() + + defp unique_post_id(id) do + if Repo.get(Post, id) || Repo.get(Translation, id) do + Ecto.UUID.generate() + else + id + end + end + defp normalize_title(nil), do: "" defp normalize_title(title), do: title diff --git a/lib/bds/scripts.ex b/lib/bds/scripts.ex index d9bee5f..bf3642f 100644 --- a/lib/bds/scripts.ex +++ b/lib/bds/scripts.ex @@ -140,6 +140,54 @@ defmodule BDS.Scripts do {:ok, scripts} end + def sync_script_from_file(script_id) do + case Repo.get(Script, script_id) do + nil -> + {:error, :not_found} + + %Script{file_path: file_path} when file_path in [nil, ""] -> + {:error, :not_found} + + %Script{} = script -> + project = Projects.get_project!(script.project_id) + full_path = Path.join(Projects.project_data_dir(project), script.file_path) + + if File.exists?(full_path) do + {:ok, upsert_script_from_file(script.project_id, project, full_path)} + else + {:error, :not_found} + end + end + end + + def sync_published_script_file(script_id) do + case Repo.get(Script, script_id) do + nil -> + {:error, :not_found} + + %Script{file_path: file_path, status: status} = script + when file_path not in [nil, ""] and status == :published -> + full_path = full_file_path(script.project_id, script.file_path) + body = published_script_body(script) + :ok = Persistence.atomic_write(full_path, serialize_script_file(script, body)) + {:ok, script} + + %Script{} -> + {:error, :not_found} + end + end + + def import_orphan_script_file(project_id, relative_path) do + project = Projects.get_project!(project_id) + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + if File.exists?(full_path) do + {:ok, upsert_script_from_file(project_id, project, full_path)} + else + {:error, :not_found} + end + end + defp default_entrypoint(:macro), do: "render" defp default_entrypoint(_kind), do: "main" @@ -213,6 +261,21 @@ defmodule BDS.Scripts do end end + defp published_script_body(%Script{content: content}) when is_binary(content), do: content + + defp published_script_body(script) do + case File.read(full_file_path(script.project_id, script.file_path)) do + {:ok, contents} -> + case Frontmatter.parse_document(contents) do + {:ok, %{body: body}} -> body + {:error, _reason} -> "" + end + + {:error, _reason} -> + "" + end + end + defp upsert_script_from_file(project_id, project, path) do contents = File.read!(path) {:ok, %{fields: fields}} = Frontmatter.parse_document(contents) diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index af4846a..d0396d3 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -186,6 +186,53 @@ defmodule BDS.Templates do end end + def sync_template_from_file(template_id) do + case Repo.get(Template, template_id) do + nil -> + {:error, :not_found} + + %Template{file_path: file_path} when file_path in [nil, ""] -> + {:error, :not_found} + + %Template{} = template -> + project = Projects.get_project!(template.project_id) + full_path = Path.join(Projects.project_data_dir(project), template.file_path) + + if File.exists?(full_path) do + {:ok, upsert_template_from_file(template.project_id, project, full_path)} + else + {:error, :not_found} + end + end + end + + def sync_published_template_file(template_id) do + case Repo.get(Template, template_id) do + nil -> + {:error, :not_found} + + %Template{file_path: file_path, status: status} = template + when file_path not in [nil, ""] and status == :published -> + full_path = full_file_path(template.project_id, template.file_path) + :ok = Persistence.atomic_write(full_path, serialize_template_file(template, published_template_body(template))) + {:ok, template} + + %Template{} -> + {:error, :not_found} + end + end + + def import_orphan_template_file(project_id, relative_path) do + project = Projects.get_project!(project_id) + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + if File.exists?(full_path) do + {:ok, upsert_template_from_file(project_id, project, full_path)} + else + {:error, :not_found} + end + end + defp unique_slug(project_id, base_slug, fallback, exclude_id \\ nil) do normalized = if base_slug == "", do: fallback, else: base_slug diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index 748b107..a431202 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -173,9 +173,16 @@ "Metadata": "Metadaten", "Metadata Diff": "Metadaten-Diff", "Metadata diff complete": "Metadaten-Diff abgeschlossen", + "Metadata diff import complete": "Import verwaister Metadaten-Dateien abgeschlossen", + "Metadata diff repair complete": "Metadaten-Diff-Reparatur abgeschlossen", "Metadata flush, diffing, and rebuild hooks still need editor wiring.": "Metadaten-Schreiben, Diffing und Rebuild-Hooks brauchen noch die Editor-Anbindung.", "Comparing database and filesystem metadata": "Vergleicht Datenbank- und Dateisystem-Metadaten", "Database state compared against filesystem metadata": "Datenbankstatus mit Dateisystem-Metadaten verglichen", + "DB to File": "DB nach Datei", + "File to DB": "Datei nach DB", + "Import Metadata Diff Orphans": "Verwaiste Metadaten-Dateien importieren", + "Import Metadata Diff Orphans queued": "Import verwaister Metadaten-Dateien eingereiht", + "Importing orphan files": "Importiert verwaiste Dateien", "Maintenance": "Wartung", "Missing": "Fehlend", "Missing Pages": "Fehlende Seiten", @@ -187,7 +194,10 @@ "No active background tasks": "Keine aktiven Hintergrundaufgaben", "No background tasks running": "Keine Hintergrundaufgaben aktiv", "No items": "Keine Einträge", + "No metadata diff items selected": "Keine Metadaten-Diff-Einträge ausgewählt", "No missing pages": "Keine fehlenden Seiten", + "No orphan files selected": "Keine verwaisten Dateien ausgewählt", + "No repair action available": "Keine Reparaturaktion verfügbar", "No orphan translation files": "Keine verwaisten Übersetzungsdateien", "No shell output yet": "Noch keine Shell-Ausgabe", "Offline": "Offline", @@ -198,6 +208,10 @@ "Open in Browser": "Im Browser öffnen", "Opened URL": "URL geöffnet", "Orphan Files": "Verwaiste Dateien", + "Refreshing metadata diff": "Metadaten-Diff wird aktualisiert", + "Repair Metadata Diff": "Metadaten-Diff reparieren", + "Repair Metadata Diff queued": "Metadaten-Diff-Reparatur eingereiht", + "Repairing metadata differences": "Metadaten-Unterschiede werden repariert", "Orphan Reports": "Berichte zu verwaisten Dateien", "Orphans": "Verwaiste", "Output": "Ausgabe", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index 811739b..1f0c3e9 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -173,9 +173,16 @@ "Metadata": "Metadata", "Metadata Diff": "Metadata Diff", "Metadata diff complete": "Metadata diff complete", + "Metadata diff import complete": "Metadata diff import complete", + "Metadata diff repair complete": "Metadata diff repair complete", "Metadata flush, diffing, and rebuild hooks still need editor wiring.": "Metadata flush, diffing, and rebuild hooks still need editor wiring.", "Comparing database and filesystem metadata": "Comparing database and filesystem metadata", "Database state compared against filesystem metadata": "Database state compared against filesystem metadata", + "DB to File": "DB to File", + "File to DB": "File to DB", + "Import Metadata Diff Orphans": "Import Metadata Diff Orphans", + "Import Metadata Diff Orphans queued": "Import Metadata Diff Orphans queued", + "Importing orphan files": "Importing orphan files", "Maintenance": "Maintenance", "Missing": "Missing", "Missing Pages": "Missing Pages", @@ -187,7 +194,14 @@ "No active background tasks": "No active background tasks", "No background tasks running": "No background tasks running", "No items": "No items", + "No metadata diff items selected": "No metadata diff items selected", "No missing pages": "No missing pages", + "No orphan files selected": "No orphan files selected", + "No repair action available": "No repair action available", + "Refreshing metadata diff": "Refreshing metadata diff", + "Repair Metadata Diff": "Repair Metadata Diff", + "Repair Metadata Diff queued": "Repair Metadata Diff queued", + "Repairing metadata differences": "Repairing metadata differences", "No orphan translation files": "No orphan translation files", "No shell output yet": "No shell output yet", "Offline": "Offline", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index f0fae31..65585fb 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -173,9 +173,16 @@ "Metadata": "Metadatos", "Metadata Diff": "Diff de metadatos", "Metadata diff complete": "Diff de metadatos completado", + "Metadata diff import complete": "Importación de huérfanos del diff de metadatos completada", + "Metadata diff repair complete": "Reparación del diff de metadatos completada", "Metadata flush, diffing, and rebuild hooks still need editor wiring.": "El guardado de metadatos, el diff y los hooks de reconstrucción todavía necesitan la conexión del editor.", "Comparing database and filesystem metadata": "Comparando metadatos de la base de datos y del sistema de archivos", "Database state compared against filesystem metadata": "Estado de la base de datos comparado con los metadatos del sistema de archivos", + "DB to File": "BD a archivo", + "File to DB": "Archivo a BD", + "Import Metadata Diff Orphans": "Importar huérfanos del diff de metadatos", + "Import Metadata Diff Orphans queued": "Importación de huérfanos del diff de metadatos en cola", + "Importing orphan files": "Importando archivos huérfanos", "Maintenance": "Mantenimiento", "Missing": "Faltante", "Missing Pages": "Páginas faltantes", @@ -187,7 +194,14 @@ "No active background tasks": "No hay tareas activas en segundo plano", "No background tasks running": "No hay tareas en segundo plano en ejecución", "No items": "No hay elementos", + "No metadata diff items selected": "No hay elementos del diff de metadatos seleccionados", "No missing pages": "No faltan páginas", + "No orphan files selected": "No hay archivos huérfanos seleccionados", + "No repair action available": "No hay ninguna acción de reparación disponible", + "Refreshing metadata diff": "Actualizando el diff de metadatos", + "Repair Metadata Diff": "Reparar diff de metadatos", + "Repair Metadata Diff queued": "Reparación del diff de metadatos en cola", + "Repairing metadata differences": "Reparando diferencias de metadatos", "No orphan translation files": "No hay archivos de traducción huérfanos", "No shell output yet": "Aún no hay salida del shell", "Offline": "Sin conexión", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index 39de281..3ecf161 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -173,9 +173,16 @@ "Metadata": "Métadonnées", "Metadata Diff": "Diff des métadonnées", "Metadata diff complete": "Diff des métadonnées terminé", + "Metadata diff import complete": "Import des fichiers orphelins du diff des métadonnées terminé", + "Metadata diff repair complete": "Réparation du diff des métadonnées terminée", "Metadata flush, diffing, and rebuild hooks still need editor wiring.": "L’écriture des métadonnées, le diff et les hooks de reconstruction ont encore besoin du câblage de l’éditeur.", "Comparing database and filesystem metadata": "Comparaison des métadonnées entre la base et le système de fichiers", "Database state compared against filesystem metadata": "État de la base comparé aux métadonnées du système de fichiers", + "DB to File": "BD vers fichier", + "File to DB": "Fichier vers BD", + "Import Metadata Diff Orphans": "Importer les fichiers orphelins du diff des métadonnées", + "Import Metadata Diff Orphans queued": "Import des fichiers orphelins du diff des métadonnées mis en file", + "Importing orphan files": "Import des fichiers orphelins", "Maintenance": "Maintenance", "Missing": "Manquant", "Missing Pages": "Pages manquantes", @@ -187,7 +194,14 @@ "No active background tasks": "Aucune tâche d’arrière-plan active", "No background tasks running": "Aucune tâche d’arrière-plan en cours", "No items": "Aucun élément", + "No metadata diff items selected": "Aucun élément du diff des métadonnées sélectionné", "No missing pages": "Aucune page manquante", + "No orphan files selected": "Aucun fichier orphelin sélectionné", + "No repair action available": "Aucune action de réparation disponible", + "Refreshing metadata diff": "Actualisation du diff des métadonnées", + "Repair Metadata Diff": "Réparer le diff des métadonnées", + "Repair Metadata Diff queued": "Réparation du diff des métadonnées mise en file", + "Repairing metadata differences": "Réparation des différences de métadonnées", "No orphan translation files": "Aucun fichier de traduction orphelin", "No shell output yet": "Aucune sortie du shell pour l’instant", "Offline": "Hors ligne", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index e301021..6535851 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -173,9 +173,16 @@ "Metadata": "Metadati", "Metadata Diff": "Diff metadati", "Metadata diff complete": "Diff metadati completato", + "Metadata diff import complete": "Importazione degli orfani del diff dei metadati completata", + "Metadata diff repair complete": "Riparazione del diff dei metadati completata", "Metadata flush, diffing, and rebuild hooks still need editor wiring.": "Il salvataggio dei metadati, il diff e gli hook di ricostruzione hanno ancora bisogno del collegamento nell’editor.", "Comparing database and filesystem metadata": "Confronto tra i metadati del database e del filesystem", "Database state compared against filesystem metadata": "Stato del database confrontato con i metadati del filesystem", + "DB to File": "DB su file", + "File to DB": "File su DB", + "Import Metadata Diff Orphans": "Importa orfani del diff dei metadati", + "Import Metadata Diff Orphans queued": "Importazione degli orfani del diff dei metadati accodata", + "Importing orphan files": "Importazione dei file orfani", "Maintenance": "Manutenzione", "Missing": "Mancante", "Missing Pages": "Pagine mancanti", @@ -187,7 +194,14 @@ "No active background tasks": "Nessuna attività in background attiva", "No background tasks running": "Nessuna attività in background in esecuzione", "No items": "Nessun elemento", + "No metadata diff items selected": "Nessun elemento del diff dei metadati selezionato", "No missing pages": "Nessuna pagina mancante", + "No orphan files selected": "Nessun file orfano selezionato", + "No repair action available": "Nessuna azione di riparazione disponibile", + "Refreshing metadata diff": "Aggiornamento del diff dei metadati", + "Repair Metadata Diff": "Ripara diff metadati", + "Repair Metadata Diff queued": "Riparazione del diff dei metadati accodata", + "Repairing metadata differences": "Riparazione delle differenze dei metadati", "No orphan translation files": "Nessun file di traduzione orfano", "No shell output yet": "Nessun output della shell per ora", "Offline": "Offline", diff --git a/priv/ui/app.css b/priv/ui/app.css index e06783a..d5780bf 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -3501,6 +3501,171 @@ button svg * { text-align: left; } +.metadata-diff-tool { + display: flex; + flex-direction: column; + gap: 16px; +} + +.metadata-diff-tabs { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.metadata-diff-tab, +.metadata-diff-field-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--line, #3c3c3c); + background: var(--panel-2, #252526); + color: inherit; +} + +.metadata-diff-tab.active, +.metadata-diff-field-pill.active { + border-color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 18%, var(--panel-2, #252526)); +} + +.metadata-diff-field-pill { + padding: 4px; + gap: 4px; +} + +.metadata-diff-field-pill-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border: 0; + background: transparent; + color: inherit; + border-radius: 999px; +} + +.metadata-diff-field-pill-actions, +.orphan-files-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.metadata-diff-action-button { + padding: 4px 8px; + border-radius: 999px; +} + +.tab-badge, +.field-pill-count { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--panel-1, #1e1e1e); + border: 1px solid var(--line, #3c3c3c); + font-size: 11px; +} + +.metadata-diff-field-pills, +.metadata-diff-results, +.diff-item-list, +.diff-item-fields { + display: flex; + flex-direction: column; + gap: 12px; +} + +.metadata-diff-field-pills { + flex-direction: row; + flex-wrap: wrap; +} + +.diff-item-card { + border: 1px solid var(--line, #3c3c3c); + border-radius: 12px; + background: var(--panel-2, #252526); + padding: 16px; +} + +.diff-item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.diff-item-meta { + margin-top: 4px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.diff-field-row { + display: grid; + grid-template-columns: minmax(110px, 160px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.diff-field-name { + font-weight: 600; +} + +.diff-field-values { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.diff-field-value { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--line, #3c3c3c); + background: var(--panel-1, #1e1e1e); + min-width: 0; +} + +.diff-source-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--vscode-descriptionForeground); +} + +.orphan-files-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.orphan-files-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.orphan-path { + word-break: break-all; +} + +.metadata-diff-empty { + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; +} + @media (max-width: 1100px) { [data-testid="media-editor"] > .editor-content.media-editor, .setting-row, @@ -3517,6 +3682,11 @@ button svg * { flex-direction: column; } + .diff-field-row, + .diff-field-values { + grid-template-columns: 1fr; + } + [data-testid="media-editor"] .media-details { width: 100%; } diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 3745116..dfb2f20 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -864,18 +864,34 @@ defmodule BDS.Desktop.ShellLiveTest do %{label: "Orphans", value: "1"} ], payload: %{ - summary: %{diff_count: 1, orphan_count: 1}, + summary: %{diff_count: 3, orphan_count: 2}, diff_reports: [ %{ entity_type: "post", entity_id: "post-1", differences: [ - %{field: "slug", db_value: "hello-db", file_value: "hello-file"} + %{field: "slug", db_value: "hello-db", file_value: "hello-file"}, + %{field: "title", db_value: "Hello DB", file_value: "Hello File"} + ] + }, + %{ + entity_type: "post_translation", + entity_id: "post-1-de", + differences: [ + %{field: "excerpt", db_value: "Kurz DB", file_value: "Kurz Datei"} + ] + }, + %{ + entity_type: "media", + entity_id: "media-1", + differences: [ + %{field: "alt", db_value: "Alt DB", file_value: "Alt Datei"} ] } ], orphan_reports: [ - %{path: "posts/2026/04/orphan.md", entity_type: "post"} + %{path: "posts/2026/04/orphan.md", entity_type: "post"}, + %{path: "media/2026/04/orphan.txt.meta", entity_type: "media"} ] } } @@ -909,10 +925,173 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-tab-type="metadata_diff") assert html =~ "Metadaten-Diff" + assert html =~ ~s(data-testid="metadata-diff-tab") + assert html =~ ~s(data-entity-tab="posts") + assert html =~ ~s(data-entity-tab="media") + assert html =~ ~s(data-testid="metadata-diff-field-pill") + assert html =~ "slug" + assert html =~ "title" assert html =~ "slug" assert html =~ "hello-db" assert html =~ "hello-file" assert html =~ "posts/2026/04/orphan.md" + + html = + view + |> element("[data-testid='metadata-diff-tab'][data-entity-tab='media']") + |> render_click() + + assert html =~ "Alt DB" + assert html =~ "Alt Datei" + refute html =~ "hello-db" + refute html =~ "posts/2026/04/orphan.md" + assert html =~ "media/2026/04/orphan.txt.meta" + + _html = + view + |> element("[data-testid='metadata-diff-tab'][data-entity-tab='posts']") + |> render_click() + + html = + view + |> element("[data-testid='metadata-diff-field-pill'][data-field='slug']") + |> render_click() + + assert html =~ "hello-db" + assert html =~ "hello-file" + refute html =~ "Kurz DB" + refute html =~ "posts/2026/04/orphan.md" + end + + test "metadata diff repair actions queue a repair task and refresh the diff result", %{ + project: project, + temp_dir: temp_dir + } do + :ok = BDS.Tasks.clear_finished() + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Database Post", + content: "Body", + excerpt: "Summary", + language: "en" + }) + + assert {:ok, published_post} = Posts.publish_post(post.id) + + post_path = Path.join(temp_dir, published_post.file_path) + + File.write!( + post_path, + [ + "---", + "id: #{published_post.id}", + "title: Filesystem Post", + "slug: #{published_post.slug}", + "excerpt: Summary", + "status: published", + "language: en", + "createdAt: #{published_post.created_at}", + "updatedAt: #{published_post.updated_at + 1}", + "publishedAt: #{published_post.published_at}", + "tags:", + "categories:", + "---", + "Body", + "" + ] + |> Enum.join("\n") + ) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + assert {:ok, queued} = BDS.Desktop.ShellCommands.execute("metadata_diff") + completed_task!(queued.task_id) + send(view.pid, :refresh_task_status) + + html = render(view) + assert html =~ ~s(data-testid="metadata-diff-repair-button") + assert html =~ ~s(data-field="title") + + existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id)) + + html = + view + |> element("[data-testid='metadata-diff-repair-button'][data-direction='file_to_db'][data-field='title']") + |> render_click() + + assert html =~ "Repair Metadata Diff" + + repair_task = new_task!(existing_ids, "Repair Metadata Diff") + completed_task!(repair_task.id) + send(view.pid, :refresh_task_status) + _html = render(view) + + assert Repo.get!(Post, published_post.id).title == "Filesystem Post" + + assert {:ok, diff} = BDS.Maintenance.metadata_diff(project.id) + refute Enum.any?(diff.diff_reports, &(&1.entity_id == published_post.id)) + end + + test "metadata diff orphan import action queues an import task and removes the orphan", %{ + project: project, + temp_dir: temp_dir + } do + :ok = BDS.Tasks.clear_finished() + + orphan_relative_path = Path.join(["posts", "2026", "04", "orphan-post.md"]) + orphan_full_path = Path.join(temp_dir, orphan_relative_path) + File.mkdir_p!(Path.dirname(orphan_full_path)) + + File.write!( + orphan_full_path, + [ + "---", + "id: orphan-post", + "title: Orphan Post", + "slug: orphan-post", + "status: published", + "createdAt: 1", + "updatedAt: 1", + "publishedAt: 1", + "tags:", + "categories:", + "---", + "Orphan body", + "" + ] + |> Enum.join("\n") + ) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + assert {:ok, queued} = BDS.Desktop.ShellCommands.execute("metadata_diff") + completed_task!(queued.task_id) + send(view.pid, :refresh_task_status) + + html = render(view) + assert html =~ ~s(data-testid="metadata-diff-import-button") + assert html =~ orphan_relative_path + + existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id)) + + html = + view + |> element("[data-testid='metadata-diff-import-button']") + |> render_click() + + assert html =~ "Import Metadata Diff Orphans" + + import_task = new_task!(existing_ids, "Import Metadata Diff Orphans") + completed_task!(import_task.id) + send(view.pid, :refresh_task_status) + _html = render(view) + + assert Repo.get_by(Post, project_id: project.id, file_path: orphan_relative_path) + + assert {:ok, diff} = BDS.Maintenance.metadata_diff(project.id) + refute orphan_relative_path in Enum.map(diff.orphan_reports, & &1.file_path) end test "post tabs render a real editor and drive save publish discard flows", %{project: project} do @@ -1053,6 +1232,36 @@ defmodule BDS.Desktop.ShellLiveTest do assert discarded_post.title == "Updated Shell Post" end + defp completed_task!(task_id, attempts \\ 50) + + defp completed_task!(_task_id, 0), do: flunk("task did not complete in time") + + defp completed_task!(task_id, attempts) do + case Enum.find(BDS.Tasks.list_tasks(), &(&1.id == task_id and &1.status == :completed)) do + nil -> + Process.sleep(20) + completed_task!(task_id, attempts - 1) + + task -> + task + end + end + + defp new_task!(existing_ids, name, attempts \\ 50) + + defp new_task!(_existing_ids, _name, 0), do: flunk("new task was not created in time") + + defp new_task!(existing_ids, name, attempts) do + case Enum.find(BDS.Tasks.list_tasks(), &(&1.name == name and not MapSet.member?(existing_ids, &1.id))) do + nil -> + Process.sleep(20) + new_task!(existing_ids, name, attempts - 1) + + task -> + task + end + end + test "published post editor loads body from file and renders markdown-only editor", %{project: project} do {:ok, post} = Posts.create_post(%{ diff --git a/test/bds/maintenance_test.exs b/test/bds/maintenance_test.exs index 4bbfff9..b69fbfa 100644 --- a/test/bds/maintenance_test.exs +++ b/test/bds/maintenance_test.exs @@ -813,6 +813,404 @@ defmodule BDS.MaintenanceTest do end) end + test "repair_metadata_diff syncs supported filesystem metadata back into the database", %{ + project: project, + temp_dir: temp_dir + } do + fixture = seed_metadata_repair_fixture(project, temp_dir) + + File.write!( + Path.join([temp_dir, "meta", "project.json"]), + Jason.encode!(%{ + "name" => "Filesystem Blog", + "description" => "Filesystem description", + "publicUrl" => "https://filesystem.example", + "mainLanguage" => "fr", + "defaultAuthor" => "Filesystem Author", + "maxPostsPerPage" => 12, + "blogmarkCategory" => "notes", + "picoTheme" => "slate", + "semanticSimilarityEnabled" => true, + "blogLanguages" => ["fr", "de"] + }) + ) + + File.write!(Path.join([temp_dir, "meta", "categories.json"]), Jason.encode!(["notes", "updates"])) + + File.write!( + Path.join([temp_dir, "meta", "category-meta.json"]), + Jason.encode!(%{ + "notes" => %{"title" => "Filesystem Notes", "showTitle" => false, "renderInLists" => true} + }) + ) + + File.write!( + Path.join([temp_dir, "meta", "publishing.json"]), + Jason.encode!(%{ + "sshHost" => "files.example", + "sshUser" => "files-user", + "sshRemotePath" => "/srv/files", + "sshMode" => "rsync" + }) + ) + + write_post_frontmatter(fixture.post_path, %{ + "id" => fixture.post.id, + "title" => "Filesystem Post", + "slug" => fixture.post.slug, + "excerpt" => "Filesystem summary", + "status" => "published", + "author" => "Filesystem Writer", + "language" => "fr", + "doNotTranslate" => false, + "templateSlug" => nil, + "createdAt" => fixture.post.created_at, + "updatedAt" => fixture.post.updated_at + 1, + "publishedAt" => fixture.post.published_at, + "tags" => ["beta"], + "categories" => ["updates"] + }, "Filesystem body") + + write_post_frontmatter(fixture.post_translation_path, %{ + "id" => fixture.post_translation.id, + "translationFor" => fixture.post_translation.translation_for, + "language" => fixture.post_translation.language, + "title" => "Datei Beitrag", + "excerpt" => "Datei Zusammenfassung", + "status" => "published", + "createdAt" => fixture.post_translation.created_at, + "updatedAt" => fixture.post_translation.updated_at + 1, + "publishedAt" => fixture.post_translation.published_at + }, "Datei Inhalt") + + File.write!( + fixture.media_sidecar_path, + [ + "id: #{fixture.media.id}", + "originalName: #{fixture.media.original_name}", + "mimeType: #{fixture.media.mime_type}", + "size: #{fixture.media.size}", + "title: Filesystem media title", + "alt: Filesystem alt", + "caption: Filesystem caption", + "author: Filesystem Photographer", + "language: fr", + "createdAt: #{fixture.media.created_at}", + "updatedAt: #{fixture.media.updated_at + 1}", + "tags:", + " - beta", + "" + ] + |> Enum.join("\n") + ) + + File.write!( + fixture.media_translation_sidecar_path, + [ + "translationFor: #{fixture.media.id}", + "language: #{fixture.media_translation.language}", + "title: Datei Medium", + "alt: Datei Alt", + "caption: Datei Bildtext", + "" + ] + |> Enum.join("\n") + ) + + write_script_frontmatter(fixture.script_path, fixture.script, %{ + "title" => "Filesystem Script", + "entrypoint" => "run", + "enabled" => false + }, "function run() return false end") + + write_template_frontmatter(fixture.template_path, fixture.template, %{ + "title" => "Filesystem Template", + "enabled" => false + }, "
Filesystem
") + + items = [ + %{entity_type: "project", entity_id: project.id}, + %{entity_type: "categories", entity_id: project.id}, + %{entity_type: "category_meta", entity_id: project.id}, + %{entity_type: "publishing", entity_id: project.id}, + %{entity_type: "post", entity_id: fixture.post.id}, + %{entity_type: "post_translation", entity_id: fixture.post_translation.id}, + %{entity_type: "media", entity_id: fixture.media.id}, + %{entity_type: "media_translation", entity_id: fixture.media_translation.id}, + %{entity_type: "script", entity_id: fixture.script.id}, + %{entity_type: "template", entity_id: fixture.template.id} + ] + + assert {:ok, %{repaired: 10, failed: 0}} = + BDS.Maintenance.repair_metadata_diff(project.id, "file_to_db", items) + + assert {:ok, metadata} = BDS.Metadata.get_project_metadata(project.id) + assert metadata.name == "Filesystem Blog" + assert metadata.categories == ["notes", "updates"] + assert metadata.category_settings["notes"]["title"] == "Filesystem Notes" + assert metadata.publishing_preferences["ssh_mode"] == "rsync" + + repaired_post = Repo.get!(BDS.Posts.Post, fixture.post.id) + assert repaired_post.title == "Filesystem Post" + assert repaired_post.excerpt == "Filesystem summary" + assert repaired_post.author == "Filesystem Writer" + assert repaired_post.language == "fr" + assert repaired_post.tags == ["beta"] + assert repaired_post.categories == ["updates"] + + repaired_translation = Repo.get!(BDS.Posts.Translation, fixture.post_translation.id) + assert repaired_translation.title == "Datei Beitrag" + assert repaired_translation.excerpt == "Datei Zusammenfassung" + + repaired_media = Repo.get!(BDS.Media.Media, fixture.media.id) + assert repaired_media.title == "Filesystem media title" + assert repaired_media.alt == "Filesystem alt" + assert repaired_media.author == "Filesystem Photographer" + assert repaired_media.language == "fr" + assert repaired_media.tags == ["beta"] + + repaired_media_translation = Repo.get!(BDS.Media.Translation, fixture.media_translation.id) + assert repaired_media_translation.title == "Datei Medium" + assert repaired_media_translation.alt == "Datei Alt" + + repaired_script = Repo.get!(BDS.Scripts.Script, fixture.script.id) + assert repaired_script.title == "Filesystem Script" + assert repaired_script.entrypoint == "run" + refute repaired_script.enabled + + repaired_template = Repo.get!(BDS.Templates.Template, fixture.template.id) + assert repaired_template.title == "Filesystem Template" + refute repaired_template.enabled + end + + test "repair_metadata_diff syncs supported database metadata back into files", %{ + project: project, + temp_dir: temp_dir + } do + fixture = seed_metadata_repair_fixture(project, temp_dir) + + assert {:ok, _metadata} = + BDS.Metadata.update_project_metadata(project.id, %{ + name: "Database Blog", + description: "Database description", + public_url: "https://database.example", + main_language: "en", + default_author: "Database Author", + max_posts_per_page: 25, + blogmark_category: "article", + pico_theme: "blue", + semantic_similarity_enabled: false, + blog_languages: ["en", "de"] + }) + + assert {:ok, _metadata} = + BDS.Metadata.set_publishing_preferences(project.id, %{ + ssh_host: "db.example", + ssh_user: "db-user", + ssh_remote_path: "/srv/db", + ssh_mode: "scp" + }) + + assert {:ok, _metadata} = BDS.Metadata.add_category(project.id, "guides") + assert {:ok, _metadata} = BDS.Metadata.update_category_settings(project.id, "notes", %{title: "DB Notes", show_title: true}) + + from(post in BDS.Posts.Post, where: post.id == ^fixture.post.id) + |> Repo.update_all(set: [ + title: "Database Post", + excerpt: "Database summary", + author: "Database Writer", + language: "en", + tags: ["gamma"], + categories: ["guides"], + updated_at: fixture.post.updated_at + 2 + ]) + + from(translation in BDS.Posts.Translation, where: translation.id == ^fixture.post_translation.id) + |> Repo.update_all(set: [title: "DB Beitrag", excerpt: "DB Zusammenfassung", updated_at: fixture.post_translation.updated_at + 2]) + + from(media in BDS.Media.Media, where: media.id == ^fixture.media.id) + |> Repo.update_all(set: [ + title: "Database media title", + alt: "Database alt", + caption: "Database caption", + author: "Database Photographer", + language: "en", + tags: ["gamma"], + updated_at: fixture.media.updated_at + 2 + ]) + + from(translation in BDS.Media.Translation, where: translation.id == ^fixture.media_translation.id) + |> Repo.update_all(set: [title: "DB Medium", alt: "DB Alt", caption: "DB Bildtext"]) + + from(script in BDS.Scripts.Script, where: script.id == ^fixture.script.id) + |> Repo.update_all(set: [title: "Database Script", entrypoint: "run", enabled: false, updated_at: fixture.script.updated_at + 2]) + + from(template in BDS.Templates.Template, where: template.id == ^fixture.template.id) + |> Repo.update_all(set: [title: "Database Template", enabled: false, updated_at: fixture.template.updated_at + 2]) + + items = [ + %{entity_type: "project", entity_id: project.id}, + %{entity_type: "categories", entity_id: project.id}, + %{entity_type: "category_meta", entity_id: project.id}, + %{entity_type: "publishing", entity_id: project.id}, + %{entity_type: "post", entity_id: fixture.post.id}, + %{entity_type: "post_translation", entity_id: fixture.post_translation.id}, + %{entity_type: "media", entity_id: fixture.media.id}, + %{entity_type: "media_translation", entity_id: fixture.media_translation.id}, + %{entity_type: "script", entity_id: fixture.script.id}, + %{entity_type: "template", entity_id: fixture.template.id} + ] + + assert {:ok, %{repaired: 10, failed: 0}} = + BDS.Maintenance.repair_metadata_diff(project.id, "db_to_file", items) + + project_json = Jason.decode!(File.read!(Path.join([temp_dir, "meta", "project.json"]))) + assert project_json["name"] == "Database Blog" + assert project_json["mainLanguage"] == "en" + + categories_json = Jason.decode!(File.read!(Path.join([temp_dir, "meta", "categories.json"]))) + assert "guides" in categories_json + + category_meta_json = Jason.decode!(File.read!(Path.join([temp_dir, "meta", "category-meta.json"]))) + assert category_meta_json["notes"]["title"] == "DB Notes" + + publishing_json = Jason.decode!(File.read!(Path.join([temp_dir, "meta", "publishing.json"]))) + assert publishing_json["sshMode"] == "scp" + + assert File.read!(fixture.post_path) =~ "title: Database Post" + assert File.read!(fixture.post_path) =~ "excerpt: Database summary" + assert File.read!(fixture.post_translation_path) =~ "title: DB Beitrag" + assert File.read!(fixture.media_sidecar_path) =~ ~s(title: "Database media title") + assert File.read!(fixture.media_translation_sidecar_path) =~ ~s(title: "DB Medium") + assert File.read!(fixture.script_path) =~ "title: Database Script" + assert File.read!(fixture.script_path) =~ "entrypoint: run" + assert File.read!(fixture.template_path) =~ "title: Database Template" + assert File.read!(fixture.template_path) =~ "enabled: false" + end + + test "import_metadata_diff_orphans imports orphan files for supported entity types", %{ + project: project, + temp_dir: temp_dir + } do + fixture = seed_metadata_repair_fixture(project, temp_dir) + + post_orphan_path = "posts/2026/04/orphan-post.md" + post_translation_orphan_path = "posts/2026/04/orphan-post.es.md" + media_orphan_file_path = Path.join([temp_dir, "media", "2026", "04", "orphan.txt"]) + media_orphan_path = "media/2026/04/orphan.txt.meta" + media_translation_orphan_path = "media/2026/04/orphan.txt.es.meta" + script_orphan_path = "scripts/orphan.lua" + template_orphan_path = "templates/orphan-view.liquid" + + File.write!(Path.join(temp_dir, post_orphan_path), [ + "---", + "id: orphan-post", + "title: Orphan Post", + "slug: orphan-post", + "status: published", + "createdAt: 1", + "updatedAt: 1", + "publishedAt: 1", + "tags:", + " - orphan", + "categories:", + " - notes", + "---", + "Orphan body", + "" + ] |> Enum.join("\n")) + + File.write!(Path.join(temp_dir, post_translation_orphan_path), [ + "---", + "id: orphan-post-es", + "translationFor: #{fixture.post.id}", + "language: es", + "title: Verwaister Beitrag", + "excerpt: Verwaiste Zusammenfassung", + "status: published", + "createdAt: 1", + "updatedAt: 1", + "publishedAt: 1", + "---", + "Verwaister Inhalt", + "" + ] |> Enum.join("\n")) + + File.write!(media_orphan_file_path, "orphan media") + + File.write!(Path.join(temp_dir, media_orphan_path), [ + "id: orphan-media", + "originalName: orphan.txt", + "mimeType: text/plain", + "size: 12", + "title: Orphan Media", + "createdAt: 1", + "updatedAt: 1", + "tags:", + " - orphan", + "" + ] |> Enum.join("\n")) + + File.write!(Path.join(temp_dir, media_translation_orphan_path), [ + "translationFor: orphan-media", + "language: es", + "title: Verwaistes Medium", + "alt: Verwaister Alt", + "caption: Verwaister Bildtext", + "" + ] |> Enum.join("\n")) + + File.write!(Path.join(temp_dir, script_orphan_path), [ + "---", + "id: orphan-script", + "projectId: #{project.id}", + "slug: orphan-script", + "title: Orphan Script", + "kind: utility", + "entrypoint: main", + "enabled: true", + "version: 1", + "createdAt: 1", + "updatedAt: 1", + "---", + "function main() return true end", + "" + ] |> Enum.join("\n")) + + File.write!(Path.join(temp_dir, template_orphan_path), [ + "---", + "id: orphan-template", + "projectId: #{project.id}", + "slug: orphan-view", + "title: Orphan View", + "kind: list", + "enabled: true", + "version: 1", + "createdAt: 1", + "updatedAt: 1", + "---", + "
Orphan
", + "" + ] |> Enum.join("\n")) + + assert {:ok, %{imported: 6, failed: 0}} = + BDS.Maintenance.import_metadata_diff_orphans(project.id, [ + %{file_path: post_orphan_path}, + %{file_path: post_translation_orphan_path}, + %{file_path: media_orphan_path}, + %{file_path: media_translation_orphan_path}, + %{file_path: script_orphan_path}, + %{file_path: template_orphan_path} + ]) + + assert Repo.get_by(BDS.Posts.Post, project_id: project.id, file_path: post_orphan_path) + assert Repo.get_by(BDS.Posts.Translation, project_id: project.id, file_path: post_translation_orphan_path) + assert Repo.get_by(BDS.Media.Media, project_id: project.id, sidecar_path: media_orphan_path) + assert Repo.get_by(BDS.Media.Translation, project_id: project.id, translation_for: "orphan-media", language: "es") + assert Repo.get_by(BDS.Scripts.Script, project_id: project.id, file_path: script_orphan_path) + assert Repo.get_by(BDS.Templates.Template, project_id: project.id, file_path: template_orphan_path) + end + defp collect_progress_events(acc \\ []) do receive do {:rebuild_progress, value, message} -> collect_progress_events([{value, message} | acc]) @@ -821,6 +1219,196 @@ defmodule BDS.MaintenanceTest do end end + defp seed_metadata_repair_fixture(project, temp_dir) do + source_path = Path.join(temp_dir, "repair-source.txt") + File.write!(source_path, "repair media") + + assert {:ok, _metadata} = + BDS.Metadata.update_project_metadata(project.id, %{ + name: "Initial Blog", + description: "Initial description", + public_url: "https://initial.example", + main_language: "en", + default_author: "Initial Author", + max_posts_per_page: 10, + blogmark_category: "article", + pico_theme: "amber", + semantic_similarity_enabled: false, + blog_languages: ["en"] + }) + + assert {:ok, _metadata} = BDS.Metadata.add_category(project.id, "notes") + + assert {:ok, _metadata} = + BDS.Metadata.update_category_settings(project.id, "notes", %{ + title: "Initial Notes", + show_title: true, + render_in_lists: true + }) + + assert {:ok, _metadata} = + BDS.Metadata.set_publishing_preferences(project.id, %{ + ssh_host: "initial.example", + ssh_user: "initial-user", + ssh_remote_path: "/srv/initial", + ssh_mode: "scp" + }) + + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Initial Post", + content: "Initial body", + excerpt: "Initial summary", + author: "Initial Writer", + language: "en", + tags: ["alpha"], + categories: ["notes"] + }) + + assert {:ok, published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, post_translation} = + BDS.Posts.upsert_post_translation(published_post.id, "de", %{ + title: "Initial Beitrag", + excerpt: "Initial Zusammenfassung", + content: "Initial Inhalt" + }) + + assert {:ok, _republished_post} = BDS.Posts.publish_post(published_post.id) + published_post_translation = Repo.get!(BDS.Posts.Translation, post_translation.id) + + assert {:ok, media} = + BDS.Media.import_media(%{ + project_id: project.id, + source_path: source_path, + title: "Initial media title", + alt: "Initial alt", + caption: "Initial caption", + author: "Initial Photographer", + language: "en", + tags: ["alpha"] + }) + + assert {:ok, _media_translation} = + BDS.Media.upsert_media_translation(media.id, "de", %{ + title: "Initial Medium", + alt: "Initial Alt", + caption: "Initial Bildtext" + }) + + assert {:ok, script} = + BDS.Scripts.create_script(%{ + project_id: project.id, + title: "Initial Script", + kind: :utility, + entrypoint: "main", + content: "function main() return true end" + }) + + assert {:ok, published_script} = BDS.Scripts.publish_script(script.id) + + assert {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Initial Template", + kind: :list, + content: "
Initial
" + }) + + assert {:ok, published_template} = BDS.Templates.publish_template(template.id) + + %{ + post: Repo.get!(BDS.Posts.Post, published_post.id), + post_path: Path.join(temp_dir, published_post.file_path), + post_translation: published_post_translation, + post_translation_path: Path.join(temp_dir, published_post_translation.file_path), + media: Repo.get!(BDS.Media.Media, media.id), + media_sidecar_path: Path.join(temp_dir, media.sidecar_path), + media_translation: Repo.get_by!(BDS.Media.Translation, translation_for: media.id, language: "de"), + media_translation_sidecar_path: Path.join(temp_dir, "#{media.file_path}.de.meta"), + script: Repo.get!(BDS.Scripts.Script, published_script.id), + script_path: Path.join(temp_dir, published_script.file_path), + template: Repo.get!(BDS.Templates.Template, published_template.id), + template_path: Path.join(temp_dir, published_template.file_path) + } + end + + defp write_post_frontmatter(path, fields, body) do + frontmatter = + [ + "---", + "id: #{fields["id"]}", + if(fields["translationFor"], do: "translationFor: #{fields["translationFor"]}", else: nil), + if(fields["title"], do: "title: #{fields["title"]}", else: nil), + if(fields["slug"], do: "slug: #{fields["slug"]}", else: nil), + if(fields["excerpt"], do: "excerpt: #{fields["excerpt"]}", else: nil), + if(fields["status"], do: "status: #{fields["status"]}", else: nil), + if(fields["author"], do: "author: #{fields["author"]}", else: nil), + if(fields["language"], do: "language: #{fields["language"]}", else: nil), + if(Map.has_key?(fields, "doNotTranslate"), do: "doNotTranslate: #{fields["doNotTranslate"]}", else: nil), + if(Map.has_key?(fields, "templateSlug"), do: "templateSlug: #{fields["templateSlug"] || ""}", else: nil), + "createdAt: #{fields["createdAt"]}", + "updatedAt: #{fields["updatedAt"]}", + if(fields["publishedAt"], do: "publishedAt: #{fields["publishedAt"]}", else: nil), + if(Map.has_key?(fields, "tags"), do: ["tags:" | Enum.map(fields["tags"], &" - #{&1}")], else: nil), + if(Map.has_key?(fields, "categories"), do: ["categories:" | Enum.map(fields["categories"], &" - #{&1}")], else: nil), + "---", + body, + "" + ] + |> List.flatten() + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + + File.write!(path, frontmatter) + end + + defp write_script_frontmatter(path, script, overrides, body) do + File.write!( + path, + [ + "---", + "id: #{script.id}", + "projectId: #{script.project_id}", + "slug: #{script.slug}", + "title: #{Map.get(overrides, "title", script.title)}", + "kind: #{script.kind}", + "entrypoint: #{Map.get(overrides, "entrypoint", script.entrypoint)}", + "enabled: #{Map.get(overrides, "enabled", script.enabled)}", + "version: #{script.version}", + "createdAt: #{script.created_at}", + "updatedAt: #{script.updated_at}", + "---", + body, + "" + ] + |> Enum.join("\n") + ) + end + + defp write_template_frontmatter(path, template, overrides, body) do + File.write!( + path, + [ + "---", + "id: #{template.id}", + "projectId: #{template.project_id}", + "slug: #{template.slug}", + "title: #{Map.get(overrides, "title", template.title)}", + "kind: #{template.kind}", + "enabled: #{Map.get(overrides, "enabled", template.enabled)}", + "version: #{template.version}", + "createdAt: #{template.created_at}", + "updatedAt: #{template.updated_at}", + "---", + body, + "" + ] + |> Enum.join("\n") + ) + end + defp assert_incremental_progress(events) do assert Enum.any?(events, fn {value, _message} -> value > 0.0 and value < 1.0 end) assert Enum.any?(events, fn {value, _message} -> value == 1.0 end)