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

204
AUDIT.md Normal file
View File

@@ -0,0 +1,204 @@
# Elixir Antipattern Audit — Working Document
Verified 2026-06-25 against `lib/bds/` (233 modules). Every item below is a standalone task.
Follow `AGENTS.md`: test-first, fix all failures, clean build/credo/dialyzer/deps.audit.
---
## Task 1 — Delete the dead externally-driven task API
**File:** `lib/bds/tasks.ex` (573 lines, GenServer)
`tasks.ex` has two task patterns. One is live; one is dead.
- **Live (keep):** `submit_task/3` runs a work function under `Task.Supervisor`; the GenServer auto-tracks lifecycle via its `{ref, result}` and `{:DOWN, ...}` handlers, and progress flows through the `reporter` closure passed into the work function. Used by 8 call sites across 3 modules (`publishing.ex:57`; `auto_translation.ex:182,215`; `shell_commands.ex:83,94,219,418,554`). The read/control functions are also exposed through the Lua script API (`lib/bds/scripting/capabilities.ex:240-247`): `get`, `status_snapshot`, `cancel`, `get_all`, `get_running`, `clear_completed`.
- **Dead (delete):** the externally-driven pattern — register a task that runs elsewhere, then manually push progress and mark it complete/failed. Zero production callers; only `test/bds/tasks_test.exs` and `test/bds/scripting/api_test.exs` exercise it.
### Delete exactly these
Public functions and their matching `handle_call` clauses:
| Public function | `handle_call` clause |
|---|---|
| `register_external_task/2` (line 51) | line 166 |
| `report_progress/3` (line 55) | line 172 |
| `complete_task/2` (line 59) | line 176 |
| `fail_task/2` (line 63) | line 192 |
### Keep these — do NOT delete
- `submit_task/3`, `get_task/1`, `status_snapshot/0`, `list_tasks/0`, `list_running_tasks/0`, `cancel_task/1`, `clear_completed/0` — all live, most exposed via the script API.
- `clear_finished/0` (line 43) and its `handle_call` (line 120). It has zero production callers but is used as a test-reset helper in 5 test files (`tasks_test.exs:7`, `post_translations_test.exs:57`, `real_blog_rebuild_diagnostic_test.exs:41`, `shell_commands_test.exs:105`, `shell_live_test.exs:1409`). `clear_completed/0` is not a drop-in replacement — it clears only `:completed`, not `:failed`/`:cancelled`, and its semantics are documented in the script API.
- `maybe_report_progress/4` (private, line 347). It is shared: the `{:task_progress, ...}` `handle_info` (line 209) on the live path calls it. It does not become unused.
### Tests
- Delete only the test blocks in `tasks_test.exs` / `api_test.exs` that exclusively cover `register_external_task` / `report_progress` / `complete_task` / `fail_task`.
- Do NOT touch test setup/`on_exit` that calls `clear_finished/0` for reset — those tests do not cover it, they use it.
### Verify after
- `grep -rn "register_external_task\|complete_task\|fail_task" lib test` returns no hits.
- `grep -rn "Tasks.report_progress" lib test` returns no hits (`Maintenance.Progress.report_progress/4` and the script-side `bds.report_progress` are different functions — leave them).
- No deleted function is wired into `lib/bds/scripting/capabilities.ex` or `api_docs.ex` (confirmed: the script API exposes none of the four).
- Build/test/credo/dialyzer clean.
---
## Task 2 — Log silently-swallowed errors
**Pattern:** a `rescue`/`catch` clause that discards the error with no log line and returns a bare `:ok`, `nil`, or `:error` (e.g. `rescue _error -> :ok`, `catch :exit, _reason -> :ok`, `rescue _error -> nil`).
> **Inventory is non-exhaustive — treat the table as a starting set, not a closed list.**
> Before remediating, re-sweep with `grep -rnE "_(error|reason|exception|e) -> (nil|:ok|:error)" lib` (≈27 hits).
> The table below is not the full set — e.g. `main_window.ex` 174/175 returns `nil`, and `_error -> nil`
> sites exist in `scripting/capabilities/app_shell.ex:142` and `settings_editor/ai_settings.ex:174`. Triage all hits, not just the table.
| File | Lines | Clause / return |
|------|-------|-----------------|
| `lib/bds/desktop/shutdown.ex` | 5 `rescue` + 5 `catch` pairs: 27/29, 104/106, 122/124, 135/137, 160/162 | `_error -> :ok` / `:exit,_reason -> :ok` |
| `lib/bds/desktop/main_window.ex` | 24/25 | `catch :exit,_reason -> :ok` |
| `lib/bds/desktop/deep_link.ex` | 68/69 | `catch :exit,_reason -> :ok` |
| `lib/bds/git.ex` | 564/565 | `catch :error,_reason -> :ok` |
| `lib/bds/desktop/automation.ex` | 15/16, 222, 370/371 | `catch :exit -> :ok`, `rescue -> :ok`, `catch :error -> :ok` |
| `lib/bds/preview.ex` | 448 | `rescue _error -> :ok` |
| `lib/bds/desktop/shell_live/post_editor/list_values.ex` | 93 | `_error -> :ok` |
| `lib/bds/desktop/shell_live/import_editor/analysis_state.ex` | 283 | `_error -> :ok` |
| `lib/bds/desktop/shell_live/import_editor.ex` | 1420 | `_error -> :ok` |
| `lib/bds/desktop/shell_live/chat_editor.ex` | 999 | `_error -> :ok` |
| `lib/bds/mcp/server.ex` | 338 (`rescue` keyword), body 339 | `_error -> :ok` |
| `lib/bds/embeddings/index.ex` | 347/348 → `:ok`, **387/388 → `:error`** | both `rescue _exception -> ...` |
**Action:** add a log line to each swallowing clause without changing control flow or its return value. The recipe is per-clause, not a single find/replace — the var name and return value vary:
- `rescue`/`catch` returning `:ok` → bind the value and log, keep `:ok`: `error -> Logger.debug("swallowed in <ctx>: #{inspect(error)}"); :ok`
- The `index.ex:387` clause returns **`:error`**, not `:ok` — log, then keep returning `:error`. The `_error -> :ok` recipe does not apply here.
- `catch :exit,_reason`/`catch :error,_reason` clauses bind `_reason` (rename to `reason`), not `error`.
Use `Logger.warning` only where the error is genuinely unexpected.
**`shutdown.ex` / Wx-teardown exception:** the `shutdown.ex`, `main_window.ex`, and `deep_link.ex` catches wrap Wx event-table / `Desktop.Env` calls during teardown or headless startup. Use `Logger.debug` only — do not escalate to `warning`.
**Done when:** no `rescue`/`catch` clause in the listed files (plus any found by the re-sweep) discards its error without a log line, and every clause keeps its original return value.
---
## Task 3 — Add @spec to tasks.ex and embeddings.ex
| File | Public functions | Current @specs |
|------|------------------|----------------|
| `lib/bds/tasks.ex` | 10 non-callback public functions after Task 1 | 0 |
| `lib/bds/embeddings.ex` | 20 | 0 |
**Scope of "public functions":** spec every non-callback public function. In `tasks.ex` after deletion that is **10**: the 8 client-API functions (`submit_task/3`, `get_task/1`, `status_snapshot/0`, `list_tasks/0`, `list_running_tasks/0`, `clear_completed/0`, `clear_finished/0`, `cancel_task/1`) **plus** `start_link/1` (line 12) and `topic/0` (line 16) — `topic/0` is used outside the module (`shell_commands.ex:566`). Do NOT spec the three `@impl` GenServer callbacks (`init/1` line 67, `handle_call/3` line 79, `handle_info/2` line 208); `start_link/1` is **not** an `@impl` callback. (`tasks.ex` has 31 total public `def`s; 21 are callback clauses.) Limited to these two files — adjacent surfaces like `ai.ex` are already fully specced; this is not a repo-wide gap.
**Action:** add `@spec` to every public function. Run `mix dialyzer` to validate inferred types.
**Sequence:** do this after Task 1 so you don't spec the deleted functions.
**Done when:** 100% `@spec` coverage on public functions in both files; dialyzer clean.
---
## Task 4 — Dedup sidebar `maybe_where_all_*` helpers
**File:** `lib/bds/ui/sidebar.ex` (1,137 lines)
Three near-identical helpers — `maybe_where_all_tags/2` (429), `maybe_where_all_categories/2` (445), `maybe_where_all_media_tags/2` (695) — differing only in the JSON field name and one extra `AND lower(value) != 'page'` condition.
**Action:** extract one parametric helper, keeping the `(query, []) -> query` empty-list clauses. All three queries use a single positional binding, so do NOT pass a binding argument — parametrize over the JSON field atom and an optional "exclude page" flag, building the fragment with `field(x, ^field_atom)`:
```elixir
defp where_all_in_json_array(query, items, field_atom, exclude_page? \\ false)
```
Three ~15-line functions become one helper + three 2-line callers.
**Done when:** identical query output (verify via existing sidebar/search tests); build/credo clean.
---
## Task 5 — Enrich the AI facade @moduledoc (no code change)
**File:** `lib/bds/ai.ex` (210 lines)
This module is the stable public AI surface and the airplane-mode boundary required by `AGENTS.md`. It is NOT pure delegation: 8 functions hold real logic — `put_endpoint`, `get_endpoint`, `delete_endpoint`, `set_airplane_mode`, `airplane_mode?`, `airplane_endpoint_configured?`, `put_model_preference`, `get_model_preference`. It is used by 6 production modules (`posts/auto_translation.ex`, `scripting/capabilities/bridges.ex`, `chat_editor/message_build.ex`, `chat_editor/model_selection.ex`, `settings_editor/ai_settings.ex`, `sidebar_create.ex`). The MCP server does not use it (0 `BDS.AI` references in `lib/bds/mcp/`).
A `@moduledoc` already exists (line 2) but only describes endpoint/catalog/dispatch. **Action:** extend it to state that this module owns the airplane-mode / endpoint / model-preference logic and is the stable public AI surface, with `Catalog`/`OneShot`/`Chat` as internals. No code change.
---
## Task 6 — WITHDRAWN (already handled)
Original claim: `Task.async/1` in `sidebar_create.ex:50` result is never handled.
**False on current HEAD.** The parent LiveView already handles both the task
reply and the monitor-down path: `handle_info({ref, result}, ...)` at
`shell_live.ex:563` (→ `handle_file_picker_result/2`) and
`handle_info({:DOWN, ref, ...}, ...)` at `shell_live.ex:585`, both keyed on the
stored `file_picker_task` ref. The result is not lost.
Only remaining nit (not worth its own task): the picker uses `Task.async`
(linked) rather than `Task.Supervisor.async_nolink`, so a crash in the picker
task *could* take down the LiveView before `{:DOWN}` fires. Low-risk, not
zero-risk: `file_picker.ex` is a simple path that mostly returns tagged tuples.
No reproduced failure mode and no separate task — if `sidebar_create.ex:50` is
edited for another reason, switch `Task.async``Task.Supervisor.async_nolink`
opportunistically.
---
## Task 7 — Dedup `search_posts` / `search_media`
**File:** `lib/bds/search.ex` (862 lines)
Mirrored trios: `search_posts/3` (108) → `search_posts_blank/2` (118) → `search_posts_fts/3` (139), and `search_media/3` (182) → `search_media_blank/2` (192) → `search_media_fts/3` (213), plus `apply_post_filters/2` (254) / `apply_media_filters/2` (267). Same CTE+join FTS *pattern*.
**The shapes are NOT identical** (earlier "common `%{entities:}`" claim was wrong):
- Return maps differ by key: `%{posts: ...}` (132, 167) vs `%{media: ...}` (206, 241) — no shared `entities:` key.
- FTS subquery select + join keys differ: `post_id` (146, 152) vs `media_id` (220, 226).
**Action:** do NOT force one broad `search_entities/5`. Extract the genuinely shared *pieces* instead — blank-search query builder, FTS CTE/join setup (parametrized by FTS table + join-key column), and pagination assembly — and let each entity keep its own result key and filter function. Smaller shared helpers, not one generic mega-function.
**Caution:** FTS SQL and filter semantics must stay byte-identical per entity, and each public function must keep returning its existing `%{posts:}` / `%{media:}` shape — verify against existing search tests before and after.
**Done when:** all search tests pass unchanged; net line reduction; credo clean.
---
## Task 8 — Extract api_docs static data (only if recompile hurts)
**File:** `lib/bds/scripting/api_docs.ex` (1,554 lines)
A `@methods` module attribute of map literals, surfaced by a public `render/0`
(`api_docs.ex:1211`); any API change forces a full recompile. (The module is
not inert static data — the earlier "0 public functions" note was wrong.)
**Conditional, not a default cleanup.** Extracting to `priv/api_docs/methods.yaml`
(or JSON) loaded at startup trades compile-time structure for runtime schema
validation — a tradeoff, not a clear win. **Only do this after measuring actual
compile pain.** If recompile isn't hurting, skip. The API-docs-sync tests
(`AGENTS.md`) must still pass against the loaded data.
---
## Task 9 — Split oversized files (one per PR)
Guideline: no file > ~600 lines; split by responsibility.
| File | Lines | Notes |
|------|-------|-------|
| `scripting/api_docs.ex` | 1,554 | mostly data; if Task 8 runs it shrinks, but Task 8 is conditional/skippable — if skipped, split the non-data code here instead |
| `desktop/shell_live.ex` | 1,466 | 68 public functions — split into sub-modules; do this first |
| `ui/sidebar.ex` | 1,137 | shrinks after Task 4 |
| `ai/chat_tools.ex` | 1,096 | review for splitting |
| `ai/chat.ex` | 1,024 | review for splitting |
| `search.ex` | 862 | shrinks after Task 7; split → `search/posts.ex`, `search/media.ex`, `search/filters.ex` |
**Action:** lowest priority; one file per change set, each its own reviewable PR. Start with `shell_live.ex`.
---
## Order
1. Task 1 — delete dead task API (pure deletion, no behavior risk)
2. Task 2 — log swallowed errors
3. Task 3 — @specs (after Task 1)
4. Task 4 — sidebar dedup
5. Task 5 — AI facade moduledoc
6. ~~Task 6~~ — withdrawn (already handled in `shell_live.ex`)
7. Task 7 — search dedup
8. Task 8 — api_docs extract (conditional: only if recompile pain is measured)
9. Task 9 — file splitting (ongoing, lowest priority)

