Compare commits

...

3 Commits

Author SHA1 Message Date
57bdcef990 fix: more refactorings for cleaning code 2026-06-25 15:50:08 +02:00
52809b39b1 fix: part of refactoring 2026-06-25 15:42:42 +02:00
e3e94ac2d9 fix: refactorings to remove anti-patterns 2026-06-25 15:42:32 +02:00
35 changed files with 1738 additions and 1217 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
@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

@@ -9,6 +9,7 @@ defmodule BDS.AI.Chat do
alias BDS.AI.CatalogProvider
alias BDS.AI.ChatConversation
alias BDS.AI.ChatMessage
alias BDS.AI.ChatTitleGenerator
alias BDS.AI.ChatTools
alias BDS.AI.InFlight
alias BDS.AI.OpenAICompatibleRuntime
@@ -24,8 +25,6 @@ defmodule BDS.AI.Chat do
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
@default_max_output_tokens 16_384
@title_max_output_tokens 256
@chat_title_max_length 30
@chat_max_tool_rounds 10
@chat_await_timeout_margin_ms 5_000
@default_context_window 128_000
@@ -34,7 +33,7 @@ defmodule BDS.AI.Chat do
def start_chat(attrs \\ %{}) when is_map(attrs) do
now = Persistence.now_ms()
model = MapUtils.attr(attrs, :model)
title = MapUtils.attr(attrs, :title) || generated_chat_title(model)
title = MapUtils.attr(attrs, :title) || ChatTitleGenerator.generated_chat_title(model)
%ChatConversation{}
|> ChatConversation.changeset(%{
@@ -430,11 +429,11 @@ defmodule BDS.AI.Chat do
conversation = Repo.get!(ChatConversation, conversation_id)
cond do
chat_user_message_count(conversation_id) < 1 ->
ChatTitleGenerator.chat_user_message_count(conversation_id) < 1 ->
Logger.debug("Chat title generation skipped reason=:no_user_messages")
{:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) ->
not ChatTitleGenerator.generated_chat_title?(conversation.title, conversation.model) ->
Logger.debug(
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
)
@@ -444,7 +443,7 @@ defmodule BDS.AI.Chat do
true ->
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
case generate_chat_title(user_content, opts) do
case ChatTitleGenerator.generate_chat_title(user_content, opts) do
{:ok, title} when is_binary(title) and title != "" ->
now = Persistence.now_ms()
@@ -465,87 +464,6 @@ defmodule BDS.AI.Chat do
end
end
defp generate_chat_title(user_content, opts) when is_binary(user_content) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end
end
defp build_chat_title_request(user_content, model) do
%{
operation: :chat_title,
model: model,
max_output_tokens: @title_max_output_tokens,
messages: [
%{
"role" => "system",
"content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
},
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
]
}
end
defp sanitize_chat_title(title) when is_binary(title) do
title =
title
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_leading("'")
|> String.trim_trailing("\"")
|> String.trim_trailing("'")
|> String.trim_trailing(".")
|> String.trim_trailing("!")
|> String.trim_trailing("?")
if String.length(title) > @chat_title_max_length do
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
else
title
end
end
defp sanitize_chat_title(_title), do: ""
defp chat_user_message_count(conversation_id) do
Repo.aggregate(
from(message in ChatMessage,
where: message.conversation_id == ^conversation_id and message.role == :user
),
:count,
:id
)
end
defp generated_chat_title?(title, model) do
title in [generated_chat_title(nil), generated_chat_title(model)]
end
defp chat_round(
_conversation,
_messages,
@@ -882,9 +800,6 @@ defmodule BDS.AI.Chat do
)
end
defp generated_chat_title(nil), do: "New Chat"
defp generated_chat_title(model), do: "Chat with #{model}"
defp load_chat_messages(conversation_id) do
Repo.all(
from message in ChatMessage,

View File

@@ -0,0 +1,99 @@
defmodule BDS.AI.ChatTitleGenerator do
@moduledoc false
require Logger
alias BDS.AI.ChatMessage
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime
alias BDS.Repo
import Ecto.Query
@title_max_output_tokens 256
@chat_title_max_length 30
def generate_chat_title(user_content, opts) when is_binary(user_content) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end
end
def build_chat_title_request(user_content, model) do
%{
operation: :chat_title,
model: model,
max_output_tokens: @title_max_output_tokens,
messages: [
%{
"role" => "system",
"content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
},
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
]
}
end
def sanitize_chat_title(title) when is_binary(title) do
title =
title
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_leading("'")
|> String.trim_trailing("\"")
|> String.trim_trailing("'")
|> String.trim_trailing(".")
|> String.trim_trailing("!")
|> String.trim_trailing("?")
if String.length(title) > @chat_title_max_length do
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
else
title
end
end
def sanitize_chat_title(_title), do: ""
def chat_user_message_count(conversation_id) do
Repo.aggregate(
from(message in ChatMessage,
where: message.conversation_id == ^conversation_id and message.role == :user
),
:count,
:id
)
end
def generated_chat_title?(title, model) do
title in [generated_chat_title(nil), generated_chat_title(model)]
end
def generated_chat_title(nil), do: "New Chat"
def generated_chat_title(model), do: "Chat with #{model}"
end

View File

@@ -0,0 +1,49 @@
defmodule BDS.AI.ChatToolQueryHelpers do
@moduledoc false
alias BDS.Search
def normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
def normalize_limit(_value), do: 10
def normalize_offset(value) when is_integer(value) and value >= 0, do: value
def normalize_offset(_value), do: 0
def search_filters(arguments) do
%{}
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|> BDS.MapUtils.maybe_put(
:missing_translation_language,
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
)
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
def search_all_counted_posts(project_id, arguments) do
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
filters = Map.put(filters, :limit, max(total, 1))
{:ok, result} = Search.search_posts(project_id, "", filters)
result
end
def search_result(project_id, query, filters, mapper) do
{:ok, result} = Search.search_posts(project_id, query, filters)
%{
posts: Enum.map(result.posts, mapper),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
end

View File

@@ -0,0 +1,350 @@
defmodule BDS.AI.ChatToolSchemas do
@moduledoc false
def tool_spec(name, description, parameters) do
%{
"type" => "function",
"function" => %{
"name" => name,
"description" => description,
"parameters" => parameters
}
}
end
def limit_schema do
%{
"type" => "object",
"properties" => %{
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
}
}
end
def post_search_schema(require_query) do
schema = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"language" => %{"type" => "string"},
"missingTranslationLanguage" => %{"type" => "string"},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
"offset" => %{"type" => "integer", "minimum" => 0}
}
}
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
end
def count_posts_schema do
%{
"type" => "object",
"properties" => %{
"groupBy" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => ["year", "month", "tag", "category", "status"]
}
},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["groupBy"]
}
end
def post_id_schema do
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
end
def media_id_schema(extra_properties \\ %{}) do
%{
"type" => "object",
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
"required" => ["mediaId"]
}
end
def update_post_metadata_schema do
%{
"type" => "object",
"properties" => %{
"postId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"excerpt" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["postId"]
}
end
def update_media_metadata_schema do
%{
"type" => "object",
"properties" => %{
"mediaId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"alt" => %{"type" => "string"},
"caption" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["mediaId"]
}
end
def render_table_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column header names"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows, each row is an array of cell values"
}
}
}
end
def render_chart_schema do
%{
"type" => "object",
"properties" => %{
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" =>
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
},
"title" => %{"type" => "string", "description" => "Optional chart title"},
"series" => %{
"type" => "array",
"description" => "Array of data points.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Data point label"},
"value" => %{"type" => "number", "description" => "Data point value"},
"segments" => %{
"type" => "array",
"description" =>
"Segments within this data point. Required for stacked-bar and heatmap charts.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "number"}
},
"required" => ["label", "value"]
}
}
},
"required" => ["label"]
}
}
},
"required" => ["chartType", "series"]
}
end
def render_form_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional form title"},
"fields" => %{
"type" => "array",
"description" => "Form fields to display",
"items" => %{
"type" => "object",
"properties" => %{
"key" => %{"type" => "string", "description" => "Field identifier"},
"label" => %{"type" => "string", "description" => "Field label shown to user"},
"inputType" => %{
"type" => "string",
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
"description" => "Type of input control"
},
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
"defaultValue" => %{"type" => "string", "description" => "Default value"},
"options" => %{
"type" => "array",
"description" => "Options for select fields",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "string"}
}
}
},
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
},
"required" => ["key", "label", "inputType"]
}
},
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
}
}
end
def render_card_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Card title"},
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
"actions" => %{
"type" => "array",
"description" => "Optional action buttons on the card",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Button label"},
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
"payload" => %{
"type" => "object",
"description" => "Optional action payload"
}
},
"required" => ["label", "action"]
}
}
}
}
end
def render_metric_schema do
%{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Metric label"},
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
}
}
end
def render_list_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional list title"},
"items" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "List items"
}
}
}
end
def render_tabs_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional tabs title"},
"tabs" => %{
"type" => "array",
"description" => "Array of tabs",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Tab label"},
"content" => %{
"type" => "array",
"description" => "Content items within the tab",
"items" => %{
"type" => "object",
"properties" => %{
"type" => %{
"type" => "string",
"enum" => ["text", "metric", "list", "chart", "table"],
"description" => "Content type"
},
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
"items" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Items (for type list)"
},
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" => "Chart type (for type chart)"
},
"series" => %{
"type" => "array",
"description" => "Data series (for type chart)"
},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column headers (for type table)"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows (for type table)"
}
},
"required" => ["type"]
}
}
},
"required" => ["label", "content"]
}
}
}
}
end
def render_mindmap_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional mind map title"},
"nodes" => %{
"type" => "array",
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
"items" => %{
"type" => "object",
"properties" => %{
"id" => %{"type" => "string", "description" => "Unique node identifier"},
"label" => %{"type" => "string", "description" => "Node label text"},
"children" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "IDs of child nodes"
}
},
"required" => ["id", "label"]
}
}
}
}
end
end

