feat: TUI settings panel on key 6 with per-section preference editors backed by the same GUI settings backends (issue #29)

This commit is contained in:
2026-07-17 08:38:26 +02:00
parent b6b1d16e54
commit 505527ae4f
13 changed files with 2572 additions and 672 deletions

View File

@@ -107,6 +107,24 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp do_save_ai(socket, reload, append_output) do
attrs = ai_attrs(socket.assigns)
case save_attrs(attrs) do
:ok ->
LiveToast.send_toast(:info, dgettext("ui", "AI settings saved."))
socket
|> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
surface_ai_error(socket, reload, append_output, reason)
end
end
# The full save pipeline as a pure function so the TUI settings form
# (BDS.UI.SettingsForm) drives the exact same writes as the GUI editor.
@spec save_attrs(map()) :: :ok | {:error, term()}
def save_attrs(attrs) do
with :ok <-
put_endpoint_preferences(
:online,
@@ -156,15 +174,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
attrs.offline_chat_images
),
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
LiveToast.send_toast(:info, dgettext("ui", "AI settings saved."))
socket
|> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
surface_ai_error(socket, reload, append_output, reason)
:ok
end
end

View File

@@ -49,6 +49,11 @@ defmodule BDS.Metadata do
@typedoc "Attribute map for `update_project_metadata/2`."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@spec supported_pico_themes() :: [String.t()]
def supported_pico_themes do
["default" | @supported_pico_themes |> MapSet.delete("default") |> Enum.sort()]
end
@spec get_project_metadata(String.t()) :: {:ok, metadata_state()}
def get_project_metadata(project_id) do
project = Projects.get_project!(project_id)

View File

@@ -10,7 +10,11 @@ defmodule BDS.TUI do
## Keys
Sidebar focus: `↑/↓`/`j/k` navigate, `enter` open, `n` new post,
`1..5` switch view (posts/media/templates/scripts/tags), `7` git panel
`1..5` switch view (posts/media/templates/scripts/tags), `6` settings
(the GUI preferences sections as sidebar entries; enter opens a
section's form — enter edits a text field in a status-line prompt,
toggles a boolean or cycles an enum, `ctrl+s` saves through the same
backends as the GUI, esc closes), `7` git panel
(changed files plus commit history in the sidebar, scrollable
whole-folder diff in the main area; `c` commit all, `u` pull, `s` push,
enter jumps the diff to the selected file), `r` refresh,
@@ -39,13 +43,14 @@ defmodule BDS.TUI do
alias BDS.Posts
alias BDS.Projects
alias BDS.UI.PostEditor.{Draft, Metadata, Persistence}
alias BDS.UI.SettingsForm
alias BDS.UI.Sidebar
alias ExRatatui.Layout
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Clear, List, Markdown, Paragraph, Tabs, Textarea}
@views ~w(posts media templates scripts tags git)
@views ~w(posts media templates scripts tags settings git)
@git_diff_scroll_step 10
@git_max_diff_lines 5000
@@ -72,6 +77,7 @@ defmodule BDS.TUI do
filters_by_view: %{},
git: nil,
git_commit: nil,
settings_form: nil,
report: nil,
handled_task_ids: nil,
task_polling: false,
@@ -139,6 +145,10 @@ defmodule BDS.TUI do
when search != nil,
do: search_key(key, state)
def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{settings_form: form} = state)
when form != nil,
do: settings_form_key(key, state)
def handle_event(%ExRatatui.Event.Key{code: "esc"}, %{image: image} = state)
when image != nil,
do: {:noreply, %{state | image: nil}}
@@ -159,15 +169,13 @@ defmodule BDS.TUI do
defp sidebar_key(%{code: code}, state) when code in ["up", "k"],
do: {:noreply, move_selection(state, -1)}
defp sidebar_key(%{code: code}, state) when code in ~w(1 2 3 4 5) do
# Settings is panel "6" (issue #29) and git panel "7" (issue #30),
# matching the GUI panel numbering.
defp sidebar_key(%{code: code}, state) when code in ~w(1 2 3 4 5 6 7) do
view = Enum.at(@views, String.to_integer(code) - 1)
{:noreply, load_sidebar(%{state | view: view, selected: 0, image: nil})}
end
# Git is panel "7", matching the GUI panel numbering (issue #30).
defp sidebar_key(%{code: "7"}, state),
do: {:noreply, load_sidebar(%{state | view: "git", selected: 0, image: nil})}
defp sidebar_key(%{code: "c"}, %{view: "git"} = state) do
if state.git do
{:noreply, %{state | git_commit: %{input: ""}}}
@@ -230,6 +238,8 @@ defmodule BDS.TUI do
%{route: "post", id: post_id} -> {:noreply, open_post(state, post_id)}
%{route: "media", id: media_id} -> {:noreply, open_media(state, media_id)}
%{route: "git_file", id: path} -> {:noreply, jump_to_file_diff(state, path)}
%{route: "settings", id: "settings-" <> section} -> {:noreply, open_settings_form(state, section)}
%{route: "style"} -> {:noreply, open_settings_form(state, "style")}
%{id: _id} -> {:noreply, toast(state, view_label(state.view), dgettext("ui", "Editing this item is not available in the terminal UI yet."))}
_other -> {:noreply, state}
end
@@ -397,6 +407,153 @@ defmodule BDS.TUI do
defp format_git_error({:git_failed, output}), do: output
defp format_git_error(reason), do: inspect(reason)
# ── Events: settings form (issue #29) ─────────────────────────────────────
# A settings sidebar entry opens its section as a generic typed-field
# form in the main area, backed by BDS.UI.SettingsForm — the same
# preferences the GUI settings editor operates on.
defp open_settings_form(state, section) do
form = SettingsForm.load(section, state.project_id)
%{
state
| settings_form: %{
section: form.section,
title: form.title,
fields: form.fields,
selected: seek_field(form.fields, 0, 1),
input: nil,
dirty: false
},
image: nil
}
end
# Text fields edit in a status-line prompt (like the commit message):
# enter commits the value into the field, esc cancels just the prompt.
defp settings_form_key(%{code: "esc"}, %{settings_form: %{input: input}} = state)
when input != nil,
do: {:noreply, put_in(state.settings_form.input, nil)}
defp settings_form_key(%{code: "enter"}, %{settings_form: %{input: input} = form} = state)
when input != nil do
fields =
Enum.map(form.fields, fn field ->
if field.key == input.key, do: %{field | value: input.value}, else: field
end)
{:noreply, %{state | settings_form: %{form | fields: fields, input: nil, dirty: true}}}
end
defp settings_form_key(%{code: "backspace"}, %{settings_form: %{input: input}} = state)
when input != nil do
{:noreply, put_in(state.settings_form.input.value, String.slice(input.value, 0..-2//1))}
end
defp settings_form_key(%{code: code, modifiers: []}, %{settings_form: %{input: input}} = state)
when input != nil and byte_size(code) >= 1 do
if String.length(code) == 1 do
{:noreply, put_in(state.settings_form.input.value, input.value <> code)}
else
{:noreply, state}
end
end
defp settings_form_key(_key, %{settings_form: %{input: input}} = state) when input != nil,
do: {:noreply, state}
defp settings_form_key(%{code: "esc"}, state),
do: {:noreply, %{state | settings_form: nil}}
defp settings_form_key(%{code: "s", modifiers: ["ctrl"]}, state),
do: save_settings_form(state)
defp settings_form_key(%{code: code}, state) when code in ["down", "j"],
do: {:noreply, move_field_selection(state, 1)}
defp settings_form_key(%{code: code}, state) when code in ["up", "k"],
do: {:noreply, move_field_selection(state, -1)}
defp settings_form_key(%{code: "enter"}, %{settings_form: form} = state) do
case Enum.at(form.fields, form.selected) do
%{type: :text} = field ->
{:noreply,
put_in(state.settings_form.input, %{key: field.key, label: field.label, value: field.value})}
%{type: :bool} = field ->
{:noreply, update_field(state, field.key, not field.value)}
%{type: :enum, options: options} = field when options != [] ->
index = Enum.find_index(options, &(&1 == field.value)) || 0
{:noreply, update_field(state, field.key, Enum.at(options, rem(index + 1, length(options))))}
_other ->
{:noreply, state}
end
end
defp settings_form_key(_key, state), do: {:noreply, state}
defp update_field(%{settings_form: form} = state, key, value) do
fields =
Enum.map(form.fields, fn field ->
if field.key == key, do: %{field | value: value}, else: field
end)
%{state | settings_form: %{form | fields: fields, dirty: true}}
end
defp save_settings_form(%{settings_form: form} = state) do
values = Map.new(form.fields, fn field -> {field.key, field.value} end)
case SettingsForm.save(form.section, state.project_id, values) do
:ok ->
reloaded = SettingsForm.load(form.section, state.project_id)
state = %{
state
| settings_form: %{
form
| fields: reloaded.fields,
selected: min(form.selected, max(length(reloaded.fields) - 1, 0)),
input: nil,
dirty: false
}
}
{:noreply, toast(state, form.title, dgettext("ui", "Saved."))}
{:error, reason} ->
{:noreply, toast(state, form.title, settings_error(reason))}
end
end
defp settings_error(reason) when is_binary(reason), do: reason
defp settings_error(reason), do: inspect(reason)
# Selection skips read-only :info rows in the given direction.
defp move_field_selection(%{settings_form: form} = state, delta) do
count = length(form.fields)
if count == 0 do
state
else
next = seek_field(form.fields, clamp(form.selected + delta, count), delta)
put_in(state.settings_form.selected, next)
end
end
defp seek_field(fields, index, delta) do
case Enum.at(fields, index) do
%{type: :info} ->
next = clamp(index + sign(delta), length(fields))
if next == index, do: index, else: seek_field(fields, next, delta)
_other ->
index
end
end
# ── Events: sidebar search (vi-style "/") ─────────────────────────────────
# The prompt lives in the status line and filters the current view live
@@ -970,6 +1127,25 @@ defmodule BDS.TUI do
defp list_selected([], _selected), do: nil
defp list_selected(items, selected), do: min(selected, length(items) - 1)
defp main_widgets(%{settings_form: form}, rect) when form != nil do
items = Enum.map(form.fields, &field_line/1)
title =
form.title <>
if(form.dirty, do: "", else: "") <>
"" <> dgettext("ui", "enter edit · ctrl+s save · esc close")
[
{%List{
items: items,
selected: list_selected(items, form.selected),
scroll_padding: 2,
highlight_style: %Style{fg: :black, bg: :cyan},
block: %Block{title: title, borders: [:all]}
}, rect}
]
end
defp main_widgets(%{view: "git", editor: nil, git: nil}, rect) do
[
{%Paragraph{
@@ -1055,8 +1231,11 @@ defmodule BDS.TUI do
end
defp status_widgets(state, rect) do
settings_input = state.settings_form && state.settings_form.input
text =
cond do
settings_input -> settings_input.label <> ": " <> settings_input.value
state.git_commit -> dgettext("ui", "Commit message") <> ": " <> state.git_commit.input
state.search -> "/" <> state.search.input
state.busy -> dgettext("ui", "Working…")
@@ -1315,18 +1494,25 @@ defmodule BDS.TUI do
defp truncate(text) when byte_size(text) > 40, do: String.slice(text, 0, 40) <> ""
defp truncate(text), do: text
defp default_status(%{settings_form: form}) when form != nil,
do:
dgettext(
"ui",
"enter edit/toggle/cycle · ctrl+s save · esc close · ctrl+q quit"
)
defp default_status(%{focus: :sidebar, view: "git"}),
do:
dgettext(
"ui",
"c commit · u pull · s push · enter jump to file · pgup/pgdn scroll diff · 1-5 views · ctrl+q quit"
"c commit · u pull · s push · enter jump to file · pgup/pgdn scroll diff · 1-7 views · ctrl+q quit"
)
defp default_status(%{focus: :sidebar}),
do:
dgettext(
"ui",
"enter open · n new post · 1-5 views · p projects · / search · : commands (:? help) · r refresh · ctrl+q quit"
"enter open · n new post · 1-7 views · p projects · / search · : commands (:? help) · r refresh · ctrl+q quit"
)
defp default_status(%{focus: :editor}),
@@ -1453,6 +1639,12 @@ defmodule BDS.TUI do
end
end
defp field_line(%{type: :bool} = field),
do: if(field.value, do: "[x] ", else: "[ ] ") <> field.label
defp field_line(%{type: :info, value: ""} = field), do: field.label
defp field_line(field), do: "#{field.label}: #{field.value}"
defp item_label(item) do
title =
item[:title] || item[:name] || item[:original_name] || item[:label] || item[:id] || "?"
@@ -1466,6 +1658,7 @@ defmodule BDS.TUI do
defp view_label("templates"), do: dgettext("ui", "Templates")
defp view_label("scripts"), do: dgettext("ui", "Scripts")
defp view_label("tags"), do: dgettext("ui", "Tags")
defp view_label("settings"), do: dgettext("ui", "Settings")
defp view_label("git"), do: dgettext("ui", "Git")
# ── Post editor ──────────────────────────────────────────────────────────

494
lib/bds/ui/settings_form.ex Normal file
View File

@@ -0,0 +1,494 @@
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`) 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,
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.")
)
])
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
# ── 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 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
# Project, technology and style all live in the same metadata record;
# `Metadata.update_project_metadata/2` treats missing keys as nil, so the
# current values are always passed along and only the edits override them.
defp save_project_metadata(project_id, overrides) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
attrs =
metadata
|> Map.take([
:name,
:description,
:public_url,
:main_language,
:default_author,
:max_posts_per_page,
:image_import_concurrency,
:blogmark_category,
:pico_theme,
:semantic_similarity_enabled,
:blog_languages
])
|> Map.merge(overrides)
case Metadata.update_project_metadata(project_id, attrs) 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