View File

@@ -1,7 +1,15 @@
defmodule BDS.AI do defmodule BDS.AI do
@moduledoc """ @moduledoc """
Public interface for AI features — endpoint configuration, secret management, Stable public interface for AI features.
model catalog access, and dispatching chat and one-shot inference requests.
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 alias BDS.AI.Catalog

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ defmodule BDS.Desktop.ShellLive do
Bridges, Bridges,
ChatEditor, ChatEditor,
GalleryImport, GalleryImport,
GitHandler,
ImportEditor, ImportEditor,
MediaEditor, MediaEditor,
MenuEditor, MenuEditor,
@@ -22,6 +23,7 @@ defmodule BDS.Desktop.ShellLive do
SettingsEditor, SettingsEditor,
SidebarDelete, SidebarDelete,
TagsEditor, TagsEditor,
TabActions,
TemplateEditor TemplateEditor
} }
@@ -34,13 +36,14 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Desktop.ShellLive.{ alias BDS.Desktop.ShellLive.{
ChatSurface, ChatSurface,
Layout, Layout,
PanelRenderer,
SessionUtil, SessionUtil,
ShellCommandRunner, ShellCommandRunner,
SidebarCreate, SidebarCreate,
SocketState,
TabHelpers, TabHelpers,
TaskLocalization, TaskLocalization,
TitlebarMenu TitlebarMenu,
UrlState
} }
import TaskLocalization, import TaskLocalization,
@@ -64,8 +67,6 @@ defmodule BDS.Desktop.ShellLive do
@refresh_interval 1_500 @refresh_interval 1_500
def refresh_interval, do: @refresh_interval def refresh_interval, do: @refresh_interval
@output_entry_limit 20
@sidebar_filter_events [ @sidebar_filter_events [
"toggle_sidebar_filters", "toggle_sidebar_filters",
"toggle_sidebar_archive", "toggle_sidebar_archive",
@@ -180,7 +181,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:panel_git_entries, []) |> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{}) |> assign(:auto_save_timers, %{})
|> reload_shell(workbench) |> reload_shell(workbench)
|> apply_url_params(params) |> UrlState.apply_params(params)
|> tap(&sync_menu_bar_locale/1)} |> tap(&sync_menu_bar_locale/1)}
end end
@@ -208,7 +209,7 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, {:noreply,
socket socket
|> refresh_sidebar(workbench) |> refresh_sidebar(workbench)
|> push_url_state()} |> UrlState.push()}
end end
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
@@ -241,17 +242,18 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event(event, _params, socket) when event in @git_action_events do 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 end
def handle_event("git_commit", params, socket) do def handle_event("git_commit", params, socket) do
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim() message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
{:noreply, commit_git(socket, message)}
{:noreply, GitHandler.commit(socket, message)}
end end
def handle_event("git_initialize", params, socket) do def handle_event("git_initialize", params, socket) do
remote_url = params |> get_in(["git", "remote_url"]) |> normalize_git_remote_url() remote_url = params |> get_in(["git", "remote_url"]) |> GitHandler.normalize_remote_url()
{:noreply, initialize_git(socket, remote_url)} {:noreply, GitHandler.initialize(socket, remote_url)}
end end
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
@@ -270,7 +272,7 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do 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 =
Workbench.open_tab( Workbench.open_tab(
@@ -286,11 +288,11 @@ defmodule BDS.Desktop.ShellLive do
socket socket
|> assign(:tab_meta, tab_meta) |> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench) |> refresh_layout(workbench)
|> push_url_state()} |> UrlState.push()}
end end
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do 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) type_atom = BoundedAtoms.editor_route(type, :post)
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id) workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
@@ -300,7 +302,7 @@ defmodule BDS.Desktop.ShellLive do
socket socket
|> assign(:tab_meta, tab_meta) |> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench) |> refresh_layout(workbench)
|> push_url_state()} |> UrlState.push()}
end end
def handle_event( def handle_event(
@@ -722,167 +724,9 @@ defmodule BDS.Desktop.ShellLive do
index(assigns) index(assigns)
end end
defp refresh_layout(socket, workbench) do defp refresh_layout(socket, workbench), do: SocketState.refresh_layout(socket, workbench)
git_badge_count = socket.assigns[:git_badge_count] || 0
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
task_status = defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
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_content(socket, workbench) do defp refresh_content(socket, workbench) do
projects = projects =
@@ -971,12 +815,6 @@ defmodule BDS.Desktop.ShellLive do
defp activity_label("Source Control"), do: dgettext("ui", "Git") defp activity_label("Source Control"), do: dgettext("ui", "Git")
defp activity_label(label), do: label 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 sidebar_header_label(label), do: label
defp timeline_height(entry, entries) do defp timeline_height(entry, entries) do
@@ -988,12 +826,6 @@ defmodule BDS.Desktop.ShellLive do
max(4, (entry.count || 0) / max_count * 100) max(4, (entry.count || 0) / max_count * 100)
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
defp create_sidebar_item(socket, kind), defp create_sidebar_item(socket, kind),
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks()) do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
@@ -1097,125 +929,11 @@ defmodule BDS.Desktop.ShellLive do
socket socket
|> assign(:tab_meta, tab_meta) |> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench) |> refresh_layout(workbench)
|> push_url_state() |> UrlState.push()
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)
end end
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id] 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 sidebar_create_action(view), do: SidebarCreate.action(view)
defp set_page_language(socket, language) do defp set_page_language(socket, language) do
@@ -1258,7 +976,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filters_by_view, %{})
|> append_output_entry(title, message_fun.(project)) |> append_output_entry(title, message_fun.(project))
|> reload_shell(Workbench.new()) |> reload_shell(Workbench.new())
|> push_url_state() |> UrlState.push()
{:error, reason} -> {:error, reason} ->
socket socket
@@ -1268,11 +986,8 @@ defmodule BDS.Desktop.ShellLive do
end end
end end
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do defp append_output_entry(socket, title, message, details \\ nil, level \\ "info"),
entry = %{title: title, message: message, details: details, level: level} do: SocketState.append_output_entry(socket, title, message, details, level)
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp handle_native_menu_action(socket, action) do defp handle_native_menu_action(socket, action) do
case BoundedAtoms.menu_action(action) do case BoundedAtoms.menu_action(action) do
@@ -1293,7 +1008,7 @@ defmodule BDS.Desktop.ShellLive do
socket socket
|> assign(:tab_meta, tab_meta) |> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench) |> refresh_sidebar(workbench)
|> push_url_state() |> UrlState.push()
MapSet.member?(@socket_menu_actions, action) -> MapSet.member?(@socket_menu_actions, action) ->
handle_socket_menu_action(socket, 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, :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, :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 defp handle_socket_menu_action(socket, :quit) do
Shutdown.request_quit() 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 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 \\ %{}), defp apply_shell_command(socket, action, params \\ %{}),
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks()) do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
alias BDS.AI alias BDS.AI
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
require Logger
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@spec ai_form(term()) :: term() @spec ai_form(term()) :: term()
@@ -171,7 +172,9 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp safe_endpoint(kind) do defp safe_endpoint(kind) do
case AI.get_endpoint(kind) do case AI.get_endpoint(kind) do
{:ok, ep} -> ep {:ok, ep} -> ep
_error -> nil error ->
Logger.debug("swallowed AI settings safe_endpoint error: #{inspect(error)}")
nil
end end
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.Wx
alias Desktop.Window alias Desktop.Window
require Logger
require Record require Record
Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl")) Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl"))
@@ -25,9 +26,13 @@ defmodule BDS.Desktop.Shutdown do
:ok :ok
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown install_handlers error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown install_handlers exit: #{inspect(reason)}")
:ok
end end
@spec request_quit() :: :ok @spec request_quit() :: :ok
@@ -102,9 +107,13 @@ defmodule BDS.Desktop.Shutdown do
fun.() fun.()
:ok :ok
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown persist_step error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown persist_step exit: #{inspect(reason)}")
:ok
end end
# heart, when present, would relaunch the app after we kill the BEAM, so it # heart, when present, would relaunch the app after we kill the BEAM, so it
@@ -120,9 +129,13 @@ defmodule BDS.Desktop.Shutdown do
_ -> :ok _ -> :ok
end end
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown kill_heart error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown kill_heart exit: #{inspect(reason)}")
:ok
end end
defp kill_beam do defp kill_beam do
@@ -133,9 +146,13 @@ defmodule BDS.Desktop.Shutdown do
os_kill_fun().(os_pid) os_kill_fun().(os_pid)
:ok :ok
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown os_kill error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown os_kill exit: #{inspect(reason)}")
:ok
end end
defp os_kill_fun do defp os_kill_fun do
@@ -158,9 +175,13 @@ defmodule BDS.Desktop.Shutdown do
:ok :ok
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown maybe_hide_window error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown maybe_hide_window exit: #{inspect(reason)}")
:ok
end end
@doc false @doc false

