defmodule BDS.CLI.Commands do @moduledoc """ Implementations of the `bds-cli` subcommands (issue #25). Every command runs synchronously against the engine modules the GUI uses and returns `:ok`, `{:ok, message}`, or `{:error, message}`. Mutations write `BDS.CliSync` notification rows so a concurrently running app refreshes its state (see `specs/cli_sync.allium`). """ import Ecto.Query alias BDS.AI alias BDS.CliSync alias BDS.Desktop.ShellLive.GalleryImport alias BDS.Generation alias BDS.Git alias BDS.Maintenance alias BDS.Media alias BDS.Metadata alias BDS.Posts alias BDS.Posts.AutoTranslation alias BDS.Projects alias BDS.Publishing alias BDS.Repo alias BDS.Scripting alias BDS.Scripts alias BDS.Scripts.Script alias BDS.Search alias BDS.Settings alias BDS.Tasks @site_sections [:core, :single, :category, :tag, :date] @bulk_entity_types ~w(post media script template) @gallery_concurrency 2 @poll_interval_ms 250 @doc "Dispatches a parsed subcommand path to its implementation." @spec dispatch([atom()], Optimus.ParseResult.t()) :: :ok | {:ok, String.t()} | {:error, term()} def dispatch([:rebuild], %{flags: %{incremental: true}}), do: with_project(&incremental_rebuild/1) def dispatch([:rebuild], _parse_result), do: with_project(&full_rebuild/1) def dispatch([:repair], %{args: %{part: part}}), do: with_project(&repair(part, &1)) def dispatch([:render], %{flags: %{incremental: true, force: true}}), do: {:error, "--incremental and --force are mutually exclusive"} def dispatch([:render], %{flags: %{incremental: true}}), do: with_project(&incremental_render/1) def dispatch([:render], %{flags: flags}), do: with_project(&full_render(&1, flags[:force] == true)) def dispatch([:upload], _parse_result), do: with_project(&upload/1) def dispatch([:push], _parse_result) do with_project(fn project -> with {:ok, result} <- Git.push(project.id) do print_git_output(result) {:ok, "Pushed"} end end) end def dispatch([:pull], _parse_result) do with_project(fn project -> with {:ok, result} <- Git.pull(project.id) do print_git_output(result) # Update the cache database from the pulled files, like the app's # incremental rebuild: file→db for every difference plus orphans. incremental_rebuild(project) end end) end def dispatch([:post], %{flags: flags, options: options}) do with_project(fn project -> with {:ok, attrs} <- post_attrs(flags, options), {:ok, attrs} <- ensure_language(attrs), {:ok, post} <- create_post(project, attrs) do unless flags[:no_translate], do: translate_post(post) {:ok, "Created post #{post.id} (#{post.slug}, #{post.language})"} end end) end def dispatch([:media], %{args: %{file: file}, options: options}) do with_project(fn project -> with {:ok, metadata} <- Metadata.get_project_metadata(project.id), :ok <- ensure_files_exist([file]) do language = present(options[:language]) || metadata.main_language targets = GalleryImport.translate_targets(metadata, language) case GalleryImport.import_and_enrich(file, project.id, language, targets) do {:ok, media, title} -> :ok = notify("media", media.id, :created) {:ok, "Imported media #{media.id} (#{title})"} {:error, reason} -> {:error, format_reason(reason)} end end end) end def dispatch([:gallery], %{flags: flags, options: options, unknown: unknown}) do with_project(fn project -> with {:ok, attrs, images} <- gallery_attrs(flags, options, unknown), :ok <- ensure_files_exist(images), {:ok, attrs} <- ensure_language(attrs), {:ok, post} <- create_post(project, attrs) do import_gallery_images(project, post, images) :ok = notify("media", "*", :created) :ok = notify("post", post.id, :updated) unless flags[:no_translate], do: translate_post(post) {:ok, "Created gallery post #{post.id} (#{post.slug}) with #{length(images)} images"} end end) end def dispatch([:config, :get], %{args: %{key: key}}) do case Settings.get_global_setting(key) do nil -> {:error, "#{key} is not set"} value -> {:ok, value} end end def dispatch([:config, :set], %{args: %{key: key, value: value}}) do with :ok <- Settings.put_global_setting(key, value) do :ok = notify("setting", key, :updated) {:ok, "#{key} = #{value}"} end end def dispatch([:config, :list], _parse_result) do Settings.list_global_settings() |> Enum.each(fn {key, value} -> IO.puts("#{key}=#{value}") end) :ok end def dispatch([:project, :list], _parse_result) do Enum.each(Projects.list_projects(), fn project -> marker = if project.is_active, do: "* ", else: " " IO.puts("#{marker}#{project.id} #{project.slug} #{project.name}") end) :ok end def dispatch([:project, :add], %{args: %{path: path}, options: options}) do expanded = Path.expand(path) if File.dir?(expanded) do name = present(options[:name]) || Path.basename(expanded) with {:ok, project} <- Projects.create_project(%{name: name, data_path: expanded}) do :ok = notify("project", project.id, :created) {:ok, "Added project #{project.id} (#{project.slug}); switch with: project switch #{project.slug}"} end else {:error, "#{expanded} is not a directory"} end end def dispatch([:project, :switch], %{args: %{project: reference}}) do with {:ok, project} <- resolve_project(reference), {:ok, project} <- Projects.set_active_project(project.id) do :ok = notify("project", project.id, :updated) {:ok, "Active project: #{project.name}"} else {:error, :not_found} -> {:error, "No project matches #{inspect(reference)}"} {:error, reason} -> {:error, format_reason(reason)} end end def dispatch([:tui], _parse_result) do {:error, "The TUI is started by the bds-cli launcher script (BDS_MODE=tui); " <> "it is not available through a direct BDS.CLI invocation"} end def dispatch([:lua], %{args: %{script: slug}, unknown: script_args}) do with_project(fn project -> case Repo.one( from script in Script, where: script.project_id == ^project.id and script.slug == ^slug ) do nil -> {:error, "No script with slug #{inspect(slug)} in the active project"} %Script{kind: kind} when kind != :utility -> {:error, "Script #{inspect(slug)} is a #{kind} script; only utility (task) scripts can be run"} %Script{enabled: false} -> {:error, "Script #{inspect(slug)} is disabled"} script -> run_lua_job(project, script, script_args) end end) end # ── rebuild ─────────────────────────────────────────────────────────────── defp full_rebuild(project) do project.id |> Maintenance.full_rebuild_steps() |> Enum.reduce_while(:ok, fn step, :ok -> IO.puts("==> #{step.name}") case step.work.(progress_printer()) do {:error, message} -> {:halt, {:error, message}} _result -> {:cont, :ok} end end) |> case do :ok -> notify_bulk_change() {:ok, "Rebuild complete"} error -> error end end defp incremental_rebuild(project) do reporter = progress_printer() {:ok, diff} = Maintenance.metadata_diff(project.id, on_progress: reporter) items = Enum.map(diff.diff_reports, &%{entity_type: &1.entity_type, entity_id: &1.entity_id}) orphans = Enum.map(diff.orphan_reports, &%{file_path: &1.file_path}) if items != [] do {:ok, _repair} = Maintenance.repair_metadata_diff(project.id, :file_to_db, items, on_progress: reporter) end if orphans != [] do {:ok, _import} = Maintenance.import_metadata_diff_orphans(project.id, orphans, on_progress: reporter) end if items != [] or orphans != [], do: notify_bulk_change() {:ok, "Applied #{length(items)} differences and imported #{length(orphans)} orphan files from the filesystem"} end # ── repair ──────────────────────────────────────────────────────────────── defp repair("post-links", project) do :ok = Posts.rebuild_post_links(project.id, on_progress: progress_printer()) :ok = notify("post", "*", :updated) {:ok, "Post links rebuilt"} end defp repair("media-links", project) do {:ok, result} = Media.rebuild_media_links(project.id, on_progress: progress_printer()) :ok = notify("media", "*", :updated) {:ok, "Media links rebuilt (#{result.links} links)"} end defp repair("thumbnails", project) do result = Media.regenerate_missing_thumbnails(project.id, on_progress: progress_printer()) :ok = notify("media", "*", :updated) {:ok, "Missing thumbnails regenerated (#{inspect(Map.get(result, :generated, 0))} generated)"} end defp repair("embeddings", project) do case Maintenance.rebuild_embedding_index(project.id, on_progress: progress_printer()) do {:error, message} -> {:error, message} result -> {:ok, "Embedding index rebuilt (#{result.rebuilt_count} posts)"} end end defp repair("search", project) do :ok = Search.reindex_posts(project.id, on_progress: progress_printer()) :ok = Search.reindex_media(project.id, on_progress: progress_printer()) {:ok, "Search text reindexed"} end # ── render ──────────────────────────────────────────────────────────────── defp full_render(project, force?) do render_opts = if force?, do: [force: true], else: [] Enum.each(@site_sections, fn section -> IO.puts("==> Rendering #{section}") {:ok, _result} = Generation.render_site_section( project.id, section, [on_progress: progress_printer()] ++ render_opts ) end) IO.puts("==> Building search index") {:ok, _index} = Generation.build_site_search_index( project.id, [on_progress: progress_printer()] ++ render_opts ) {:ok, "Site rendered"} end defp incremental_render(project) do {:ok, report} = Generation.validate_site(project.id, @site_sections, on_progress: progress_printer()) {:ok, applied} = Generation.apply_validation(project.id, report, on_progress: progress_printer()) {:ok, "Validation applied (#{applied.rendered_url_count} rendered, " <> "#{applied.deleted_url_count} deleted)"} end # ── upload ──────────────────────────────────────────────────────────────── defp upload(project) 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 await_task(job.task_id, "Upload complete") end end 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, "Publishing preferences are incomplete; configure the upload host in Settings"} end end defp upload_credentials(_prefs), do: {:error, "Publishing preferences are incomplete; configure the upload host in Settings"} # ── post / gallery ──────────────────────────────────────────────────────── defp post_attrs(%{stdin: true}, _options) do with {:ok, data} <- read_stdin_json() do json_post_attrs(data) end end defp post_attrs(_flags, options) do case present(options[:title]) do nil -> {:error, "--title is required (or pass --stdin with JSON post data)"} title -> {:ok, %{ title: title, content: options[:content] || "", excerpt: present(options[:excerpt]), author: present(options[:author]), language: present(options[:language]), template_slug: present(options[:template]), tags: split_list(options[:tags]), categories: split_list(options[:categories]) }} end end defp gallery_attrs(%{stdin: true}, _options, _unknown) do with {:ok, data} <- read_stdin_json(), {:ok, attrs} <- json_post_attrs(data) do case Map.get(data, "images") do images when is_list(images) and images != [] -> {:ok, attrs, images} _other -> {:error, "JSON gallery data needs a non-empty \"images\" array"} end end end defp gallery_attrs(flags, options, unknown) do case unknown do [] -> {:error, "Pass the image files as arguments (or use --stdin with JSON gallery data)"} images -> with {:ok, attrs} <- post_attrs(flags, options) do {:ok, attrs, images} end end end defp json_post_attrs(data) when is_map(data) do case present(Map.get(data, "title")) do nil -> {:error, "JSON post data needs a \"title\""} title -> {:ok, %{ title: title, content: Map.get(data, "content") || "", excerpt: present(Map.get(data, "excerpt")), author: present(Map.get(data, "author")), language: present(Map.get(data, "language")), template_slug: present(Map.get(data, "template")), tags: json_list(Map.get(data, "tags")), categories: json_list(Map.get(data, "categories")) }} end end defp json_post_attrs(_data), do: {:error, "JSON post data must be an object"} defp read_stdin_json do case IO.read(:stdio, :eof) do :eof -> {:error, "No JSON data on stdin"} {:error, reason} -> {:error, "Could not read stdin: #{inspect(reason)}"} data -> case Jason.decode(data) do {:ok, decoded} -> {:ok, decoded} {:error, error} -> {:error, "Invalid JSON on stdin: #{Exception.message(error)}"} end end end # Language auto-detection (issue #25): ask the configured AI endpoint — # BDS.AI internally routes to the local model in airplane mode — and fall # back to the offline search heuristic, informing the user (the CLI # equivalent of the airplane-mode toast). defp ensure_language(%{language: language} = attrs) when is_binary(language), do: {:ok, attrs} defp ensure_language(attrs) do text = String.trim("#{attrs.title}\n#{attrs.content}") language = case AI.detect_language(text) do {:ok, %{language_code: language}} when is_binary(language) -> language _other -> IO.puts("AI language detection unavailable; using the offline heuristic") Search.detect_language(text) end {:ok, %{attrs | language: language}} end defp create_post(project, attrs) do case Posts.create_post(Map.put(attrs, :project_id, project.id)) do {:ok, post} -> :ok = notify("post", post.id, :created) {:ok, post} {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset_message(changeset)} end end # Automatic translation mirrors the GUI: schedule the background tasks and # wait for them, so the CLI exits only when the work is done. When nothing # gets scheduled (airplane mode without a local model, or a single-language # blog) the user is told instead of a silent no-op. defp translate_post(post) do before_count = length(active_tasks()) :ok = AutoTranslation.maybe_schedule(post) case length(active_tasks()) - before_count do queued when queued > 0 -> IO.puts("Waiting for #{queued} translation task(s)...") await_active_tasks() :ok = notify("post", post.id, :updated) _none -> IO.puts("Automatic translation was not scheduled (offline, unconfigured AI, or nothing to translate)") end :ok end defp import_gallery_images(project, post, images) do {:ok, metadata} = Metadata.get_project_metadata(project.id) language = post.language || metadata.main_language parent = self() runner = Task.async(fn -> GalleryImport.start( images, project.id, post.id, language, @gallery_concurrency, parent ) end) print_gallery_progress() Task.await(runner, :infinity) end defp print_gallery_progress do receive do {:add_image_processed, title} -> IO.puts("Imported #{title}") print_gallery_progress() {:add_image_error, path, reason} -> IO.puts(:stderr, "Failed to import #{path}: #{format_reason(reason)}") print_gallery_progress() {:add_images_complete, count} -> IO.puts("Processed #{count} image(s)") :ok end end defp ensure_files_exist(paths) do case Enum.reject(paths, &File.regular?/1) do [] -> :ok missing -> {:error, "No such file: #{Enum.join(missing, ", ")}"} end end # ── lua ─────────────────────────────────────────────────────────────────── # Utility ("task") scripts run with the managed-job execution budget # (unlimited time/reductions) but synchronously — the CLI is the one # waiting for the long-running activity. defp run_lua_job(project, script, args) do scripting_config = Application.fetch_env!(:bds, :scripting) case Scripting.execute_project_script( project.id, Scripts.resolved_content(script), script.entrypoint || "main", args, timeout: Keyword.get(scripting_config, :job_timeout, :infinity), max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none) ) do {:ok, nil} -> {:ok, "Script finished"} {:ok, result} -> {:ok, "Script finished: #{format_reason(result)}"} {:error, reason} -> {:error, "Script failed: #{format_reason(reason)}"} end end # ── shared helpers ──────────────────────────────────────────────────────── defp with_project(fun) do case Projects.get_active_project() do nil -> {:error, "No active project selected; use: project switch "} project -> fun.(project) end end defp resolve_project(reference) do projects = Projects.list_projects() found = Enum.find(projects, fn project -> reference in [project.id, project.slug, project.name] end) if found, do: {:ok, found}, else: {:error, :not_found} end defp await_task(task_id, success_message) do case Tasks.get_task(task_id) do nil -> {:error, "Task disappeared before completion"} %{status: :completed} -> {:ok, success_message} %{status: :failed} = task -> {:error, format_reason(task.error || "task failed")} %{message: message} = task -> print_progress(task.progress || 0.0, message) Process.sleep(@poll_interval_ms) await_task(task_id, success_message) end end defp active_tasks do Enum.filter(Tasks.list_tasks(), &(&1.status in [:pending, :running])) end defp await_active_tasks do case active_tasks() do [] -> :ok _tasks -> Process.sleep(@poll_interval_ms) await_active_tasks() end end @doc """ A 2-arity progress callback that prints deduplicated `[ 42%] message` lines to stdout. """ def progress_printer do &print_progress/2 end defp print_progress(value, message) do percent = value |> Kernel.*(100) |> round() |> min(100) |> max(0) line = {percent, message} if Process.get({__MODULE__, :last_progress}) != line do Process.put({__MODULE__, :last_progress}, line) if is_binary(message) and message != "" do IO.puts("[#{String.pad_leading(Integer.to_string(percent), 3)}%] #{message}") end end :ok end defp print_git_output(%{output: output}) when is_binary(output) and output != "" do IO.puts(String.trim_trailing(output)) end defp print_git_output(_result), do: :ok defp notify(entity_type, entity_id, action) do {:ok, _notification} = CliSync.cli_mutation_performed(entity_type, entity_id, action) :ok end # Bulk maintenance touched an unknown set of rows; a wildcard notification # per entity type makes a running app reload its lists. defp notify_bulk_change do Enum.each(@bulk_entity_types, ¬ify(&1, "*", :updated)) end defp split_list(nil), do: [] defp split_list(value) when is_binary(value) do value |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) end defp json_list(value) when is_list(value), do: Enum.map(value, &to_string/1) defp json_list(value) when is_binary(value), do: split_list(value) defp json_list(_value), do: [] defp present(nil), do: nil defp present(value) when is_binary(value) do case String.trim(value) do "" -> nil trimmed -> trimmed end end defp changeset_message(%Ecto.Changeset{} = changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {message, opts} -> Enum.reduce(opts, message, fn {key, value}, acc -> String.replace(acc, "%{#{key}}", to_string(value)) end) end) |> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) end defp format_reason(reason) when is_binary(reason), do: reason defp format_reason(%{message: message}) when is_binary(message), do: message defp format_reason(%{guidance: guidance}) when is_binary(guidance), do: guidance defp format_reason(reason), do: inspect(reason) end