Files
bDS2/lib/bds/ui/settings_form.ex

503 lines
18 KiB
Elixir

defmodule BDS.UI.SettingsForm do
@moduledoc """
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`, `: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.
"""
use Gettext, backend: BDS.Gettext
import Ecto.Query
alias BDS.Desktop.ShellLive.SettingsEditor.AISettings
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
alias BDS.Desktop.ShellLive.SettingsEditor.MCPConfig
alias BDS.I18n
alias BDS.MCP.AgentConfig
alias BDS.Metadata
alias BDS.Projects
alias BDS.Repo
alias BDS.Settings
alias BDS.Templates.Template
@type field :: %{
key: String.t(),
label: String.t(),
type: :text | :bool | :enum | :info | :action,
value: term(),
options: [String.t()]
}
@type form :: %{section: String.t(), title: String.t(), fields: [field()]}
# ── Load ─────────────────────────────────────────────────────────────────
@spec load(String.t(), String.t()) :: form()
def load("project", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
form("project", dgettext("ui", "Project"), [
text("name", dgettext("ui", "Name"), metadata.name),
text("description", dgettext("ui", "Description"), metadata.description),
text("public_url", dgettext("ui", "Public URL"), metadata.public_url),
enum(
"main_language",
dgettext("ui", "Main Language"),
metadata.main_language || "en",
supported_language_codes()
),
text("default_author", dgettext("ui", "Default Author"), metadata.default_author),
text(
"max_posts_per_page",
dgettext("ui", "Posts per Page"),
Integer.to_string(metadata.max_posts_per_page)
),
text(
"image_import_concurrency",
dgettext("ui", "Image Import Concurrency"),
Integer.to_string(metadata.image_import_concurrency)
),
enum(
"blogmark_category",
dgettext("ui", "Blogmark Category"),
metadata.blogmark_category || List.first(metadata.categories) || "article",
metadata.categories
),
text(
"blog_languages",
dgettext("ui", "Blog Languages (comma-separated)"),
Enum.join(metadata.blog_languages, ", ")
),
bool(
"semantic_similarity_enabled",
dgettext("ui", "Semantic Similarity"),
metadata.semantic_similarity_enabled
)
])
end
def load("editor", _project_id) do
stored = editor_settings()
form("editor", dgettext("ui", "Editor"), [
enum("default_mode", dgettext("ui", "Default Editor Mode"), stored["default_mode"], [
"wysiwyg",
"markdown",
"preview"
]),
enum("diff_view_style", dgettext("ui", "Diff View Style"), stored["diff_view_style"], [
"inline",
"side-by-side"
]),
bool("wrap_long_lines", dgettext("ui", "Wrap Long Lines"), stored["wrap_long_lines"]),
bool(
"hide_unchanged_regions",
dgettext("ui", "Hide Unchanged Regions"),
stored["hide_unchanged_regions"]
)
])
end
def load("content", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
post_templates = [""] ++ template_slugs(project_id, :post)
list_templates = [""] ++ template_slugs(project_id, :list)
category_fields =
metadata
|> ManagedCategories.category_rows()
|> Enum.flat_map(fn row ->
[
info("cat:#{row.name}", "── " <> row.name, ""),
text("cat:#{row.name}:title", dgettext("ui", "Title"), row.title),
bool("cat:#{row.name}:render_in_lists", dgettext("ui", "Render in Lists"), row.render_in_lists),
bool("cat:#{row.name}:show_title", dgettext("ui", "Show Title"), row.show_title),
enum(
"cat:#{row.name}:post_template_slug",
dgettext("ui", "Post Template"),
row.post_template_slug || "",
post_templates
),
enum(
"cat:#{row.name}:list_template_slug",
dgettext("ui", "List Template"),
row.list_template_slug || "",
list_templates
)
]
end)
form(
"content",
dgettext("ui", "Content"),
[text("new_category", dgettext("ui", "New Category"), "")] ++ category_fields
)
end
def load("ai", _project_id) do
stored = AISettings.ai_form(%{})
form("ai", dgettext("ui", "AI"), [
bool("offline_mode", dgettext("ui", "Airplane Mode"), stored["offline_mode"]),
text("system_prompt", dgettext("ui", "System Prompt"), stored["system_prompt"]),
text("online_url", dgettext("ui", "Online Endpoint URL"), stored["online_url"]),
text("online_api_key", dgettext("ui", "Online API Key"), stored["online_api_key"]),
text("online_chat_model", dgettext("ui", "Online Chat Model"), stored["online_chat_model"]),
bool("online_chat_tools", dgettext("ui", "Online Chat Tool Calls"), stored["online_chat_tools"]),
bool(
"online_chat_disable_reasoning",
dgettext("ui", "Online Chat Disable Reasoning"),
stored["online_chat_disable_reasoning"]
),
text("online_title_model", dgettext("ui", "Online Title Model"), stored["online_title_model"]),
text(
"online_image_analysis_model",
dgettext("ui", "Online Image Model"),
stored["online_image_analysis_model"]
),
bool("online_chat_images", dgettext("ui", "Online Image Support"), stored["online_chat_images"]),
text("offline_url", dgettext("ui", "Offline Endpoint URL"), stored["offline_url"]),
text("offline_api_key", dgettext("ui", "Offline API Key"), stored["offline_api_key"]),
text("offline_chat_model", dgettext("ui", "Offline Chat Model"), stored["offline_chat_model"]),
bool("offline_chat_tools", dgettext("ui", "Offline Chat Tool Calls"), stored["offline_chat_tools"]),
bool(
"offline_chat_disable_reasoning",
dgettext("ui", "Offline Chat Disable Reasoning"),
stored["offline_chat_disable_reasoning"]
),
text("offline_title_model", dgettext("ui", "Offline Title Model"), stored["offline_title_model"]),
text(
"offline_image_analysis_model",
dgettext("ui", "Offline Image Model"),
stored["offline_image_analysis_model"]
),
bool("offline_chat_images", dgettext("ui", "Offline Image Support"), stored["offline_chat_images"])
])
end
def load("technology", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
form("technology", dgettext("ui", "Technology"), [
bool(
"semantic_similarity_enabled",
dgettext("ui", "Semantic Similarity"),
metadata.semantic_similarity_enabled
)
])
end
def load("publishing", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
prefs = metadata.publishing_preferences
form("publishing", dgettext("ui", "Publishing"), [
text("ssh_host", dgettext("ui", "SSH Host"), Map.get(prefs, "ssh_host", "")),
text("ssh_user", dgettext("ui", "SSH User"), Map.get(prefs, "ssh_user", "")),
text("ssh_remote_path", dgettext("ui", "SSH Remote Path"), Map.get(prefs, "ssh_remote_path", "")),
enum("ssh_mode", dgettext("ui", "SSH Mode"), Map.get(prefs, "ssh_mode", "scp") || "scp", [
"scp",
"rsync"
])
])
end
def load("data", project_id) do
project = Projects.get_project(project_id)
data_path = if project, do: Projects.project_data_dir(project), else: ""
form("data", dgettext("ui", "Data"), [
info("data_path", dgettext("ui", "Data Folder"), data_path),
info(
"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
def load("mcp", _project_id) do
fields =
Enum.map(MCPConfig.mcp_rows(), fn row ->
if row.supported? do
bool("mcp:#{row.id}", row.label, row.configured?)
else
info("mcp:#{row.id}", row.label, dgettext("ui", "not supported yet"))
end
end)
form("mcp", dgettext("ui", "MCP"), fields)
end
def load("style", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
current = if metadata.pico_theme in [nil, ""], do: "default", else: metadata.pico_theme
form("style", dgettext("ui", "Style"), [
enum("pico_theme", dgettext("ui", "Theme"), current, Metadata.supported_pico_themes())
])
end
def load(section, _project_id) do
form(section, section, [])
end
# ── Save ─────────────────────────────────────────────────────────────────
@spec save(String.t(), String.t(), %{String.t() => term()}) :: :ok | {:error, term()}
def save("project", project_id, values) do
save_project_metadata(project_id, %{
name: blank_to_nil(values["name"]),
description: blank_to_nil(values["description"]),
public_url: blank_to_nil(values["public_url"]),
main_language: blank_to_nil(values["main_language"]),
default_author: blank_to_nil(values["default_author"]),
max_posts_per_page: parse_integer(values["max_posts_per_page"], 50),
image_import_concurrency: parse_integer(values["image_import_concurrency"], 4),
blogmark_category: blank_to_nil(values["blogmark_category"]),
blog_languages: split_languages(values["blog_languages"]),
semantic_similarity_enabled: values["semantic_similarity_enabled"] == true
})
end
def save("editor", _project_id, values) do
with :ok <- Settings.put_global_setting("ui.preferred_editor_mode", values["default_mode"]),
:ok <- Settings.put_global_setting("ui.git_diff_view_style", values["diff_view_style"]),
:ok <-
Settings.put_global_setting(
"ui.git_diff_word_wrap",
boolean_string(values["wrap_long_lines"])
) do
Settings.put_global_setting(
"ui.git_diff_hide_unchanged_regions",
boolean_string(values["hide_unchanged_regions"])
)
end
end
def save("content", project_id, values) do
with :ok <- maybe_add_category(project_id, values["new_category"]) do
values
|> category_settings_by_name()
|> Enum.reduce_while(:ok, fn {category, settings}, _acc ->
case Metadata.update_category_settings(project_id, category, settings) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
end
def save("ai", _project_id, values) do
AISettings.save_attrs(%{
online_url: blank_to_nil(values["online_url"]),
online_api_key: blank_to_nil(values["online_api_key"]),
online_chat_model: blank_to_nil(values["online_chat_model"]),
online_chat_tools: values["online_chat_tools"] == true,
online_chat_disable_reasoning: values["online_chat_disable_reasoning"] == true,
online_title_model: blank_to_nil(values["online_title_model"]),
online_image_analysis_model: blank_to_nil(values["online_image_analysis_model"]),
online_chat_images: values["online_chat_images"] == true,
offline_url: blank_to_nil(values["offline_url"]),
offline_api_key: blank_to_nil(values["offline_api_key"]),
offline_mode: values["offline_mode"] == true,
offline_chat_model: blank_to_nil(values["offline_chat_model"]),
offline_chat_tools: values["offline_chat_tools"] == true,
offline_chat_disable_reasoning: values["offline_chat_disable_reasoning"] == true,
offline_title_model: blank_to_nil(values["offline_title_model"]),
offline_image_analysis_model: blank_to_nil(values["offline_image_analysis_model"]),
offline_chat_images: values["offline_chat_images"] == true,
system_prompt: to_string(values["system_prompt"] || "")
})
end
def save("technology", project_id, values) do
save_project_metadata(project_id, %{
semantic_similarity_enabled: values["semantic_similarity_enabled"] == true
})
end
def save("publishing", project_id, values) do
case Metadata.set_publishing_preferences(project_id, %{
ssh_host: blank_to_nil(values["ssh_host"]),
ssh_user: blank_to_nil(values["ssh_user"]),
ssh_remote_path: blank_to_nil(values["ssh_remote_path"]),
ssh_mode: values["ssh_mode"] || "scp"
}) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
def save("mcp", _project_id, values) do
MCPConfig.mcp_rows()
|> Enum.filter(& &1.supported?)
|> Enum.reduce_while(:ok, fn row, _acc ->
case toggle_mcp_agent(row, Map.get(values, "mcp:#{row.id}", row.configured?)) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
def save("style", project_id, values) do
save_project_metadata(project_id, %{pico_theme: values["pico_theme"]})
end
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}
defp text(key, label, value),
do: %{key: key, label: label, type: :text, value: to_string(value || ""), options: []}
defp bool(key, label, value),
do: %{key: key, label: label, type: :bool, value: value == true, options: []}
defp enum(key, label, value, options),
do: %{key: key, label: label, type: :enum, value: to_string(value || ""), options: options}
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",
"diff_view_style" => Settings.get_global_setting("ui.git_diff_view_style") || "inline",
"wrap_long_lines" => Settings.get_global_setting("ui.git_diff_word_wrap") == "true",
"hide_unchanged_regions" =>
Settings.get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
}
end
defp supported_language_codes, do: Enum.map(I18n.supported_languages(), & &1.code)
defp template_slugs(project_id, kind) do
Repo.all(
from template in Template,
where: template.project_id == ^project_id and template.kind == ^kind,
order_by: [asc: template.slug],
select: template.slug
)
end
defp save_project_metadata(project_id, overrides) do
case Metadata.update_project_metadata(project_id, overrides) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp maybe_add_category(project_id, name) do
case String.trim(to_string(name || "")) do
"" ->
:ok
category ->
case Metadata.add_category(project_id, category) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
defp category_settings_by_name(values) do
values
|> Enum.flat_map(fn {key, value} ->
case String.split(key, ":", parts: 3) do
["cat", name, property] -> [{name, property, value}]
_other -> []
end
end)
|> Enum.group_by(fn {name, _property, _value} -> name end)
|> Enum.map(fn {name, entries} ->
{name, Map.new(entries, fn {_name, property, value} -> category_setting(property, value) end)}
end)
end
defp category_setting("title", value), do: {:title, blank_to_nil(value)}
defp category_setting("render_in_lists", value), do: {:render_in_lists, value == true}
defp category_setting("show_title", value), do: {:show_title, value == true}
defp category_setting("post_template_slug", value), do: {:post_template_slug, blank_to_nil(value)}
defp category_setting("list_template_slug", value), do: {:list_template_slug, blank_to_nil(value)}
defp toggle_mcp_agent(%{configured?: configured?}, wanted) when wanted == configured?, do: :ok
defp toggle_mcp_agent(%{id: agent_id}, true) do
case AgentConfig.add_to_config(agent_id, install_root: Application.app_dir(:bds)) do
{:ok, _payload} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp toggle_mcp_agent(%{id: agent_id}, _wanted) do
case AgentConfig.remove_from_config(agent_id) do
{:ok, _payload} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp split_languages(value) do
value
|> to_string()
|> String.split(~r/[,\s]+/, trim: true)
|> Enum.uniq()
end
defp parse_integer(value, fallback) do
case Integer.parse(to_string(value || "")) do
{parsed, _rest} -> parsed
:error -> fallback
end
end
defp boolean_string(true), do: "true"
defp boolean_string(_value), do: "false"
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil
trimmed -> trimmed
end
end
end