feat: issue #25 implemented - cli
This commit is contained in:
@@ -12,6 +12,11 @@ defmodule BDS.Application do
|
||||
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
|
||||
end
|
||||
|
||||
# CLI mode (issue #25): a one-shot command run against the shared database.
|
||||
# No HTTP listener, SSH daemon, window, or sync watcher — the CLI is the
|
||||
# external writer the watcher in a concurrently running app listens for.
|
||||
def mode_children(:cli, _env), do: []
|
||||
|
||||
# Local TUI: the headless server plus a TUI on the launching terminal, so
|
||||
# a GUI (via tunnel) and the local terminal can work in parallel. Quitting
|
||||
# this TUI stops the whole VM — the terminal is the app in this mode.
|
||||
|
||||
335
lib/bds/cli.ex
Normal file
335
lib/bds/cli.ex
Normal file
@@ -0,0 +1,335 @@
|
||||
defmodule BDS.CLI do
|
||||
@moduledoc """
|
||||
Command-line interface for managing content in a bDS2 workspace (issue #25).
|
||||
|
||||
Runs inside the release VM (`BDS_MODE=cli`) against the same settings and
|
||||
cache database as the GUI/TUI application; mutations write
|
||||
`BDS.CliSync.Notification` rows so a concurrently running app picks them
|
||||
up through `BDS.CliSync.Watcher`.
|
||||
|
||||
The launcher script (`cli/bin/bds-cli` in the release, installable to
|
||||
`~/.local/bin` via `BDS.CLI.Install`) passes the argv via `BDS_CLI_ARGV`
|
||||
(unit-separator joined) because `bin/bds eval` cannot forward arguments.
|
||||
"""
|
||||
|
||||
alias BDS.CLI.Commands
|
||||
|
||||
@argv_env "BDS_CLI_ARGV"
|
||||
@argv_separator <<0x1F>>
|
||||
|
||||
@doc "Release entry point: run the command from `BDS_CLI_ARGV` and halt."
|
||||
@spec main() :: no_return()
|
||||
def main do
|
||||
System.halt(run(env_argv()))
|
||||
end
|
||||
|
||||
@doc "Decodes the unit-separator-joined argv from the launcher script."
|
||||
@spec env_argv(String.t() | nil) :: [String.t()]
|
||||
def env_argv(value \\ System.get_env(@argv_env))
|
||||
def env_argv(nil), do: []
|
||||
def env_argv(value), do: String.split(value, @argv_separator, trim: true)
|
||||
|
||||
@doc "Parses and executes an argv, returning the process exit code."
|
||||
@spec run([String.t()]) :: non_neg_integer()
|
||||
def run(argv) when is_list(argv) do
|
||||
optimus = optimus()
|
||||
|
||||
case Optimus.parse(optimus, argv) do
|
||||
{:ok, %Optimus.ParseResult{}} ->
|
||||
print_help(optimus, [])
|
||||
0
|
||||
|
||||
{:ok, subcommand_path, parse_result} ->
|
||||
execute(subcommand_path, parse_result)
|
||||
|
||||
:help ->
|
||||
print_help(optimus, [])
|
||||
0
|
||||
|
||||
{:help, subcommand_path} ->
|
||||
print_help(optimus, subcommand_path)
|
||||
0
|
||||
|
||||
:version ->
|
||||
IO.puts(version())
|
||||
0
|
||||
|
||||
{:error, errors} ->
|
||||
print_errors(optimus, [], errors)
|
||||
1
|
||||
|
||||
{:error, subcommand_path, errors} ->
|
||||
print_errors(optimus, subcommand_path, errors)
|
||||
1
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Executes a parsed subcommand, returning the process exit code."
|
||||
@spec execute([atom()], Optimus.ParseResult.t()) :: non_neg_integer()
|
||||
def execute(subcommand_path, parse_result) do
|
||||
{:ok, _apps} = Application.ensure_all_started(:bds)
|
||||
|
||||
subcommand_path
|
||||
|> Commands.dispatch(parse_result)
|
||||
|> report()
|
||||
end
|
||||
|
||||
defp report(:ok), do: 0
|
||||
|
||||
defp report({:ok, message}) when is_binary(message) do
|
||||
IO.puts(message)
|
||||
0
|
||||
end
|
||||
|
||||
defp report({:error, message}) do
|
||||
IO.puts(:stderr, "Error: " <> format_error(message))
|
||||
1
|
||||
end
|
||||
|
||||
defp format_error(message) when is_binary(message), do: message
|
||||
defp format_error(%{message: message}) when is_binary(message), do: message
|
||||
defp format_error(other), do: inspect(other)
|
||||
|
||||
@repair_parts ~w(post-links media-links thumbnails embeddings search)
|
||||
|
||||
@doc "The Optimus command definition (public for tests and help rendering)."
|
||||
def optimus do
|
||||
Optimus.new!(
|
||||
name: "bds-cli",
|
||||
description: "bDS2 workspace CLI",
|
||||
about:
|
||||
"Manages content in a bDS2 workspace using the same settings and " <>
|
||||
"cache database as the desktop application.",
|
||||
version: version(),
|
||||
allow_unknown_args: false,
|
||||
parse_double_dash: true,
|
||||
subcommands: [
|
||||
rebuild: [
|
||||
name: "rebuild",
|
||||
about: "Rebuild the caching database from the workspace files",
|
||||
flags: [
|
||||
incremental: [
|
||||
long: "--incremental",
|
||||
help:
|
||||
"Run a metadata diff and auto-apply every difference from " <>
|
||||
"file to database instead of a full rebuild"
|
||||
]
|
||||
]
|
||||
],
|
||||
repair: [
|
||||
name: "repair",
|
||||
about: "Run one of the standard repair tasks outside the full rebuild",
|
||||
args: [
|
||||
part: [
|
||||
value_name: "PART",
|
||||
help: "One of: " <> Enum.join(@repair_parts, ", "),
|
||||
required: true,
|
||||
parser: &parse_repair_part/1
|
||||
]
|
||||
]
|
||||
],
|
||||
render: [
|
||||
name: "render",
|
||||
about: "Render the blog from the current content",
|
||||
flags: [
|
||||
incremental: [
|
||||
long: "--incremental",
|
||||
help: "Validate the generated output and apply only the differences"
|
||||
],
|
||||
force: [
|
||||
long: "--force",
|
||||
help: "Full re-render ignoring (but updating) stored content hashes"
|
||||
]
|
||||
]
|
||||
],
|
||||
upload: [
|
||||
name: "upload",
|
||||
about: "Upload the rendered site to the configured host (rsync/scp)"
|
||||
],
|
||||
push: [
|
||||
name: "push",
|
||||
about: "Push the project repository to its origin"
|
||||
],
|
||||
pull: [
|
||||
name: "pull",
|
||||
about: "Pull the project repository and update the cache database"
|
||||
],
|
||||
post: [
|
||||
name: "post",
|
||||
about:
|
||||
"Create a post from parameters or from JSON on stdin " <>
|
||||
"(keys: title, content, excerpt, author, language, tags, categories, template)",
|
||||
flags: [
|
||||
stdin: [long: "--stdin", help: "Read post data as JSON from stdin"],
|
||||
no_translate: [
|
||||
long: "--no-translate",
|
||||
help: "Skip automatic translation of the created post"
|
||||
]
|
||||
],
|
||||
options: post_content_options()
|
||||
],
|
||||
media: [
|
||||
name: "media",
|
||||
about:
|
||||
"Import an image with automatically generated title, alt text, " <>
|
||||
"caption and translations",
|
||||
args: [
|
||||
file: [
|
||||
value_name: "FILE",
|
||||
help: "Path of the image file to import",
|
||||
required: true
|
||||
]
|
||||
],
|
||||
options: [
|
||||
language: [
|
||||
long: "--language",
|
||||
value_name: "LANG",
|
||||
help: "Language for the generated texts (defaults to the project main language)"
|
||||
]
|
||||
]
|
||||
],
|
||||
gallery: [
|
||||
name: "gallery",
|
||||
about:
|
||||
"Create a gallery post and import all referenced images as new " <>
|
||||
"media with generated texts and translations",
|
||||
allow_unknown_args: true,
|
||||
flags: [
|
||||
stdin: [
|
||||
long: "--stdin",
|
||||
help: "Read gallery data as JSON from stdin (adds an \"images\" key to post keys)"
|
||||
]
|
||||
],
|
||||
options: post_content_options()
|
||||
],
|
||||
config: [
|
||||
name: "config",
|
||||
about: "Read and write global preference values",
|
||||
subcommands: [
|
||||
get: [
|
||||
name: "get",
|
||||
about: "Print a preference value",
|
||||
args: [key: [value_name: "KEY", help: "Preference key", required: true]]
|
||||
],
|
||||
set: [
|
||||
name: "set",
|
||||
about: "Set a preference value",
|
||||
args: [
|
||||
key: [value_name: "KEY", help: "Preference key", required: true],
|
||||
value: [value_name: "VALUE", help: "New value", required: true]
|
||||
]
|
||||
],
|
||||
list: [
|
||||
name: "list",
|
||||
about: "List all preference keys and values"
|
||||
]
|
||||
]
|
||||
],
|
||||
project: [
|
||||
name: "project",
|
||||
about: "Manage the projects registered in the cache database",
|
||||
subcommands: [
|
||||
list: [
|
||||
name: "list",
|
||||
about: "List all projects"
|
||||
],
|
||||
add: [
|
||||
name: "add",
|
||||
about: "Open a project folder and add it to the cache database",
|
||||
args: [
|
||||
path: [value_name: "PATH", help: "Project data folder", required: true]
|
||||
],
|
||||
options: [
|
||||
name: [
|
||||
long: "--name",
|
||||
value_name: "NAME",
|
||||
help: "Project name (defaults to the folder name)"
|
||||
]
|
||||
]
|
||||
],
|
||||
switch: [
|
||||
name: "switch",
|
||||
about: "Switch the active project",
|
||||
args: [
|
||||
project: [
|
||||
value_name: "PROJECT",
|
||||
help: "Project id, slug, or name",
|
||||
required: true
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
tui: [
|
||||
name: "tui",
|
||||
about: "Open the app in TUI mode for interactive use"
|
||||
],
|
||||
lua: [
|
||||
name: "lua",
|
||||
about: "Run a utility (long-running task) Lua script from the database",
|
||||
allow_unknown_args: true,
|
||||
args: [
|
||||
script: [
|
||||
value_name: "SCRIPT",
|
||||
help: "Script slug in the active project",
|
||||
required: true
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
defp post_content_options do
|
||||
[
|
||||
title: [long: "--title", value_name: "TITLE", help: "Post title"],
|
||||
content: [long: "--content", value_name: "MARKDOWN", help: "Post body (markdown)"],
|
||||
excerpt: [long: "--excerpt", value_name: "TEXT", help: "Post excerpt"],
|
||||
author: [long: "--author", value_name: "NAME", help: "Post author"],
|
||||
language: [
|
||||
long: "--language",
|
||||
value_name: "LANG",
|
||||
help: "Post language (auto-detected from the content when omitted)"
|
||||
],
|
||||
template: [long: "--template", value_name: "SLUG", help: "Template slug"],
|
||||
tags: [long: "--tags", value_name: "TAGS", help: "Comma-separated tags"],
|
||||
categories: [
|
||||
long: "--categories",
|
||||
value_name: "CATEGORIES",
|
||||
help: "Comma-separated categories"
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp parse_repair_part(value) when value in @repair_parts, do: {:ok, value}
|
||||
|
||||
defp parse_repair_part(value),
|
||||
do: {:error, "unknown repair part #{inspect(value)}; expected one of: #{Enum.join(@repair_parts, ", ")}"}
|
||||
|
||||
defp print_help(optimus, subcommand_path) do
|
||||
optimus
|
||||
|> Optimus.Help.help(subcommand_path, terminal_width())
|
||||
|> Enum.each(&IO.puts/1)
|
||||
end
|
||||
|
||||
defp print_errors(optimus, [], errors) do
|
||||
optimus |> Optimus.Errors.format(errors) |> Enum.each(&IO.puts(:stderr, &1))
|
||||
end
|
||||
|
||||
defp print_errors(optimus, subcommand_path, errors) do
|
||||
optimus
|
||||
|> Optimus.Errors.format(subcommand_path, errors)
|
||||
|> Enum.each(&IO.puts(:stderr, &1))
|
||||
end
|
||||
|
||||
defp terminal_width do
|
||||
case Optimus.Term.width() do
|
||||
{:ok, width} -> width
|
||||
_other -> 80
|
||||
end
|
||||
end
|
||||
|
||||
defp version do
|
||||
to_string(Application.spec(:bds, :vsn) || "dev")
|
||||
end
|
||||
end
|
||||
695
lib/bds/cli/commands.ex
Normal file
695
lib/bds/cli/commands.ex
Normal file
@@ -0,0 +1,695 @@
|
||||
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>"}
|
||||
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
|
||||
57
lib/bds/cli/install.ex
Normal file
57
lib/bds/cli/install.ex
Normal file
@@ -0,0 +1,57 @@
|
||||
defmodule BDS.CLI.Install do
|
||||
@moduledoc """
|
||||
Installs the `bds-cli` launcher into `~/.local/bin` (issue #25), used by
|
||||
the install buttons in the GUI and TUI settings.
|
||||
|
||||
The installed file is a two-line shim exec'ing the release's
|
||||
`cli/bin/bds-cli` launcher (shipped via `rel/overlays`), so the CLI always
|
||||
runs the same release — and therefore the same settings and cache
|
||||
database — as the installed application.
|
||||
"""
|
||||
|
||||
@launcher_name "bds-cli"
|
||||
|
||||
@doc """
|
||||
Writes the `~/.local/bin/bds-cli` shim. Returns `{:ok, installed_path}`,
|
||||
or `{:error, :no_release}` when not running from a packaged release (dev
|
||||
runs have no launcher to point at).
|
||||
"""
|
||||
@spec install(keyword()) :: {:ok, Path.t()} | {:error, :no_release | File.posix()}
|
||||
def install(opts \\ []) do
|
||||
bin_dir = Keyword.get(opts, :bin_dir, Path.expand("~/.local/bin"))
|
||||
|
||||
with {:ok, launcher} <- launcher_path(Keyword.get(opts, :release_root)) do
|
||||
installed = Path.join(bin_dir, @launcher_name)
|
||||
|
||||
with :ok <- File.mkdir_p(bin_dir),
|
||||
:ok <- File.write(installed, shim_script(launcher)),
|
||||
:ok <- File.chmod(installed, 0o755) do
|
||||
{:ok, installed}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
The release's CLI launcher script, resolved from `RELEASE_ROOT` (set by
|
||||
release scripts) or the code root dir (the release root when running a
|
||||
release). `{:error, :no_release}` when neither contains the launcher.
|
||||
"""
|
||||
@spec launcher_path(Path.t() | nil) :: {:ok, Path.t()} | {:error, :no_release}
|
||||
def launcher_path(release_root \\ nil) do
|
||||
[release_root, System.get_env("RELEASE_ROOT"), to_string(:code.root_dir())]
|
||||
|> Enum.reject(&(&1 in [nil, ""]))
|
||||
|> Enum.map(&Path.join([&1, "cli", "bin", @launcher_name]))
|
||||
|> Enum.find(&File.regular?/1)
|
||||
|> case do
|
||||
nil -> {:error, :no_release}
|
||||
path -> {:ok, path}
|
||||
end
|
||||
end
|
||||
|
||||
defp shim_script(launcher) do
|
||||
"""
|
||||
#!/bin/sh
|
||||
exec "#{launcher}" "$@"
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -120,7 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
"rebuild_embedding_index",
|
||||
"Rebuild Embedding Index",
|
||||
"Embeddings",
|
||||
fn report -> rebuild_embedding_index_work(project, report) end
|
||||
fn report -> Maintenance.rebuild_embedding_index(project.id, on_progress: report) end
|
||||
)
|
||||
end
|
||||
|
||||
@@ -221,7 +221,7 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
defp dispatch("rebuild_database", project, _params) do
|
||||
group_id = task_group_id("rebuild_database")
|
||||
attrs = %{group_id: group_id, group_name: "Maintenance"}
|
||||
[first_step | remaining_steps] = rebuild_database_steps(project)
|
||||
[first_step | remaining_steps] = Maintenance.full_rebuild_steps(project.id)
|
||||
|
||||
{:ok, posts_task} =
|
||||
Tasks.submit_task(first_step.name, first_step.work, attrs)
|
||||
@@ -692,102 +692,6 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
|> length() > 1
|
||||
end
|
||||
|
||||
defp rebuild_database_steps(project) do
|
||||
[
|
||||
%{
|
||||
name: "Rebuild Posts From Files",
|
||||
work: fn report ->
|
||||
{:ok, posts} =
|
||||
Maintenance.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} =
|
||||
Maintenance.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} =
|
||||
Maintenance.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} =
|
||||
Maintenance.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 = 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_work(project, report) end
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp rebuild_embedding_index_work(project, report) do
|
||||
case Embeddings.rebuild_project(project.id, on_progress: report) do
|
||||
{:ok, rebuilt_post_ids} ->
|
||||
report.(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
|
||||
|
||||
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
||||
|
||||
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
|
||||
|
||||
@@ -15,14 +15,7 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
||||
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
||||
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
main_language = metadata.main_language || language
|
||||
blog_languages = metadata.blog_languages || []
|
||||
|
||||
translate_targets =
|
||||
[main_language | blog_languages]
|
||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||
|> Enum.uniq()
|
||||
|
||||
translate_targets = translate_targets(metadata, language)
|
||||
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
||||
|
||||
tasks =
|
||||
@@ -48,6 +41,36 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
||||
send(parent, {:add_images_complete, length(paths)})
|
||||
end
|
||||
|
||||
@doc """
|
||||
The translation targets for AI-generated media texts: the project main
|
||||
language plus all blog languages, minus the source `language`.
|
||||
"""
|
||||
def translate_targets(metadata, language) do
|
||||
main_language = metadata.main_language || language
|
||||
blog_languages = metadata.blog_languages || []
|
||||
|
||||
[main_language | blog_languages]
|
||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Imports a single file and runs the same best-effort AI enrichment
|
||||
(title/alt/caption plus translations) as the gallery pipeline, without
|
||||
linking the media to a post. Used by the CLI `media` command (issue #25).
|
||||
|
||||
Returns `{:ok, media, display_title}` or `{:error, reason}`.
|
||||
"""
|
||||
def import_and_enrich(path, project_id, language, translate_targets) do
|
||||
case Media.import_media(%{project_id: project_id, source_path: path}) do
|
||||
{:ok, media} ->
|
||||
{:ok, media, enrich_media(media, language, translate_targets)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp drain_tasks(
|
||||
[],
|
||||
tasks,
|
||||
|
||||
@@ -69,6 +69,15 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("install_cli_tool", _params, socket) do
|
||||
case BDS.UI.SettingsForm.run_action("install_cli") do
|
||||
{:ok, message} -> LiveToast.send_toast(:info, message)
|
||||
{:error, message} -> LiveToast.send_toast(:error, message)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("change_settings_search", %{"query" => query}, socket) do
|
||||
socket =
|
||||
socket
|
||||
@@ -302,7 +311,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
query,
|
||||
~w(mcp claude copilot gemini opencode mistral codex agent server)
|
||||
),
|
||||
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)),
|
||||
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem cli)),
|
||||
supported_languages: @supported_languages,
|
||||
protected_categories: ManagedCategories.protected_categories()
|
||||
}
|
||||
|
||||
@@ -381,6 +381,15 @@
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button>
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label class="setting-label"><%= dgettext("ui", "Command Line Tool") %></label>
|
||||
<p class="setting-description"><%= dgettext("ui", "Install the bds-cli tool to ~/.local/bin; it uses the same settings and cache database as the app") %></p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="install_cli_tool" phx-target={@myself}><%= dgettext("ui", "Install CLI Tool") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -142,4 +142,106 @@ defmodule BDS.Maintenance do
|
||||
|
||||
{: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
|
||||
|
||||
@@ -13,15 +13,18 @@ defmodule BDS.Server do
|
||||
@doc """
|
||||
Resolves the boot mode from the `BDS_MODE` environment variable:
|
||||
`"server"` (headless + SSH daemon), `"tui"` (headless + SSH daemon +
|
||||
a TUI attached to the launching terminal), anything else is desktop.
|
||||
a TUI attached to the launching terminal), `"cli"` (headless one-shot
|
||||
command run — no SSH daemon, no HTTP listener, no watcher; issue #25),
|
||||
anything else is desktop.
|
||||
"""
|
||||
@spec mode(String.t() | nil) :: :desktop | :server | :tui
|
||||
@spec mode(String.t() | nil) :: :desktop | :server | :tui | :cli
|
||||
def mode(value \\ System.get_env("BDS_MODE"))
|
||||
|
||||
def mode(value) when is_binary(value) do
|
||||
case String.downcase(value) do
|
||||
"server" -> :server
|
||||
"tui" -> :tui
|
||||
"cli" -> :cli
|
||||
_other -> :desktop
|
||||
end
|
||||
end
|
||||
@@ -41,9 +44,9 @@ defmodule BDS.Server do
|
||||
signal and a local launch always has a graphical session. Windows always
|
||||
boots the GUI.
|
||||
"""
|
||||
@spec effective_mode(:desktop | :server | :tui, {atom(), atom()}, %{
|
||||
@spec effective_mode(:desktop | :server | :tui | :cli, {atom(), atom()}, %{
|
||||
optional(String.t()) => String.t()
|
||||
}) :: :desktop | :server | :tui
|
||||
}) :: :desktop | :server | :tui | :cli
|
||||
def effective_mode(mode \\ mode(), os_type \\ :os.type(), env \\ System.get_env())
|
||||
|
||||
def effective_mode(:desktop, {:unix, :darwin}, env) do
|
||||
@@ -79,11 +82,14 @@ defmodule BDS.Server do
|
||||
Called from `config/runtime.exs`: runtime config is the only hook that
|
||||
runs before dependency applications start, in both `mix run` and releases.
|
||||
"""
|
||||
@spec prepare_boot_env(:desktop | :server | :tui, (-> :ok)) :: :desktop | :server | :tui
|
||||
@spec prepare_boot_env(:desktop | :server | :tui | :cli, (-> :ok)) ::
|
||||
:desktop | :server | :tui | :cli
|
||||
def prepare_boot_env(mode \\ effective_mode(), redirect_logs \\ &redirect_logging_to_file/0) do
|
||||
if mode != :desktop, do: System.put_env("NO_WX", "1")
|
||||
|
||||
if mode == :tui do
|
||||
# CLI mode owns stdout for command output the same way the local TUI owns
|
||||
# the terminal: console logging goes to the rotating file instead.
|
||||
if mode in [:tui, :cli] do
|
||||
Application.put_env(:bumblebee, :progress_bar_enabled, false)
|
||||
redirect_logs.()
|
||||
end
|
||||
@@ -138,7 +144,7 @@ defmodule BDS.Server do
|
||||
and clients arrive through the key-authenticated SSH tunnel, which the
|
||||
per-boot webview token would otherwise lock out.
|
||||
"""
|
||||
@spec desktop_auth_required?(:desktop | :server | :tui) :: boolean()
|
||||
@spec desktop_auth_required?(:desktop | :server | :tui | :cli) :: boolean()
|
||||
def desktop_auth_required?(mode \\ effective_mode())
|
||||
def desktop_auth_required?(:desktop), do: true
|
||||
def desktop_auth_required?(_mode), do: false
|
||||
|
||||
@@ -3,10 +3,17 @@ defmodule BDS.Settings do
|
||||
Persistence layer for global application settings stored as key-value pairs in the database.
|
||||
"""
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.Repo
|
||||
alias BDS.Settings.Setting
|
||||
|
||||
@spec list_global_settings() :: [{String.t(), String.t()}]
|
||||
def list_global_settings do
|
||||
Repo.all(from setting in Setting, order_by: setting.key, select: {setting.key, setting.value})
|
||||
end
|
||||
|
||||
@spec get_global_setting(String.t()) :: String.t() | nil
|
||||
def get_global_setting(key) do
|
||||
case Repo.get(Setting, key) do
|
||||
|
||||
@@ -487,6 +487,15 @@ defmodule BDS.TUI do
|
||||
index = Enum.find_index(options, &(&1 == field.value)) || 0
|
||||
{:noreply, update_field(state, field.key, Enum.at(options, rem(index + 1, length(options))))}
|
||||
|
||||
%{type: :action} = field ->
|
||||
message =
|
||||
case SettingsForm.run_action(field.key) do
|
||||
{:ok, message} -> message
|
||||
{:error, message} -> message
|
||||
end
|
||||
|
||||
{:noreply, toast(state, field.label, message)}
|
||||
|
||||
_other ->
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@@ -3,8 +3,8 @@ defmodule BDS.UI.SettingsForm do
|
||||
Renderer-agnostic preferences forms (issue #29).
|
||||
|
||||
Exposes each settings section of the GUI settings editor as a flat list
|
||||
of typed fields (`:text`, `:bool`, `:enum`, `:info`) that the TUI can
|
||||
render and edit generically. `save/3` writes through the same backends
|
||||
of typed fields (`:text`, `:bool`, `:enum`, `:info`, `:action`) that the
|
||||
TUI can render and edit generically. `save/3` writes through the same backends
|
||||
as the GUI editor — `BDS.Metadata`, `BDS.Settings`, `BDS.AI` and
|
||||
`BDS.MCP.AgentConfig` — so both frontends operate on the same
|
||||
preferences.
|
||||
@@ -28,7 +28,7 @@ defmodule BDS.UI.SettingsForm do
|
||||
@type field :: %{
|
||||
key: String.t(),
|
||||
label: String.t(),
|
||||
type: :text | :bool | :enum | :info,
|
||||
type: :text | :bool | :enum | :info | :action,
|
||||
value: term(),
|
||||
options: [String.t()]
|
||||
}
|
||||
@@ -217,6 +217,11 @@ defmodule BDS.UI.SettingsForm do
|
||||
"data_hint",
|
||||
dgettext("ui", "Maintenance"),
|
||||
dgettext("ui", "Rebuild and maintenance commands are available under the : prompt.")
|
||||
),
|
||||
action(
|
||||
"install_cli",
|
||||
dgettext("ui", "Command Line Tool"),
|
||||
dgettext("ui", "Install CLI Tool")
|
||||
)
|
||||
])
|
||||
end
|
||||
@@ -351,6 +356,28 @@ defmodule BDS.UI.SettingsForm do
|
||||
|
||||
def save(_section, _project_id, _values), do: :ok
|
||||
|
||||
# ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Runs an `:action` field (issue #25) and returns a localized result
|
||||
message for the invoking frontend (GUI toast or TUI status line).
|
||||
"""
|
||||
@spec run_action(String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def run_action("install_cli") do
|
||||
case BDS.CLI.Install.install() do
|
||||
{:ok, path} ->
|
||||
{:ok, dgettext("ui", "CLI tool installed to %{path}", path: path)}
|
||||
|
||||
{:error, :no_release} ->
|
||||
{:error, dgettext("ui", "The CLI tool can only be installed from the packaged application")}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, dgettext("ui", "Installing the CLI tool failed: %{reason}", reason: inspect(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
def run_action(_key), do: {:error, dgettext("ui", "Unknown settings action")}
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
defp form(section, title, fields), do: %{section: section, title: title, fields: fields}
|
||||
@@ -367,6 +394,9 @@ defmodule BDS.UI.SettingsForm do
|
||||
defp info(key, label, value),
|
||||
do: %{key: key, label: label, type: :info, value: value, options: []}
|
||||
|
||||
defp action(key, label, caption),
|
||||
do: %{key: key, label: label, type: :action, value: caption, options: []}
|
||||
|
||||
defp editor_settings do
|
||||
%{
|
||||
"default_mode" => Settings.get_global_setting("ui.preferred_editor_mode") || "markdown",
|
||||
|
||||
Reference in New Issue
Block a user