defmodule BDS.Desktop.ShellCommands do @moduledoc false alias BDS.Embeddings alias BDS.Generation alias BDS.Maintenance alias BDS.Metadata alias BDS.Posts alias BDS.Preview alias BDS.Projects alias BDS.Publishing alias BDS.Search alias BDS.Tasks @site_sections [:core, :single, :category, :tag, :date] def execute(action, params \\ %{}) def execute(action, params) when is_atom(action) do execute(Atom.to_string(action), params) end def execute(action, params) when is_binary(action) and is_map(params) do with {:ok, project} <- active_project() do dispatch(action, project, params) end end defp dispatch("open_in_browser", project, _params) do with {:ok, server} <- Preview.start_preview(project.id) do {:ok, %{ kind: "open_url", action: "open_in_browser", title: "Open in Browser", message: "Preview server ready", project_id: project.id, url: preview_url(server) }} end end defp dispatch("preview_post", project, _params) do with {:ok, server} <- Preview.start_preview(project.id) do {:ok, %{ kind: "open_url", action: "preview_post", title: "Preview Post", message: "Preview server ready", project_id: project.id, url: preview_url(server) }} end end defp dispatch("open_data_folder", project, _params) do path = Projects.project_data_dir(project) case open_system_path(path) do :ok -> {:ok, %{ kind: "output", action: "open_data_folder", title: "Open Data Folder", message: path, project_id: project.id, level: "info" }} {:error, reason} -> {:error, %{action: "open_data_folder", message: "#{path}: #{inspect(reason)}"}} end end defp dispatch("reindex_text", project, _params) do queue_task(project, "reindex_text", "Reindex Text", "Search", fn report -> report.(0.2, "Clearing and rebuilding text indexes") :ok = Search.reindex_project(project.id) report.(1.0, "Text indexes rebuilt") %{project_id: project.id} end) end defp dispatch("rebuild_embedding_index", project, _params) do queue_task(project, "rebuild_embedding_index", "Rebuild Embedding Index", "Embeddings", fn report -> report.(0.2, "Rebuilding semantic index") {:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id) report.(1.0, "Embedding index rebuilt") %{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)} end) end defp dispatch("rebuild_database", project, _params) do queue_task(project, "rebuild_database", "Rebuild Database", "Maintenance", fn report -> report.(0.1, "Rebuilding posts") {:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post") report.(0.3, "Rebuilding media") {:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media") report.(0.5, "Rebuilding scripts") {:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script") report.(0.7, "Rebuilding templates") {:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template") report.(0.9, "Rebuilding embeddings") {:ok, embeddings} = Maintenance.rebuild_from_filesystem(project.id, "embedding") report.(1.0, "Database rebuild complete") %{ project_id: project.id, counts: %{ posts: length(posts), media: length(media), scripts: length(scripts), templates: length(templates), embeddings: length(embeddings) } } end) end defp dispatch("generate_sitemap", project, _params) do queue_task(project, "generate_sitemap", "Generate Sitemap", "Generation", fn report -> report.(0.2, "Generating site output") {:ok, generation} = Generation.generate_site(project.id, @site_sections) report.(1.0, "Generated site output") %{project_id: project.id, sections: generation.sections, generated_count: length(generation.generated_files)} end) end defp dispatch("validate_site", project, _params) do with {:ok, report} <- Generation.validate_site(project.id, @site_sections) do {:ok, %{ kind: "open_editor", action: "validate_site", project_id: project.id, route: "site_validation", title: "Site Validation", subtitle: "Generated output checked against expected site files", editorMeta: [ %{label: "Missing", value: Integer.to_string(length(report.missing_pages))}, %{label: "Extra", value: Integer.to_string(length(report.extra_pages))}, %{label: "Stale", value: Integer.to_string(length(report.stale_pages))} ], payload: normalize_site_validation(report) }} end end defp dispatch("metadata_diff", project, _params) do with {:ok, report} <- Maintenance.metadata_diff(project.id) do {:ok, %{ kind: "open_editor", action: "metadata_diff", project_id: project.id, route: "metadata_diff", title: "Metadata Diff", subtitle: "Database state compared against filesystem metadata", editorMeta: [ %{label: "Diffs", value: Integer.to_string(length(report.diff_reports))}, %{label: "Orphans", value: Integer.to_string(length(report.orphan_reports))} ], payload: normalize_metadata_diff(report) }} end end defp dispatch("validate_translations", project, _params) do with {:ok, report} <- Posts.validate_translations(project.id) do {:ok, %{ kind: "open_editor", action: "validate_translations", project_id: project.id, route: "translation_validation", title: "Translation Validation", subtitle: "Published posts checked against required blog languages", editorMeta: [ %{label: "Missing", value: Integer.to_string(length(report.missing))}, %{label: "Orphans", value: Integer.to_string(length(report.orphan_files))}, %{label: "Skipped", value: Integer.to_string(length(report.do_not_translate_posts))} ], payload: normalize_translation_validation(report) }} end end defp dispatch("find_duplicates", project, _params) do with {:ok, pairs} <- Embeddings.find_duplicates(project.id) do {:ok, %{ kind: "open_editor", action: "find_duplicates", project_id: project.id, route: "find_duplicates", title: "Find Duplicates", subtitle: "Potential duplicate posts found via embeddings", editorMeta: [%{label: "Pairs", value: Integer.to_string(length(pairs))}], payload: normalize_duplicate_pairs(pairs) }} end end defp dispatch("upload_site", project, _params) do with {:ok, metadata} <- Metadata.get_project_metadata(project.id), {:ok, credentials} <- upload_credentials(metadata.publishing_preferences), {:ok, job} <- Publishing.upload_site(project.id, credentials) do {:ok, %{ kind: "task_queued", action: "upload_site", title: "Upload Site", message: "Upload queued", project_id: project.id, task_id: job.task_id, publish_job_id: job.id, panel_tab: "tasks" }} end end defp dispatch(action, _project, _params) do {:error, %{action: action, message: "Unsupported shell command"}} end defp queue_task(project, action, title, group_name, work) do {:ok, task} = Tasks.submit_task(title, work, %{ group_id: project.id, group_name: group_name }) {:ok, %{ kind: "task_queued", action: action, title: title, message: "#{title} queued", project_id: project.id, task_id: task.id, panel_tab: "tasks" }} end defp active_project do case Projects.get_active_project() do nil -> {:error, %{message: "No active project selected"}} project -> {:ok, project} end end defp preview_url(server) do "http://#{server.host}:#{server.port}/" end defp normalize_site_validation(report) do %{ summary: %{ missing_count: length(report.missing_pages), extra_count: length(report.extra_pages), stale_count: length(report.stale_pages) }, missing_pages: report.missing_pages, extra_pages: report.extra_pages, stale_pages: report.stale_pages, sections: Enum.map(report.sections, &to_string/1) } end defp normalize_metadata_diff(report) do %{ summary: %{ diff_count: length(report.diff_reports), orphan_count: length(report.orphan_reports) }, diff_reports: Enum.map(report.diff_reports, &stringify_map/1), orphan_reports: Enum.map(report.orphan_reports, &stringify_map/1) } end defp normalize_translation_validation(report) do %{ summary: %{ missing_count: length(report.missing), orphan_count: length(report.orphan_files), do_not_translate_count: length(report.do_not_translate_posts) }, missing: Enum.map(report.missing, &stringify_map/1), orphan_files: report.orphan_files, do_not_translate_posts: report.do_not_translate_posts } end defp normalize_duplicate_pairs(pairs) do %{ summary: %{pair_count: length(pairs)}, pairs: Enum.map(pairs, &stringify_map/1) } end defp stringify_map(map) when is_map(map) do Map.new(map, fn {key, value} -> {to_string(key), stringify_value(value)} end) end 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) defp stringify_value(value), do: value defp upload_credentials(prefs) when is_map(prefs) do credentials = %{ ssh_host: Map.get(prefs, "ssh_host"), ssh_user: Map.get(prefs, "ssh_user"), ssh_remote_path: Map.get(prefs, "ssh_remote_path"), ssh_mode: Map.get(prefs, "ssh_mode") } if Enum.all?([credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path], &is_binary/1) do {:ok, credentials} else {:error, %{action: "upload_site", message: "Publishing preferences are incomplete"}} end end defp open_system_path(path) do {command, args} = case :os.type() do {:unix, :darwin} -> {"open", [path]} {:unix, _other} -> {"xdg-open", [path]} {:win32, _other} -> {"cmd", ["/c", "start", "", path]} end case System.cmd(command, args, stderr_to_stdout: true) do {_output, 0} -> :ok {output, status} -> {:error, {status, String.trim(output)}} end rescue error -> {:error, error} end end