View File

@@ -4,6 +4,8 @@ defmodule BDS.AI.ChatTools do
import Ecto.Query
alias BDS.AI.Chat
alias BDS.AI.ChatToolQueryHelpers, as: QueryHelpers
alias BDS.AI.ChatToolSchemas, as: Schemas
alias BDS.Media, as: MediaContext
alias BDS.Media.Media
alias BDS.MCP.Queries
@@ -12,7 +14,6 @@ defmodule BDS.AI.ChatTools do
alias BDS.Posts.PostMedia
alias BDS.Projects.Project
alias BDS.Repo
alias BDS.Search
@spec execute(String.t(), map(), String.t() | nil) :: map()
def execute("blog_stats", _arguments, project_id) do
@@ -58,8 +59,14 @@ defmodule BDS.AI.ChatTools do
def execute("search_posts", arguments, project_id) do
project_id = project_id || active_project_id()
filters = search_filters(arguments)
search_result(project_id, arguments["query"] || "", filters, &Queries.post_summary/1)
filters = QueryHelpers.search_filters(arguments)
QueryHelpers.search_result(
project_id,
arguments["query"] || "",
filters,
&Queries.post_summary/1
)
end
def execute("read_post_by_slug", arguments, project_id) do
@@ -81,11 +88,11 @@ defmodule BDS.AI.ChatTools do
def execute("list_posts", arguments, project_id) do
project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"])
offset = normalize_offset(arguments["offset"])
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
limit = QueryHelpers.normalize_limit(arguments["limit"])
offset = QueryHelpers.normalize_offset(arguments["offset"])
filters = QueryHelpers.search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
search_result(project_id, "", filters, fn post ->
QueryHelpers.search_result(project_id, "", filters, fn post ->
post
|> Queries.post_summary()
|> Map.put("url", "/posts/#{post.slug}")
@@ -95,7 +102,7 @@ defmodule BDS.AI.ChatTools do
def execute("list_media", arguments, project_id) do
project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"])
limit = QueryHelpers.normalize_limit(arguments["limit"])
Repo.all(
from(media in Media,
@@ -194,7 +201,7 @@ defmodule BDS.AI.ChatTools do
def execute("count_posts", arguments, project_id) do
project_id = project_id || active_project_id()
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
result = search_all_counted_posts(project_id, arguments)
result = QueryHelpers.search_all_counted_posts(project_id, arguments)
groups =
result.posts
@@ -321,7 +328,7 @@ defmodule BDS.AI.ChatTools do
%{
name: "blog_stats",
spec:
tool_spec("blog_stats", "Return aggregate blog statistics", %{
Schemas.tool_spec("blog_stats", "Return aggregate blog statistics", %{
"type" => "object",
"properties" => %{}
})
@@ -329,7 +336,7 @@ defmodule BDS.AI.ChatTools do
%{
name: "get_blog_stats",
spec:
tool_spec(
Schemas.tool_spec(
"get_blog_stats",
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
%{"type" => "object", "properties" => %{}}
@@ -338,7 +345,7 @@ defmodule BDS.AI.ChatTools do
%{
name: "check_term",
spec:
tool_spec(
Schemas.tool_spec(
"check_term",
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
%{
@@ -351,16 +358,16 @@ defmodule BDS.AI.ChatTools do
%{
name: "search_posts",
spec:
tool_spec(
Schemas.tool_spec(
"search_posts",
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
post_search_schema(true)
Schemas.post_search_schema(true)
)
},
%{
name: "read_post",
spec:
tool_spec(
Schemas.tool_spec(
"read_post",
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{
@@ -373,7 +380,7 @@ defmodule BDS.AI.ChatTools do
%{
name: "read_post_by_slug",
spec:
tool_spec(
Schemas.tool_spec(
"read_post_by_slug",
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{
@@ -386,37 +393,37 @@ defmodule BDS.AI.ChatTools do
%{
name: "list_posts",
spec:
tool_spec(
Schemas.tool_spec(
"list_posts",
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
post_search_schema(false)
Schemas.post_search_schema(false)
)
},
%{
name: "get_media",
spec:
tool_spec(
Schemas.tool_spec(
"get_media",
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
media_id_schema()
Schemas.media_id_schema()
)
},
%{
name: "list_media",
spec:
tool_spec(
Schemas.tool_spec(
"list_media",
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
limit_schema()
Schemas.limit_schema()
)
},
%{
name: "view_image",
spec:
tool_spec(
Schemas.tool_spec(
"view_image",
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
media_id_schema(%{
Schemas.media_id_schema(%{
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
})
)
@@ -424,25 +431,25 @@ defmodule BDS.AI.ChatTools do
%{
name: "update_post_metadata",
spec:
tool_spec(
Schemas.tool_spec(
"update_post_metadata",
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
update_post_metadata_schema()
Schemas.update_post_metadata_schema()
)
},
%{
name: "update_media_metadata",
spec:
tool_spec(
Schemas.tool_spec(
"update_media_metadata",
"Update metadata for a media file: title, alt text, caption, or tags.",
update_media_metadata_schema()
Schemas.update_media_metadata_schema()
)
},
%{
name: "list_tags",
spec:
tool_spec(
Schemas.tool_spec(
"list_tags",
"List all tags used across blog posts with post counts.",
%{
@@ -454,7 +461,7 @@ defmodule BDS.AI.ChatTools do
%{
name: "list_categories",
spec:
tool_spec(
Schemas.tool_spec(
"list_categories",
"List all categories used across blog posts with post counts.",
%{"type" => "object", "properties" => %{}}
@@ -463,46 +470,46 @@ defmodule BDS.AI.ChatTools do
%{
name: "count_posts",
spec:
tool_spec(
Schemas.tool_spec(
"count_posts",
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
count_posts_schema()
Schemas.count_posts_schema()
)
},
%{
name: "get_post_backlinks",
spec:
tool_spec(
Schemas.tool_spec(
"get_post_backlinks",
"Get all posts that link to a specific post.",
post_id_schema()
Schemas.post_id_schema()
)
},
%{
name: "get_post_outlinks",
spec:
tool_spec(
Schemas.tool_spec(
"get_post_outlinks",
"Get all posts that a specific post links to.",
post_id_schema()
Schemas.post_id_schema()
)
},
%{
name: "get_post_media",
spec:
tool_spec(
Schemas.tool_spec(
"get_post_media",
"Get media files linked to a specific post.",
post_id_schema()
Schemas.post_id_schema()
)
},
%{
name: "get_media_posts",
spec:
tool_spec(
Schemas.tool_spec(
"get_media_posts",
"Get posts that use a specific media file.",
media_id_schema()
Schemas.media_id_schema()
)
}
]
@@ -515,73 +522,73 @@ defmodule BDS.AI.ChatTools do
%{
name: "render_card",
spec:
tool_spec(
Schemas.tool_spec(
"render_card",
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
render_card_schema()
Schemas.render_card_schema()
)
},
%{
name: "render_table",
spec:
tool_spec(
Schemas.tool_spec(
"render_table",
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
render_table_schema()
Schemas.render_table_schema()
)
},
%{
name: "render_chart",
spec:
tool_spec(
Schemas.tool_spec(
"render_chart",
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
render_chart_schema()
Schemas.render_chart_schema()
)
},
%{
name: "render_form",
spec:
tool_spec(
Schemas.tool_spec(
"render_form",
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
render_form_schema()
Schemas.render_form_schema()
)
},
%{
name: "render_metric",
spec:
tool_spec(
Schemas.tool_spec(
"render_metric",
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
render_metric_schema()
Schemas.render_metric_schema()
)
},
%{
name: "render_list",
spec:
tool_spec(
Schemas.tool_spec(
"render_list",
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
render_list_schema()
Schemas.render_list_schema()
)
},
%{
name: "render_tabs",
spec:
tool_spec(
Schemas.tool_spec(
"render_tabs",
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
render_tabs_schema()
Schemas.render_tabs_schema()
)
},
%{
name: "render_mindmap",
spec:
tool_spec(
Schemas.tool_spec(
"render_mindmap",
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
render_mindmap_schema()
Schemas.render_mindmap_schema()
)
}
]
@@ -590,394 +597,6 @@ defmodule BDS.AI.ChatTools do
end
end
defp tool_spec(name, description, parameters) do
%{
"type" => "function",
"function" => %{
"name" => name,
"description" => description,
"parameters" => parameters
}
}
end
defp limit_schema do
%{
"type" => "object",
"properties" => %{
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
}
}
end
defp post_search_schema(require_query) do
schema = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"language" => %{"type" => "string"},
"missingTranslationLanguage" => %{"type" => "string"},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
"offset" => %{"type" => "integer", "minimum" => 0}
}
}
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
end
defp count_posts_schema do
%{
"type" => "object",
"properties" => %{
"groupBy" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => ["year", "month", "tag", "category", "status"]
}
},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["groupBy"]
}
end
defp post_id_schema do
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
end
defp media_id_schema(extra_properties \\ %{}) do
%{
"type" => "object",
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
"required" => ["mediaId"]
}
end
defp update_post_metadata_schema do
%{
"type" => "object",
"properties" => %{
"postId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"excerpt" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["postId"]
}
end
defp update_media_metadata_schema do
%{
"type" => "object",
"properties" => %{
"mediaId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"alt" => %{"type" => "string"},
"caption" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["mediaId"]
}
end
defp render_table_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column header names"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows, each row is an array of cell values"
}
}
}
end
defp render_chart_schema do
%{
"type" => "object",
"properties" => %{
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" =>
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
},
"title" => %{"type" => "string", "description" => "Optional chart title"},
"series" => %{
"type" => "array",
"description" => "Array of data points.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Data point label"},
"value" => %{"type" => "number", "description" => "Data point value"},
"segments" => %{
"type" => "array",
"description" =>
"Segments within this data point. Required for stacked-bar and heatmap charts.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "number"}
},
"required" => ["label", "value"]
}
}
},
"required" => ["label"]
}
}
},
"required" => ["chartType", "series"]
}
end
defp render_form_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional form title"},
"fields" => %{
"type" => "array",
"description" => "Form fields to display",
"items" => %{
"type" => "object",
"properties" => %{
"key" => %{"type" => "string", "description" => "Field identifier"},
"label" => %{"type" => "string", "description" => "Field label shown to user"},
"inputType" => %{
"type" => "string",
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
"description" => "Type of input control"
},
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
"defaultValue" => %{"type" => "string", "description" => "Default value"},
"options" => %{
"type" => "array",
"description" => "Options for select fields",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "string"}
}
}
},
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
},
"required" => ["key", "label", "inputType"]
}
},
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
}
}
end
defp render_card_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Card title"},
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
"actions" => %{
"type" => "array",
"description" => "Optional action buttons on the card",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Button label"},
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
"payload" => %{
"type" => "object",
"description" => "Optional action payload"
}
},
"required" => ["label", "action"]
}
}
}
}
end
defp render_metric_schema do
%{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Metric label"},
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
}
}
end
defp render_list_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional list title"},
"items" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "List items"
}
}
}
end
defp render_tabs_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional tabs title"},
"tabs" => %{
"type" => "array",
"description" => "Array of tabs",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Tab label"},
"content" => %{
"type" => "array",
"description" => "Content items within the tab",
"items" => %{
"type" => "object",
"properties" => %{
"type" => %{
"type" => "string",
"enum" => ["text", "metric", "list", "chart", "table"],
"description" => "Content type"
},
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
"items" => %{
"type" => "array", "items" => %{"type" => "string"},
"description" => "Items (for type list)"
},
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" => "Chart type (for type chart)"
},
"series" => %{
"type" => "array",
"description" => "Data series (for type chart)"
},
"columns" => %{
"type" => "array", "items" => %{"type" => "string"},
"description" => "Column headers (for type table)"
},
"rows" => %{
"type" => "array", "items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows (for type table)"
}
},
"required" => ["type"]
}
}
},
"required" => ["label", "content"]
}
}
}
}
end
defp render_mindmap_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional mind map title"},
"nodes" => %{
"type" => "array",
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
"items" => %{
"type" => "object",
"properties" => %{
"id" => %{"type" => "string", "description" => "Unique node identifier"},
"label" => %{"type" => "string", "description" => "Node label text"},
"children" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "IDs of child nodes"
}
},
"required" => ["id", "label"]
}
}
}
}
end
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
defp normalize_limit(_value), do: 10
defp normalize_offset(value) when is_integer(value) and value >= 0, do: value
defp normalize_offset(_value), do: 0
defp search_filters(arguments) do
%{}
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|> BDS.MapUtils.maybe_put(
:missing_translation_language,
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
)
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
defp search_all_counted_posts(project_id, arguments) do
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
filters = Map.put(filters, :limit, max(total, 1))
{:ok, result} = Search.search_posts(project_id, "", filters)
result
end
defp search_result(project_id, query, filters, mapper) do
{:ok, result} = Search.search_posts(project_id, query, filters)
%{
posts: Enum.map(result.posts, mapper),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
defp with_post(arguments, project_id, not_found_result, success_fun) do
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],

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
}
@@ -29,25 +31,20 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Desktop.ShellLive.PostEditor
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
alias BDS.Desktop.ShellLive.SidebarEvents
alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState
alias BDS.Desktop.ShellLive.{
ChatSurface,
ContentState,
Layout,
PanelRenderer,
SessionUtil,
ShellCommandRunner,
SidebarCreate,
SocketState,
TabHelpers,
TaskLocalization,
TitlebarMenu
TitlebarMenu,
UrlState
}
import TaskLocalization,
only: [
localize_task_status: 2
]
import TabHelpers,
only: [
tab_id_for_route: 2,
@@ -64,8 +61,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 +175,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 +203,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 +236,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 +266,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 +282,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 +296,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,241 +718,13 @@ 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}
defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
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]
defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench)
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
projects =
case ShellData.project_snapshot() do
{:ok, data} -> data
{:error, :not_ready} -> ShellData.default_project_snapshot()
end
dashboard =
case ShellData.dashboard(projects.active_project_id) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
end
git_badge_count =
case ShellData.git_badge_count(projects.active_project_id) do
{:ok, count} -> count
{:error, :not_ready} -> 0
end
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
projects.active_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(:projects, projects)
|> assign(:current_project, ShellData.current_project(projects))
|> assign(:dashboard, dashboard)
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|> assign(
:dashboard_tag_cloud_items,
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
)
|> assign(:git_badge_count, git_badge_count)
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
defp reload_shell(socket, workbench) do
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
raw_task_status = BDS.Tasks.status_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode =
if connected?(socket) do
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
else
Map.get(socket.assigns, :offline_mode, true)
end
task_status = localize_task_status(raw_task_status, page_language)
socket
|> assign(:tab_meta, tab_meta)
|> assign(:task_status, task_status)
|> assign(:offline_mode, offline_mode)
|> assign(:assistant_cards, ShellData.assistant_cards())
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> refresh_content(workbench)
end
defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench)
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
@@ -971,12 +739,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 +750,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 +853,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 +900,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 +910,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 +932,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 +956,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 +988,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,83 @@
defmodule BDS.Desktop.ShellLive.ContentState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.AI
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{SidebarState, SocketState, TabHelpers, TaskLocalization, TitlebarMenu}
def refresh_content(socket, workbench) do
projects =
case ShellData.project_snapshot() do
{:ok, data} -> data
{:error, :not_ready} -> ShellData.default_project_snapshot()
end
dashboard =
case ShellData.dashboard(projects.active_project_id) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
end
git_badge_count =
case ShellData.git_badge_count(projects.active_project_id) do
{:ok, count} -> count
{:error, :not_ready} -> 0
end
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
projects.active_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(:projects, projects)
|> assign(:current_project, ShellData.current_project(projects))
|> assign(:dashboard, dashboard)
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|> assign(
:dashboard_tag_cloud_items,
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
)
|> assign(:git_badge_count, git_badge_count)
|> assign(:sidebar_data, sidebar_data)
|> SocketState.refresh_layout(workbench)
end
def reload_shell(socket, workbench) do
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
raw_task_status = BDS.Tasks.status_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode =
if Phoenix.LiveView.connected?(socket) do
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
else
Map.get(socket.assigns, :offline_mode, true)
end
task_status = TaskLocalization.localize_task_status(raw_task_status, page_language)
socket
|> assign(:tab_meta, tab_meta)
|> assign(:task_status, task_status)
|> assign(:offline_mode, offline_mode)
|> assign(:assistant_cards, ShellData.assistant_cards())
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> refresh_content(workbench)
end
end

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

View File

@@ -0,0 +1,58 @@
defmodule BDS.CSM036FileSplitTest do
use ExUnit.Case, async: false
@shell_live_path "lib/bds/desktop/shell_live.ex"
@shell_live_content_state_path "lib/bds/desktop/shell_live/content_state.ex"
@chat_path "lib/bds/ai/chat.ex"
@chat_title_generator_path "lib/bds/ai/chat_title_generator.ex"
@chat_tools_path "lib/bds/ai/chat_tools.ex"
@chat_tool_schemas_path "lib/bds/ai/chat_tool_schemas.ex"
describe "source-level: shell_live refresh orchestration split" do
test "shell_live delegates refresh_content and reload_shell to a helper module" do
shell_live_source = File.read!(@shell_live_path)
content_state_source = File.read!(@shell_live_content_state_path)
refute shell_live_source =~ "defp refresh_content(socket, workbench) do"
refute shell_live_source =~ "defp reload_shell(socket, workbench) do"
assert shell_live_source =~ "defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench)"
assert shell_live_source =~ "defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench)"
assert content_state_source =~ "def refresh_content(socket, workbench) do"
assert content_state_source =~ "def reload_shell(socket, workbench) do"
end
end
describe "source-level: ai chat title split" do
test "chat title generation logic lives in a dedicated helper module" do
chat_source = File.read!(@chat_path)
title_generator_source = File.read!(@chat_title_generator_path)
refute chat_source =~ "defp generate_chat_title(user_content, opts)"
refute chat_source =~ "defp build_chat_title_request(user_content, model)"
refute chat_source =~ "defp sanitize_chat_title(title) when is_binary(title)"
assert title_generator_source =~ "def generate_chat_title(user_content, opts)"
assert title_generator_source =~ "def build_chat_title_request(user_content, model)"
assert title_generator_source =~ "def sanitize_chat_title(title) when is_binary(title)"
end
end
describe "source-level: ai chat tool schema split" do
test "chat tool JSON schema builders live in a dedicated helper module" do
chat_tools_source = File.read!(@chat_tools_path)
chat_tool_schemas_source = File.read!(@chat_tool_schemas_path)
refute chat_tools_source =~ "defp tool_spec(name, description, parameters) do"
refute chat_tools_source =~ "defp render_chart_schema do"
refute chat_tools_source =~ "defp render_form_schema do"
refute chat_tools_source =~ "defp post_search_schema(require_query) do"
assert chat_tool_schemas_source =~ "def tool_spec(name, description, parameters) do"
assert chat_tool_schemas_source =~ "def render_chart_schema do"
assert chat_tool_schemas_source =~ "def render_form_schema do"
assert chat_tool_schemas_source =~ "def post_search_schema(require_query) do"
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, running_task} =
BDS.Tasks.register_external_task("preview build", %{
group_id: "generation",
group_name: "Generation"
})
runner = self()
assert {:ok, _running_task} =
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 ->
_ = BDS.Tasks.complete_task(running_task.id)
send(task_pid, :release_task)
end)
source =

View File

@@ -2,11 +2,14 @@ defmodule BDS.TasksTest do
use ExUnit.Case, async: false
setup do
cleanup_task_server()
original = Application.get_env(:bds, :tasks, [])
Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250)
:ok = BDS.Tasks.clear_finished()
on_exit(fn ->
cleanup_task_server()
Application.put_env(:bds, :tasks, original)
_ = BDS.Tasks.clear_finished()
end)
@@ -131,74 +134,129 @@ defmodule BDS.TasksTest do
end
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 :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.
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
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.
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.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
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} =
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_name: "Generation"
})
}
)
assert task.status == :running
assert task.status == :pending
assert task.group_id == "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"))
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 ==
:completed
end
test "status_snapshot exposes active task details for the desktop shell" do
runner = self()
assert {:ok, first} =
BDS.Tasks.register_external_task("preview build", %{
group_id: "generation",
group_name: "Generation"
})
BDS.Tasks.submit_task(
"preview build",
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} =
BDS.Tasks.register_external_task("reindex text", %{
group_id: "search",
group_name: "Search"
})
BDS.Tasks.submit_task(
"reindex text",
fn _report ->
send(runner, {:started, "reindex text", self()})
receive do
:release -> {:ok, :done}
end
end,
%{group_id: "search", group_name: "Search"}
)
on_exit(fn ->
_ = BDS.Tasks.complete_task(first.id)
_ = BDS.Tasks.complete_task(second.id)
task_state = :sys.get_state(BDS.Tasks)
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)
assert :ok = BDS.Tasks.report_progress(first.id, 0.5, "halfway")
_ = receive_started()
_ = receive_started()
snapshot = BDS.Tasks.status_snapshot()
@@ -250,12 +308,23 @@ defmodule BDS.TasksTest do
finished_task_ttl_ms: 1
)
assert {:ok, task} = BDS.Tasks.register_external_task("short lived")
assert {:ok, running} = BDS.Tasks.register_external_task("still running")
runner = self()
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
Process.sleep(20)
@@ -273,15 +342,15 @@ defmodule BDS.TasksTest do
finished_task_ttl_ms: 50
)
assert {:ok, first} = BDS.Tasks.register_external_task("first finished")
assert {:ok, second} = BDS.Tasks.register_external_task("second finished")
assert {:ok, first} = BDS.Tasks.submit_task("first finished", fn _report -> {:ok, :done} end)
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
assert is_reference(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
assert second_timer == first_timer
@@ -336,4 +405,34 @@ defmodule BDS.TasksTest do
flunk("task did not reach expected state")
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