697 lines
22 KiB
Elixir
697 lines
22 KiB
Elixir
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),
|
|
on_output: fn text -> IO.puts(text) end
|
|
) 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>"}
|
|
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
|