View File

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

View File

@@ -7,6 +7,15 @@ defmodule BDS.Embeddings.DismissedDuplicatePair do
@primary_key {:id, :string, autogenerate: false} @primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string @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 schema "dismissed_duplicate_pairs" do
belongs_to :project, BDS.Projects.Project, type: :string belongs_to :project, BDS.Projects.Project, type: :string
field :post_id_a, :string field :post_id_a, :string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,67 +3,69 @@ defmodule BDS.Tasks do
use GenServer 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_max_concurrent 3
@default_progress_throttle_ms 250 @default_progress_throttle_ms 250
@default_recent_finished_limit 10 @default_recent_finished_limit 10
@default_finished_task_ttl_ms :timer.hours(1) @default_finished_task_ttl_ms :timer.hours(1)
@topic "tasks" @topic "tasks"
@spec start_link(term()) :: GenServer.on_start()
def start_link(_opts) do def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__) GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end end
@spec topic() :: String.t()
def topic, do: @topic 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 \\ %{}) def submit_task(name, work, attrs \\ %{})
when is_binary(name) and is_function(work, 1) and is_map(attrs) do when is_binary(name) and is_function(work, 1) and is_map(attrs) do
GenServer.call(__MODULE__, {:submit_task, name, work, attrs}) GenServer.call(__MODULE__, {:submit_task, name, work, attrs})
end end
@spec get_task(task_id()) :: task_info() | nil
def get_task(task_id) when is_binary(task_id) do def get_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:get_task, task_id}) GenServer.call(__MODULE__, {:get_task, task_id})
end end
@spec status_snapshot() :: status_snapshot()
def status_snapshot do def status_snapshot do
GenServer.call(__MODULE__, :status_snapshot) GenServer.call(__MODULE__, :status_snapshot)
end end
@spec list_tasks() :: [task_info()]
def list_tasks do def list_tasks do
GenServer.call(__MODULE__, :list_tasks) GenServer.call(__MODULE__, :list_tasks)
end end
@spec list_running_tasks() :: [task_info()]
def list_running_tasks do def list_running_tasks do
GenServer.call(__MODULE__, :list_running_tasks) GenServer.call(__MODULE__, :list_running_tasks)
end end
@spec clear_completed() :: :ok
def clear_completed do def clear_completed do
GenServer.call(__MODULE__, :clear_completed) GenServer.call(__MODULE__, :clear_completed)
end end
@spec clear_finished() :: :ok
def clear_finished do def clear_finished do
GenServer.call(__MODULE__, :clear_finished) GenServer.call(__MODULE__, :clear_finished)
end end
@spec cancel_task(task_id()) :: :ok | {:error, :not_found | :not_running}
def cancel_task(task_id) when is_binary(task_id) do def cancel_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:cancel_task, task_id}) GenServer.call(__MODULE__, {:cancel_task, task_id})
end 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 @impl true
def init(_state) do def init(_state) do
{:ok, {:ok,
@@ -163,48 +165,6 @@ defmodule BDS.Tasks do
end end
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 @impl true
def handle_info({:task_progress, task_id, value, message}, state) do def handle_info({:task_progress, task_id, value, message}, state) do
{:noreply, maybe_report_progress(state, task_id, value, message)} {: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, []), do: query
defp maybe_where_all_tags(query, tags) do defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags)
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_categories(query, []), do: query defp maybe_where_all_categories(query, []), do: query
defp maybe_where_all_categories(query, categories) do defp maybe_where_all_categories(query, categories),
Enum.reduce(categories, query, fn category, q -> do: where_all_in_json_array(query, categories, :categories, true)
where(
q,
[p],
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?) AND lower(value) != 'page')",
p.categories,
^category
)
)
end)
end
defp base_year_month_counts(project_id, pages?) do defp base_year_month_counts(project_id, pages?) do
is_page = if pages?, do: 1, else: 0 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, []), do: query
defp maybe_where_all_media_tags(query, tags) do defp maybe_where_all_media_tags(query, tags),
Enum.reduce(tags, query, fn tag, q -> do: where_all_in_json_array(query, tags, :tags)
where(
q, defp where_all_in_json_array(query, items, field_atom, exclude_page? \\ false) do
[m], Enum.reduce(items, query, fn item, q ->
fragment( if exclude_page? do
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))", where(
m.tags, q,
^tag [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)
end end

View File

@@ -310,14 +310,26 @@ defmodule BDS.Scripting.ApiTest do
assert {:ok, _published_template} = BDS.Templates.publish_template(template.id) assert {:ok, _published_template} = BDS.Templates.publish_template(template.id)
assert {:ok, running_task} = runner = self()
BDS.Tasks.register_external_task("preview build", %{
group_id: "generation", assert {:ok, _running_task} =
group_name: "Generation" BDS.Tasks.submit_task(
}) "preview build",
fn report ->
report.(0.5, "halfway")
send(runner, {:task_started, self()})
receive do
:release_task -> {:ok, :done}
end
end,
%{group_id: "generation", group_name: "Generation"}
)
assert_receive {:task_started, task_pid}, 1_000
on_exit(fn -> on_exit(fn ->
_ = BDS.Tasks.complete_task(running_task.id) send(task_pid, :release_task)
end) end)
source = source =

View File

@@ -2,11 +2,14 @@ defmodule BDS.TasksTest do
use ExUnit.Case, async: false use ExUnit.Case, async: false
setup do setup do
cleanup_task_server()
original = Application.get_env(:bds, :tasks, []) original = Application.get_env(:bds, :tasks, [])
Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250) Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250)
:ok = BDS.Tasks.clear_finished() :ok = BDS.Tasks.clear_finished()
on_exit(fn -> on_exit(fn ->
cleanup_task_server()
Application.put_env(:bds, :tasks, original) Application.put_env(:bds, :tasks, original)
_ = BDS.Tasks.clear_finished() _ = BDS.Tasks.clear_finished()
end) end)
@@ -131,74 +134,129 @@ defmodule BDS.TasksTest do
end end
test "progress reports within 250ms throttle window are silently dropped" do test "progress reports within 250ms throttle window are silently dropped" do
assert {:ok, task} = BDS.Tasks.register_external_task("fast progress") runner = self()
assert {:ok, task} =
BDS.Tasks.submit_task("fast progress", fn report ->
send(runner, {:started, "fast progress", self()})
report.(0.25, "quarter")
report.(0.5, "half")
receive do
:release -> {:ok, :done}
end
end)
{"fast progress", worker_pid} = receive_started()
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25 assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "half")
assert task_id = task.id
# The 250ms throttle has not elapsed, so progress stays at 0.25. # The 250ms throttle has not elapsed, so progress stays at 0.25.
assert wait_for_task(task_id, & &1.progress == 0.25).progress == 0.25 assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
on_exit(fn -> BDS.Tasks.complete_task(task.id) end) send(worker_pid, :release)
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
end end
test "progress report with value 1.0 bypasses the throttle" do test "progress report with value 1.0 bypasses the throttle" do
assert {:ok, task} = BDS.Tasks.register_external_task("completion progress") runner = self()
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter") assert {:ok, task} =
BDS.Tasks.submit_task("completion progress", fn report ->
send(runner, {:started, "completion progress", self()})
report.(0.25, "quarter")
report.(1.0, "done")
receive do
:release -> {:ok, :done}
end
end)
{"completion progress", worker_pid} = receive_started()
# A completion report (1.0) must go through even if throttled. # A completion report (1.0) must go through even if throttled.
assert :ok = BDS.Tasks.report_progress(task.id, 1.0, "done")
assert wait_for_task(task.id, &(&1.progress == 1.0)).progress == 1.0 assert wait_for_task(task.id, &(&1.progress == 1.0)).progress == 1.0
assert wait_for_task(task.id, &(&1.message == "done")).message == "done" assert wait_for_task(task.id, &(&1.message == "done")).message == "done"
on_exit(fn -> BDS.Tasks.complete_task(task.id) end) send(worker_pid, :release)
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
end end
test "external tasks are registered as running and can report progress and complete" do test "submitted tasks are registered as running and can report progress and complete" do
assert {:ok, task} = assert {:ok, task} =
BDS.Tasks.register_external_task("preview build", %{ BDS.Tasks.submit_task(
"preview build",
fn report ->
report.(0.5, "halfway")
receive do
:release -> {:ok, :done}
end
end,
%{
group_id: "generation", group_id: "generation",
group_name: "Generation" group_name: "Generation"
}) }
)
assert task.status == :running assert task.status == :pending
assert task.group_id == "generation" assert task.group_id == "generation"
assert task.group_name == "Generation" assert task.group_name == "Generation"
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "halfway")
progressed = wait_for_task(task.id, &(&1.progress == 0.5 and &1.message == "halfway")) progressed = wait_for_task(task.id, &(&1.progress == 0.5 and &1.message == "halfway"))
assert progressed.status == :running assert progressed.status == :running
assert :ok = BDS.Tasks.complete_task(task.id) task_state = :sys.get_state(BDS.Tasks)
%{pid: worker_pid} = task_state.running[task.id]
send(worker_pid, :release)
assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status == assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status ==
:completed :completed
end end
test "status_snapshot exposes active task details for the desktop shell" do test "status_snapshot exposes active task details for the desktop shell" do
runner = self()
assert {:ok, first} = assert {:ok, first} =
BDS.Tasks.register_external_task("preview build", %{ BDS.Tasks.submit_task(
group_id: "generation", "preview build",
group_name: "Generation" fn report ->
}) send(runner, {:started, "preview build", self()})
report.(0.5, "halfway")
receive do
:release -> {:ok, :done}
end
end,
%{group_id: "generation", group_name: "Generation"}
)
assert {:ok, second} = assert {:ok, second} =
BDS.Tasks.register_external_task("reindex text", %{ BDS.Tasks.submit_task(
group_id: "search", "reindex text",
group_name: "Search" fn _report ->
}) send(runner, {:started, "reindex text", self()})
receive do
:release -> {:ok, :done}
end
end,
%{group_id: "search", group_name: "Search"}
)
on_exit(fn -> on_exit(fn ->
_ = BDS.Tasks.complete_task(first.id) task_state = :sys.get_state(BDS.Tasks)
_ = BDS.Tasks.complete_task(second.id)
Enum.each([first.id, second.id], fn task_id ->
case task_state.running[task_id] do
%{pid: pid} -> send(pid, :release)
nil -> :ok
end
end)
end) end)
assert :ok = BDS.Tasks.report_progress(first.id, 0.5, "halfway") _ = receive_started()
_ = receive_started()
snapshot = BDS.Tasks.status_snapshot() snapshot = BDS.Tasks.status_snapshot()
@@ -250,12 +308,23 @@ defmodule BDS.TasksTest do
finished_task_ttl_ms: 1 finished_task_ttl_ms: 1
) )
assert {:ok, task} = BDS.Tasks.register_external_task("short lived") runner = self()
assert {:ok, running} = BDS.Tasks.register_external_task("still running")
on_exit(fn -> _ = BDS.Tasks.complete_task(running.id) end) assert {:ok, task} = BDS.Tasks.submit_task("short lived", fn _report -> {:ok, :done} end)
assert {:ok, running} =
BDS.Tasks.submit_task("still running", fn _report ->
send(runner, {:started, "still running", self()})
receive do
:release -> {:ok, :done}
end
end)
{"still running", worker_pid} = receive_started()
on_exit(fn -> send(worker_pid, :release) end)
assert :ok = BDS.Tasks.complete_task(task.id)
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
Process.sleep(20) Process.sleep(20)
@@ -273,15 +342,15 @@ defmodule BDS.TasksTest do
finished_task_ttl_ms: 50 finished_task_ttl_ms: 50
) )
assert {:ok, first} = BDS.Tasks.register_external_task("first finished") assert {:ok, first} = BDS.Tasks.submit_task("first finished", fn _report -> {:ok, :done} end)
assert {:ok, second} = BDS.Tasks.register_external_task("second finished") assert {:ok, second} = BDS.Tasks.submit_task("second finished", fn _report -> {:ok, :done} end)
assert :ok = BDS.Tasks.complete_task(first.id) assert wait_for_task(first.id, &(&1.status == :completed)).status == :completed
first_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer first_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer
assert is_reference(first_timer) assert is_reference(first_timer)
assert is_integer(Process.read_timer(first_timer)) assert is_integer(Process.read_timer(first_timer))
assert :ok = BDS.Tasks.complete_task(second.id) assert wait_for_task(second.id, &(&1.status == :completed)).status == :completed
second_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer second_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer
assert second_timer == first_timer assert second_timer == first_timer
@@ -336,4 +405,34 @@ defmodule BDS.TasksTest do
flunk("task did not reach expected state") flunk("task did not reach expected state")
end end
defp cleanup_task_server do
BDS.Tasks.list_tasks()
|> Enum.filter(&(&1.status in [:pending, :running]))
|> Enum.each(fn task ->
_ = BDS.Tasks.cancel_task(task.id)
end)
wait_until(fn ->
BDS.Tasks.list_tasks()
|> Enum.all?(&(&1.status not in [:pending, :running]))
end)
:ok = BDS.Tasks.clear_finished()
end
defp wait_until(predicate, attempts \\ 100)
defp wait_until(predicate, attempts) when attempts > 0 do
if predicate.() do
:ok
else
Process.sleep(20)
wait_until(predicate, attempts - 1)
end
end
defp wait_until(_predicate, 0) do
flunk("condition was not met")
end
end end