fix: refactorings to remove anti-patterns

This commit is contained in:
2026-06-25 15:42:32 +02:00
parent d78cb5c07b
commit e3e94ac2d9
28 changed files with 1028 additions and 604 deletions

View File

@@ -1,7 +1,15 @@
defmodule BDS.AI do
@moduledoc """
Public interface for AI features — endpoint configuration, secret management,
model catalog access, and dispatching chat and one-shot inference requests.
Stable public interface for AI features.
This facade owns the app-level AI state that other modules are expected to
depend on directly: endpoint configuration, secret management, airplane-mode
gating, and per-feature model preferences, alongside model catalog access and
dispatching chat and one-shot inference requests.
`BDS.AI.Catalog`, `BDS.AI.OneShot`, and `BDS.AI.Chat` are internal
implementation modules behind this boundary. Callers should treat `BDS.AI` as
the durable surface for AI behavior and configuration.
"""
alias BDS.AI.Catalog

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Automation do
@moduledoc false
use GenServer
require Logger
@ready_timeout 60_000
@request_timeout 30_000
@@ -13,7 +14,9 @@ defmodule BDS.Desktop.Automation do
def stop_session(session) do
GenServer.stop(session, :normal, @request_timeout)
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed desktop automation stop_session exit: #{inspect(reason)}")
:ok
end
def snapshot(session) do
@@ -220,7 +223,12 @@ defmodule BDS.Desktop.Automation do
defp safe_driver_request(state, payload) do
driver_request(state, payload)
rescue
_error -> :ok
error ->
Logger.debug(
"swallowed desktop automation safe_driver_request error: #{inspect(error)} payload=#{inspect(payload)}"
)
:ok
end
defp shutdown_driver(state) do
@@ -368,7 +376,9 @@ defmodule BDS.Desktop.Automation do
defp safe_close_port(port) do
Port.close(port)
catch
:error, _reason -> :ok
:error, reason ->
Logger.debug("swallowed desktop automation safe_close_port error: #{inspect(reason)}")
:ok
end
defp normalize_simple_reply("ok"), do: :ok

View File

@@ -66,7 +66,9 @@ defmodule BDS.Desktop.DeepLink do
try do
Desktop.Env.subscribe()
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed deep_link Desktop.Env.subscribe exit: #{inspect(reason)}")
:ok
end
end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.MainWindow do
use GenServer
alias Desktop.Window
require Logger
@window_id __MODULE__
@server_name BDS.Desktop.MainWindow.Watcher
@@ -22,7 +23,9 @@ defmodule BDS.Desktop.MainWindow do
def persist_now(timeout \\ 100) do
GenServer.call(@server_name, :persist_bounds_now, timeout)
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed main_window persist_now exit: #{inspect(reason)}")
:ok
end
def window_options(extra_opts \\ []) do
@@ -169,10 +172,17 @@ defmodule BDS.Desktop.MainWindow do
end
end)
rescue
ErlangError -> nil
FunctionClauseError -> nil
error in ErlangError ->
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
nil
error in FunctionClauseError ->
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
nil
catch
:exit, _reason -> nil
:exit, reason ->
Logger.debug("swallowed main_window restore_bounds exit: #{inspect(reason)}")
nil
end
end

View File

