defmodule BDS.Maintenance do @moduledoc false import BDS.Maintenance.Progress, only: [ progress_callback: 1, report_metadata_diff_phase: 4, report_metadata_diff_complete: 1, report_started: 3, report_progress: 4 ] import BDS.Maintenance.Repair, only: [ normalize_entity_type: 1, normalize_repair_direction: 1, repair_metadata_diff_item: 3, repair_embedding_batch: 5, import_metadata_diff_orphan: 2 ] import BDS.Maintenance.DiffReports, only: [ project_metadata_diff_reports: 1, post_diff_reports: 2, post_translation_diff_reports: 2, media_diff_reports: 2, media_translation_diff_reports: 2, script_diff_reports: 2, template_diff_reports: 2 ] import BDS.Maintenance.FileScan, only: [orphan_reports: 2] alias BDS.Embeddings alias BDS.Projects 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") normalized_direction = normalize_repair_direction(direction) result = case repair_embedding_batch(project_id, normalized_direction, items, on_progress, total) do {:ok, batch_result} -> batch_result :unsupported -> items |> Enum.with_index(1) |> Enum.reduce(%{repaired: 0, failed: 0}, fn {item, index}, acc -> next_acc = case repair_metadata_diff_item(project_id, normalized_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) 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) :media -> BDS.Media.rebuild_media_from_files(project_id, opts) :script -> BDS.Scripts.rebuild_scripts_from_files(project_id, opts) :template -> BDS.Templates.rebuild_templates_from_files(project_id, opts) :embedding -> Embeddings.rebuild_project(project_id, opts) :unsupported -> {:error, :unsupported_entity_type} end end def metadata_diff(project_id, opts \\ []) def metadata_diff(project_id, opts) when is_binary(project_id) and is_list(opts) do project = Projects.get_project!(project_id) on_progress = progress_callback(opts) phases = [ {"Comparing project metadata", fn -> project_metadata_diff_reports(project_id) end}, {"Comparing post metadata", fn -> post_diff_reports(project_id, project) end}, {"Comparing post translations", fn -> post_translation_diff_reports(project_id, project) end}, {"Comparing media metadata", fn -> media_diff_reports(project_id, project) end}, {"Comparing media translations", fn -> media_translation_diff_reports(project_id, project) end}, {"Comparing script metadata", fn -> script_diff_reports(project_id, project) end}, {"Comparing template metadata", fn -> template_diff_reports(project_id, project) end}, {"Comparing embeddings", fn -> Embeddings.diff_reports(project_id) end} ] total_phases = length(phases) + 1 diff_reports = phases |> Enum.with_index(1) |> Enum.flat_map(fn {{label, fun}, index} -> :ok = report_metadata_diff_phase(on_progress, index, total_phases, label) fun.() end) :ok = report_metadata_diff_phase(on_progress, total_phases, total_phases, "Scanning orphan files") orphan_reports = orphan_reports(project_id, project) :ok = report_metadata_diff_complete(on_progress) {:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}} end @doc """ The canonical full-rebuild sequence, shared by the GUI "Rebuild Database" command and the CLI `rebuild` command so the two can never drift. Each step is `%{name: String.t(), work: (report -> result)}` where `report` is a 2-arity progress callback and a `{:error, message}` result marks failure. """ def full_rebuild_steps(project_id) when is_binary(project_id) do [ %{ name: "Rebuild Posts From Files", work: fn report -> {:ok, posts} = rebuild_from_filesystem(project_id, "post", on_progress: report, rebuild_embeddings: false ) report.(1.0, "Post rebuild complete") %{project_id: project_id, counts: %{posts: length(posts)}} end }, %{ name: "Rebuild Media From Files", work: fn report -> {:ok, media} = rebuild_from_filesystem(project_id, "media", on_progress: report) report.(1.0, "Media rebuild complete") %{project_id: project_id, counts: %{media: length(media)}} end }, %{ name: "Rebuild Scripts From Files", work: fn report -> {:ok, scripts} = rebuild_from_filesystem(project_id, "script", on_progress: report) report.(1.0, "Script rebuild complete") %{project_id: project_id, counts: %{scripts: length(scripts)}} end }, %{ name: "Rebuild Templates From Files", work: fn report -> {:ok, templates} = rebuild_from_filesystem(project_id, "template", on_progress: report) report.(1.0, "Template rebuild complete") %{project_id: project_id, counts: %{templates: length(templates)}} end }, %{ name: "Rebuild Post Links", work: fn report -> :ok = BDS.Posts.rebuild_post_links(project_id, on_progress: report) report.(1.0, "Post links rebuilt") %{project_id: project_id} end }, %{ name: "Regenerate Missing Thumbnails", work: fn report -> result = BDS.Media.regenerate_missing_thumbnails(project_id, on_progress: report) report.(1.0, "Missing thumbnails regenerated") Map.put(result, :project_id, project_id) end }, %{ name: "Rebuild Embedding Index", work: fn report -> rebuild_embedding_index(project_id, on_progress: report) end } ] end @doc """ Rebuilds the embedding index for the project, mapping backend failures to a user-facing message (`{:error, message}`). """ def rebuild_embedding_index(project_id, opts \\ []) when is_binary(project_id) do on_progress = progress_callback(opts) case Embeddings.rebuild_project(project_id, on_progress: on_progress) do {:ok, rebuilt_post_ids} -> on_progress.(1.0, "Embedding index rebuilt") %{ project_id: project_id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids) } {:error, reason} -> {:error, embedding_error_message(reason)} end end defp embedding_error_message(reason) do detail = case reason do message when is_binary(message) -> message {:embedding_backend_unavailable, _inner} -> "the embedding service did not start" other -> inspect(other) end "Could not build the embedding index: #{detail}. The model is downloaded on first use, " <> "so check your internet connection — or turn off semantic similarity in Settings." end end