@@ -13,6 +13,7 @@ defmodule BDS.Desktop.ShellLive do
Bridges,
ChatEditor,
GalleryImport,
GitHandler,
ImportEditor,
MediaEditor,
MenuEditor,
@@ -22,6 +23,7 @@ defmodule BDS.Desktop.ShellLive do
SettingsEditor,
SidebarDelete,
TagsEditor,
TabActions,
TemplateEditor
}
@@ -34,13 +36,14 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Desktop.ShellLive.{
ChatSurface,
Layout,
PanelRenderer,
SessionUtil,
ShellCommandRunner,
SidebarCreate,
SocketState,
TabHelpers,
TaskLocalization,
TitlebarMenu
TitlebarMenu,
UrlState
}
import TaskLocalization,
@@ -64,8 +67,6 @@ defmodule BDS.Desktop.ShellLive do
@refresh_interval 1_500
def refresh_interval, do: @refresh_interval
@output_entry_limit 20
@sidebar_filter_events [
"toggle_sidebar_filters",
"toggle_sidebar_archive",
@@ -180,7 +181,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{})
|> reload_shell(workbench)
|> apply_url_params(params)
|> UrlState.apply_params(params)
|> tap(&sync_menu_bar_locale/1)}
end
@@ -208,7 +209,7 @@ defmodule BDS.Desktop.ShellLive do
{:noreply,
socket
|> refresh_sidebar(workbench)
|> push_url_state()}
|> UrlState.push()}
end
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
@@ -241,17 +242,18 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event(event, _params, socket) when event in @git_action_events do
{:noreply, run_git_action(socket, event)}
{:noreply, GitHandler.run_action(socket, event)}
end
def handle_event("git_commit", params, socket) do
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
{:noreply, commit_git(socket, message)}
{:noreply, GitHandler.commit(socket, message)}
end
def handle_event("git_initialize", params, socket) do
remote_url = params |> get_in(["git", "remote_url"]) |> normalize_git_remote_url()
{:noreply, initialize_git(socket, remote_url)}
remote_url = params |> get_in(["git", "remote_url"]) |> GitHandler.normalize_remote_url()
{:noreply, GitHandler.initialize(socket, remote_url)}
end
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
@@ -270,7 +272,7 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
socket = TabActions.auto_save_current_post(socket)
workbench =
Workbench.open_tab(
@@ -286,11 +288,11 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> push_url_state()}
|> UrlState.push()}
end
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
socket = TabActions.auto_save_current_post(socket)
type_atom = BoundedAtoms.editor_route(type, :post)
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
@@ -300,7 +302,7 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> push_url_state()}
|> UrlState.push()}
end
def handle_event(
@@ -722,167 +724,9 @@ defmodule BDS.Desktop.ShellLive do
index(assigns)
end
defp refresh_layout(socket, workbench) do
git_badge_count = socket.assigns[:git_badge_count] || 0
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
defp refresh_layout(socket, workbench), do: SocketState.refresh_layout(socket, workbench)
task_status =
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode = Map.get(socket.assigns, :offline_mode, true)
sidebar_data = socket.assigns[:sidebar_data] || %{}
current_tab = current_tab(workbench)
prev_tab = socket.assigns[:current_tab]
prev_panel_tab =
case socket.assigns[:workbench] do
%Workbench{panel: %{active_tab: tab}} -> tab
_ -> nil
end
socket =
socket
|> assign(:workbench, workbench)
|> assign(:activity_buttons, activity_buttons)
|> assign(
:sidebar_header,
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
)
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:current_tab, current_tab)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(
:status,
ShellData.status_bar(workbench, task_status, dashboard,
ui_language: page_language,
offline_mode: offline_mode
)
)
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
refresh_panel_data(socket)
else
socket
end
end
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
current_tab != prev_tab or panel_tab != prev_panel_tab
end
defp refresh_panel_data(socket) do
panel_tab = socket.assigns.workbench.panel.active_tab
socket
|> assign(
:panel_post_links,
if(panel_tab == :post_links,
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
)
)
|> assign(
:panel_git_entries,
if(panel_tab == :git_log,
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
else: socket.assigns[:panel_git_entries] || []
)
)
end
defp push_url_state(socket) do
workbench = socket.assigns.workbench
params =
%{}
|> put_url_view(workbench.active_view)
|> put_url_tab(workbench.active_tab)
query = URI.encode_query(params)
path = if query == "", do: "/", else: "/?" <> query
push_event(socket, "url-state", %{path: path})
end
defp put_url_view(params, :posts), do: params
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
defp put_url_tab(params, nil), do: params
defp put_url_tab(params, {type, id}),
do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
defp apply_url_params(socket, params) when is_map(params) and map_size(params) > 0 do
workbench = socket.assigns.workbench
workbench = apply_url_view(workbench, Map.get(params, "view"))
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
if workbench == socket.assigns.workbench do
socket
else
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench)
end
end
defp apply_url_params(socket, _params), do: socket
defp apply_url_view(workbench, nil), do: workbench
defp apply_url_view(workbench, view_str) do
view = BoundedAtoms.sidebar_view(view_str, nil)
if view && view != workbench.active_view do
Workbench.click_activity(workbench, view)
else
workbench
end
end
defp apply_url_tab(workbench, nil), do: workbench
defp apply_url_tab(workbench, tab_str) do
case String.split(tab_str, ":", parts: 2) do
[type_str, id] ->
type = BoundedAtoms.editor_route(type_str, nil)
if type && workbench.active_tab != {type, id} do
Workbench.open_tab(workbench, type, id, :preview)
else
workbench
end
_ ->
workbench
end
end
defp refresh_sidebar(socket, workbench) do
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
project_id,
active_view_id,
ShellSidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
defp refresh_content(socket, workbench) do
projects =
@@ -971,12 +815,6 @@ defmodule BDS.Desktop.ShellLive do
defp activity_label("Source Control"), do: dgettext("ui", "Git")
defp activity_label(label), do: label
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp sidebar_header_label(label), do: label
defp timeline_height(entry, entries) do
@@ -988,12 +826,6 @@ defmodule BDS.Desktop.ShellLive do
max(4, (entry.count || 0) / max_count * 100)
end
defp current_tab(%{active_tab: nil}), do: nil
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
defp create_sidebar_item(socket, kind),
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
@@ -1097,125 +929,11 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> push_url_state()
end
defp run_git_action(socket, event) do
project_id = current_project_id(socket)
{label, result} =
case event do
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
end
socket
|> append_git_result(label, result)
|> refresh_sidebar(socket.assigns.workbench)
end
defp commit_git(socket, "") do
socket
|> append_output_entry(
dgettext("ui", "Commit"),
dgettext("ui", "Commit message is required"),
nil,
"error"
)
|> refresh_sidebar(socket.assigns.workbench)
end
defp commit_git(socket, message) do
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
{:ok, _result} ->
workbench = close_git_diff_tabs(socket.assigns.workbench)
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> append_output_entry(dgettext("ui", "Commit"), message)
|> refresh_sidebar(workbench)
|> push_url_state()
{:error, reason} ->
socket
|> append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|> refresh_sidebar(socket.assigns.workbench)
end
end
defp initialize_git(socket, remote_url) do
project_id = current_project_id(socket)
case git_call(project_id, &BDS.Git.initialize_repo/1) do
{:ok, _repo} ->
_ = maybe_set_git_remote(project_id, remote_url)
socket
|> append_output_entry(
dgettext("ui", "Initialize Git"),
dgettext("ui", "Repository initialized")
)
|> refresh_sidebar(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output_entry(
dgettext("ui", "Initialize Git"),
format_git_error(reason),
nil,
"error"
)
|> refresh_sidebar(socket.assigns.workbench)
end
end
defp git_call(nil, _fun), do: {:error, :no_project}
defp git_call("default", _fun), do: {:error, :no_project}
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
defp prune_lfs(nil), do: {:error, :no_project}
defp prune_lfs("default"), do: {:error, :no_project}
defp prune_lfs(project_id) when is_binary(project_id),
do: BDS.Git.prune_lfs_cache(project_id, 10)
defp maybe_set_git_remote(_project_id, nil), do: :ok
defp maybe_set_git_remote(project_id, remote_url),
do: BDS.Git.set_remote(project_id, remote_url)
defp append_git_result(socket, label, {:ok, _result}) do
append_output_entry(socket, label, dgettext("ui", "Done"))
end
defp append_git_result(socket, label, {:error, reason}) do
append_output_entry(socket, label, format_git_error(reason), nil, "error")
end
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
defp format_git_error(%{message: message}) when is_binary(message), do: message
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
defp format_git_error(reason), do: inspect(reason)
defp close_git_diff_tabs(workbench) do
workbench.tabs
|> Enum.filter(&(&1.type == :git_diff))
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
|> UrlState.push()
end
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
defp normalize_git_remote_url(value) do
case value |> to_string() |> String.trim() do
"" -> nil
url -> url
end
end
defp sidebar_create_action(view), do: SidebarCreate.action(view)
defp set_page_language(socket, language) do
@@ -1258,7 +976,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:sidebar_filters_by_view, %{})
|> append_output_entry(title, message_fun.(project))
|> reload_shell(Workbench.new())
|> push_url_state()
|> UrlState.push()
{:error, reason} ->
socket
@@ -1268,11 +986,8 @@ defmodule BDS.Desktop.ShellLive do
end
end
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
entry = %{title: title, message: message, details: details, level: level}
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info"),
do: SocketState.append_output_entry(socket, title, message, details, level)
defp handle_native_menu_action(socket, action) do
case BoundedAtoms.menu_action(action) do
@@ -1293,7 +1008,7 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench)
|> push_url_state()
|> UrlState.push()
MapSet.member?(@socket_menu_actions, action) ->
handle_socket_menu_action(socket, action)
@@ -1317,8 +1032,10 @@ defmodule BDS.Desktop.ShellLive do
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket)
defp handle_socket_menu_action(socket, :save), do: TabActions.save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected), do: TabActions.publish_current_tab(socket)
defp handle_socket_menu_action(socket, :quit) do
Shutdown.request_quit()
@@ -1347,62 +1064,6 @@ defmodule BDS.Desktop.ShellLive do
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
defp auto_save_current_post(
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
) do
if Workbench.dirty?(workbench, :post, post_id) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
end
socket
end
defp auto_save_current_post(socket), do: socket
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
send_update(SettingsEditor, id: "settings-editor", action: :save_project)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
send_update(MenuEditor, id: "menu-editor", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
send_update(TagsEditor, id: "tags-editor", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
socket
end
defp save_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
socket
end
defp publish_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
defp apply_shell_command(socket, action, params \\ %{}),
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())

View File

@@ -996,7 +996,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue
_error -> :ok
error ->
Logger.debug("swallowed chat_editor allow_repo_sandbox error: #{inspect(error)}")
:ok
end
else
:ok

View File

@@ -0,0 +1,126 @@
defmodule BDS.Desktop.ShellLive.GitHandler do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers, UrlState}
alias BDS.UI.Workbench
use Gettext, backend: BDS.Gettext
def run_action(socket, event) do
project_id = current_project_id(socket)
{label, result} =
case event do
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
end
socket
|> append_git_result(label, result)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
def commit(socket, "") do
socket
|> SocketState.append_output_entry(
dgettext("ui", "Commit"),
dgettext("ui", "Commit message is required"),
nil,
"error"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
def commit(socket, message) do
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
{:ok, _result} ->
workbench = close_git_diff_tabs(socket.assigns.workbench)
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> SocketState.append_output_entry(dgettext("ui", "Commit"), message, nil, "info")
|> SocketState.refresh_sidebar(workbench)
|> UrlState.push()
{:error, reason} ->
socket
|> SocketState.append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
end
def initialize(socket, remote_url) do
project_id = current_project_id(socket)
case git_call(project_id, &BDS.Git.initialize_repo/1) do
{:ok, _repo} ->
_ = maybe_set_git_remote(project_id, remote_url)
socket
|> SocketState.append_output_entry(
dgettext("ui", "Initialize Git"),
dgettext("ui", "Repository initialized"),
nil,
"info"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
{:error, reason} ->
socket
|> SocketState.append_output_entry(
dgettext("ui", "Initialize Git"),
format_git_error(reason),
nil,
"error"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
end
def normalize_remote_url(value) do
case value |> to_string() |> String.trim() do
"" -> nil
url -> url
end
end
defp git_call(nil, _fun), do: {:error, :no_project}
defp git_call("default", _fun), do: {:error, :no_project}
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
defp prune_lfs(nil), do: {:error, :no_project}
defp prune_lfs("default"), do: {:error, :no_project}
defp prune_lfs(project_id) when is_binary(project_id),
do: BDS.Git.prune_lfs_cache(project_id, 10)
defp maybe_set_git_remote(_project_id, nil), do: :ok
defp maybe_set_git_remote(project_id, remote_url), do: BDS.Git.set_remote(project_id, remote_url)
defp append_git_result(socket, label, {:ok, _result}) do
SocketState.append_output_entry(socket, label, dgettext("ui", "Done"), nil, "info")
end
defp append_git_result(socket, label, {:error, reason}) do
SocketState.append_output_entry(socket, label, format_git_error(reason), nil, "error")
end
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
defp format_git_error(%{message: message}) when is_binary(message), do: message
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
defp format_git_error(reason), do: inspect(reason)
defp close_git_diff_tabs(workbench) do
workbench.tabs
|> Enum.filter(&(&1.type == :git_diff))
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
end
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
alias BDS.Desktop.ShellLive.Notify
require Logger
alias BDS.Desktop.ShellLive.ImportEditor.{
AnalysisState,
@@ -1417,7 +1418,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue
_error -> :ok
error ->
Logger.debug("swallowed import_editor allow_repo_sandbox error: #{inspect(error)}")
:ok
end
else
:ok

View File

@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
alias BDS.Desktop.{FilePicker, FolderPicker}
require Logger
use Gettext, backend: BDS.Gettext
@spec change_definition(term(), term(), term()) :: term()
@@ -280,7 +281,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue
_error -> :ok
error ->
Logger.debug(
"swallowed import_editor analysis_state allow_repo_sandbox error: #{inspect(error)}"
)
:ok
end
else
:ok

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
@moduledoc false
alias BDS.{Metadata, Tags}
require Logger
@spec field_key(term()) :: term()
def field_key(:tags), do: "tags"
@@ -91,7 +92,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
:ok
end
rescue
_error -> :ok
error ->
Logger.warning("swallowed list_values ensure_list_value error: #{inspect(error)}")
:ok
end
@spec csv_to_list(term()) :: term()

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
alias BDS.AI
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
require Logger
use Gettext, backend: BDS.Gettext
@spec ai_form(term()) :: term()
@@ -171,7 +172,9 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp safe_endpoint(kind) do
case AI.get_endpoint(kind) do
{:ok, ep} -> ep
_error -> nil
error ->
Logger.debug("swallowed AI settings safe_endpoint error: #{inspect(error)}")
nil
end
end

View File

@@ -0,0 +1,123 @@
defmodule BDS.Desktop.ShellLive.SocketState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{PanelRenderer, SidebarState}
alias BDS.UI.Workbench
use Gettext, backend: BDS.Gettext
@output_entry_limit 20
def refresh_layout(socket, workbench) do
git_badge_count = socket.assigns[:git_badge_count] || 0
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
task_status =
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode = Map.get(socket.assigns, :offline_mode, true)
sidebar_data = socket.assigns[:sidebar_data] || %{}
current_tab = current_tab(workbench)
prev_tab = socket.assigns[:current_tab]
prev_panel_tab =
case socket.assigns[:workbench] do
%Workbench{panel: %{active_tab: tab}} -> tab
_ -> nil
end
socket =
socket
|> assign(:workbench, workbench)
|> assign(:activity_buttons, activity_buttons)
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:current_tab, current_tab)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(
:status,
ShellData.status_bar(workbench, task_status, dashboard,
ui_language: page_language,
offline_mode: offline_mode
)
)
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
refresh_panel_data(socket)
else
socket
end
end
def refresh_sidebar(socket, workbench) do
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
project_id,
active_view_id,
SidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
def append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
entry = %{title: title, message: message, details: details, level: level}
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
current_tab != prev_tab or panel_tab != prev_panel_tab
end
defp refresh_panel_data(socket) do
panel_tab = socket.assigns.workbench.panel.active_tab
socket
|> assign(
:panel_post_links,
if(panel_tab == :post_links,
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
)
)
|> assign(
:panel_git_entries,
if(panel_tab == :git_log,
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
else: socket.assigns[:panel_git_entries] || []
)
)
end
defp activity_label("AI Assistant"), do: dgettext("ui", "Chat")
defp activity_label("Source Control"), do: dgettext("ui", "Git")
defp activity_label(label), do: label
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp current_tab(%{active_tab: nil}), do: nil
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
end

View File

@@ -0,0 +1,72 @@
defmodule BDS.Desktop.ShellLive.TabActions do
@moduledoc false
alias BDS.Desktop.ShellLive.{
MediaEditor,
MenuEditor,
PostEditor,
ScriptEditor,
SocketState,
SettingsEditor,
TagsEditor,
TemplateEditor
}
alias BDS.UI.Workbench
def auto_save_current_post(
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
) do
if Workbench.dirty?(workbench, :post, post_id) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
end
socket
end
def auto_save_current_post(socket), do: socket
def save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
Phoenix.LiveView.send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
Phoenix.LiveView.send_update(SettingsEditor, id: "settings-editor", action: :save_project)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
Phoenix.LiveView.send_update(MenuEditor, id: "menu-editor", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
Phoenix.LiveView.send_update(TagsEditor, id: "tags-editor", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
Phoenix.LiveView.send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
Phoenix.LiveView.send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
socket
end
def save_current_tab(socket), do: SocketState.refresh_layout(socket, socket.assigns.workbench)
def publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
socket
end
def publish_current_tab(socket), do: SocketState.refresh_layout(socket, socket.assigns.workbench)
end

View File

@@ -0,0 +1,78 @@
defmodule BDS.Desktop.ShellLive.UrlState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers}
alias BDS.UI.Workbench
def push(socket) do
workbench = socket.assigns.workbench
params =
%{}
|> put_url_view(workbench.active_view)
|> put_url_tab(workbench.active_tab)
query = URI.encode_query(params)
path = if query == "", do: "/", else: "/?" <> query
Phoenix.LiveView.push_event(socket, "url-state", %{path: path})
end
def apply_params(socket, params) when is_map(params) and map_size(params) > 0 do
workbench = socket.assigns.workbench
workbench = apply_url_view(workbench, Map.get(params, "view"))
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
if workbench == socket.assigns.workbench do
socket
else
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> SocketState.refresh_sidebar(workbench)
end
end
def apply_params(socket, _params), do: socket
defp put_url_view(params, :posts), do: params
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
defp put_url_tab(params, nil), do: params
defp put_url_tab(params, {type, id}), do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
defp apply_url_view(workbench, nil), do: workbench
defp apply_url_view(workbench, view_str) do
view = BoundedAtoms.sidebar_view(view_str, nil)
if view && view != workbench.active_view do
Workbench.click_activity(workbench, view)
else
workbench
end
end
defp apply_url_tab(workbench, nil), do: workbench
defp apply_url_tab(workbench, tab_str) do
case String.split(tab_str, ":", parts: 2) do
[type_str, id] ->
type = BoundedAtoms.editor_route(type_str, nil)
if type && workbench.active_tab != {type, id} do
Workbench.open_tab(workbench, type, id, :preview)
else
workbench
end
_ ->
workbench
end
end
end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.Shutdown do
alias Desktop.Wx
alias Desktop.Window
require Logger
require Record
Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl"))
@@ -25,9 +26,13 @@ defmodule BDS.Desktop.Shutdown do
:ok
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown install_handlers error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown install_handlers exit: #{inspect(reason)}")
:ok
end
@spec request_quit() :: :ok
@@ -102,9 +107,13 @@ defmodule BDS.Desktop.Shutdown do
fun.()
:ok
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown persist_step error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown persist_step exit: #{inspect(reason)}")
:ok
end
# heart, when present, would relaunch the app after we kill the BEAM, so it
@@ -120,9 +129,13 @@ defmodule BDS.Desktop.Shutdown do
_ -> :ok
end
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown kill_heart error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown kill_heart exit: #{inspect(reason)}")
:ok
end
defp kill_beam do
@@ -133,9 +146,13 @@ defmodule BDS.Desktop.Shutdown do
os_kill_fun().(os_pid)
:ok
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown os_kill error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown os_kill exit: #{inspect(reason)}")
:ok
end
defp os_kill_fun do
@@ -158,9 +175,13 @@ defmodule BDS.Desktop.Shutdown do
:ok
rescue
_error -> :ok
error ->
Logger.debug("swallowed shutdown maybe_hide_window error: #{inspect(error)}")
:ok
catch
:exit, _reason -> :ok
:exit, reason ->
Logger.debug("swallowed shutdown maybe_hide_window exit: #{inspect(reason)}")
:ok
end
@doc false

View File

@@ -4,6 +4,13 @@ defmodule BDS.Embeddings do
import Ecto.Query
require Logger
@type project_id :: String.t()
@type post_id :: String.t()
@type progress_opts :: keyword()
@type similarity_map :: %{optional(post_id()) => float()}
@type indexed_posts_result :: {:ok, [post_id()]} | {:error, term()}
@type duplicate_pair_ids :: [{post_id(), post_id()}]
alias BDS.Persistence
alias BDS.Embeddings.DismissedDuplicatePair
alias BDS.Embeddings.Index
@@ -18,11 +25,19 @@ defmodule BDS.Embeddings do
@exact_match_score 0.999999
@key_batch_size 199
@spec model_id() :: term()
def model_id, do: configured_backend().model_info().model_id
@spec dimensions() :: pos_integer()
def dimensions, do: configured_backend().model_info().dimensions
@spec index_path(project_id()) :: String.t()
def index_path(project_id), do: Index.path(project_id)
@spec reindex_all(project_id()) :: indexed_posts_result()
def reindex_all(project_id), do: rebuild_project(project_id)
@spec refresh_snapshot(project_id()) :: :ok
def refresh_snapshot(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do
:ok = rebuild_snapshot(project_id)
@@ -31,6 +46,8 @@ defmodule BDS.Embeddings do
:ok
end
@spec get_indexing_progress(project_id()) ::
{:ok, %{indexed: non_neg_integer(), total: non_neg_integer()}}
def get_indexing_progress(project_id) when is_binary(project_id) do
indexed =
Repo.one(
@@ -49,6 +66,7 @@ defmodule BDS.Embeddings do
{:ok, %{indexed: indexed, total: total}}
end
@spec sync_post(Post.t() | post_id()) :: :ok
def sync_post(%Post{} = post) do
if enabled_for_project?(post.project_id) do
sync_post_if_enabled(post, refresh_index: true)
@@ -64,6 +82,7 @@ defmodule BDS.Embeddings do
end
end
@spec repair_posts(project_id(), [post_id()]) :: indexed_posts_result()
def repair_posts(project_id, post_ids) when is_binary(project_id) and is_list(post_ids) do
if enabled_for_project?(project_id) do
post_ids = Enum.uniq(post_ids)
@@ -91,6 +110,8 @@ defmodule BDS.Embeddings do
end
end
@spec rebuild_project(project_id()) :: indexed_posts_result()
@spec rebuild_project(project_id(), progress_opts()) :: indexed_posts_result()
def rebuild_project(project_id, opts \\ [])
def rebuild_project(project_id, opts) when is_binary(project_id) and is_list(opts) do
@@ -130,6 +151,7 @@ defmodule BDS.Embeddings do
end
end
@spec diff_reports(project_id()) :: [map()]
def diff_reports(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do
keys_by_post =
@@ -347,6 +369,7 @@ defmodule BDS.Embeddings do
end)
end
@spec remove_post(post_id()) :: :ok
def remove_post(post_id) when is_binary(post_id) do
project_id =
case Repo.get_by(Key, post_id: post_id) do
@@ -369,6 +392,7 @@ defmodule BDS.Embeddings do
:ok
end
@spec index_unindexed(project_id()) :: indexed_posts_result()
def index_unindexed(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do
posts =
@@ -398,6 +422,8 @@ defmodule BDS.Embeddings do
end
end
@spec find_similar(post_id()) :: {:ok, [map()]}
@spec find_similar(post_id(), pos_integer()) :: {:ok, [map()]}
def find_similar(post_id, limit \\ 5) when is_binary(post_id) and is_integer(limit) do
case source_post_and_vector(post_id) do
{:disabled, _project_id} ->
@@ -431,6 +457,7 @@ defmodule BDS.Embeddings do
end
end
@spec compute_similarities(post_id(), [post_id()]) :: {:ok, similarity_map()}
def compute_similarities(source_post_id, target_post_ids)
when is_binary(source_post_id) and is_list(target_post_ids) do
case source_post_and_vector(source_post_id) do
@@ -468,6 +495,7 @@ defmodule BDS.Embeddings do
end
end
@spec suggest_tags(post_id(), term()) :: {:ok, [String.t()]}
def suggest_tags(post_id, _input_text) when is_binary(post_id) do
with {:ok, _post} <- fetch_post(post_id),
{:ok, similar} <- find_similar(post_id, 10) do
@@ -497,6 +525,8 @@ defmodule BDS.Embeddings do
end
end
@spec find_duplicates(project_id()) :: {:ok, [map()]}
@spec find_duplicates(project_id(), progress_opts()) :: {:ok, [map()]}
def find_duplicates(project_id, opts \\ []) when is_binary(project_id) do
if enabled_for_project?(project_id) do
on_progress = progress_callback(opts)
@@ -521,6 +551,8 @@ defmodule BDS.Embeddings do
end
end
@spec dismiss_duplicate_pair(post_id(), post_id()) ::
{:ok, DismissedDuplicatePair.t()} | {:error, :not_found}
def dismiss_duplicate_pair(post_id_a, post_id_b)
when is_binary(post_id_a) and is_binary(post_id_b) do
with {:ok, post_a} <- fetch_post(post_id_a),
@@ -552,6 +584,8 @@ defmodule BDS.Embeddings do
end
end
@spec dismiss_duplicate_pairs(duplicate_pair_ids()) ::
{:ok, [DismissedDuplicatePair.t()]} | {:error, term()}
def dismiss_duplicate_pairs(pair_ids) when is_list(pair_ids) do
pair_ids
|> Enum.filter(fn

View File

@@ -7,6 +7,15 @@ defmodule BDS.Embeddings.DismissedDuplicatePair do
@primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string
@type t :: %__MODULE__{
id: String.t() | nil,
project_id: String.t() | nil,
project: term(),
post_id_a: String.t() | nil,
post_id_b: String.t() | nil,
dismissed_at: integer() | nil
}
schema "dismissed_duplicate_pairs" do
belongs_to :project, BDS.Projects.Project, type: :string
field :post_id_a, :string

View File

@@ -23,6 +23,7 @@ defmodule BDS.Embeddings.Index do
alias BDS.Projects
alias BDS.ProgressReporter
require Logger
@neighbor_limit 21
@debounce_ms 5_000
@@ -345,7 +346,12 @@ defmodule BDS.Embeddings.Index do
write_meta(index_path, dim, labels)
:ok
rescue
_exception -> :ok
exception ->
Logger.debug(
"swallowed embeddings index persist error for #{project_id}: #{inspect(exception)}"
)
:ok
end
defp write_meta(index_path, dim, labels) do
@@ -385,7 +391,12 @@ defmodule BDS.Embeddings.Index do
_other -> :error
end
rescue
_exception -> :error
exception ->
Logger.debug(
"swallowed embeddings index load_from_disk error for #{project_id}: #{inspect(exception)}"
)
:error
end
defp read_meta(index_path) do

View File

@@ -2,6 +2,7 @@ defmodule BDS.Git do
@moduledoc false
alias BDS.Projects
require Logger
@default_local_timeout_ms 15_000
@default_network_timeout_ms 120_000
@@ -562,7 +563,9 @@ defmodule BDS.Git do
defp safe_port_close(port) do
Port.close(port)
catch
:error, _reason -> :ok
:error, reason ->
Logger.debug("swallowed git safe_port_close error: #{inspect(reason)}")
:ok
end
defp port_os_pid(port) do

View File

@@ -2,6 +2,7 @@ defmodule BDS.MCP.Server do
@moduledoc false
use GenServer
require Logger
@host "127.0.0.1"
@server_name "Blogging Desktop Server"
@@ -336,7 +337,9 @@ defmodule BDS.MCP.Server do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, owner_pid, self())
rescue
_error -> :ok
error ->
Logger.debug("swallowed MCP server maybe_allow_repo error: #{inspect(error)}")
:ok
end
end

View File

@@ -2,6 +2,7 @@ defmodule BDS.Preview do
@moduledoc false
use GenServer
require Logger
alias BDS.Posts
alias BDS.Posts.Translation
@@ -446,7 +447,9 @@ defmodule BDS.Preview do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, owner_pid, self())
rescue
_error -> :ok
error ->
Logger.debug("swallowed preview maybe_allow_repo error: #{inspect(error)}")
:ok
end
end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Scripting.Capabilities.AppShell do
alias BDS.Desktop.FolderPicker
alias BDS.Desktop.MenuBar
require Logger
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
@@ -139,7 +140,9 @@ defmodule BDS.Scripting.Capabilities.AppShell do
nil
rescue
_error -> nil
error ->
Logger.debug("swallowed app_shell trigger_menu_action error: #{inspect(error)}")
nil
end
def test_mode? do

View File

@@ -116,24 +116,9 @@ defmodule BDS.Search do
end
defp search_posts_blank(project_id, filters) do
base = from(post in Post, where: post.project_id == ^project_id)
filtered = apply_post_filters(base, filters)
total = count_query(filtered)
posts =
filtered
|> order_by([p], desc: p.created_at)
|> limit(^filters.limit)
|> offset(^filters.offset)
|> Repo.all()
{:ok,
%{
posts: posts,
total: total,
offset: filters.offset,
limit: filters.limit
}}
Post
|> blank_search_query(project_id, filters, &apply_post_filters/2)
|> paginate_search(filters, :posts, &order_by(&1, [p], desc: p.created_at))
end
defp search_posts_fts(project_id, query_text, filters) do
@@ -146,29 +131,9 @@ defmodule BDS.Search do
select: %{post_id: f.post_id, fts_rowid: fragment("rowid")}
)
base =
Post
|> with_cte("fts_results", as: ^fts_subquery)
|> join(:inner, [p], fts in "fts_results", on: fts.post_id == p.id)
|> where([p], p.project_id == ^project_id)
filtered = apply_post_filters(base, filters)
total = count_query(filtered)
posts =
filtered
|> order_by([_, fts], fts.fts_rowid)
|> limit(^filters.limit)
|> offset(^filters.offset)
|> Repo.all()
{:ok,
%{
posts: posts,
total: total,
offset: filters.offset,
limit: filters.limit
}}
Post
|> fts_search_query(project_id, filters, fts_subquery, :post_id, &apply_post_filters/2)
|> paginate_search(filters, :posts, &order_by(&1, [_, fts], fts.fts_rowid))
end
@spec search_media(String.t(), String.t() | nil, search_filters()) ::
@@ -190,24 +155,9 @@ defmodule BDS.Search do
end
defp search_media_blank(project_id, filters) do
base = from(media in Media, where: media.project_id == ^project_id)
filtered = apply_media_filters(base, filters)
total = count_query(filtered)
media_items =
filtered
|> order_by([m], desc: m.created_at)
|> limit(^filters.limit)
|> offset(^filters.offset)
|> Repo.all()
{:ok,
%{
media: media_items,
total: total,
offset: filters.offset,
limit: filters.limit
}}
Media
|> blank_search_query(project_id, filters, &apply_media_filters/2)
|> paginate_search(filters, :media, &order_by(&1, [m], desc: m.created_at))
end
defp search_media_fts(project_id, query_text, filters) do
@@ -220,25 +170,38 @@ defmodule BDS.Search do
select: %{media_id: f.media_id, fts_rowid: fragment("rowid")}
)
base =
Media
|> with_cte("fts_results", as: ^fts_subquery)
|> join(:inner, [m], fts in "fts_results", on: fts.media_id == m.id)
|> where([m], m.project_id == ^project_id)
Media
|> fts_search_query(project_id, filters, fts_subquery, :media_id, &apply_media_filters/2)
|> paginate_search(filters, :media, &order_by(&1, [_, fts], fts.fts_rowid))
end
filtered = apply_media_filters(base, filters)
total = count_query(filtered)
defp blank_search_query(schema, project_id, filters, apply_filters) do
schema
|> where([entity], field(entity, :project_id) == ^project_id)
|> apply_filters.(filters)
end
media_items =
filtered
|> order_by([_, fts], fts.fts_rowid)
defp fts_search_query(schema, project_id, filters, fts_subquery, join_key, apply_filters) do
schema
|> with_cte("fts_results", as: ^fts_subquery)
|> join(:inner, [entity], fts in "fts_results", on: field(fts, ^join_key) == entity.id)
|> where([entity], field(entity, :project_id) == ^project_id)
|> apply_filters.(filters)
end
defp paginate_search(query, filters, result_key, order_query) do
total = count_query(query)
items =
query
|> order_query.()
|> limit(^filters.limit)
|> offset(^filters.offset)
|> Repo.all()
{:ok,
%{
media: media_items,
result_key => items,
total: total,
offset: filters.offset,
limit: filters.limit

View File

@@ -3,67 +3,69 @@ defmodule BDS.Tasks do
use GenServer
@type task_id :: String.t()
@type task_attrs :: map()
@type task_info :: map()
@type status_snapshot :: map()
@type progress_reporter :: (number(), String.t() | nil -> any())
@type task_work :: (progress_reporter() -> term())
@default_max_concurrent 3
@default_progress_throttle_ms 250
@default_recent_finished_limit 10
@default_finished_task_ttl_ms :timer.hours(1)
@topic "tasks"
@spec start_link(term()) :: GenServer.on_start()
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@spec topic() :: String.t()
def topic, do: @topic
@spec submit_task(String.t(), task_work()) :: {:ok, task_info()}
@spec submit_task(String.t(), task_work(), task_attrs()) :: {:ok, task_info()}
def submit_task(name, work, attrs \\ %{})
when is_binary(name) and is_function(work, 1) and is_map(attrs) do
GenServer.call(__MODULE__, {:submit_task, name, work, attrs})
end
@spec get_task(task_id()) :: task_info() | nil
def get_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:get_task, task_id})
end
@spec status_snapshot() :: status_snapshot()
def status_snapshot do
GenServer.call(__MODULE__, :status_snapshot)
end
@spec list_tasks() :: [task_info()]
def list_tasks do
GenServer.call(__MODULE__, :list_tasks)
end
@spec list_running_tasks() :: [task_info()]
def list_running_tasks do
GenServer.call(__MODULE__, :list_running_tasks)
end
@spec clear_completed() :: :ok
def clear_completed do
GenServer.call(__MODULE__, :clear_completed)
end
@spec clear_finished() :: :ok
def clear_finished do
GenServer.call(__MODULE__, :clear_finished)
end
@spec cancel_task(task_id()) :: :ok | {:error, :not_found | :not_running}
def cancel_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:cancel_task, task_id})
end
def register_external_task(name, attrs \\ %{}) when is_binary(name) and is_map(attrs) do
GenServer.call(__MODULE__, {:register_external_task, name, attrs})
end
def report_progress(task_id, value, message) when is_binary(task_id) do
GenServer.call(__MODULE__, {:report_progress, task_id, value, message})
end
def complete_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:complete_task, task_id})
end
def fail_task(task_id, error_message) when is_binary(task_id) do
GenServer.call(__MODULE__, {:fail_task, task_id, error_message})
end
@impl true
def init(_state) do
{:ok,
@@ -163,48 +165,6 @@ defmodule BDS.Tasks do
end
end
def handle_call({:register_external_task, name, attrs}, _from, state) do
task = new_task(name, :running, attrs) |> Map.put(:started_at, DateTime.utc_now())
next_state = put_in(state, [:tasks, task.id], task)
{:reply, {:ok, public_task(task)}, next_state}
end
def handle_call({:report_progress, task_id, value, message}, _from, state) do
{:reply, :ok, maybe_report_progress(state, task_id, value, message)}
end
def handle_call({:complete_task, task_id}, _from, state) do
next_state =
state
|> update_task(task_id, %{
status: :completed,
progress: 1.0,
finished_at: DateTime.utc_now()
})
|> start_queued_tasks()
|> schedule_finished_task_eviction()
broadcast_terminal_task(next_state.tasks[task_id])
{:reply, :ok, next_state}
end
def handle_call({:fail_task, task_id, error_message}, _from, state) do
next_state =
state
|> update_task(task_id, %{
status: :failed,
message: error_message,
finished_at: DateTime.utc_now()
})
|> start_queued_tasks()
|> schedule_finished_task_eviction()
broadcast_terminal_task(next_state.tasks[task_id])
{:reply, :ok, next_state}
end
@impl true
def handle_info({:task_progress, task_id, value, message}, state) do
{:noreply, maybe_report_progress(state, task_id, value, message)}

View File

@@ -426,35 +426,12 @@ defmodule BDS.UI.Sidebar do
defp maybe_where_all_tags(query, []), do: query
defp maybe_where_all_tags(query, tags) do
Enum.reduce(tags, query, fn tag, q ->
where(
q,
[p],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
p.tags,
^tag
)
)
end)
end
defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags)
defp maybe_where_all_categories(query, []), do: query
defp maybe_where_all_categories(query, categories) do
Enum.reduce(categories, query, fn category, q ->
where(
q,
[p],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?) AND lower(value) != 'page')",
p.categories,
^category
)
)
end)
end
defp maybe_where_all_categories(query, categories),
do: where_all_in_json_array(query, categories, :categories, true)
defp base_year_month_counts(project_id, pages?) do
is_page = if pages?, do: 1, else: 0
@@ -692,17 +669,32 @@ defmodule BDS.UI.Sidebar do
defp maybe_where_all_media_tags(query, []), do: query
defp maybe_where_all_media_tags(query, tags) do
Enum.reduce(tags, query, fn tag, q ->
where(
q,
[m],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
m.tags,
^tag
defp maybe_where_all_media_tags(query, tags),
do: where_all_in_json_array(query, tags, :tags)
defp where_all_in_json_array(query, items, field_atom, exclude_page? \\ false) do
Enum.reduce(items, query, fn item, q ->
if exclude_page? do
where(
q,
[x],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?) AND lower(value) != 'page')",
field(x, ^field_atom),
^item
)
)
)
else
where(
q,
[x],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
field(x, ^field_atom),
^item
)
)
end
end)
end